import { useSyncExternalStore, useRef, useCallback } from 'react'; import { useAviationPlayer } from '../AviationContext'; import { subscribePlaybackChannels, playbackFlags } from './playbackSnapshot'; import type { AviationPlayer } from '../Aviation'; import type { AviationError } from '../errors'; import type { PlaybackState, ItemSource } from '../specs/types.nitro'; import type { MediaItem } from '../specs/MediaItem.nitro'; /** * Snapshot of the current playback state for an AviationPlayer. */ export interface PlaybackInfo { /** Current playback state (idle, loading, ready, playing, paused, buffering, stopped, error). */ state: PlaybackState; /** Whether the engine is currently playing audio/video. */ isPlaying: boolean; /** Whether playback is paused. */ isPaused: boolean; /** Whether the engine has loaded content and is ready to play. */ isLoaded: boolean; /** Whether the engine is in a buffering state. */ isBuffering: boolean; /** Whether the current media is a live stream. */ isLive: boolean; /** The currently loaded media item (or undefined). */ currentItem: MediaItem | undefined; /** How the current item was loaded (queue, direct, external, none). */ currentItemSource: ItemSource; /** * Last playback error, if state is 'error'. * * Contains a typed `code` for programmatic handling, the original * `message` string, and an `isRetryable` hint. * * @example * ```ts * if (error?.code === 'NETWORK_ERROR' && error.isRetryable) { * showRetryButton(); * } * ``` */ error: AviationError | undefined; } const IDLE_INFO: PlaybackInfo = { state: 'idle', isPlaying: false, isPaused: false, isLoaded: false, isBuffering: false, isLive: false, currentItem: undefined, currentItemSource: 'none', error: undefined, }; // Stable subscribe function — must NOT be recreated on every render // or useSyncExternalStore will infinite-loop. // // We subscribe to position too because `getSnapshot` derives `isLive` from // `store.mediaPosition.isLive`. Without the position subscription, an // on-demand → live transition wouldn't trigger a re-evaluation until the // next state/item/error change. /** * React hook that subscribes to a player's playback state. * * Uses `useSyncExternalStore` for tear-free reads. Re-renders only when * state, current item, or error actually changes. * * Reads the nearest {@link AviationProvider} by default. Pass a player * explicitly to observe a specific player outside provider scope. * * @example * ```tsx * function PlayerControls() { * const { state, isPlaying, currentItem } = usePlayback(); * return {isPlaying ? 'Playing' : state} - {currentItem?.title}; * } * ``` * * --- * */ export function usePlayback(playerArg?: AviationPlayer): PlaybackInfo { const player = useAviationPlayer(playerArg); const store = player.store; const ref = useRef(IDLE_INFO); const subscribePlayback = useCallback( (cb: () => void) => subscribePlaybackChannels(store, cb), [store] ); const getSnapshot = () => { const state = store.playbackState; const item = store.currentItem; const source = store.currentItemSource; const error = store.error; const isLive = store.mediaPosition.isLive; const prev = ref.current; if ( prev.state === state && prev.currentItem === item && prev.currentItemSource === source && prev.error === error && prev.isLive === isLive ) { return prev; } ref.current = { state, ...playbackFlags(state), isLive, currentItem: item, currentItemSource: source, error, }; return ref.current; }; return useSyncExternalStore(subscribePlayback, getSnapshot); }