All files / web/src/hooks useSessionSong.ts

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

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                                                                                                                                                                                                               
'use client'

/**
 * React Query hook for session song status.
 *
 * Polls the song API when generation is in progress and listens
 * for Socket.IO events for instant notification when ready.
 */

import { useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createSocket } from '@/lib/socket'
import { sessionSongKeys } from '@/lib/queryKeys'
import { api } from '@/lib/queryClient'

import type { SessionSongFailureKind } from '@/db/schema/session-songs'
import type { SongLyricsSection } from '@/lib/song/alignment'

interface SessionSongData {
  id: string
  status: string
  title: string | null
  durationSeconds: number | null
  audioPath: string | null
  /** URL for the word-alignment JSON. May 404 for legacy songs without timestamps. */
  alignmentPath: string | null
  /** Per-section lyrics for the synced-lyrics player. Null until completed. */
  lyrics: SongLyricsSection[] | null
  triggerSource: string | null
  failureKind: SessionSongFailureKind | null
  /** Raw error string — server only includes for owners/admins; null otherwise. */
  errorDetail: string | null
  /** True when the requesting viewer is the player's account owner or an admin. */
  viewerIsOwner: boolean
  createdAt: number | null
  completedAt: number | null
}

interface SessionSongResponse {
  song: SessionSongData | null
}

interface UseSessionSongOptions {
  playerId: string
  planId: string | undefined
  enabled?: boolean
}

export function useSessionSong({ playerId, planId, enabled = true }: UseSessionSongOptions) {
  const queryClient = useQueryClient()

  const query = useQuery({
    queryKey: sessionSongKeys.forPlan(planId ?? ''),
    queryFn: async (): Promise<SessionSongResponse> => {
      const res = await api(`curriculum/${playerId}/sessions/plans/${planId}/song`)
      if (!res.ok) return { song: null }
      return res.json() as Promise<SessionSongResponse>
    },
    enabled: enabled && !!planId,
    // Poll every 5s while generating
    refetchInterval: (query) => {
      const song = query.state.data?.song
      if (!song) return false
      if (song.status === 'completed' || song.status === 'failed') return false
      return 5000
    },
  })

  // Listen for Socket.IO instant notification
  useEffect(() => {
    if (!planId || !enabled) return

    const socket = createSocket()

    const eventName = `session-song:ready:${planId}`
    socket.on(eventName, () => {
      queryClient.invalidateQueries({
        queryKey: sessionSongKeys.forPlan(planId),
      })
    })

    return () => {
      socket.off(eventName)
      socket.disconnect()
    }
  }, [planId, enabled, queryClient])

  const song = query.data?.song ?? null
  const isGenerating = !!song && song.status !== 'completed' && song.status !== 'failed'
  const isReady = song?.status === 'completed'
  const hasFailed = song?.status === 'failed'

  return {
    song,
    isGenerating,
    isReady,
    hasFailed,
    failureKind: song?.failureKind ?? null,
    errorDetail: song?.errorDetail ?? null,
    viewerIsOwner: song?.viewerIsOwner ?? false,
    isLoading: query.isLoading,
  }
}