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
+1 -1
View File
@@ -1 +1 @@
module.exports = require('./plugin/build').default;
module.exports = require("./plugin/build").default;
+2 -2
View File
@@ -88,7 +88,7 @@ function PlayerExample({
useEffect(() => {
refreshTracks();
}, [refreshTracks, trackLabel]);
}, [refreshTracks]);
const pushLog = useCallback((message: string) => {
setLogs((prev) => {
@@ -194,7 +194,7 @@ function PlayerExample({
const cyclePlaybackRate = () => {
const rates = [0.75, 1, 1.25, 1.5, 2];
const index = rates.findIndex((rate) => rate === playbackRate);
const index = rates.indexOf(playbackRate);
const nextIndex = (index + 1) % rates.length;
player.playbackRate = rates[nextIndex];
};
+5 -1
View File
@@ -15,7 +15,11 @@ const config = {
...(useWeb && {
resolver: {
extraNodeModules: {
"react-native": path.resolve(__dirname, "node_modules", "react-native-web"),
"react-native": path.resolve(
__dirname,
"node_modules",
"react-native-web",
),
},
},
}),
+1 -1
View File
@@ -1,4 +1,4 @@
const path = require("path");
const path = require("node:path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const appDirectory = path.resolve(__dirname);
+6 -6
View File
@@ -1,8 +1,8 @@
import { createRunOncePlugin } from '@expo/config-plugins';
import type { ConfigPlugin } from '@expo/config-plugins';
import pkg from '../../package.json';
import { withMediaNotifications } from './withMediaNotifications';
import { withPip } from './withPip';
import type { ConfigPlugin } from "@expo/config-plugins";
import { createRunOncePlugin } from "@expo/config-plugins";
import pkg from "../../package.json";
import { withMediaNotifications } from "./withMediaNotifications";
import { withPip } from "./withPip";
const withOmni: ConfigPlugin = (config) => {
let nextConfig = withPip(config);
@@ -12,4 +12,4 @@ const withOmni: ConfigPlugin = (config) => {
export default createRunOncePlugin(withOmni, pkg.name, pkg.version);
export { withPip, withMediaNotifications };
export { withMediaNotifications, withPip };
+37 -20
View File
@@ -1,51 +1,60 @@
import { AndroidConfig, withAndroidManifest, type ConfigPlugin } from '@expo/config-plugins';
import {
AndroidConfig,
type ConfigPlugin,
withAndroidManifest,
} from "@expo/config-plugins";
function ensureUsesPermission(
androidManifest: AndroidConfig.Manifest.AndroidManifest,
permission: string
permission: string,
): void {
const usesPermissions = androidManifest.manifest['uses-permission'] ?? [];
const alreadyDefined = usesPermissions.some((item) => item.$['android:name'] === permission);
const usesPermissions = androidManifest.manifest["uses-permission"] ?? [];
const alreadyDefined = usesPermissions.some(
(item) => item.$["android:name"] === permission,
);
if (!alreadyDefined) {
usesPermissions.push({
$: {
'android:name': permission,
"android:name": permission,
},
});
}
androidManifest.manifest['uses-permission'] = usesPermissions;
androidManifest.manifest["uses-permission"] = usesPermissions;
}
function patchManifestForMediaNotifications(
androidManifest: AndroidConfig.Manifest.AndroidManifest
androidManifest: AndroidConfig.Manifest.AndroidManifest,
): AndroidConfig.Manifest.AndroidManifest {
const mainApplication = AndroidConfig.Manifest.getMainApplication(androidManifest);
const mainApplication =
AndroidConfig.Manifest.getMainApplication(androidManifest);
if (!mainApplication) {
console.warn(
'AndroidManifest.xml is missing a <application android:name=".MainApplication" /> element - skipping Omni media service config.'
'AndroidManifest.xml is missing a <application android:name=".MainApplication" /> element - skipping Omni media service config.',
);
return androidManifest;
}
const services = mainApplication.service ?? [];
const serviceName = 'dev.zoriya.omni.OmniPlayerService';
const hasService = services.some((service) => service.$['android:name'] === serviceName);
const serviceName = "dev.zoriya.omni.OmniPlayerService";
const hasService = services.some(
(service) => service.$["android:name"] === serviceName,
);
if (!hasService) {
services.push({
$: {
'android:name': serviceName,
'android:enabled': 'true',
'android:exported': 'true',
'android:foregroundServiceType': 'mediaPlayback',
"android:name": serviceName,
"android:enabled": "true",
"android:exported": "true",
"android:foregroundServiceType": "mediaPlayback",
},
'intent-filter': [
"intent-filter": [
{
action: [
{
$: {
'android:name': 'androidx.media3.session.MediaSessionService',
"android:name": "androidx.media3.session.MediaSessionService",
},
},
],
@@ -55,14 +64,22 @@ function patchManifestForMediaNotifications(
}
mainApplication.service = services;
ensureUsesPermission(androidManifest, 'android.permission.FOREGROUND_SERVICE');
ensureUsesPermission(androidManifest, 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK');
ensureUsesPermission(
androidManifest,
"android.permission.FOREGROUND_SERVICE",
);
ensureUsesPermission(
androidManifest,
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
);
return androidManifest;
}
export const withMediaNotifications: ConfigPlugin = (config) => {
return withAndroidManifest(config, (manifestConfig) => {
manifestConfig.modResults = patchManifestForMediaNotifications(manifestConfig.modResults);
manifestConfig.modResults = patchManifestForMediaNotifications(
manifestConfig.modResults,
);
return manifestConfig;
});
};
+92 -52
View File
@@ -1,20 +1,38 @@
import { AndroidConfig, withAndroidManifest, withMainActivity, type ConfigPlugin } from '@expo/config-plugins';
import { addImports, appendContentsInsideDeclarationBlock } from '@expo/config-plugins/build/android/codeMod';
import {
AndroidConfig,
type ConfigPlugin,
withAndroidManifest,
withMainActivity,
} from "@expo/config-plugins";
import {
addImports,
appendContentsInsideDeclarationBlock,
} from "@expo/config-plugins/build/android/codeMod";
const MODE_MARKER = 'OmniView.onActivityPipModeChanged(';
const UI_STATE_MARKER = 'OmniView.onActivityPipTransitionToPip(';
const MODE_MARKER = "OmniView.onActivityPipModeChanged(";
const UI_STATE_MARKER = "OmniView.onActivityPipTransitionToPip(";
function patchMainActivityForPip(mainActivity: string, language: 'java' | 'kt'): string {
const isJava = language === 'java';
function patchMainActivityForPip(
mainActivity: string,
language: "java" | "kt",
): string {
const isJava = language === "java";
if (mainActivity.includes(MODE_MARKER) && mainActivity.includes(UI_STATE_MARKER)) {
if (
mainActivity.includes(MODE_MARKER) &&
mainActivity.includes(UI_STATE_MARKER)
) {
return mainActivity;
}
const withRequiredImports = addImports(
mainActivity,
['android.app.PictureInPictureUiState', 'android.os.Build', 'dev.zoriya.omni.OmniView'],
isJava
[
"android.app.PictureInPictureUiState",
"android.os.Build",
"dev.zoriya.omni.OmniView",
],
isJava,
);
let output = withRequiredImports;
@@ -22,43 +40,51 @@ function patchMainActivityForPip(mainActivity: string, language: 'java' | 'kt'):
if (!output.includes(MODE_MARKER)) {
const modeChangedBlock = isJava
? [
'\n @Override',
' public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {',
' super.onPictureInPictureModeChanged(isInPictureInPictureMode);',
' OmniView.onActivityPipModeChanged(this, isInPictureInPictureMode);',
' }\n',
"\n @Override",
" public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {",
" super.onPictureInPictureModeChanged(isInPictureInPictureMode);",
" OmniView.onActivityPipModeChanged(this, isInPictureInPictureMode);",
" }\n",
]
: [
'\n override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {',
' super.onPictureInPictureModeChanged(isInPictureInPictureMode)',
' OmniView.onActivityPipModeChanged(this, isInPictureInPictureMode)',
' }\n',
"\n override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {",
" super.onPictureInPictureModeChanged(isInPictureInPictureMode)",
" OmniView.onActivityPipModeChanged(this, isInPictureInPictureMode)",
" }\n",
];
output = appendContentsInsideDeclarationBlock(output, 'class MainActivity', modeChangedBlock.join('\n'));
output = appendContentsInsideDeclarationBlock(
output,
"class MainActivity",
modeChangedBlock.join("\n"),
);
}
if (!output.includes(UI_STATE_MARKER)) {
const uiStateChangedBlock = isJava
? [
'\n @Override',
' public void onPictureInPictureUiStateChanged(PictureInPictureUiState pipState) {',
' super.onPictureInPictureUiStateChanged(pipState);',
' if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && pipState.isTransitioningToPip()) {',
' OmniView.onActivityPipTransitionToPip(this);',
' }',
' }\n',
"\n @Override",
" public void onPictureInPictureUiStateChanged(PictureInPictureUiState pipState) {",
" super.onPictureInPictureUiStateChanged(pipState);",
" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && pipState.isTransitioningToPip()) {",
" OmniView.onActivityPipTransitionToPip(this);",
" }",
" }\n",
]
: [
'\n override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {',
' super.onPictureInPictureUiStateChanged(pipState)',
' if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && pipState.isTransitioningToPip) {',
' OmniView.onActivityPipTransitionToPip(this)',
' }',
' }\n',
"\n override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {",
" super.onPictureInPictureUiStateChanged(pipState)",
" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && pipState.isTransitioningToPip) {",
" OmniView.onActivityPipTransitionToPip(this)",
" }",
" }\n",
];
output = appendContentsInsideDeclarationBlock(output, 'class MainActivity', uiStateChangedBlock.join('\n'));
output = appendContentsInsideDeclarationBlock(
output,
"class MainActivity",
uiStateChangedBlock.join("\n"),
);
}
return output;
@@ -67,47 +93,61 @@ function patchMainActivityForPip(mainActivity: string, language: 'java' | 'kt'):
function ensureUsesFeature(
androidManifest: AndroidConfig.Manifest.AndroidManifest,
featureName: string,
required: 'true' | 'false'
required: "true" | "false",
): void {
const usesFeature = androidManifest.manifest['uses-feature'] ?? [];
const existing = usesFeature.find((feature) => feature.$['android:name'] === featureName);
const usesFeature = androidManifest.manifest["uses-feature"] ?? [];
const existing = usesFeature.find(
(feature) => feature.$["android:name"] === featureName,
);
if (existing) {
existing.$['android:required'] = required;
existing.$["android:required"] = required;
} else {
usesFeature.push({
$: {
'android:name': featureName,
'android:required': required,
"android:name": featureName,
"android:required": required,
},
});
}
androidManifest.manifest['uses-feature'] = usesFeature;
androidManifest.manifest["uses-feature"] = usesFeature;
}
function ensureConfigChanges(mainActivity: AndroidConfig.Manifest.ManifestActivity): void {
const requiredChanges = ['screenSize', 'smallestScreenSize', 'screenLayout', 'orientation'];
const existing = mainActivity.$['android:configChanges'] ?? '';
function ensureConfigChanges(
mainActivity: AndroidConfig.Manifest.ManifestActivity,
): void {
const requiredChanges = [
"screenSize",
"smallestScreenSize",
"screenLayout",
"orientation",
];
const existing = mainActivity.$["android:configChanges"] ?? "";
const existingSet = new Set(
existing
.split('|')
.split("|")
.map((item) => item.trim())
.filter(Boolean)
.filter(Boolean),
);
requiredChanges.forEach((item) => existingSet.add(item));
mainActivity.$['android:configChanges'] = Array.from(existingSet).join('|');
mainActivity.$["android:configChanges"] = Array.from(existingSet).join("|");
}
function patchManifestForPip(
androidManifest: AndroidConfig.Manifest.AndroidManifest
androidManifest: AndroidConfig.Manifest.AndroidManifest,
): AndroidConfig.Manifest.AndroidManifest {
ensureUsesFeature(androidManifest, 'android.software.picture_in_picture', 'false');
ensureUsesFeature(
androidManifest,
"android.software.picture_in_picture",
"false",
);
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(androidManifest);
mainActivity.$['android:supportsPictureInPicture'] = 'true';
mainActivity.$['android:resizeableActivity'] = 'true';
const mainActivity =
AndroidConfig.Manifest.getMainActivityOrThrow(androidManifest);
mainActivity.$["android:supportsPictureInPicture"] = "true";
mainActivity.$["android:resizeableActivity"] = "true";
ensureConfigChanges(mainActivity);
return androidManifest;
@@ -117,7 +157,7 @@ export const withPip: ConfigPlugin = (config) => {
const withMainActivityPatched = withMainActivity(config, (activityConfig) => {
activityConfig.modResults.contents = patchMainActivityForPip(
activityConfig.modResults.contents,
activityConfig.modResults.language
activityConfig.modResults.language,
);
return activityConfig;
});
+8 -2
View File
@@ -20,7 +20,10 @@ const updateViewManagerFiles = async (file) => {
const viewManagerStr = await readFile(viewManagerFile, { encoding: "utf8" });
await writeFile(
viewManagerFile,
viewManagerStr.replace(/com\.margelo\.nitro\.omni\.\*/g, "dev.zoriya.omni.*"),
viewManagerStr.replace(
/com\.margelo\.nitro\.omni\.\*/g,
"dev.zoriya.omni.*",
),
);
};
@@ -49,6 +52,9 @@ const androidWorkaround = async () => {
}
const str = await readFile(androidOnLoadFile, { encoding: "utf8" });
await writeFile(androidOnLoadFile, str.replace(/com\/margelo\/nitro\//g, "dev/zoriya/"));
await writeFile(
androidOnLoadFile,
str.replace(/com\/margelo\/nitro\//g, "dev/zoriya/"),
);
};
androidWorkaround();
+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>;
+21 -6
View File
@@ -9,6 +9,7 @@ import {
useRef,
useSyncExternalStore,
} from "react";
import { usePlayerState } from "./events";
import {
getSubtitleFormat,
isCustomSubtitle,
@@ -58,7 +59,8 @@ 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 }) => {
import("jassub")
.then(({ default: JASSUB }) => {
const instance = new JASSUB({
video: el,
subUrl: subtitle.link,
@@ -67,17 +69,21 @@ const SubtitleOverlay = ({
...(fonts?.length && { fonts }),
...(jassub?.workerUrl && { workerUrl: jassub.workerUrl }),
...(jassub?.wasmUrl && { wasmUrl: jassub.wasmUrl }),
...(jassub?.modernWasmUrl && { modernWasmUrl: jassub.modernWasmUrl }),
...(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 }) => {
import("libpgs")
.then(({ PgsRenderer }) => {
const instance = new PgsRenderer({
video: el,
subUrl: subtitle.link,
@@ -86,7 +92,8 @@ const SubtitleOverlay = ({
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>
)}
{castStatus !== "connected" && castStatus !== "connecting" && (
<SubtitleOverlay
video={ref}
assets={subtitleAssets}
fonts={player.source?.fonts}
/>
)}
</VideoPlayer.Container>
);
};