mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-11 16:38:50 +00:00
378 lines
9.5 KiB
TypeScript
378 lines
9.5 KiB
TypeScript
import type { MediaVideoRendition } from "@videojs/core";
|
|
import type { VideoPlayerStore } from "@videojs/core/dom";
|
|
import {
|
|
selectAudioTrack,
|
|
selectQuality,
|
|
selectTextTrack,
|
|
} from "@videojs/react";
|
|
import { stateMapper } from "./events.web";
|
|
import type {
|
|
CastStatus,
|
|
OmniPlayer,
|
|
PlayerStatus,
|
|
Rendition,
|
|
Track,
|
|
} from "./types/player";
|
|
import type { CastOptions, Source, Subtitle } from "./types/source";
|
|
|
|
export type SubtitleFormat = "vtt" | "ass" | "pgs" | "native";
|
|
|
|
export const getSubtitleFormat = (subtitle: {
|
|
mimeType?: string;
|
|
link: string;
|
|
}): SubtitleFormat => {
|
|
const mime = subtitle.mimeType?.toLowerCase() ?? "";
|
|
const ext = subtitle.link.split(/[?#]/)[0]?.split(".").pop()?.toLowerCase();
|
|
if (
|
|
mime.includes("ass") ||
|
|
mime.includes("ssa") ||
|
|
ext === "ass" ||
|
|
ext === "ssa"
|
|
)
|
|
return "ass";
|
|
if (mime.includes("pgs") || ext === "sup") return "pgs";
|
|
if (mime.includes("vtt") || ext === "vtt") return "vtt";
|
|
return "native";
|
|
};
|
|
|
|
export const isCustomSubtitle = (subtitle: {
|
|
mimeType?: string;
|
|
link: string;
|
|
}): boolean => {
|
|
const format = getSubtitleFormat(subtitle);
|
|
return format === "ass" || format === "pgs";
|
|
};
|
|
|
|
export class WebOmniPlayer implements OmniPlayer {
|
|
_store: VideoPlayerStore;
|
|
onPrev = new Set<() => void>();
|
|
onNext = new Set<() => void>();
|
|
|
|
constructor(store: VideoPlayerStore) {
|
|
this._store = store;
|
|
}
|
|
|
|
get castStatus(): CastStatus {
|
|
return stateMapper.castStatus.mapper(this._store.state);
|
|
}
|
|
|
|
toggleCastStatus(): void {
|
|
this._store.state.toggleRemotePlayback();
|
|
}
|
|
|
|
castOptions: CastOptions | null = null;
|
|
_source: Source | null = null;
|
|
private _showNotification = false;
|
|
|
|
// Selected ASS/PGS subtitle (drawn by the overlay); `null` when the active
|
|
// subtitle is native or off. Exposed as an external store so the view can
|
|
// react to selection changes.
|
|
private overlaySubtitle: Subtitle | null = null;
|
|
private overlayListeners = new Set<() => void>();
|
|
|
|
get source(): Source | null {
|
|
return this._source;
|
|
}
|
|
|
|
setSource(source?: Source): void {
|
|
this.source = source ?? null;
|
|
}
|
|
|
|
set source(source: Source | null) {
|
|
this._source = source;
|
|
// Drop the overlay subtitle if it is not part of the new source.
|
|
if (
|
|
this.overlaySubtitle &&
|
|
!source?.subtitles.some((s) => s.id === this.overlaySubtitle?.id)
|
|
) {
|
|
this.setOverlaySubtitle(null);
|
|
}
|
|
this.updateMediaSession();
|
|
}
|
|
|
|
get showNotification(): boolean {
|
|
return this._showNotification;
|
|
}
|
|
|
|
set showNotification(value: boolean) {
|
|
this._showNotification = value;
|
|
this.updateMediaSession();
|
|
}
|
|
|
|
get status(): PlayerStatus {
|
|
const state = this._store.state;
|
|
return stateMapper.status.mapper(state);
|
|
}
|
|
|
|
get isPlaying(): boolean {
|
|
return !this._store.state.paused && !this._store.state.ended;
|
|
}
|
|
|
|
get currentTime(): number {
|
|
return this._store.state.currentTime;
|
|
}
|
|
|
|
set currentTime(time: number) {
|
|
this._store.seek(time).catch(() => {});
|
|
}
|
|
|
|
get buffered(): number {
|
|
const buffered = this._store.state.buffered;
|
|
if (buffered.length === 0) return 0;
|
|
return buffered[buffered.length - 1]?.[1] ?? 0;
|
|
}
|
|
|
|
get duration(): number {
|
|
return this._store.state.duration;
|
|
}
|
|
|
|
get playbackRate(): number {
|
|
return this._store.state.playbackRate;
|
|
}
|
|
|
|
set playbackRate(rate: number) {
|
|
this._store.setPlaybackRate(rate);
|
|
}
|
|
|
|
get volume(): number {
|
|
return this._store.state.volume;
|
|
}
|
|
|
|
set volume(vol: number) {
|
|
this._store.setVolume(vol);
|
|
}
|
|
|
|
get muted(): boolean {
|
|
return this._store.state.muted;
|
|
}
|
|
|
|
set muted(value: boolean) {
|
|
if (value !== this.muted) {
|
|
this._store.toggleMuted();
|
|
}
|
|
}
|
|
|
|
get isAutoQuality(): boolean {
|
|
const quality = selectQuality(this._store.state);
|
|
if (!quality) return true;
|
|
// ABR ("auto") is on whenever no rendition is explicitly pinned.
|
|
return !quality.videoRenditionList.some((r) => r.selected);
|
|
}
|
|
|
|
play(): void {
|
|
this._store.play();
|
|
this.setPlaybackState("playing");
|
|
}
|
|
|
|
pause(): void {
|
|
this._store.pause();
|
|
this.setPlaybackState("paused");
|
|
}
|
|
|
|
seekBy(offset: number): void {
|
|
this._store.seek(this.currentTime + offset).catch(() => {});
|
|
}
|
|
|
|
playPrev(): void {
|
|
for (const cb of this.onPrev) cb();
|
|
}
|
|
|
|
playNext(): void {
|
|
for (const cb of this.onNext) cb();
|
|
}
|
|
|
|
get hasPrev(): boolean {
|
|
return this._source?.metadata?.hasPrev ?? false;
|
|
}
|
|
|
|
get hasNext(): boolean {
|
|
return this._source?.metadata?.hasNext ?? false;
|
|
}
|
|
|
|
get videos(): Track[] {
|
|
// hls.js does not support alternative video tracks (e.g. camera angles)
|
|
return [];
|
|
}
|
|
|
|
selectVideo(_video: Track): void {
|
|
// hls.js does not support alternative video tracks
|
|
}
|
|
|
|
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,
|
|
}));
|
|
}
|
|
|
|
selectAudio(audio: Track): void {
|
|
const tracks = selectAudioTrack(this._store.state);
|
|
if (!tracks) return;
|
|
tracks.selectAudioTrack(audio.id);
|
|
}
|
|
|
|
private get overlaySubtitles(): Subtitle[] {
|
|
return (this._source?.subtitles ?? []).filter(isCustomSubtitle);
|
|
}
|
|
|
|
get subtitles(): Track[] {
|
|
const textTracks = selectTextTrack(this._store.state)?.textTrackList ?? [];
|
|
const native = textTracks
|
|
.filter((x) => x.kind === "subtitles" || x.kind === "captions")
|
|
.map((track) => ({
|
|
id: track.id!,
|
|
label: track.label,
|
|
language: track.language,
|
|
selected: track.mode === "showing",
|
|
}));
|
|
const overlay = this.overlaySubtitles.map((sub) => ({
|
|
id: sub.id,
|
|
label: sub.label,
|
|
language: sub.language,
|
|
selected: this.overlaySubtitle?.id === sub.id,
|
|
}));
|
|
return [...native, ...overlay];
|
|
}
|
|
|
|
selectSubtitle(subtitle?: Track): void {
|
|
const overlay = subtitle
|
|
? this.overlaySubtitles.find((s) => s.id === subtitle.id)
|
|
: undefined;
|
|
|
|
const tracks = selectTextTrack(this._store.state);
|
|
tracks?.selectSubtitlesTrack(overlay || !subtitle ? "off" : subtitle.id);
|
|
this.setOverlaySubtitle(overlay ?? null);
|
|
|
|
if (this.castStatus === "connected") {
|
|
try {
|
|
// if the cast receiver handles overlay subtitles like us, send this message.
|
|
const ctx = (
|
|
window as unknown as {
|
|
// biome-ignore lint/suspicious/noExplicitAny: cast sender SDK global
|
|
cast?: { framework?: { CastContext?: { getInstance(): any } } };
|
|
}
|
|
).cast?.framework?.CastContext?.getInstance?.();
|
|
ctx
|
|
?.getCurrentSession?.()
|
|
?.sendMessage("urn:x-cast:dev.zoriya.omni", {
|
|
subtitle: overlay?.id ?? null,
|
|
})
|
|
?.catch?.(() => {});
|
|
} catch {
|
|
// no active cast session / sender SDK not loaded
|
|
}
|
|
}
|
|
}
|
|
|
|
private setOverlaySubtitle(sub: Subtitle | null): void {
|
|
if (this.overlaySubtitle === sub) return;
|
|
this.overlaySubtitle = sub;
|
|
for (const listener of this.overlayListeners) listener();
|
|
}
|
|
|
|
// External store used by the view to render the ASS/PGS overlay.
|
|
subscribeOverlaySubtitle = (callback: () => void): (() => void) => {
|
|
this.overlayListeners.add(callback);
|
|
return () => this.overlayListeners.delete(callback);
|
|
};
|
|
|
|
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),
|
|
}));
|
|
}
|
|
|
|
selectRendition(rendition?: Rendition): void {
|
|
const tracks = selectQuality(this._store.state);
|
|
if (!tracks) return;
|
|
tracks.selectVideoRendition(rendition ? rendition.id : "auto");
|
|
}
|
|
|
|
private setPlaybackState(state: MediaSessionPlaybackState): void {
|
|
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) {
|
|
return;
|
|
}
|
|
if (this._showNotification) navigator.mediaSession.playbackState = state;
|
|
}
|
|
|
|
private updateMediaSession(): void {
|
|
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) {
|
|
return;
|
|
}
|
|
const session = navigator.mediaSession;
|
|
const actions: MediaSessionAction[] = [
|
|
"play",
|
|
"pause",
|
|
"seekbackward",
|
|
"seekforward",
|
|
"seekto",
|
|
"previoustrack",
|
|
"nexttrack",
|
|
];
|
|
|
|
if (!this._showNotification) {
|
|
session.metadata = null;
|
|
for (const action of actions) {
|
|
try {
|
|
session.setActionHandler(action, null);
|
|
} catch {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const metadata = this._source?.metadata;
|
|
if (metadata && typeof MediaMetadata !== "undefined") {
|
|
session.metadata = new MediaMetadata({
|
|
title: metadata.title,
|
|
artist: metadata.artist ?? "",
|
|
album: metadata.album ?? "",
|
|
artwork: metadata.imageLink ? [{ src: metadata.imageLink }] : [],
|
|
});
|
|
}
|
|
|
|
const set = (
|
|
action: MediaSessionAction,
|
|
handler: MediaSessionActionHandler | null,
|
|
) => {
|
|
try {
|
|
session.setActionHandler(action, handler);
|
|
} catch {}
|
|
};
|
|
set("play", () => this.play());
|
|
set("pause", () => this.pause());
|
|
set("seekbackward", (d) => this.seekBy(-(d.seekOffset ?? 10)));
|
|
set("seekforward", (d) => this.seekBy(d.seekOffset ?? 10));
|
|
set("seekto", (d) => {
|
|
if (d.seekTime != null) this.currentTime = d.seekTime;
|
|
});
|
|
set("previoustrack", this.hasPrev ? () => this.playPrev() : null);
|
|
set("nexttrack", this.hasNext ? () => this.playNext() : null);
|
|
}
|
|
}
|