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