import { useSyncExternalStore, useRef, useCallback } from 'react';
import { useAviationPlayer } from '../AviationContext';
import { subscribePlaybackChannels, playbackFlags } from './playbackSnapshot';
import type { AviationError } from '../errors';
import type { PlaybackState, ItemSource } from '../specs/types.nitro';
import type { MediaItem } from '../specs/MediaItem.nitro';
import type { MediaItemConfig } from '../specs/types.nitro';
import type { AviationPlayer } from '../Aviation';
/**
* Scoped playback state for a specific media item.
*
* All fields mirror {@link PlaybackInfo} but `isOwner` indicates whether
* the given item is currently loaded on the engine. When `isOwner` is false,
* all state fields reflect idle/false — the engine may be active with
* different content but that state is not exposed here.
*/
export interface ScopedPlaybackInfo {
/** Whether this item is the one currently loaded on the engine. */
isOwner: boolean;
/** Current playback state. `'idle'` when this item is not the active one. */
state: PlaybackState;
/** Whether this item is currently playing. */
isPlaying: boolean;
/** Whether this item is paused. */
isPaused: boolean;
/** Whether this item is loaded and ready. */
isLoaded: boolean;
/** Whether this item is buffering. */
isBuffering: boolean;
/** Whether the current media is a live stream. */
isLive: boolean;
/** How this item was loaded (queue, direct, external, none). */
currentItemSource: ItemSource;
/** Last playback error for this item, if state is 'error'. */
error: AviationError | undefined;
}
const IDLE_SCOPED: ScopedPlaybackInfo = {
isOwner: false,
state: 'idle',
isPlaying: false,
isPaused: false,
isLoaded: false,
isBuffering: false,
isLive: false,
currentItemSource: 'none',
error: undefined,
};
/**
* Item identifier accepted by {@link usePlaybackFor}.
*
* Either a {@link MediaItem} HybridObject, a {@link MediaItemConfig} plain
* object, or anything carrying a `uri` string. Ownership is decided by URI
* (with a reference-equality fast path), so callers don't need to keep a
* stable HybridObject reference across renders for this hook to work.
*/
export type PlaybackItemRef = MediaItem | MediaItemConfig | { uri: string };
/**
* Scoped variant of {@link usePlayback} that only reflects player state when
* the given item is the currently active one.
*
* @param item - The media item to scope to. Accepts a `MediaItem`
* HybridObject, a `MediaItemConfig`, or any object with a `uri` field.
* Pass `undefined` to get idle state.
*
* @example
* ```tsx
* function AudioPlayerControls({ track }: { track: MediaItemConfig }) {
* const { isOwner, isPlaying } = usePlaybackFor(track);
*
* // isPlaying is only true when THIS track is the engine's current
* // content — starting a video won't flip this to true.
* return ;
* }
* ```
*
* @remarks
* Ownership matches when:
* 1. The passed `item` is the same JS reference as the engine's
* `currentItem` (cheap fast path), OR
* 2. They have the same `uri` string.
*
* The URI fallback is what makes this hook robust to the Nitro bridge
* not preserving JS-side reference identity for HybridObjects.
*/
export function usePlaybackFor(
item: PlaybackItemRef | undefined,
playerArg?: AviationPlayer
): ScopedPlaybackInfo {
const player = useAviationPlayer(playerArg);
const store = player.store;
const ref = useRef(IDLE_SCOPED);
const subscribePlayback = useCallback(
(cb: () => void) => subscribePlaybackChannels(store, cb),
[store]
);
const getSnapshot = () => {
const currentItem = store.currentItem;
const itemUri =
item !== undefined ? (item as { uri?: string }).uri : undefined;
const currentUri = currentItem !== undefined ? currentItem.uri : undefined;
const isOwner =
item !== undefined &&
currentItem !== undefined &&
(currentItem === (item as unknown as MediaItem) ||
(itemUri !== undefined && itemUri === currentUri));
if (!isOwner) {
// Not the active item — return stable idle state.
if (ref.current.isOwner === false) return ref.current;
ref.current = IDLE_SCOPED;
return ref.current;
}
// This item is active — reflect full engine state.
const state = store.playbackState;
const source = store.currentItemSource;
const error = store.error;
const isLive = store.mediaPosition.isLive;
const prev = ref.current;
if (
prev.isOwner &&
prev.state === state &&
prev.currentItemSource === source &&
prev.error === error &&
prev.isLive === isLive
) {
return prev;
}
ref.current = {
isOwner: true,
state,
...playbackFlags(state),
isLive,
currentItemSource: source,
error,
};
return ref.current;
};
return useSyncExternalStore(subscribePlayback, getSnapshot);
}