All files / web/src/components/practice SessionSongPlayer.tsx

0% Statements 0/131
0% Branches 0/1
0% Functions 0/1
0% Lines 0/131

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132                                                                                                                                                                                                                                                                       
'use client'

/**
 * Kid-friendly audio player for AI-generated session celebration songs.
 *
 * Delegates the "ready" state to <SyncedLyricsPlayer> — the integrated
 * lyrics + playback surface where the lyrics ARE the player (each word
 * is a seek target, active word highlights as it's sung).
 *
 * States:
 * - Generating: animated shimmer with "Creating your song..." text
 * - Ready: <SyncedLyricsPlayer variant="compact" />
 * - Failed: <SongFailureCard>
 * - Absent: renders nothing (don't show errors to kids)
 */

import { useEffect, useRef } from 'react'
import { useSessionSong } from '@/hooks/useSessionSong'
import { ShareSongPopover } from '@/components/song/ShareSongPopover'
import { SyncedLyricsPlayer } from '@/components/song/SyncedLyricsPlayer'
import { SongFailureCard } from './SongFailureCard'
import { css } from '../../../styled-system/css'

interface SessionSongPlayerProps {
  playerId: string
  planId: string
  /** Whether to trigger a completion fallback POST if no song exists */
  triggerFallback?: boolean
}

export function SessionSongPlayer({
  playerId,
  planId,
  triggerFallback = false,
}: SessionSongPlayerProps) {
  const { song, isGenerating, isReady, failureKind, errorDetail, viewerIsOwner } = useSessionSong({
    playerId,
    planId,
    enabled: true,
  })

  const fallbackTriggered = useRef(false)

  // Fire completion fallback trigger if needed
  useEffect(() => {
    if (triggerFallback && !song && !fallbackTriggered.current) {
      fallbackTriggered.current = true
      fetch(`/api/curriculum/${playerId}/sessions/plans/${planId}/song`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ triggerSource: 'completion_fallback' }),
      }).catch(() => {
        // Fire and forget
      })
    }
  }, [triggerFallback, song, playerId, planId])

  // Don't render anything if there's no song at all
  if (!song && !isGenerating) return null

  // Show a soft failure card instead of silently swallowing failures.
  // Kid sees a warm one-liner; account owners/admins also see remediation.
  if (song?.status === 'failed') {
    return (
      <SongFailureCard
        failureKind={failureKind}
        errorDetail={errorDetail}
        viewerIsOwner={viewerIsOwner}
      />
    )
  }

  return (
    <div
      data-component="session-song-player"
      className={css({
        mx: 'auto',
        maxW: '480px',
        mb: 4,
      })}
    >
      {isGenerating && !isReady && (
        <div
          className={css({
            display: 'flex',
            alignItems: 'center',
            gap: 3,
            p: 4,
            borderRadius: 'xl',
            bg: 'purple.50',
            _dark: { bg: 'purple.900/30' },
          })}
        >
          <div
            className={css({
              w: 8,
              h: 8,
              borderRadius: 'full',
              bg: 'purple.200',
              _dark: { bg: 'purple.700' },
              animation: 'pulse 1.5s ease-in-out infinite',
              flexShrink: 0,
            })}
          />
          <span
            className={css({
              fontSize: 'sm',
              color: 'purple.700',
              _dark: { color: 'purple.200' },
              fontWeight: 'medium',
            })}
          >
            Creating your song...
          </span>
        </div>
      )}

      {isReady && song?.audioPath && (
        <SyncedLyricsPlayer
          audioPath={song.audioPath}
          alignmentPath={song.alignmentPath}
          lyrics={song.lyrics ?? []}
          title={song.title}
          variant="compact"
          autoPlay
          footer={song.id ? <ShareSongPopover songId={song.id} songTitle={song.title} /> : null}
        />
      )}
    </div>
  )
}