import { useSyncExternalStore, useRef, useCallback } from 'react'; import { useAviationPlayer } from '../AviationContext'; import type { MediaItem } from '../specs/MediaItem.nitro'; import type { RepeatMode } from '../specs/types.nitro'; import type { AviationPlayer } from '../Aviation'; /** * Snapshot of the queue state from an AviationPlayer. */ export interface QueueStateInfo { /** Ordered list of items in the queue. */ items: MediaItem[]; /** Index of the currently playing item in the queue (-1 if none). */ currentIndex: number; /** Total number of items in the queue. */ count: number; /** Current repeat mode. */ repeatMode: RepeatMode; /** Whether shuffle is enabled. */ shuffleEnabled: boolean; } type QueueStateSnapshot = QueueStateInfo & { queueRevision: number; }; const IDLE_QUEUE: QueueStateSnapshot = { items: [], currentIndex: -1, count: 0, repeatMode: 'off', shuffleEnabled: false, queueRevision: 0, }; /** * React hook that subscribes to queue state changes from an AviationPlayer. * * Re-renders when the queue index, items, or configuration changes. * Uses `useSyncExternalStore` for tear-free reads. * * Reads the nearest provider player unless a player is passed explicitly. * * @example * ```tsx * function QueueDisplay() { * const { items, currentIndex, repeatMode } = useQueueState(); * return ( * ( * * {item.title} * * )} * /> * ); * } * ``` */ export function useQueueState(playerArg?: AviationPlayer): QueueStateInfo { const player = useAviationPlayer(playerArg); const store = player.store; const ref = useRef(IDLE_QUEUE); const subscribeQueue = useCallback( (cb: () => void): (() => void) => { const unsubs = [ store.subscribeQueue(cb), store.subscribeCurrentItem(cb), store.subscribeCastState(cb), ]; return () => unsubs.forEach((u) => u()); }, [store] ); const getSnapshot = () => { const engine = player.tryGetEngine(); if (!engine) return IDLE_QUEUE; // Read currentIndex from the store, not from engine.queueIndex. // // The store is updated by the wired onCurrentItemChange callback // and the cast queue-item-changed handler, so it's the same source // of truth our subscription listens to. Using engine.queueIndex // here would be reading a different signal (the native sync // getter) than the one driving re-renders, so when the callback // chain has any hiccup the hook stays at its initial cached value // even though a fresh engine.queueIndex read would have the // correct number. const currentIndex = store.queueIndex; const count = engine.queueCount; const repeatMode = engine.repeatMode; const shuffleEnabled = engine.shuffleEnabled; const queueRevision = store.queueRevision; const prev = ref.current; // Compare scalar fields and store.queueRevision first. Avoid calling // engine.queueItems (which returns a new array from native every time) // unless the queue actually changed. if ( prev.currentIndex === currentIndex && prev.count === count && prev.repeatMode === repeatMode && prev.shuffleEnabled === shuffleEnabled && prev.queueRevision === queueRevision ) { return prev; } ref.current = { items: engine.queueItems, currentIndex, count, repeatMode, shuffleEnabled, queueRevision, }; return ref.current; }; return useSyncExternalStore(subscribeQueue, getSnapshot); }