Compare commits

..
2 Commits
Author SHA1 Message Date
zoriya 6ef72f22d2 fix(vlc): fix pip aspect ratio 2026-08-05 13:00:53 +02:00
zoriya 26a2731679 fix(backend): allow backend switch 2026-08-04 20:56:24 +02:00
11 changed files with 107 additions and 58 deletions
@@ -168,18 +168,25 @@ class OmniPlayer(
} }
} }
override fun dispose() { @Volatile
showNotification = false private var released = false
super.dispose()
eventMap.dispose() override fun release() {
if (released) return
released = true
showNotification = false
runOnMainThread { runOnMainThread {
eventMap.dispose()
castContext?.removeCastStateListener(castStateListener) castContext?.removeCastStateListener(castStateListener)
// release both cast and local players.
player.release() player.release()
} }
} }
override fun dispose() {
release()
super.dispose()
}
private fun buildMediaItem( private fun buildMediaItem(
src: com.margelo.nitro.omni.VideoSrc, src: com.margelo.nitro.omni.VideoSrc,
metadata: com.margelo.nitro.omni.Metadata?, metadata: com.margelo.nitro.omni.Metadata?,
@@ -1,6 +1,7 @@
package dev.zoriya.omni package dev.zoriya.omni
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import android.util.Rational import android.util.Rational
@@ -117,6 +118,7 @@ class OmniView(val context: ThemedReactContext) :
override var subtitleAssets: SubtitleAssets? = null override var subtitleAssets: SubtitleAssets? = null
init { init {
view.setBackgroundColor(Color.BLACK)
context.addLifecycleEventListener(this) context.addLifecycleEventListener(this)
} }
@@ -99,11 +99,14 @@ class VlcPlayer(ctx: Context) :
private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build() private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build()
@Volatile @Volatile
private var playerError: PlaybackException? = null private var playerError: PlaybackException? = null
@Volatile
private var released = false
private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY
private var userInitiatedTransition: Boolean = false private var userInitiatedTransition: Boolean = false
@Volatile @Volatile
private var cachedBufferedPosition: Long = 0L private var cachedBufferedPosition: Long = 0L
private var boundSurfaceView: SurfaceView? = null private var boundSurfaceView: SurfaceView? = null
private var lastVideoSize: VideoSize = VideoSize.UNKNOWN
private val availableCommands: Player.Commands = Player.Commands.Builder() private val availableCommands: Player.Commands = Player.Commands.Builder()
.add(COMMAND_PLAY_PAUSE) .add(COMMAND_PLAY_PAUSE)
@@ -136,6 +139,7 @@ class VlcPlayer(ctx: Context) :
override fun onEvent(event: MediaPlayer.Event) { override fun onEvent(event: MediaPlayer.Event) {
when (event.type) { when (event.type) {
MediaPlayer.Event.Opening -> { MediaPlayer.Event.Opening -> {
lastVideoSize = VideoSize.UNKNOWN
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_BUFFERING) it.onPlaybackStateChanged(STATE_BUFFERING)
} }
@@ -158,6 +162,7 @@ class VlcPlayer(ctx: Context) :
it.onIsPlayingChanged(true) it.onIsPlayingChanged(true)
it.onTracksChanged(getCurrentTracks()) it.onTracksChanged(getCurrentTracks())
} }
maybeNotifyVideoSizeChanged()
} }
MediaPlayer.Event.Paused -> { MediaPlayer.Event.Paused -> {
@@ -211,12 +216,19 @@ class VlcPlayer(ctx: Context) :
// this improves perf & battery life (native -> js bridge is expensive) // this improves perf & battery life (native -> js bridge is expensive)
MediaPlayer.Event.TimeChanged -> Unit MediaPlayer.Event.TimeChanged -> Unit
// vlc has no video-size event, Vout event still has unknown size.
// This is the first callback with known size.
MediaPlayer.Event.PositionChanged -> {
if (lastVideoSize == VideoSize.UNKNOWN) maybeNotifyVideoSizeChanged()
}
MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESAdded,
MediaPlayer.Event.ESDeleted, MediaPlayer.Event.ESDeleted,
MediaPlayer.Event.ESSelected -> { MediaPlayer.Event.ESSelected -> {
notifyListeners(EVENT_TRACKS_CHANGED) { notifyListeners(EVENT_TRACKS_CHANGED) {
it.onTracksChanged(getCurrentTracks()) it.onTracksChanged(getCurrentTracks())
} }
maybeNotifyVideoSizeChanged()
} }
MediaPlayer.Event.LengthChanged -> { MediaPlayer.Event.LengthChanged -> {
@@ -353,6 +365,7 @@ class VlcPlayer(ctx: Context) :
media.addOption(":start-time=${targetMs / 1000.0}") media.addOption(":start-time=${targetMs / 1000.0}")
player.setMedia(media) player.setMedia(media)
media.release()
} }
} }
@@ -430,7 +443,7 @@ class VlcPlayer(ctx: Context) :
when { when {
playerError != null -> STATE_IDLE playerError != null -> STATE_IDLE
currentMediaItemIndex == INDEX_UNSET -> STATE_IDLE currentMediaItemIndex == INDEX_UNSET -> STATE_IDLE
player.media == null -> STATE_IDLE player.media?.also { it.release() } == null -> STATE_IDLE
player.playerState == IMedia.State.Opening -> STATE_BUFFERING player.playerState == IMedia.State.Opening -> STATE_BUFFERING
player.isPlaying -> STATE_READY player.isPlaying -> STATE_READY
player.isSeekable && player.time >= player.length && player.length > 0 -> STATE_ENDED player.isSeekable && player.time >= player.length && player.length > 0 -> STATE_ENDED
@@ -519,13 +532,17 @@ class VlcPlayer(ctx: Context) :
} }
override fun release() { override fun release() {
if (released) return
released = true
player.setEventListener(null) player.setEventListener(null)
listeners.release() listeners.release()
abandonAudioFocus() abandonAudioFocus()
player.stop() player.stop()
clearVideoSurface() clearVideoSurface()
player.release() applicationHandler.post {
libVLC.release() player.release()
libVLC.release()
}
} }
override fun getCurrentTracks(): Tracks { override fun getCurrentTracks(): Tracks {
@@ -902,10 +919,30 @@ class VlcPlayer(ctx: Context) :
override fun clearVideoTextureView(textureView: TextureView?) = Unit override fun clearVideoTextureView(textureView: TextureView?) = Unit
/**
* VLC never pushes a video-size event, so emit our own once the selected video
* track (and its sample aspect ratio) is known. Listeners such as OmniView use
* this to size the content frame -> the PIP aspect ratio matches the video
* instead of the raw SurfaceView bounds (ExoPlayer does this on its own).
*/
private fun maybeNotifyVideoSizeChanged() {
val size = getVideoSize()
if (size == VideoSize.UNKNOWN || size == lastVideoSize) return
lastVideoSize = size
notifyListeners(EVENT_VIDEO_SIZE_CHANGED) {
it.onVideoSizeChanged(size)
}
}
override fun getVideoSize(): VideoSize { override fun getVideoSize(): VideoSize {
val videoTrack = player.getSelectedTrack(IMedia.Track.Type.Video) as? VideoTrack ?: return VideoSize.UNKNOWN val videoTrack = player.getSelectedTrack(IMedia.Track.Type.Video) as? VideoTrack ?: return VideoSize.UNKNOWN
if (videoTrack.width <= 0 || videoTrack.height <= 0) return VideoSize.UNKNOWN if (videoTrack.width <= 0 || videoTrack.height <= 0) return VideoSize.UNKNOWN
return VideoSize(videoTrack.width, videoTrack.height) val par = if (videoTrack.sarNum > 0 && videoTrack.sarDen > 0) {
videoTrack.sarNum.toFloat() / videoTrack.sarDen.toFloat()
} else {
1f
}
return VideoSize(videoTrack.width, videoTrack.height, par)
} }
override fun getSurfaceSize(): Size = Size.UNKNOWN override fun getSurfaceSize(): Size = Size.UNKNOWN
+8 -39
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, 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,
@@ -134,10 +134,6 @@ function PlayerExample({
); );
const switchBackend = (target: AndroidBackend) => { const switchBackend = (target: AndroidBackend) => {
if (target === backend) return; if (target === backend) return;
// Pause before tearing down: the native media-session guard refuses a
// second player while the current one is still *playing*, so this keeps
// the transition clean (we don't preserve state across a switch anyway).
player.pause();
onSwitchBackend(target); onSwitchBackend(target);
}; };
@@ -461,27 +457,13 @@ function App(): React.JSX.Element {
// notification stays hidden) before any source is set. // notification stays hidden) before any source is set.
const [hasSource, setHasSource] = useState(false); const [hasSource, setHasSource] = useState(false);
const [backend, setBackend] = useState<AndroidBackend>("vlc"); const [backend, setBackend] = useState<AndroidBackend>("vlc");
const [pendingBackend, setPendingBackend] = useState<AndroidBackend | null>(
null,
);
// Switching backend recreates the native player, so we fully unmount the // Switching the backend just updates the prop: OmniProvider recreates (and
// provider first (rendering nothing) and remount it on the next tick with // disposes) the native player internally, so the change applies live.
// the new backend. This lets the old OmniView/player tear down before the
// new one is created, avoiding the "only one view"/"two players" guards.
const handleSwitchBackend = useCallback((next: AndroidBackend) => { const handleSwitchBackend = useCallback((next: AndroidBackend) => {
setPendingBackend(next); setBackend(next);
}, []); }, []);
useEffect(() => {
if (pendingBackend === null) return;
const id = setTimeout(() => {
setBackend(pendingBackend);
setPendingBackend(null);
}, 300);
return () => clearTimeout(id);
}, [pendingBackend]);
const handlePrev = useCallback(() => { const handlePrev = useCallback(() => {
setCurrentIndex((index) => (index === 0 ? PLAYLIST.length - 1 : index - 1)); setCurrentIndex((index) => (index === 0 ? PLAYLIST.length - 1 : index - 1));
}, []); }, []);
@@ -525,18 +507,8 @@ function App(): React.JSX.Element {
[currentIndex], [currentIndex],
); );
// While switching, render nothing so the current player is torn down first.
if (pendingBackend !== null) {
return (
<View style={styles.switching}>
<Text style={styles.subheading}>Switching to {pendingBackend}…</Text>
</View>
);
}
return ( return (
<OmniProvider <OmniProvider
key={backend}
source={hasSource ? source : undefined} source={hasSource ? source : undefined}
backend={{ android: backend }} backend={{ android: backend }}
cast={{ receiverApplicationId: "D8FB0FC1" }} cast={{ receiverApplicationId: "D8FB0FC1" }}
@@ -564,12 +536,6 @@ const styles = StyleSheet.create({
paddingVertical: 12, paddingVertical: 12,
gap: 12, gap: 12,
}, },
switching: {
flex: 1,
backgroundColor: "#0b1020",
alignItems: "center",
justifyContent: "center",
},
heading: { heading: {
fontSize: 24, fontSize: 24,
fontWeight: "700", fontWeight: "700",
@@ -581,7 +547,10 @@ const styles = StyleSheet.create({
}, },
video: { video: {
width: "100%", width: "100%",
aspectRatio: 16 / 9, // Intentionally wider than the 16:9 sample videos so the view's aspect
// ratio differs from the video's. This surfaces the PIP aspect-ratio bug
// where PIP would otherwise inherit the view's shape, not the video's.
aspectRatio: 21 / 9,
borderRadius: 14, borderRadius: 14,
overflow: "hidden", overflow: "hidden",
}, },
@@ -241,6 +241,10 @@ namespace margelo::nitro::omni {
} }
// Methods // Methods
void JHybridOmniPlayerSpec::release() {
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("release");
method(_javaPart);
}
void JHybridOmniPlayerSpec::play() { void JHybridOmniPlayerSpec::play() {
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("play"); static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("play");
method(_javaPart); method(_javaPart);
@@ -78,6 +78,7 @@ namespace margelo::nitro::omni {
public: public:
// Methods // Methods
void release() override;
void play() override; void play() override;
void pause() override; void pause() override;
void seekBy(double offset) override; void seekBy(double offset) override;
@@ -114,6 +114,10 @@ abstract class HybridOmniPlayerSpec: HybridObject() {
abstract val renditions: Array<Rendition> abstract val renditions: Array<Rendition>
// Methods // Methods
@DoNotStrip
@Keep
abstract fun release(): Unit
@DoNotStrip @DoNotStrip
@Keep @Keep
abstract fun play(): Unit abstract fun play(): Unit
+1
View File
@@ -39,6 +39,7 @@ namespace margelo::nitro::omni {
prototype.registerHybridGetter("audios", &HybridOmniPlayerSpec::getAudios); prototype.registerHybridGetter("audios", &HybridOmniPlayerSpec::getAudios);
prototype.registerHybridGetter("subtitles", &HybridOmniPlayerSpec::getSubtitles); prototype.registerHybridGetter("subtitles", &HybridOmniPlayerSpec::getSubtitles);
prototype.registerHybridGetter("renditions", &HybridOmniPlayerSpec::getRenditions); prototype.registerHybridGetter("renditions", &HybridOmniPlayerSpec::getRenditions);
prototype.registerHybridMethod("release", &HybridOmniPlayerSpec::release);
prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play); prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play);
prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause); prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause);
prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy); prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy);
+1
View File
@@ -91,6 +91,7 @@ namespace margelo::nitro::omni {
public: public:
// Methods // Methods
virtual void release() = 0;
virtual void play() = 0; virtual void play() = 0;
virtual void pause() = 0; virtual void pause() = 0;
virtual void seekBy(double offset) = 0; virtual void seekBy(double offset) = 0;
+31 -10
View File
@@ -1,9 +1,18 @@
import { createContext, type ReactNode, useContext, useEffect } from "react"; import {
createContext,
type ReactNode,
useContext,
useEffect,
useEffectEvent,
useState,
} from "react";
import { NitroModules } from "react-native-nitro-modules"; import { NitroModules } from "react-native-nitro-modules";
import type { OmniPlayerFactory } from "./specs/omni-player.nitro"; import type {
import type { OmniPlayer, PlayerBackend } from "./types/player"; OmniPlayer as NativeOmniPlayer,
OmniPlayerFactory,
} from "./specs/omni-player.nitro";
import type { AndroidBackend, OmniPlayer, PlayerBackend } from "./types/player";
import type { CastOptions, Source } from "./types/source"; import type { CastOptions, Source } from "./types/source";
import { useLazyRef } from "./utils/lazy-ref";
const ProviderFactory = const ProviderFactory =
NitroModules.createHybridObject<OmniPlayerFactory>("OmniPlayerFactory"); NitroModules.createHybridObject<OmniPlayerFactory>("OmniPlayerFactory");
@@ -23,18 +32,30 @@ export const OmniProvider = ({
children: ReactNode; children: ReactNode;
showNotification?: boolean; showNotification?: boolean;
}) => { }) => {
const player = useLazyRef(() => const [player, setPlayer] = useState<NativeOmniPlayer | null>(null);
ProviderFactory.createPlayer(source, backend, cast),
const createPlayer = useEffectEvent((aBackend: AndroidBackend) =>
ProviderFactory.createPlayer(source, { android: aBackend }, cast),
); );
useEffect(() => { useEffect(() => {
player.source = source; setPlayer(createPlayer(backend.android ?? "vlc"));
}, [source]); }, [backend.android]);
useEffect(() => { useEffect(() => {
player.showNotification = showNotification; if (!player) return;
}, [showNotification]); return () => player.release();
}, [player]);
useEffect(() => {
if (player) player.source = source;
}, [player, source]);
useEffect(() => {
if (player) player.showNotification = showNotification;
}, [player, showNotification]);
if (!player) return null;
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>; return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>;
}; };
+2
View File
@@ -80,6 +80,8 @@ export interface OmniPlayer
extends HybridObject<{ android: "kotlin" }>, extends HybridObject<{ android: "kotlin" }>,
OmniPlayerT { OmniPlayerT {
readonly eventMap: OmniEventMap; readonly eventMap: OmniEventMap;
release(): void;
} }
export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> { export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> {