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> ) } |