feat(chromecast): send custom message for overlay subtitles

This commit is contained in:
2026-07-21 19:28:36 +02:00
parent e6a06b13a4
commit a335509b4e
15 changed files with 366 additions and 241 deletions
+22 -1
View File
@@ -126,6 +126,27 @@ export const useEvent = <Event extends keyof OmniEvents>(
}
return undefined;
}, [event, player]);
// ASS/PGS (overlay) subtitle selection is not a videojs text-track change, so
// re-fire subtitleChange when the overlay subtitle changes too — consumers
// only need useEvent("subtitleChange").
useEffect(() => {
if (event !== "subtitleChange") return;
return player.subscribeOverlaySubtitle(() => {
const sel = player.subtitles.find((s) => s.selected);
const cb = callbackRef.current as (s: unknown) => void;
cb(
sel
? {
id: sel.id,
label: sel.label,
language: sel.language,
selected: true,
}
: undefined,
);
});
}, [event, player]);
};
function createMapper<Key extends keyof OmniPlayerState, State, Result>(
@@ -182,7 +203,7 @@ export const stateMapper = {
if (!r) return "unavailable";
if (r.remotePlaybackState === "connecting") return "connecting";
if (r.remotePlaybackState === "connected") return "connected";
return r.remotePlaybackAvailability
return r.remotePlaybackAvailability;
}),
};
+20
View File
@@ -242,6 +242,26 @@ export class WebOmniPlayer implements OmniPlayer {
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 {
+2
View File
@@ -7,6 +7,8 @@ export interface Source {
fonts?: string[];
metadata?: Metadata;
mixAudio?: MixAudioMode;
// contentId of a cast request. defaults to src but can be overridden here.
castId?: string;
// Opaque, consumer-defined payload forwarded verbatim to the cast receiver
// as the load request's customData. omni does not interpret it.
castData?: Record<string, string>;
+48 -33
View File
@@ -9,6 +9,7 @@ import {
useRef,
useSyncExternalStore,
} from "react";
import { usePlayerState } from "./events";
import {
getSubtitleFormat,
isCustomSubtitle,
@@ -58,35 +59,41 @@ const SubtitleOverlay = ({
const renditions = player.rendition;
const videoWidth = Math.max(0, ...renditions.map((r) => r.width));
const videoHeight = Math.max(0, ...renditions.map((r) => r.height));
import("jassub").then(({ default: JASSUB }) => {
const instance = new JASSUB({
video: el,
subUrl: subtitle.link,
...(videoWidth && { videoWidth }),
...(videoHeight && { videoHeight }),
...(fonts?.length && { fonts }),
...(jassub?.workerUrl && { workerUrl: jassub.workerUrl }),
...(jassub?.wasmUrl && { wasmUrl: jassub.wasmUrl }),
...(jassub?.modernWasmUrl && { modernWasmUrl: jassub.modernWasmUrl }),
...(jassub?.fontUrl && {
availableFonts: { "liberation sans": jassub.fontUrl },
defaultFont: "liberation sans",
}),
});
attach({ destroy: () => instance.destroy() });
});
import("jassub")
.then(({ default: JASSUB }) => {
const instance = new JASSUB({
video: el,
subUrl: subtitle.link,
...(videoWidth && { videoWidth }),
...(videoHeight && { videoHeight }),
...(fonts?.length && { fonts }),
...(jassub?.workerUrl && { workerUrl: jassub.workerUrl }),
...(jassub?.wasmUrl && { wasmUrl: jassub.wasmUrl }),
...(jassub?.modernWasmUrl && {
modernWasmUrl: jassub.modernWasmUrl,
}),
...(jassub?.fontUrl && {
availableFonts: { "liberation sans": jassub.fontUrl },
defaultFont: "liberation sans",
}),
});
attach({ destroy: () => instance.destroy() });
})
.catch((e) => console.error("[omni] failed to render ass subtitle", e));
} else {
const pgs = assetsRef.current?.pgs;
import("libpgs").then(({ PgsRenderer }) => {
const instance = new PgsRenderer({
video: el,
subUrl: subtitle.link,
workerUrl:
pgs?.workerUrl ??
new URL("libpgs/dist/libpgs.worker.js", import.meta.url).href,
});
attach({ destroy: () => instance.dispose() });
});
import("libpgs")
.then(({ PgsRenderer }) => {
const instance = new PgsRenderer({
video: el,
subUrl: subtitle.link,
workerUrl:
pgs?.workerUrl ??
new URL("libpgs/dist/libpgs.worker.js", import.meta.url).href,
});
attach({ destroy: () => instance.dispose() });
})
.catch((e) => console.error("[omni] failed to render pgs subtitle", e));
}
return () => {
@@ -116,6 +123,7 @@ export const OmniView = ({
const headersRef = useRef(src?.headers);
headersRef.current = src?.headers;
const castId = player.source?.castId;
const castData = player.source?.castData;
const config = useMemo<HlsMediaConfig>(
@@ -132,11 +140,16 @@ export const OmniView = ({
googleCast: {
receiver: player.castOptions?.receiverApplicationId,
customData: castData ?? null,
...(castId && { src: castId }),
},
}),
[player.castOptions, castData],
[player.castOptions, castData, castId],
);
// While casting, the receiver renders subtitles (the player forwards the
// selection); skip rendering them locally on the idle <video>.
const castStatus = usePlayerState("castStatus");
return (
<VideoPlayer.Container
ref={containerRef}
@@ -166,11 +179,13 @@ export const OmniView = ({
))}
</Tech>
)}
<SubtitleOverlay
video={ref}
assets={subtitleAssets}
fonts={player.source?.fonts}
/>
{castStatus !== "connected" && castStatus !== "connecting" && (
<SubtitleOverlay
video={ref}
assets={subtitleAssets}
fonts={player.source?.fonts}
/>
)}
</VideoPlayer.Container>
);
};