diff --git a/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt b/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt index 4339eae..881f0fc 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniCastTrackSelector.kt @@ -56,6 +56,10 @@ class OmniCastTrackSelector : CastTrackSelector() { indices.forEach { selections.remove(groups[it]) } 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 name: String? = tracks[i].name val language: String? = tracks[i].language @@ -64,6 +68,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/OmniMediaItemConverter.kt b/android/src/main/java/dev/zoriya/omni/OmniMediaItemConverter.kt index 9e58ea8..ce18879 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniMediaItemConverter.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniMediaItemConverter.kt @@ -57,7 +57,8 @@ class OmniMediaItemConverter : MediaItemConverter { } 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) .setContentId(subtitle.uri.toString()) .setContentType(subtitle.mimeType ?: "text/vtt") diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt index 22abcb9..b1bf619 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt @@ -361,55 +361,35 @@ class OmniPlayer( } } - override fun selectAudio(audio: Track) { - runOnMainThreadSync { - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .setPreferredAudioLanguage(audio.language) - .setPreferredAudioLabels(*(audio.label?.let { arrayOf(it) } ?: emptyArray())) - .build() - } - } + override fun selectAudio(audio: Track) = selectTrack(C.TRACK_TYPE_AUDIO, audio) - 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 + override fun selectSubtitle(subtitle: Track?) = selectTrack(C.TRACK_TYPE_TEXT, subtitle) + + private fun selectTrack(type: Int, track: Track?) { 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() + val params = player.trackSelectionParameters.buildUpon() + .clearOverridesOfType(type) + if (track == null) { + params.setTrackTypeDisabled(type, true) } 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. + 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() } } @@ -417,18 +397,30 @@ class OmniPlayer( val groups = player.currentTracks.groups.filter { it.type == trackType } if (groups.isEmpty()) return emptyArray() - return groups.map { - it.getTrackFormat(0).run { - Track( - id = it.mediaTrackGroup.id, - label = this.label, - language = this.language, - selected = it.isSelected - ) - } + // when casting tracks are stripped of some metadata, recover it + val castTracks = castMediaTracksById() + + return groups.map { group -> + val format = group.getTrackFormat(0) + + 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() } + private fun castMediaTracksById(): Map { + if (!isCasting) return emptyMap() + val tracks = castContext?.sessionManager?.currentCastSession + ?.remoteMediaClient?.mediaInfo?.mediaTracks ?: return emptyMap() + return tracks.associateBy { it.id } + } + private fun getRenditions(): Array { val group = player.currentTracks.groups.firstOrNull { it.isSelected && it.type == C.TRACK_TYPE_VIDEO } @@ -492,10 +484,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/example/App.tsx b/example/App.tsx index a4a03c9..b0cadb1 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -536,6 +536,8 @@ function App(): React.JSX.Element { { id: "kusu", link: "https://jassub.pages.dev/subtitles/Kusriya%20S2%20OP1v3.ass", + label: "ass test", + language: "jp", }, { id: "pgs", diff --git a/src/events.web.tsx b/src/events.web.tsx index 18f4780..9483c78 100644 --- a/src/events.web.tsx +++ b/src/events.web.tsx @@ -127,9 +127,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(() => { diff --git a/src/player.web.tsx b/src/player.web.tsx index ed16cce..42cd9a1 100644 --- a/src/player.web.tsx +++ b/src/player.web.tsx @@ -217,47 +217,31 @@ export class WebOmniPlayer implements OmniPlayer { get subtitles(): Track[] { 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!, label: track.label, 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 { + const tracks = selectTextTrack(this._store.state); const overlay = subtitle ? this.overlaySubtitles.find((s) => s.id === subtitle.id) : 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); 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" && (