feat(state): add state for videos/audios/sub/rend

This commit is contained in:
2026-08-03 19:10:45 +02:00
parent a44cbfbdea
commit 685d3beb2a
28 changed files with 909 additions and 268 deletions
+8
View File
@@ -60,6 +60,14 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
case "source":
em.addSourceListener(setState);
return () => em.removeSourceListener(setState);
case "videos":
case "audios":
case "subtitles":
em.addTracksListener(key, setState);
return () => em.removeTracksListener(key, setState);
case "renditions":
em.addRenditionsListener(setState);
return () => em.removeRenditionsListener(setState);
}
}, [player, key]);
+56 -12
View File
@@ -202,6 +202,32 @@ export const stateMapper = {
if (r.remotePlaybackState === "connected") return "connected";
return r.remotePlaybackAvailability;
}),
...createMapper("audios", selectAudioTrack, (audio) => {
if (!audio) return [];
return audio.audioTrackList.map((track, i) => ({
id: track.id ?? i.toString(),
label: track.label,
language: track.language,
selected: track.enabled,
}));
}),
...createMapper("renditions", selectQuality, (quality) => {
if (!quality) return [];
const active = quality.activeVideoRendition;
return quality.videoRenditionList.map((rendition, i) => ({
id: rendition.id ?? i.toString(),
width: rendition.width ?? 0,
height: rendition.height ?? 0,
bitrate: rendition.bitrate ?? 0,
selected:
rendition.selected ||
(active != null &&
rendition.id === active.id &&
rendition.width === active.width &&
rendition.height === active.height &&
rendition.bitrate === active.bitrate),
}));
}),
};
// biome-ignore-start lint/correctness/useHookAtTopLevel: key must be const
@@ -216,18 +242,36 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
key: Key,
_refresh?: number,
): OmniPlayerState[Key] {
if (key === "source") {
const player = usePlayer() as WebOmniPlayer;
const source = useSyncExternalStore(
player.subscribeSource,
() => player.source,
() => player.source,
);
return source as OmniPlayerState[Key];
switch (key as keyof OmniPlayerState) {
case "source": {
const player = usePlayer() as WebOmniPlayer;
const source = useSyncExternalStore(
player.subscribeSource,
() => player.source,
() => player.source,
);
return source as OmniPlayerState[Key];
}
case "videos": {
const player = usePlayer();
return player.videos as OmniPlayerState[Key];
}
case "subtitles": {
const player = usePlayer() as WebOmniPlayer;
useStoreSelector(selectTextTrack);
useSyncExternalStore(
player.subscribeOverlaySubtitle,
player.getOverlaySubtitle,
() => null,
);
return player.subtitles as OmniPlayerState[Key];
}
default: {
const config = stateMapper[key];
const ret = useStoreSelector(config?.selector as Selector<any, any>);
if (!config) throw new Error(`No mapper for ${key}`);
return config.mapper(ret) as OmniPlayerState[Key];
}
}
const config = stateMapper[key as keyof Omit<OmniPlayerState, "source">];
const ret = useStoreSelector(config?.selector as Selector<any, any>);
if (!config) throw new Error(`No mapper for ${key}`);
return config.mapper(ret) as OmniPlayerState[Key];
}
// biome-ignore-end lint/correctness/useHookAtTopLevel: key must be const
+3 -34
View File
@@ -1,4 +1,3 @@
import type { MediaVideoRendition } from "@videojs/core";
import type { VideoPlayerStore } from "@videojs/core/dom";
import {
selectAudioTrack,
@@ -76,7 +75,6 @@ export class WebOmniPlayer implements OmniPlayer {
return () => this.sourceListeners.delete(callback);
};
get source(): Source | undefined {
return this._source;
}
@@ -203,14 +201,7 @@ export class WebOmniPlayer implements OmniPlayer {
}
get audios(): Track[] {
const audio = selectAudioTrack(this._store.state);
if (!audio) return [];
return audio.audioTrackList.map((track, i) => ({
id: track.id ?? i.toString(),
label: track.label,
language: track.language,
selected: track.enabled,
}));
return stateMapper.audios.mapper(selectAudioTrack(this._store.state));
}
selectAudio(audio: Track): void {
@@ -266,30 +257,8 @@ export class WebOmniPlayer implements OmniPlayer {
getOverlaySubtitle = (): Subtitle | null => this.overlaySubtitle;
get rendition(): Rendition[] {
function isSameRendition(
a: MediaVideoRendition,
b: MediaVideoRendition | null,
): boolean {
return (
b != null &&
a.id === b.id &&
a.width === b.width &&
a.height === b.height &&
a.bitrate === b.bitrate
);
}
const quality = selectQuality(this._store.state);
if (!quality) return [];
const active = quality.activeVideoRendition;
return quality.videoRenditionList.map((rendition, i) => ({
id: rendition.id ?? i.toString(),
width: rendition.width ?? 0,
height: rendition.height ?? 0,
bitrate: rendition.bitrate ?? 0,
selected: rendition.selected || isSameRendition(rendition, active),
}));
get renditions(): Rendition[] {
return stateMapper.renditions.mapper(selectQuality(this._store.state));
}
selectRendition(rendition?: Rendition): void {
+17 -1
View File
@@ -6,14 +6,24 @@ import type {
OmniPlayer as OmniPlayerT,
PlayerBackend,
PlayerStatus,
Rendition,
Track,
} from "../types/player";
import type { CastOptions, Source } from "../types/source";
export type NumberProperty = Exclude<
keyof OmniPlayerState,
"status" | "isPlaying" | "muted" | "source"
| "status"
| "isPlaying"
| "muted"
| "source"
| "videos"
| "audios"
| "subtitles"
| "renditions"
>;
export type BoolProperty = "isPlaying" | "muted" | "isAutoQuality";
export type TrackProperty = "videos" | "audios" | "subtitles";
export interface OmniEventMap extends HybridObject<{ android: "kotlin" }> {
addStateListener(key: NumberProperty, cb: (value: number) => void): void;
@@ -32,6 +42,12 @@ export interface OmniEventMap extends HybridObject<{ android: "kotlin" }> {
addSourceListener(cb: (value?: Source) => void): void;
removeSourceListener(cb: (value?: Source) => void): void;
addTracksListener(key: TrackProperty, cb: (value: Track[]) => void): void;
removeTracksListener(key: TrackProperty, cb: (value: Track[]) => void): void;
addRenditionsListener(cb: (value: Rendition[]) => void): void;
removeRenditionsListener(cb: (value: Rendition[]) => void): void;
addOnEndListener(cb: OmniEvents["end"]): void;
removeOnEndListener(cb: OmniEvents["end"]): void;
+5 -4
View File
@@ -15,13 +15,9 @@ export interface OmniPlayer extends OmniPlayerState {
readonly hasPrev: boolean;
readonly hasNext: boolean;
readonly videos: Track[];
selectVideo(video: Track): void;
readonly audios: Track[];
selectAudio(audio: Track): void;
readonly subtitles: Track[];
selectSubtitle(subtitle?: Track): void;
readonly rendition: Rendition[];
selectRendition(rendition?: Rendition): void;
}
@@ -38,6 +34,11 @@ export interface OmniPlayerState {
muted: boolean;
readonly isAutoQuality: boolean;
readonly castStatus: CastStatus;
readonly videos: Track[];
readonly audios: Track[];
readonly subtitles: Track[];
readonly renditions: Rendition[];
}
export type PlayerStatus = "idle" | "loading" | "readyToPlay" | "error";
+1 -1
View File
@@ -52,7 +52,7 @@ const SubtitleOverlay = ({
// jassub uses this as libass' storage size so subtitles stay sharp
// even while a low-bitrate rendition is playing — otherwise the
// raster tracks the currently-decoded (possibly tiny) frame size.
const renditions = player.rendition;
const renditions = player.renditions;
const videoWidth = Math.max(0, ...renditions.map((r) => r.width));
const videoHeight = Math.max(0, ...renditions.map((r) => r.height));
import("jassub")