Compare commits

...
4 Commits
Author SHA1 Message Date
zoriya 3739e760cf fix(cast): use modern cast menus 2026-07-28 19:47:50 +02:00
zoriya 099eeabe18 fix(cast): fix cast notification metadata 2026-07-28 19:47:50 +02:00
zoriya d8f96962e3 feat(cast): use native tracks for subtitles 2026-07-28 19:47:50 +02:00
zoriya 12be950ea0 fix(pip): disable pip when casting 2026-07-27 00:29:05 +02:00
13 changed files with 125 additions and 144 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ dependencies {
implementation("androidx.media3:media3-exoplayer-hls:1.10.0") implementation("androidx.media3:media3-exoplayer-hls:1.10.0")
implementation("androidx.media3:media3-cast:1.10.0") implementation("androidx.media3:media3-cast:1.10.0")
implementation("com.google.android.gms:play-services-cast-framework:21.5.0") implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
implementation("androidx.mediarouter:mediarouter:1.7.0") implementation("androidx.mediarouter:mediarouter:1.7.0")
} }
@@ -13,6 +13,7 @@ class OmniCastOptionsProvider : OptionsProvider {
return CastOptions.Builder() return CastOptions.Builder()
.setReceiverApplicationId(appId) .setReceiverApplicationId(appId)
.setShowSystemOutputSwitcherOnCastIconClick(true)
.build() .build()
} }
@@ -56,6 +56,10 @@ class OmniCastTrackSelector : CastTrackSelector() {
indices.forEach { selections.remove(groups[it]) } indices.forEach { selections.remove(groups[it]) }
if (disabled) return if (disabled) return
val overrides = request.trackSelectionParameters.overrides
val overrideMatch = indices.firstOrNull { i ->
overrides.keys.any { it.id == groups[i].id }
}
val match = indices.firstOrNull { i -> val match = indices.firstOrNull { i ->
val name: String? = tracks[i].name val name: String? = tracks[i].name
val language: String? = tracks[i].language val language: String? = tracks[i].language
@@ -64,6 +68,7 @@ class OmniCastTrackSelector : CastTrackSelector() {
Util.normalizeLanguageCode(language) in languages) Util.normalizeLanguageCode(language) in languages)
} }
val chosen = when { val chosen = when {
overrideMatch != null -> overrideMatch
match != null -> match match != null -> match
previous.isNotEmpty() -> previous.first() previous.isNotEmpty() -> previous.first()
selectDefault -> indices.first() selectDefault -> indices.first()
@@ -43,8 +43,14 @@ class OmniMediaItemConverter : MediaItemConverter {
val meta = mediaItem.mediaMetadata val meta = mediaItem.mediaMetadata
val castMetadata = CastMediaMetadata(CastMediaMetadata.MEDIA_TYPE_MOVIE).apply { val castMetadata = CastMediaMetadata(CastMediaMetadata.MEDIA_TYPE_MOVIE).apply {
meta.title?.let { putString(CastMediaMetadata.KEY_TITLE, it.toString()) } meta.title?.let { putString(CastMediaMetadata.KEY_TITLE, it.toString()) }
meta.artist?.let { putString(CastMediaMetadata.KEY_ARTIST, it.toString()) } meta.artist?.let {
meta.albumTitle?.let { putString(CastMediaMetadata.KEY_ALBUM_TITLE, it.toString()) } putString(CastMediaMetadata.KEY_ARTIST, it.toString())
putString(CastMediaMetadata.KEY_SUBTITLE, it.toString())
}
meta.albumTitle?.let {
putString(CastMediaMetadata.KEY_ALBUM_TITLE, it.toString())
putString(CastMediaMetadata.KEY_STUDIO, it.toString())
}
meta.artworkUri?.let { addImage(WebImage(it)) } meta.artworkUri?.let { addImage(WebImage(it)) }
} }
@@ -57,7 +63,8 @@ class OmniMediaItemConverter : MediaItemConverter {
} }
val tracks = local.subtitleConfigurations.mapIndexed { index, subtitle -> val tracks = local.subtitleConfigurations.mapIndexed { index, subtitle ->
MediaTrack.Builder(index.toLong(), MediaTrack.TYPE_TEXT) // caf tracks are 1 based instead of 0 based. videojs does the same
MediaTrack.Builder((index + 1).toLong(), MediaTrack.TYPE_TEXT)
.setSubtype(MediaTrack.SUBTYPE_SUBTITLES) .setSubtype(MediaTrack.SUBTYPE_SUBTITLES)
.setContentId(subtitle.uri.toString()) .setContentId(subtitle.uri.toString())
.setContentType(subtitle.mimeType ?: "text/vtt") .setContentType(subtitle.mimeType ?: "text/vtt")
@@ -37,6 +37,7 @@ import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import androidx.mediarouter.app.MediaRouteChooserDialog import androidx.mediarouter.app.MediaRouteChooserDialog
import androidx.mediarouter.app.SystemOutputSwitcherDialogController
import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastState import com.google.android.gms.cast.framework.CastState
import com.google.android.gms.cast.framework.CastStateListener import com.google.android.gms.cast.framework.CastStateListener
@@ -124,6 +125,9 @@ class OmniPlayer(
override val castStatus: CastStatus override val castStatus: CastStatus
get() = runOnMainThreadSync { computeCastStatus() } get() = runOnMainThreadSync { computeCastStatus() }
val isCasting: Boolean
get() = castContext?.sessionManager?.currentCastSession?.isConnected == true
private fun computeCastStatus(): CastStatus { private fun computeCastStatus(): CastStatus {
val cc = castContext ?: return CastStatus.UNSUPPORTED val cc = castContext ?: return CastStatus.UNSUPPORTED
return when (cc.castState) { return when (cc.castState) {
@@ -144,6 +148,9 @@ class OmniPlayer(
return@runOnMainThread return@runOnMainThread
} }
val activity = ctx.currentActivity ?: return@runOnMainThread val activity = ctx.currentActivity ?: return@runOnMainThread
if (SystemOutputSwitcherDialogController.showDialog(activity)) {
return@runOnMainThread
}
val selector = cc.mergedSelector ?: return@runOnMainThread val selector = cc.mergedSelector ?: return@runOnMainThread
MediaRouteChooserDialog(activity).apply { routeSelector = selector }.show() MediaRouteChooserDialog(activity).apply { routeSelector = selector }.show()
} }
@@ -358,55 +365,35 @@ class OmniPlayer(
} }
} }
override fun selectAudio(audio: Track) { override fun selectAudio(audio: Track) = selectTrack(C.TRACK_TYPE_AUDIO, audio)
runOnMainThreadSync {
player.trackSelectionParameters = player.trackSelectionParameters
.buildUpon()
.setPreferredAudioLanguage(audio.language)
.setPreferredAudioLabels(*(audio.label?.let { arrayOf(it) } ?: emptyArray()))
.build()
}
}
override fun selectSubtitle(subtitle: Track?) { override fun selectSubtitle(subtitle: Track?) = selectTrack(C.TRACK_TYPE_TEXT, subtitle)
// Custom (ASS/PGS) subtitles can't be rendered by the cast receiver's
// native pipeline, so while casting they are forwarded over omni's private fun selectTrack(type: Int, track: Track?) {
// message channel (the receiver draws them as an overlay). This mirrors
// the web behavior.
val custom = subtitle?.let { track ->
source?.subtitles?.firstOrNull { it.id == track.id }?.let { sub ->
val mime = sub.mimeType?.lowercase() ?: ""
val ext = sub.link.substringBefore('?').substringBefore('#')
.substringAfterLast('.', "").lowercase()
mime.contains("ass") || mime.contains("ssa") || mime.contains("pgs") ||
ext == "ass" || ext == "ssa" || ext == "sup"
}
} ?: false
runOnMainThreadSync { runOnMainThreadSync {
val session = castContext?.sessionManager?.currentCastSession val params = player.trackSelectionParameters.buildUpon()
val casting = session?.isConnected == true .clearOverridesOfType(type)
if (subtitle == null || (custom && casting)) { if (track == null) {
player.trackSelectionParameters = player.trackSelectionParameters params.setTrackTypeDisabled(type, true)
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
} else { } else {
player.trackSelectionParameters = player.trackSelectionParameters params.setTrackTypeDisabled(type, false)
.buildUpon() val group = player.currentTracks.groups.firstOrNull {
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) it.type == type && it.mediaTrackGroup.id == track.id
.setPreferredTextLanguage(subtitle.language) }?.mediaTrackGroup
.setPreferredTextLabels(*(subtitle.label?.let { arrayOf(it) } ?: emptyArray())) if (group != null) {
.build() params.setOverrideForType(TrackSelectionOverride(group, 0))
} } else {
if (casting) { val labels = track.label?.let { arrayOf(it) } ?: emptyArray()
val id = if (custom) subtitle?.id else null if (type == C.TRACK_TYPE_AUDIO) {
val payload = JSONObject().apply { put("subtitle", id ?: JSONObject.NULL) } params.setPreferredAudioLanguage(track.language)
try { .setPreferredAudioLabels(*labels)
session?.sendMessage(CAST_MESSAGE_NAMESPACE, payload.toString()) } else {
} catch (_: Throwable) { params.setPreferredTextLanguage(track.language)
// best effort; receiver may not support the namespace. .setPreferredTextLabels(*labels)
}
} }
} }
player.trackSelectionParameters = params.build()
} }
} }
@@ -414,18 +401,30 @@ class OmniPlayer(
val groups = player.currentTracks.groups.filter { it.type == trackType } val groups = player.currentTracks.groups.filter { it.type == trackType }
if (groups.isEmpty()) return emptyArray() if (groups.isEmpty()) return emptyArray()
return groups.map { // when casting tracks are stripped of some metadata, recover it
it.getTrackFormat(0).run { val castTracks = castMediaTracksById()
Track(
id = it.mediaTrackGroup.id, return groups.map { group ->
label = this.label, val format = group.getTrackFormat(0)
language = this.language,
selected = it.isSelected val castTrackId = group.mediaTrackGroup.id.substringAfterLast("track=", "").toLongOrNull()
) val cast = castTracks[castTrackId]
} Track(
id = group.mediaTrackGroup.id,
label = cast?.name ?: format.label,
language = cast?.language ?: format.language,
selected = group.isSelected
)
}.toTypedArray() }.toTypedArray()
} }
private fun castMediaTracksById(): Map<Long, com.google.android.gms.cast.MediaTrack> {
if (!isCasting) return emptyMap()
val tracks = castContext?.sessionManager?.currentCastSession
?.remoteMediaClient?.mediaInfo?.mediaTracks ?: return emptyMap()
return tracks.associateBy { it.id }
}
private fun getRenditions(): Array<Rendition> { private fun getRenditions(): Array<Rendition> {
val group = val group =
player.currentTracks.groups.firstOrNull { it.isSelected && it.type == C.TRACK_TYPE_VIDEO } player.currentTracks.groups.firstOrNull { it.isSelected && it.type == C.TRACK_TYPE_VIDEO }
@@ -489,10 +488,6 @@ class OmniPlayer(
companion object { companion object {
var notificationPlayer: Player? = null var notificationPlayer: Player? = null
// Cast custom-message channel (shared with the web receiver) used to
// forward overlay (ASS/PGS) subtitle selection to the receiver.
const val CAST_MESSAGE_NAMESPACE = "urn:x-cast:dev.zoriya.omni"
// MediaItem RequestMetadata extras keys carrying cast-only data. // MediaItem RequestMetadata extras keys carrying cast-only data.
const val CAST_ID_EXTRA = "dev.zoriya.omni.castId" const val CAST_ID_EXTRA = "dev.zoriya.omni.castId"
const val CAST_DATA_EXTRA = "dev.zoriya.omni.castData" const val CAST_DATA_EXTRA = "dev.zoriya.omni.castData"
@@ -185,7 +185,10 @@ class OmniView(val context: ThemedReactContext) :
return return
} }
if (boundPlayer == null || boundPlayer?.localPlayer?.isPlaying != true) { if (boundPlayer == null ||
boundPlayer?.localPlayer?.isPlaying != true ||
boundPlayer?.isCasting == true
) {
return return
} }
@@ -219,7 +222,8 @@ class OmniView(val context: ThemedReactContext) :
val autoEnterEnabled = val autoEnterEnabled =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
autoPip == true && autoPip == true &&
boundPlayer?.localPlayer?.isPlaying == true boundPlayer?.localPlayer?.isPlaying == true &&
boundPlayer?.isCasting != true
activity.setPictureInPictureParams(buildPipParams(autoEnterEnabled)) activity.setPictureInPictureParams(buildPipParams(autoEnterEnabled))
} }
+9 -5
View File
@@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { import {
type AndroidBackend, type AndroidBackend,
@@ -75,7 +75,8 @@ function PlayerExample({
const volume = usePlayerState("volume"); const volume = usePlayerState("volume");
const isAutoQuality = usePlayerState("isAutoQuality"); const isAutoQuality = usePlayerState("isAutoQuality");
const castStatus = usePlayerState("castStatus"); const castStatus = usePlayerState("castStatus");
const [logs, setLogs] = useState<string[]>([]); const [logs, setLogs] = useState<{ id: number; message: string }[]>([]);
const logId = useRef(0);
const [tracks, setTracks] = useState(() => ({ const [tracks, setTracks] = useState(() => ({
videos: [...player.videos], videos: [...player.videos],
audios: [...player.audios], audios: [...player.audios],
@@ -98,7 +99,7 @@ function PlayerExample({
const pushLog = useCallback((message: string) => { const pushLog = useCallback((message: string) => {
setLogs((prev) => { setLogs((prev) => {
const next = [message, ...prev]; const next = [{ id: logId.current++, message }, ...prev];
return next.slice(0, 8); return next.slice(0, 8);
}); });
}, []); }, []);
@@ -484,8 +485,8 @@ function PlayerExample({
<Text style={styles.logText}>Event log will appear here.</Text> <Text style={styles.logText}>Event log will appear here.</Text>
) : ( ) : (
logs.map((entry) => ( logs.map((entry) => (
<Text key={`${entry}`} style={styles.logText}> <Text key={entry.id} style={styles.logText}>
{entry} {entry.message}
</Text> </Text>
)) ))
)} )}
@@ -536,6 +537,8 @@ function App(): React.JSX.Element {
{ {
id: "kusu", id: "kusu",
link: "https://jassub.pages.dev/subtitles/Kusriya%20S2%20OP1v3.ass", link: "https://jassub.pages.dev/subtitles/Kusriya%20S2%20OP1v3.ass",
label: "ass test",
language: "jp",
}, },
{ {
id: "pgs", id: "pgs",
@@ -573,6 +576,7 @@ function App(): React.JSX.Element {
key={backend} key={backend}
source={source} source={source}
backend={{ android: backend }} backend={{ android: backend }}
cast={{receiverApplicationId: "D8FB0FC1"}}
showNotification showNotification
> >
<PlayerExample <PlayerExample
@@ -28,5 +28,8 @@
<meta-data <meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="dev.zoriya.omni.OmniCastOptionsProvider" /> android:value="dev.zoriya.omni.OmniCastOptionsProvider" />
<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
android:exported="true" />
</application> </application>
</manifest> </manifest>
+17
View File
@@ -7,6 +7,8 @@ import {
const OPTIONS_PROVIDER_NAME = const OPTIONS_PROVIDER_NAME =
"com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"; "com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME";
const OPTIONS_PROVIDER_VALUE = "dev.zoriya.omni.OmniCastOptionsProvider"; const OPTIONS_PROVIDER_VALUE = "dev.zoriya.omni.OmniCastOptionsProvider";
const MEDIA_TRANSFER_RECEIVER_NAME =
"androidx.mediarouter.media.MediaTransferReceiver";
function patchManifestForCast( function patchManifestForCast(
androidManifest: AndroidConfig.Manifest.AndroidManifest, androidManifest: AndroidConfig.Manifest.AndroidManifest,
@@ -36,6 +38,21 @@ function patchManifestForCast(
} }
mainApplication["meta-data"] = metaData; mainApplication["meta-data"] = metaData;
const receivers = mainApplication.receiver ?? [];
if (
!receivers.some(
(item) => item.$["android:name"] === MEDIA_TRANSFER_RECEIVER_NAME,
)
) {
receivers.push({
$: {
"android:name": MEDIA_TRANSFER_RECEIVER_NAME,
"android:exported": "true",
},
});
}
mainApplication.receiver = receivers;
return androidManifest; return androidManifest;
} }
-3
View File
@@ -127,9 +127,6 @@ export const useEvent = <Event extends keyof OmniEvents>(
return undefined; return undefined;
}, [event, player]); }, [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(() => { useEffect(() => {
if (event !== "subtitleChange") return; if (event !== "subtitleChange") return;
return player.subscribeOverlaySubtitle(() => { return player.subscribeOverlaySubtitle(() => {
+10 -26
View File
@@ -217,47 +217,31 @@ export class WebOmniPlayer implements OmniPlayer {
get subtitles(): Track[] { get subtitles(): Track[] {
const textTracks = selectTextTrack(this._store.state)?.textTrackList ?? []; const textTracks = selectTextTrack(this._store.state)?.textTrackList ?? [];
const native = textTracks return textTracks
.filter((x) => x.kind === "subtitles" || x.kind === "captions") .filter((x) => x.kind === "subtitles" || x.kind === "captions")
.map((track) => ({ .map((track) => ({
id: track.id!, id: track.id!,
label: track.label, label: track.label,
language: track.language, language: track.language,
selected: track.mode === "showing", selected:
track.mode === "showing" || this.overlaySubtitle?.id === track.id,
})); }));
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 { selectSubtitle(subtitle?: Track): void {
const tracks = selectTextTrack(this._store.state);
const overlay = subtitle const overlay = subtitle
? this.overlaySubtitles.find((s) => s.id === subtitle.id) ? this.overlaySubtitles.find((s) => s.id === subtitle.id)
: undefined; : undefined;
const tracks = selectTextTrack(this._store.state); if (this.castStatus === "connected") {
tracks?.selectSubtitlesTrack(subtitle ? subtitle.id : "off");
this.setOverlaySubtitle(null);
return;
}
tracks?.selectSubtitlesTrack(overlay || !subtitle ? "off" : subtitle.id); tracks?.selectSubtitlesTrack(overlay || !subtitle ? "off" : subtitle.id);
this.setOverlaySubtitle(overlay ?? null); this.setOverlaySubtitle(overlay ?? null);
if (this.castStatus === "connected") {
window.cast.framework.CastContext.getInstance()
.getCurrentSession()
?.sendMessage("urn:x-cast:dev.zoriya.omni", {
subtitle: overlay?.id ?? null,
})
.catch(() => {});
}
}
// To be used on a callback of a chromecast session, it only changes the local state
applyRemoteSubtitle(id: string | null): void {
this.setOverlaySubtitle(
id ? (this.overlaySubtitles.find((s) => s.id === id) ?? null) : null,
);
} }
private setOverlaySubtitle(sub: Subtitle | null): void { private setOverlaySubtitle(sub: Subtitle | null): void {
-30
View File
@@ -75,36 +75,6 @@ const PlayerInitializer = ({
player.showNotification = showNotification; player.showNotification = showNotification;
}, [showNotification]); }, [showNotification]);
useEffect(() => {
let media: chrome.cast.media.Media | null = null;
const onMediaUpdate = () => {
const data = media?.customData as { subtitle?: unknown } | undefined;
player.applyRemoteSubtitle(
typeof data?.subtitle === "string" ? data.subtitle : null,
);
};
const syncMedia = () => {
const next =
window.cast.framework.CastContext.getInstance()
.getCurrentSession()
?.getMediaSession() ?? null;
if (next === media) return;
media?.removeUpdateListener(onMediaUpdate);
media = next;
media?.addUpdateListener(onMediaUpdate);
onMediaUpdate();
};
const unsubscribe = store.subscribe(syncMedia);
syncMedia();
return () => {
unsubscribe();
media?.removeUpdateListener(onMediaUpdate);
};
}, [store]);
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>; return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>;
}; };
+11 -17
View File
@@ -10,11 +10,7 @@ import {
useSyncExternalStore, useSyncExternalStore,
} from "react"; } from "react";
import { usePlayerState } from "./events"; import { usePlayerState } from "./events";
import { import { getSubtitleFormat, type WebOmniPlayer } from "./player.web";
getSubtitleFormat,
isCustomSubtitle,
type WebOmniPlayer,
} from "./player.web";
import { usePlayer, VideoPlayer } from "./provider.web"; import { usePlayer, VideoPlayer } from "./provider.web";
import type { SubtitleAssets } from "./types/subtitles"; import type { SubtitleAssets } from "./types/subtitles";
import type { OmniViewProps } from "./types/view"; import type { OmniViewProps } from "./types/view";
@@ -165,18 +161,16 @@ export const OmniView = ({
crossOrigin="anonymous" crossOrigin="anonymous"
style={{ width: "100%", height: "100%", objectFit: "contain" }} style={{ width: "100%", height: "100%", objectFit: "contain" }}
> >
{(player.source?.subtitles ?? []) {(player.source?.subtitles ?? []).map((subtitle) => (
.filter((subtitle) => !isCustomSubtitle(subtitle)) <track
.map((subtitle) => ( key={subtitle.id}
<track id={subtitle.id}
key={subtitle.id} kind="subtitles"
id={subtitle.id} src={subtitle.link}
kind="subtitles" srcLang={subtitle.language}
src={subtitle.link} label={subtitle.label ?? subtitle.language ?? subtitle.id}
srcLang={subtitle.language} />
label={subtitle.label ?? subtitle.language ?? subtitle.id} ))}
/>
))}
</Tech> </Tech>
)} )}
{castStatus !== "connected" && castStatus !== "connecting" && ( {castStatus !== "connected" && castStatus !== "connecting" && (