Compare commits

..
2 Commits
Author SHA1 Message Date
zoriya 59218e8fa0 Fix git installs 2026-07-27 22:19:47 +02:00
zoriyaandClaude Opus 4.8 f98c787b15 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
2026-07-27 22:11:35 +02:00
14 changed files with 109 additions and 182 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:22.3.1") implementation("com.google.android.gms:play-services-cast-framework:21.5.0")
implementation("androidx.mediarouter:mediarouter:1.7.0") implementation("androidx.mediarouter:mediarouter:1.7.0")
} }
@@ -13,7 +13,6 @@ class OmniCastOptionsProvider : OptionsProvider {
return CastOptions.Builder() return CastOptions.Builder()
.setReceiverApplicationId(appId) .setReceiverApplicationId(appId)
.setShowSystemOutputSwitcherOnCastIconClick(true)
.build() .build()
} }
@@ -56,6 +56,8 @@ class OmniCastTrackSelector : CastTrackSelector() {
indices.forEach { selections.remove(groups[it]) } indices.forEach { selections.remove(groups[it]) }
if (disabled) return 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 overrides = request.trackSelectionParameters.overrides
val overrideMatch = indices.firstOrNull { i -> val overrideMatch = indices.firstOrNull { i ->
overrides.keys.any { it.id == groups[i].id } overrides.keys.any { it.id == groups[i].id }
@@ -43,14 +43,8 @@ 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 { meta.artist?.let { putString(CastMediaMetadata.KEY_ARTIST, it.toString()) }
putString(CastMediaMetadata.KEY_ARTIST, it.toString()) meta.albumTitle?.let { putString(CastMediaMetadata.KEY_ALBUM_TITLE, 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)) }
} }
@@ -63,8 +57,7 @@ class OmniMediaItemConverter : MediaItemConverter {
} }
val tracks = local.subtitleConfigurations.mapIndexed { index, subtitle -> val tracks = local.subtitleConfigurations.mapIndexed { index, subtitle ->
// caf tracks are 1 based instead of 0 based. videojs does the same MediaTrack.Builder(index.toLong(), MediaTrack.TYPE_TEXT)
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,7 +37,9 @@ 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 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.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
@@ -78,6 +80,14 @@ class OmniPlayer(
private val castStateListener = private val castStateListener =
CastStateListener { eventMap.emitCastStatus(computeCastStatus()) } 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 { val player: Player = runOnMainThreadSync {
castOptions?.receiverApplicationId?.let { receiverApplicationId = it } castOptions?.receiverApplicationId?.let { receiverApplicationId = it }
val cc = try { val cc = try {
@@ -91,6 +101,21 @@ class OmniPlayer(
localPlayer localPlayer
} else { } else {
cc.addCastStateListener(castStateListener) 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) val remote = RemoteCastPlayer.Builder(ctx)
.setMediaItemConverter(OmniMediaItemConverter()) .setMediaItemConverter(OmniMediaItemConverter())
.setTrackSelector(OmniCastTrackSelector()) .setTrackSelector(OmniCastTrackSelector())
@@ -106,34 +131,21 @@ class OmniPlayer(
override var showNotification: Boolean? = false override var showNotification: Boolean? = false
set(value) { set(value) {
field = value if (value == true) {
syncNotificationService()
}
private var serviceRunning = false
private fun syncNotificationService() {
val shouldShow = showNotification == true && source != null
when {
shouldShow && !serviceRunning -> {
val otherIsPlaying = notificationPlayer?.let { other -> val otherIsPlaying = notificationPlayer?.let { other ->
other !== localPlayer && runOnMainThreadSync { other.isPlaying } runOnMainThreadSync { other.isPlaying }
} == true } == true
if (otherIsPlaying) { if (otherIsPlaying) {
throw Error("Two players can't display notifications at the same time.") throw Error("Two players can't display notifications at the same time.")
} }
notificationPlayer = localPlayer notificationPlayer = localPlayer
ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java)) ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java))
serviceRunning = true } else if (field == true && notificationPlayer == localPlayer) {
}
!shouldShow && serviceRunning -> {
ctx.stopService(Intent(ctx, OmniPlayerService::class.java)) ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
if (notificationPlayer == localPlayer) notificationPlayer = null notificationPlayer = null
serviceRunning = false
} }
field = value
} }
}
override val castStatus: CastStatus override val castStatus: CastStatus
get() = runOnMainThreadSync { computeCastStatus() } get() = runOnMainThreadSync { computeCastStatus() }
@@ -161,9 +173,6 @@ 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()
} }
@@ -176,6 +185,7 @@ class OmniPlayer(
eventMap.dispose() eventMap.dispose()
runOnMainThread { runOnMainThread {
castContext?.removeCastStateListener(castStateListener) castContext?.removeCastStateListener(castStateListener)
mediaRouter?.removeCallback(mediaRouterCallback)
// release both cast and local players. // release both cast and local players.
player.release() player.release()
} }
@@ -308,9 +318,9 @@ class OmniPlayer(
field = value field = value
if (value == null) { if (value == null) {
runOnMainThreadSync { runOnMainThreadSync {
player.clearMediaItems() player.setMediaItem(MediaItem.EMPTY)
player.prepare()
} }
syncNotificationService()
return return
} }
val handleAudioFocus = val handleAudioFocus =
@@ -343,7 +353,6 @@ class OmniPlayer(
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong()) player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong())
player.prepare() player.prepare()
} }
syncNotificationService()
} }
override fun play() { override fun play() {
@@ -381,8 +390,10 @@ class OmniPlayer(
override fun selectAudio(audio: Track) = selectTrack(C.TRACK_TYPE_AUDIO, audio) override fun selectAudio(audio: Track) = selectTrack(C.TRACK_TYPE_AUDIO, audio)
override fun selectSubtitle(subtitle: Track?) = selectTrack(C.TRACK_TYPE_TEXT, subtitle) // 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?) { private fun selectTrack(type: Int, track: Track?) {
runOnMainThreadSync { runOnMainThreadSync {
val params = player.trackSelectionParameters.buildUpon() val params = player.trackSelectionParameters.buildUpon()
@@ -411,34 +422,28 @@ class OmniPlayer(
} }
} }
// 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> { private fun tracksByType(trackType: Int): Array<Track> {
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()
// when casting tracks are stripped of some metadata, recover it return groups.map {
val castTracks = castMediaTracksById() it.getTrackFormat(0).run {
Track(
return groups.map { group -> id = it.mediaTrackGroup.id,
val format = group.getTrackFormat(0) label = this.label,
language = this.language,
val castTrackId = group.mediaTrackGroup.id.substringAfterLast("track=", "").toLongOrNull() selected = it.isSelected
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 }
@@ -519,13 +524,7 @@ class OmniPlayerService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val available = OmniPlayer.notificationPlayer player = OmniPlayer.notificationPlayer ?: throw Error("No player available")
if (available == null) {
startForeground(1, createImmediateNotification())
stopSelf()
return
}
player = available
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply { val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
} }
@@ -561,8 +560,7 @@ class OmniPlayerService : MediaSessionService() {
player = current player = current
mediaSession.player = current mediaSession.player = current
} }
super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
return START_NOT_STICKY
} }
private fun createImmediateNotification(): Notification { private fun createImmediateNotification(): Notification {
@@ -603,7 +601,7 @@ class OmniPlayerService : MediaSessionService() {
} }
override fun onDestroy() { override fun onDestroy() {
if (::mediaSession.isInitialized) mediaSession.release() mediaSession.release()
super.onDestroy() super.onDestroy()
} }
} }
@@ -689,27 +689,25 @@ class VlcPlayer(ctx: Context) :
} }
override fun getCurrentTimeline(): Timeline { override fun getCurrentTimeline(): Timeline {
// snapshot the current playlist for the returned timeline. if (mediaItems.isEmpty()) return Timeline.EMPTY
// timelines are immutable
val items = mediaItems
val currentIndex = currentMediaItemIndex
val currentDurationUs = duration.takeIf { it != TIME_UNSET }?.let { it * 1000L } ?: TIME_UNSET
if (items.isEmpty()) return Timeline.EMPTY
return object : Timeline() { return object : Timeline() {
private fun durationUsForIndex(index: Int): Long = private fun durationUsForIndex(index: Int): Long {
if (index == currentIndex) currentDurationUs else TIME_UNSET if (index != currentMediaItemIndex) return TIME_UNSET
val dur = duration
return if (dur == TIME_UNSET) TIME_UNSET else dur * 1000L
}
override fun getWindowCount(): Int = items.size override fun getWindowCount(): Int = mediaItems.size
override fun getWindow( override fun getWindow(
windowIndex: Int, windowIndex: Int,
window: Window, window: Window,
defaultPositionProjectionUs: Long defaultPositionProjectionUs: Long
): Window { ): Window {
val index = windowIndex.coerceIn(items.indices) check(windowIndex in mediaItems.indices)
return window.set( return window.set(
index, windowIndex,
items[index], mediaItems[windowIndex],
null, null,
TIME_UNSET, TIME_UNSET,
TIME_UNSET, TIME_UNSET,
@@ -718,33 +716,34 @@ class VlcPlayer(ctx: Context) :
false, false,
null, null,
0L, 0L,
durationUsForIndex(index), durationUsForIndex(windowIndex),
index, 0,
index, mediaItems.size - 1,
0L 0L
) )
} }
override fun getPeriodCount(): Int = items.size override fun getPeriodCount(): Int = mediaItems.size
override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period {
val index = periodIndex.coerceIn(items.indices) check(periodIndex in mediaItems.indices)
return period.set(index, index, index, durationUsForIndex(index), 0L) return period.set(periodIndex, periodIndex, 0, durationUsForIndex(periodIndex), 0L)
} }
override fun getIndexOfPeriod(uid: Any): Int { override fun getIndexOfPeriod(uid: Any): Int {
return if (uid is Int && uid in items.indices) uid else INDEX_UNSET return if (uid is Int && uid in mediaItems.indices) uid else INDEX_UNSET
} }
override fun getUidOfPeriod(periodIndex: Int): Any { override fun getUidOfPeriod(periodIndex: Int): Any {
return periodIndex.coerceIn(items.indices) check(periodIndex in mediaItems.indices)
return periodIndex
} }
} }
} }
override fun getCurrentPeriodIndex() = currentMediaItemIndex.coerceAtLeast(0) override fun getCurrentPeriodIndex() = currentMediaItemIndex
override fun getCurrentMediaItemIndex() = currentMediaItemIndex.coerceAtLeast(0) override fun getCurrentMediaItemIndex() = currentMediaItemIndex
override fun getDuration(): Long = player.length.takeIf { it > 0 } ?: TIME_UNSET override fun getDuration(): Long = player.length.takeIf { it > 0 } ?: TIME_UNSET
-1
View File
@@ -1 +0,0 @@
module.exports = require("./plugin/build").default;
+6
View File
@@ -0,0 +1,6 @@
export {
default,
withCast,
withMediaNotifications,
withPip,
} from "./plugin/src/index";
+7 -33
View File
@@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, 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,
@@ -57,16 +57,12 @@ function PlayerExample({
trackLabel, trackLabel,
backend, backend,
onSwitchBackend, onSwitchBackend,
hasSource,
onLoad,
}: { }: {
onPrev: () => void; onPrev: () => void;
onNext: () => void; onNext: () => void;
trackLabel: string; trackLabel: string;
backend: AndroidBackend; backend: AndroidBackend;
onSwitchBackend: (backend: AndroidBackend) => void; onSwitchBackend: (backend: AndroidBackend) => void;
hasSource: boolean;
onLoad: () => void;
}): React.JSX.Element { }): React.JSX.Element {
const player = usePlayer(); const player = usePlayer();
const status = usePlayerState("status"); const status = usePlayerState("status");
@@ -79,8 +75,7 @@ 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<{ id: number; message: string }[]>([]); const [logs, setLogs] = useState<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],
@@ -103,7 +98,7 @@ function PlayerExample({
const pushLog = useCallback((message: string) => { const pushLog = useCallback((message: string) => {
setLogs((prev) => { setLogs((prev) => {
const next = [{ id: logId.current++, message }, ...prev]; const next = [message, ...prev];
return next.slice(0, 8); return next.slice(0, 8);
}); });
}, []); }, []);
@@ -237,20 +232,7 @@ function PlayerExample({
return ( return (
<ScrollView style={styles.container}> <ScrollView style={styles.container}>
<Text style={styles.heading}>react-native-omni</Text> <Text style={styles.heading}>react-native-omni</Text>
<Text style={styles.subheading}> <Text style={styles.subheading}>{trackLabel}</Text>
{hasSource ? trackLabel : "No media loaded"}
</Text>
<View style={styles.row}>
<Pressable
style={[styles.button, hasSource && styles.selectedTrackButton]}
onPress={onLoad}
>
<Text style={styles.buttonText}>
{hasSource ? "Unload media" : "Load media"}
</Text>
</Pressable>
</View>
<View style={styles.row}> <View style={styles.row}>
<Pressable <Pressable
@@ -502,8 +484,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.id} style={styles.logText}> <Text key={`${entry}`} style={styles.logText}>
{entry.message} {entry}
</Text> </Text>
)) ))
)} )}
@@ -514,9 +496,6 @@ function PlayerExample({
function App(): React.JSX.Element { function App(): React.JSX.Element {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
// Start with no media loaded so we can verify the app boots (and the media
// notification stays hidden) before any source is set.
const [hasSource, setHasSource] = useState(false);
const [backend, setBackend] = useState<AndroidBackend>("vlc"); const [backend, setBackend] = useState<AndroidBackend>("vlc");
const [pendingBackend, setPendingBackend] = useState<AndroidBackend | null>( const [pendingBackend, setPendingBackend] = useState<AndroidBackend | null>(
null, null,
@@ -557,8 +536,6 @@ 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",
@@ -594,9 +571,8 @@ function App(): React.JSX.Element {
return ( return (
<OmniProvider <OmniProvider
key={backend} key={backend}
source={hasSource ? source : undefined} source={source}
backend={{ android: backend }} backend={{ android: backend }}
cast={{receiverApplicationId: "D8FB0FC1"}}
showNotification showNotification
> >
<PlayerExample <PlayerExample
@@ -605,8 +581,6 @@ function App(): React.JSX.Element {
trackLabel={PLAYLIST[currentIndex].title} trackLabel={PLAYLIST[currentIndex].title}
backend={backend} backend={backend}
onSwitchBackend={handleSwitchBackend} onSwitchBackend={handleSwitchBackend}
hasSource={hasSource}
onLoad={() => setHasSource((v) => !v)}
/> />
</OmniProvider> </OmniProvider>
); );
@@ -28,8 +28,5 @@
<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>
+4 -8
View File
@@ -12,9 +12,8 @@
"android": " cd example && bunx react-native run-android --no-packager --port 8082", "android": " cd example && bunx react-native run-android --no-packager --port 8082",
"web": "cd example && bun web", "web": "cd example && bun web",
"typecheck": "tsc --noEmit && tsc -p plugin/tsconfig.json --noEmit", "typecheck": "tsc --noEmit && tsc -p plugin/tsconfig.json --noEmit",
"build:plugin": "tsc -p plugin/tsconfig.json", "build": "bun run typecheck && bob build",
"build": "bun run typecheck && bob build && bun run build:plugin", "prepare": "bob build",
"prepare": "bun run build",
"codegen": "nitrogen --logLevel=\"debug\" && node post-script.js", "codegen": "nitrogen --logLevel=\"debug\" && node post-script.js",
"release": "semantic-release" "release": "semantic-release"
}, },
@@ -41,8 +40,8 @@
"ios/**/*.mm", "ios/**/*.mm",
"ios/**/*.cpp", "ios/**/*.cpp",
"ios/**/*.swift", "ios/**/*.swift",
"app.plugin.js", "app.plugin.ts",
"plugin/build", "plugin/src",
"*.podspec", "*.podspec",
"README.md" "README.md"
], ],
@@ -98,8 +97,5 @@
} }
] ]
] ]
},
"patchedDependencies": {
"jassub@2.5.7": "patches/jassub@2.5.7.patch"
} }
} }
-17
View File
@@ -7,8 +7,6 @@ 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,
@@ -38,21 +36,6 @@ 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;
} }
-18
View File
@@ -126,24 +126,6 @@ export const useEvent = <Event extends keyof OmniEvents>(
} }
return undefined; return undefined;
}, [event, player]); }, [event, player]);
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>( function createMapper<Key extends keyof OmniPlayerState, State, Result>(
+9 -10
View File
@@ -216,6 +216,9 @@ export class WebOmniPlayer implements OmniPlayer {
} }
get subtitles(): Track[] { 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 textTracks = selectTextTrack(this._store.state)?.textTrackList ?? [];
return textTracks return textTracks
.filter((x) => x.kind === "subtitles" || x.kind === "captions") .filter((x) => x.kind === "subtitles" || x.kind === "captions")
@@ -223,24 +226,20 @@ export class WebOmniPlayer implements OmniPlayer {
id: track.id!, id: track.id!,
label: track.label, label: track.label,
language: track.language, language: track.language,
selected: selected: track.mode === "showing",
track.mode === "showing" || this.overlaySubtitle?.id === track.id,
})); }));
} }
selectSubtitle(subtitle?: Track): void { 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); const tracks = selectTextTrack(this._store.state);
tracks?.selectSubtitlesTrack(subtitle ? subtitle.id : "off");
const overlay = subtitle const overlay = subtitle
? this.overlaySubtitles.find((s) => s.id === subtitle.id) ? this.overlaySubtitles.find((s) => s.id === subtitle.id)
: undefined; : undefined;
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); this.setOverlaySubtitle(overlay ?? null);
} }