mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-05 05:36:17 +00:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WQ5PxMcMRmAMVxP8ZnYKnm
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<Track> {
|
||||
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"
|
||||
|
||||
@@ -98,8 +98,5 @@
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"jassub@2.5.7": "patches/jassub@2.5.7.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,27 +126,6 @@ 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>(
|
||||
|
||||
+10
-27
@@ -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 {
|
||||
|
||||
@@ -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 <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>;
|
||||
};
|
||||
|
||||
|
||||
+11
-17
@@ -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) => (
|
||||
<track
|
||||
key={subtitle.id}
|
||||
id={subtitle.id}
|
||||
kind="subtitles"
|
||||
src={subtitle.link}
|
||||
srcLang={subtitle.language}
|
||||
label={subtitle.label ?? subtitle.language ?? subtitle.id}
|
||||
/>
|
||||
))}
|
||||
{(player.source?.subtitles ?? []).map((subtitle) => (
|
||||
<track
|
||||
key={subtitle.id}
|
||||
id={subtitle.id}
|
||||
kind="subtitles"
|
||||
src={subtitle.link}
|
||||
srcLang={subtitle.language}
|
||||
label={subtitle.label ?? subtitle.language ?? subtitle.id}
|
||||
/>
|
||||
))}
|
||||
</Tech>
|
||||
)}
|
||||
{castStatus !== "connected" && castStatus !== "connecting" && (
|
||||
|
||||
Reference in New Issue
Block a user