From f98c787b150602ecda22413492309e38b0087a57 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 27 Jul 2026 17:58:16 +0200 Subject: [PATCH] feat(cast): unify ass/pgs subtitles through cast tracks ASS/PGS subtitles are now declared as regular cast text tracks; the custom receiver draws the ones it can't render natively, keyed off the active track id. Removes the custom message channel, the customData readback, and the per-platform overlay-selection state so both senders treat every subtitle the same. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01WQ5PxMcMRmAMVxP8ZnYKnm --- .../dev/zoriya/omni/OmniCastTrackSelector.kt | 7 ++ .../main/java/dev/zoriya/omni/OmniPlayer.kt | 113 ++++++++++-------- package.json | 3 - src/events.web.tsx | 21 ---- src/player.web.tsx | 37 ++---- src/provider.web.tsx | 30 ----- src/view.web.tsx | 28 ++--- 7 files changed, 90 insertions(+), 149 deletions(-) diff --git a/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt b/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt index 4339eae..7e92649 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt @@ -56,6 +56,12 @@ class OmniCastTrackSelector : CastTrackSelector() { indices.forEach { selections.remove(groups[it]) } if (disabled) return + // An explicit override selects an exact track by identity; this wins over + // language/label matching, which can't disambiguate same-language tracks. + val overrides = request.trackSelectionParameters.overrides + val overrideMatch = indices.firstOrNull { i -> + overrides.keys.any { it.id == groups[i].id } + } val match = indices.firstOrNull { i -> val name: String? = tracks[i].name val language: String? = tracks[i].language @@ -64,6 +70,7 @@ class OmniCastTrackSelector : CastTrackSelector() { Util.normalizeLanguageCode(language) in languages) } val chosen = when { + overrideMatch != null -> overrideMatch match != null -> match previous.isNotEmpty() -> previous.first() selectDefault -> indices.first() diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt index 22abcb9..f3850c0 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt @@ -37,6 +37,9 @@ import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import androidx.mediarouter.app.MediaRouteChooserDialog +import androidx.mediarouter.media.MediaRouteSelector +import androidx.mediarouter.media.MediaRouter +import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.gms.cast.framework.CastStateListener @@ -77,6 +80,14 @@ class OmniPlayer( private val castStateListener = CastStateListener { eventMap.emitCastStatus(computeCastStatus()) } + // Without a MediaRouteButton the Cast SDK never actively scans, so + // CastContext.castState stays NO_DEVICES_AVAILABLE (reported as + // "unavailable") even when receivers are on the network. Hold an active + // discovery request so castState reflects real availability. MediaRouter + // must only be touched on the main thread. + private var mediaRouter: MediaRouter? = null + private val mediaRouterCallback = object : MediaRouter.Callback() {} + val player: Player = runOnMainThreadSync { castOptions?.receiverApplicationId?.let { receiverApplicationId = it } val cc = try { @@ -90,6 +101,21 @@ class OmniPlayer( localPlayer } else { cc.addCastStateListener(castStateListener) + // Build the discovery selector from the receiver app id directly + // (cc.mergedSelector can be null this early), then keep an active + // discovery request so castState reflects real availability. + val appId = receiverApplicationId + ?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID + val selector = cc.mergedSelector ?: MediaRouteSelector.Builder() + .addControlCategory(CastMediaControlIntent.categoryForCast(appId)) + .build() + val router = MediaRouter.getInstance(ctx) + router.addCallback( + selector, + mediaRouterCallback, + MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY, + ) + mediaRouter = router val remote = RemoteCastPlayer.Builder(ctx) .setMediaItemConverter(OmniMediaItemConverter()) .setTrackSelector(OmniCastTrackSelector()) @@ -159,6 +185,7 @@ class OmniPlayer( eventMap.dispose() runOnMainThread { castContext?.removeCastStateListener(castStateListener) + mediaRouter?.removeCallback(mediaRouterCallback) // release both cast and local players. player.release() } @@ -361,57 +388,45 @@ class OmniPlayer( } } - override fun selectAudio(audio: Track) { + override fun selectAudio(audio: Track) = selectTrack(C.TRACK_TYPE_AUDIO, audio) + + // Select the exact track by its group id (stable identity) rather than by + // preferred language/label: two same-language tracks, or tracks whose labels + // don't survive the cast round-trip, can't be told apart by preference. Falls + // back to language/label only when the group can't be found yet. + private fun selectTrack(type: Int, track: Track?) { runOnMainThreadSync { - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .setPreferredAudioLanguage(audio.language) - .setPreferredAudioLabels(*(audio.label?.let { arrayOf(it) } ?: emptyArray())) - .build() + val params = player.trackSelectionParameters.buildUpon() + .clearOverridesOfType(type) + if (track == null) { + params.setTrackTypeDisabled(type, true) + } else { + params.setTrackTypeDisabled(type, false) + val group = player.currentTracks.groups.firstOrNull { + it.type == type && it.mediaTrackGroup.id == track.id + }?.mediaTrackGroup + if (group != null) { + params.setOverrideForType(TrackSelectionOverride(group, 0)) + } else { + val labels = track.label?.let { arrayOf(it) } ?: emptyArray() + if (type == C.TRACK_TYPE_AUDIO) { + params.setPreferredAudioLanguage(track.language) + .setPreferredAudioLabels(*labels) + } else { + params.setPreferredTextLanguage(track.language) + .setPreferredTextLabels(*labels) + } + } + } + player.trackSelectionParameters = params.build() } } - override fun selectSubtitle(subtitle: Track?) { - // Custom (ASS/PGS) subtitles can't be rendered by the cast receiver's - // native pipeline, so while casting they are forwarded over omni's - // 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 { - val session = castContext?.sessionManager?.currentCastSession - val casting = session?.isConnected == true - if (subtitle == null || (custom && casting)) { - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) - .build() - } else { - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) - .setPreferredTextLanguage(subtitle.language) - .setPreferredTextLabels(*(subtitle.label?.let { arrayOf(it) } ?: emptyArray())) - .build() - } - if (casting) { - val id = if (custom) subtitle?.id else null - val payload = JSONObject().apply { put("subtitle", id ?: JSONObject.NULL) } - try { - session?.sendMessage(CAST_MESSAGE_NAMESPACE, payload.toString()) - } catch (_: Throwable) { - // best effort; receiver may not support the namespace. - } - } - } - } + // ASS/PGS subtitles are declared as regular cast tracks; the receiver draws + // the ones it can't render natively, keyed off the active track id. Selection + // is uniform (see selectTrack): pick the exact track, which maps to + // activeTrackIds while casting and to native tracks locally. + override fun selectSubtitle(subtitle: Track?) = selectTrack(C.TRACK_TYPE_TEXT, subtitle) private fun tracksByType(trackType: Int): Array { val groups = player.currentTracks.groups.filter { it.type == trackType } @@ -492,10 +507,6 @@ class OmniPlayer( companion object { 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. const val CAST_ID_EXTRA = "dev.zoriya.omni.castId" const val CAST_DATA_EXTRA = "dev.zoriya.omni.castData" diff --git a/package.json b/package.json index 7a26535..fa8e315 100644 --- a/package.json +++ b/package.json @@ -98,8 +98,5 @@ } ] ] - }, - "patchedDependencies": { - "jassub@2.5.7": "patches/jassub@2.5.7.patch" } } diff --git a/src/events.web.tsx b/src/events.web.tsx index 18f4780..80d136b 100644 --- a/src/events.web.tsx +++ b/src/events.web.tsx @@ -126,27 +126,6 @@ export const useEvent = ( } 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( diff --git a/src/player.web.tsx b/src/player.web.tsx index ed16cce..c86928b 100644 --- a/src/player.web.tsx +++ b/src/player.web.tsx @@ -216,8 +216,11 @@ export class WebOmniPlayer implements OmniPlayer { } get subtitles(): Track[] { + // Every subtitle (incl. ass/pgs) is a real text track now; the cast + // receiver draws the ones it can't render natively, keyed off the active + // track. So the text-track list already contains all of them. const textTracks = selectTextTrack(this._store.state)?.textTrackList ?? []; - const native = textTracks + return textTracks .filter((x) => x.kind === "subtitles" || x.kind === "captions") .map((track) => ({ id: track.id!, @@ -225,39 +228,19 @@ export class WebOmniPlayer implements OmniPlayer { 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 { + // Selecting a text track drives local playback and, while casting, + // video.js forwards it to the receiver as an active cast track. ass/pgs + // additionally need our local overlay since the browser can't render them. + const tracks = selectTextTrack(this._store.state); + tracks?.selectSubtitlesTrack(subtitle ? subtitle.id : "off"); + 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") { - 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 { diff --git a/src/provider.web.tsx b/src/provider.web.tsx index 14f6f01..a198bfe 100644 --- a/src/provider.web.tsx +++ b/src/provider.web.tsx @@ -75,36 +75,6 @@ const PlayerInitializer = ({ player.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 {children}; }; diff --git a/src/view.web.tsx b/src/view.web.tsx index 871a543..6cdb5a6 100644 --- a/src/view.web.tsx +++ b/src/view.web.tsx @@ -10,11 +10,7 @@ import { useSyncExternalStore, } from "react"; import { usePlayerState } from "./events"; -import { - getSubtitleFormat, - isCustomSubtitle, - type WebOmniPlayer, -} from "./player.web"; +import { getSubtitleFormat, type WebOmniPlayer } from "./player.web"; import { usePlayer, VideoPlayer } from "./provider.web"; import type { SubtitleAssets } from "./types/subtitles"; import type { OmniViewProps } from "./types/view"; @@ -165,18 +161,16 @@ export const OmniView = ({ crossOrigin="anonymous" style={{ width: "100%", height: "100%", objectFit: "contain" }} > - {(player.source?.subtitles ?? []) - .filter((subtitle) => !isCustomSubtitle(subtitle)) - .map((subtitle) => ( - - ))} + {(player.source?.subtitles ?? []).map((subtitle) => ( + + ))} )} {castStatus !== "connected" && castStatus !== "connecting" && (