Compare commits

...
4 Commits
5 changed files with 160 additions and 47 deletions
@@ -106,21 +106,34 @@ class OmniPlayer(
override var showNotification: Boolean? = false override var showNotification: Boolean? = false
set(value) { set(value) {
if (value == true) { field = value
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 ->
runOnMainThreadSync { other.isPlaying } other !== localPlayer && 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))
} else if (field == true && notificationPlayer == localPlayer) { serviceRunning = true
}
!shouldShow && serviceRunning -> {
ctx.stopService(Intent(ctx, OmniPlayerService::class.java)) ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
notificationPlayer = null if (notificationPlayer == localPlayer) notificationPlayer = null
serviceRunning = false
} }
field = value
} }
}
override val castStatus: CastStatus override val castStatus: CastStatus
get() = runOnMainThreadSync { computeCastStatus() } get() = runOnMainThreadSync { computeCastStatus() }
@@ -266,7 +279,7 @@ class OmniPlayer(
override var playbackRate by mainThreadProperty( override var playbackRate by mainThreadProperty(
get = { player.playbackParameters.speed.toDouble() }, get = { player.playbackParameters.speed.toDouble() },
set = { value -> player.setPlaybackSpeed(value.toFloat().coerceAtLeast(0f)) } set = { value -> player.setPlaybackSpeed(value.toFloat().coerceAtLeast(0.01f)) }
) )
override var muted by mainThreadProperty( override var muted by mainThreadProperty(
@@ -297,6 +310,7 @@ class OmniPlayer(
runOnMainThreadSync { runOnMainThreadSync {
player.clearMediaItems() player.clearMediaItems()
} }
syncNotificationService()
return return
} }
val handleAudioFocus = val handleAudioFocus =
@@ -329,6 +343,7 @@ 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() {
@@ -504,7 +519,13 @@ class OmniPlayerService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
player = OmniPlayer.notificationPlayer ?: throw Error("No player available") val available = OmniPlayer.notificationPlayer
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)
} }
@@ -540,7 +561,8 @@ class OmniPlayerService : MediaSessionService() {
player = current player = current
mediaSession.player = current mediaSession.player = current
} }
return super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
return START_NOT_STICKY
} }
private fun createImmediateNotification(): Notification { private fun createImmediateNotification(): Notification {
@@ -581,7 +603,7 @@ class OmniPlayerService : MediaSessionService() {
} }
override fun onDestroy() { override fun onDestroy() {
mediaSession.release() if (::mediaSession.isInitialized) mediaSession.release()
super.onDestroy() super.onDestroy()
} }
} }
@@ -4,6 +4,7 @@ import android.app.PictureInPictureParams
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import android.util.Rational import android.util.Rational
import android.view.Gravity
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.View import android.view.View
@@ -11,6 +12,10 @@ import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.SubtitleView
import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ThemedReactContext
import com.margelo.nitro.omni.HybridOmniPlayerSpec import com.margelo.nitro.omni.HybridOmniPlayerSpec
@@ -68,6 +73,16 @@ class OmniView(val context: ThemedReactContext) :
} }
} }
private val contentFrame = AspectRatioFrameLayout(context).apply {
setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT)
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
Gravity.CENTER
)
view.addView(this)
}
private val surfaceView = SurfaceView(context).apply { private val surfaceView = SurfaceView(context).apply {
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
@@ -75,8 +90,19 @@ class OmniView(val context: ThemedReactContext) :
) )
holder.addCallback(this@OmniView) holder.addCallback(this@OmniView)
addOnLayoutChangeListener(this@OmniView) addOnLayoutChangeListener(this@OmniView)
contentFrame.addView(this)
}
private val subtitleView = SubtitleView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
setUserDefaultStyle()
setUserDefaultTextSize()
view.addView(this) view.addView(this)
} }
private var surfaceReady = false private var surfaceReady = false
private var boundPlayer: OmniPlayer? = null private var boundPlayer: OmniPlayer? = null
private var rootContent: ViewGroup? = null private var rootContent: ViewGroup? = null
@@ -94,6 +120,20 @@ class OmniView(val context: ThemedReactContext) :
context.addLifecycleEventListener(this) context.addLifecycleEventListener(this)
} }
override fun onVideoSizeChanged(videoSize: VideoSize) {
contentFrame.setAspectRatio(
if (videoSize.width > 0 && videoSize.height > 0) {
videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height
} else {
0f
}
)
}
override fun onCues(cueGroup: CueGroup) {
subtitleView.setCues(cueGroup.cues)
}
override fun onLayoutChange( override fun onLayoutChange(
view: View?, view: View?,
left: Int, left: Int,
@@ -219,6 +259,11 @@ class OmniView(val context: ThemedReactContext) :
return return
} }
if (boundPlayer?.isCasting == true) {
clearPictureInPictureParams()
return
}
val autoEnterEnabled = val autoEnterEnabled =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
autoPip == true && autoPip == true &&
@@ -242,9 +287,18 @@ class OmniView(val context: ThemedReactContext) :
) )
} }
private fun clampedAspectRatio(width: Int, height: Int): Rational {
val minRatio = 0.42
val maxRatio = 2.38
val ratio = width.toDouble() / height.toDouble()
if (ratio in minRatio..maxRatio) return Rational(width, height)
val clamped = ratio.coerceIn(minRatio, maxRatio)
return Rational((clamped * 1000).toInt(), 1000)
}
private fun buildPipParams(autoEnterEnabled: Boolean): PictureInPictureParams { private fun buildPipParams(autoEnterEnabled: Boolean): PictureInPictureParams {
val aspectRatio = if (surfaceView.width > 0 && surfaceView.height > 0) { val aspectRatio = if (surfaceView.width > 0 && surfaceView.height > 0) {
Rational(surfaceView.width, surfaceView.height) clampedAspectRatio(surfaceView.width, surfaceView.height)
} else { } else {
Rational(16, 9) Rational(16, 9)
} }
@@ -293,7 +347,7 @@ class OmniView(val context: ThemedReactContext) :
child.visibility = View.GONE child.visibility = View.GONE
} }
view.removeView(surfaceView) contentFrame.removeView(surfaceView)
root.addView( root.addView(
surfaceView, surfaceView,
FrameLayout.LayoutParams( FrameLayout.LayoutParams(
@@ -317,7 +371,7 @@ class OmniView(val context: ThemedReactContext) :
} }
rootContentViews = emptyList() rootContentViews = emptyList()
view.addView(surfaceView, FrameLayout.LayoutParams( contentFrame.addView(surfaceView, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT FrameLayout.LayoutParams.MATCH_PARENT
)) ))
@@ -97,9 +97,11 @@ class VlcPlayer(ctx: Context) :
private var mediaItems: List<MediaItem> = emptyList() private var mediaItems: List<MediaItem> = emptyList()
private var currentMediaItemIndex: Int = INDEX_UNSET private var currentMediaItemIndex: Int = INDEX_UNSET
private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build() private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build()
@Volatile
private var playerError: PlaybackException? = null private var playerError: PlaybackException? = null
private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY
private var userInitiatedTransition: Boolean = false private var userInitiatedTransition: Boolean = false
@Volatile
private var cachedBufferedPosition: Long = 0L private var cachedBufferedPosition: Long = 0L
private var boundSurfaceView: SurfaceView? = null private var boundSurfaceView: SurfaceView? = null
@@ -467,7 +469,11 @@ class VlcPlayer(ctx: Context) :
override fun getShuffleModeEnabled(): Boolean = false override fun getShuffleModeEnabled(): Boolean = false
override fun isLoading() = player.playerState == IMedia.State.Opening override fun isLoading(): Boolean {
val state = playbackState
if (state == STATE_IDLE || state == STATE_ENDED) return false
return player.playerState == IMedia.State.Opening
}
override fun seekTo( override fun seekTo(
mediaItemIndex: Int, mediaItemIndex: Int,
@@ -493,11 +499,11 @@ class VlcPlayer(ctx: Context) :
override fun getSeekForwardIncrement(): Long = 15_000L override fun getSeekForwardIncrement(): Long = 15_000L
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
player.rate = playbackParameters.speed.coerceAtLeast(0f) player.rate = playbackParameters.speed.coerceAtLeast(0.01f)
} }
override fun getPlaybackParameters(): PlaybackParameters = override fun getPlaybackParameters(): PlaybackParameters =
PlaybackParameters(player.rate.coerceAtLeast(0f)) PlaybackParameters(player.rate.takeIf { it > 0f } ?: 1f)
override fun stop() { override fun stop() {
player.stop() player.stop()
@@ -505,6 +511,7 @@ class VlcPlayer(ctx: Context) :
override fun release() { override fun release() {
player.setEventListener(null) player.setEventListener(null)
listeners.release()
abandonAudioFocus() abandonAudioFocus()
player.stop() player.stop()
clearVideoSurface() clearVideoSurface()
@@ -518,36 +525,43 @@ class VlcPlayer(ctx: Context) :
val selectedAudio = player.getSelectedTrack(IMedia.Track.Type.Audio) val selectedAudio = player.getSelectedTrack(IMedia.Track.Type.Audio)
val selectedSubtitle = player.getSelectedTrack(IMedia.Track.Type.Text) val selectedSubtitle = player.getSelectedTrack(IMedia.Track.Type.Text)
val videoTracks = player.getTracks(IMedia.Track.Type.Video) player.getTracks(IMedia.Track.Type.Video).orEmpty()
if (!videoTracks.isNullOrEmpty()) { .groupBy { listOf(it.language, it.name, it.description) }
val videoFormats = videoTracks.map { track -> .values.forEach { videoTracks ->
Format.Builder() val videoFormats = videoTracks.map { track ->
.setId(track.id) Format.Builder()
.setLabel(track.name) .setId(track.id)
.setLanguage(track.language) .setLabel(track.name)
.setSampleMimeType("video/x-unknown") .setLanguage(track.language)
.build() .setSampleMimeType("video/x-unknown")
.build()
}
val group = TrackGroup("vlc-video-${videoTracks.minOf { it.id }}", *videoFormats.toTypedArray())
val selected = BooleanArray(videoFormats.size) { idx ->
selectedVideo != null && selectedVideo.id == videoTracks[idx].id
}
val support = IntArray(videoFormats.size) { FORMAT_HANDLED }
result.add(Group(group, videoFormats.size > 1, support, selected))
} }
val group = TrackGroup("vlc-video", *videoFormats.toTypedArray())
val selected = BooleanArray(videoFormats.size) { idx ->
selectedVideo != null && selectedVideo.id == videoTracks[idx].id
}
val support = IntArray(videoFormats.size) { FORMAT_HANDLED }
result.add(Group(group, videoFormats.size > 1, support, selected))
}
player.getTracks(IMedia.Track.Type.Audio)?.forEach { track -> player.getTracks(IMedia.Track.Type.Audio).orEmpty()
val format = Format.Builder() .groupBy { listOf(it.language, it.name, it.description) }
.setId(track.id) .values.forEach { audioTracks ->
.setLabel(track.name) val audioFormats = audioTracks.map { track ->
.setLanguage(track.language) Format.Builder()
.setSampleMimeType("audio/x-unknown") .setId(track.id)
.build() .setLabel(track.name)
val group = TrackGroup("vlc-audio-${track.id}", format) .setLanguage(track.language)
val selected = booleanArrayOf(selectedAudio != null && selectedAudio.id == track.id) .setSampleMimeType("audio/x-unknown")
val support = intArrayOf(FORMAT_HANDLED) .build()
result.add(Group(group, false, support, selected)) }
} val group = TrackGroup("vlc-audio-${audioTracks.minOf { it.id }}", *audioFormats.toTypedArray())
val selected = BooleanArray(audioFormats.size) { idx ->
selectedAudio != null && selectedAudio.id == audioTracks[idx].id
}
val support = IntArray(audioFormats.size) { FORMAT_HANDLED }
result.add(Group(group, audioFormats.size > 1, support, selected))
}
player.getTracks(IMedia.Track.Type.Text)?.forEach { track -> player.getTracks(IMedia.Track.Type.Text)?.forEach { track ->
val format = Format.Builder() val format = Format.Builder()
@@ -742,9 +756,9 @@ class VlcPlayer(ctx: Context) :
} }
} }
override fun getCurrentPeriodIndex() = currentMediaItemIndex override fun getCurrentPeriodIndex() = currentMediaItemIndex.coerceAtLeast(0)
override fun getCurrentMediaItemIndex() = currentMediaItemIndex override fun getCurrentMediaItemIndex() = currentMediaItemIndex.coerceAtLeast(0)
override fun getDuration(): Long = player.length.takeIf { it > 0 } ?: TIME_UNSET override fun getDuration(): Long = player.length.takeIf { it > 0 } ?: TIME_UNSET
+24 -2
View File
@@ -57,12 +57,16 @@ 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");
@@ -233,7 +237,20 @@ 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}>{trackLabel}</Text> <Text style={styles.subheading}>
{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
@@ -497,6 +514,9 @@ 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,
@@ -574,7 +594,7 @@ function App(): React.JSX.Element {
return ( return (
<OmniProvider <OmniProvider
key={backend} key={backend}
source={source} source={hasSource ? source : undefined}
backend={{ android: backend }} backend={{ android: backend }}
cast={{receiverApplicationId: "D8FB0FC1"}} cast={{receiverApplicationId: "D8FB0FC1"}}
showNotification showNotification
@@ -585,6 +605,8 @@ 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>
); );
@@ -14,6 +14,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:screenOrientation="nosensor"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask" android:launchMode="singleTask"
android:resizeableActivity="true" android:resizeableActivity="true"