All files / web/src/hooks useSessionSong.ts

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

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 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155                                                                                                                                                                                                                                                                                                                     
'use client'

/**
 * React Query hook for session song status.
 *
 * Sync model (post #154): socket-only.
 *
 * - One fetch on initial mount (React Query default).
 * - The server emits `session-song:phase:<planId>` on every status transition
 *   and `session-song:ready:<planId>` on completion. Either event invalidates
 *   the query so we refetch the current truth.
 * - On socket (re)connect, invalidate once to reconcile anything we missed
 *   while disconnected. NOT a polling safety net — a bounded sync.
 * - `connectionState` is exposed so the UI can show "reconnecting" /
 *   "lost" states rather than silently masking a broken channel.
 */

import { useCallback, useEffect, useRef, useState } 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
}

export type SessionSongConnectionState = 'connected' | 'reconnecting' | 'lost'

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

/** Time disconnected before we surface 'lost' to the UI. */
const CONNECTION_LOST_THRESHOLD_MS = 30_000

export function useSessionSong({ playerId, planId, enabled = true }: UseSessionSongOptions) {
  const queryClient = useQueryClient()
  const [connectionState, setConnectionState] = useState<SessionSongConnectionState>('connected')
  const socketRef = useRef<ReturnType<typeof createSocket> | null>(null)

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

  useEffect(() => {
    if (!planId || !enabled) return

    const socket = createSocket()
    socketRef.current = socket

    let lostTimer: ReturnType<typeof setTimeout> | null = null
    const clearLostTimer = () => {
      if (lostTimer) {
        clearTimeout(lostTimer)
        lostTimer = null
      }
    }

    const invalidate = () => {
      queryClient.invalidateQueries({ queryKey: sessionSongKeys.forPlan(planId) })
    }

    const handleConnect = () => {
      clearLostTimer()
      setConnectionState('connected')
      // Reconciliation fetch — catches up on any phase/ready events that
      // fired while we were offline.
      invalidate()
    }

    const handleDisconnect = () => {
      clearLostTimer()
      setConnectionState('reconnecting')
      lostTimer = setTimeout(() => setConnectionState('lost'), CONNECTION_LOST_THRESHOLD_MS)
    }

    const phaseEvent = `session-song:phase:${planId}`
    const readyEvent = `session-song:ready:${planId}`

    socket.on('connect', handleConnect)
    socket.on('disconnect', handleDisconnect)
    socket.on(phaseEvent, invalidate)
    socket.on(readyEvent, invalidate)

    if (socket.connected) setConnectionState('connected')

    return () => {
      clearLostTimer()
      socket.off('connect', handleConnect)
      socket.off('disconnect', handleDisconnect)
      socket.off(phaseEvent, invalidate)
      socket.off(readyEvent, invalidate)
      socket.disconnect()
      socketRef.current = null
    }
  }, [planId, enabled, queryClient])

  const reconnect = useCallback(() => {
    const socket = socketRef.current
    if (!socket) return
    setConnectionState('reconnecting')
    socket.connect()
  }, [])

  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,
    connectionState,
    reconnect,
  }
}