From 125e8a323fb2185298d059835c8c294a56376a19 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 22 Apr 2026 09:22:44 +0200 Subject: [PATCH] Add media session --- android/src/main/AndroidManifest.xml | 14 ++ .../main/java/dev/zoriya/omni/MpvPlayer.kt | 219 +++++++++++------- .../dev/zoriya/omni/OmniPlaybackService.kt | 86 +++++++ .../main/java/dev/zoriya/omni/OmniPlayer.kt | 6 + 4 files changed, 241 insertions(+), 84 deletions(-) create mode 100644 android/src/main/java/dev/zoriya/omni/OmniPlaybackService.kt diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 99bd5c6..711738f 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,6 +1,20 @@ + + + + + + + + + + diff --git a/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt b/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt index 5c70178..ba7d980 100644 --- a/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt +++ b/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt @@ -2,6 +2,7 @@ package dev.zoriya.omni import android.annotation.SuppressLint import android.content.Context +import android.os.Handler import android.os.Looper import android.view.Surface import android.view.SurfaceHolder @@ -9,7 +10,6 @@ import android.view.SurfaceView import android.view.TextureView import androidx.media3.common.AudioAttributes import androidx.media3.common.BasePlayer -import androidx.media3.common.C import androidx.media3.common.C.FORMAT_HANDLED import androidx.media3.common.C.INDEX_UNSET import androidx.media3.common.C.TIME_UNSET @@ -32,14 +32,33 @@ import androidx.media3.common.Tracks import androidx.media3.common.Tracks.Group import androidx.media3.common.VideoSize import androidx.media3.common.text.CueGroup +import androidx.media3.common.util.Clock +import androidx.media3.common.util.ListenerSet import androidx.media3.common.util.Size import dev.jdtech.mpv.MPVLib -import java.util.concurrent.CopyOnWriteArraySet @SuppressLint("UnsafeOptInUsageError") class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { private val applicationLooper: Looper = Looper.getMainLooper() - private val listeners = CopyOnWriteArraySet() + private val applicationHandler = Handler(applicationLooper) + private val listeners = ListenerSet( + applicationLooper, + Clock.DEFAULT + ) { listener, flags -> + listener.onEvents(this, Player.Events(flags)) + } + + private fun notifyListeners(eventFlag: Int, callback: (Player.Listener) -> Unit) { + val notifyAction = { + listeners.queueEvent(eventFlag, callback) + listeners.flushEvents() + } + if (Looper.myLooper() == applicationLooper) { + notifyAction() + } else { + applicationHandler.post(notifyAction) + } + } internal val mpv = (MPVLib.create(ctx) ?: throw Error("Failed to initialize MPVLib")).apply { setOptionString("vo", "gpu-next") @@ -112,12 +131,16 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { override fun setMediaItems(mediaItems: List, resetPosition: Boolean) { setMediaItems( mediaItems, - if (resetPosition) 0 else C.INDEX_UNSET, - if (resetPosition) 0L else C.TIME_UNSET + if (resetPosition) 0 else INDEX_UNSET, + if (resetPosition) 0L else TIME_UNSET ) } - override fun setMediaItems(mediaItems: List, startIndex: Int, startPositionMs: Long) { + override fun setMediaItems( + mediaItems: List, + startIndex: Int, + startPositionMs: Long + ) { val target = when { mediaItems.isEmpty() -> null startIndex in mediaItems.indices -> mediaItems[startIndex] @@ -130,7 +153,7 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { mpv.command(arrayOf("stop")) - val targetMs = if (startPositionMs == C.TIME_UNSET) 0L else startPositionMs.coerceAtLeast(0L) + val targetMs = if (startPositionMs == TIME_UNSET) 0L else startPositionMs.coerceAtLeast(0L) mpv.setPropertyDouble("start", targetMs / 1000.0) if (target != null) { @@ -193,9 +216,14 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { override fun isLoading(): Boolean = mpv.getPropertyBoolean("paused-for-cache") ?: false - override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) { - if (mediaItemIndex != C.INDEX_UNSET && mediaItemIndex != 0) return - val targetMs = if (positionMs == C.TIME_UNSET) 0L else positionMs.coerceAtLeast(0L) + override fun seekTo( + mediaItemIndex: Int, + positionMs: Long, + seekCommand: Int, + isRepeatingCurrentItem: Boolean + ) { + if (mediaItemIndex != INDEX_UNSET && mediaItemIndex != 0) return + val targetMs = if (positionMs == TIME_UNSET) 0L else positionMs.coerceAtLeast(0L) mpv.command(arrayOf("seek", (targetMs / 1000.0).toString(), "absolute")) } @@ -208,7 +236,9 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { } override fun getPlaybackParameters(): PlaybackParameters { - return PlaybackParameters((mpv.getPropertyDouble("speed") ?: 1.0).toFloat().coerceAtLeast(0f)) + return PlaybackParameters( + (mpv.getPropertyDouble("speed") ?: 1.0).toFloat().coerceAtLeast(0f) + ) } override fun stop() { @@ -226,55 +256,57 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { if (count <= 0) return Tracks.EMPTY data class Entry(val id: Int, val format: Format) + val grouped = LinkedHashMap>() for (i in 0 until count) { - val base = "track-list/$i" - val type = when (mpv.getPropertyString("$base/type")) { - "video" -> TRACK_TYPE_VIDEO - "audio" -> TRACK_TYPE_AUDIO - "sub" -> TRACK_TYPE_TEXT - else -> TRACK_TYPE_UNKNOWN - } - if (type == TRACK_TYPE_UNKNOWN) continue - - val id = mpv.getPropertyInt("$base/id") ?: continue - val label = mpv.getPropertyString("$base/title") - val language = mpv.getPropertyString("$base/lang") - val codec = mpv.getPropertyString("$base/codec") - - val format = Format.Builder() - .setId(id.toString()) - .setLabel(label) - .setLanguage(language) - .setCodecs(codec) - .build() - - grouped.getOrPut>(type) { mutableListOf() } - .add(Entry(id, format)) + val base = "track-list/$i" + val type = when (mpv.getPropertyString("$base/type")) { + "video" -> TRACK_TYPE_VIDEO + "audio" -> TRACK_TYPE_AUDIO + "sub" -> TRACK_TYPE_TEXT + else -> TRACK_TYPE_UNKNOWN } + if (type == TRACK_TYPE_UNKNOWN) continue + + val id = mpv.getPropertyInt("$base/id") ?: continue + val label = mpv.getPropertyString("$base/title") + val language = mpv.getPropertyString("$base/lang") + val codec = mpv.getPropertyString("$base/codec") + + val format = Format.Builder() + .setId(id.toString()) + .setLabel(label) + .setLanguage(language) + .setCodecs(codec) + .build() + + grouped.getOrPut>(type) { mutableListOf() } + .add(Entry(id, format)) + } val selectedVideo = mpv.getPropertyInt("vid") val selectedAudio = mpv.getPropertyInt("aid") val selectedSubtitle = mpv.getPropertyInt("sid") val result = ArrayList() for ((type, entries) in grouped) { - if (entries.isEmpty()) continue - val group = TrackGroup("mpv-$type", *entries.map { it.format }.toTypedArray()) - val selected = BooleanArray(entries.size) { idx -> - val id = entries[idx].id - when (type) { - TRACK_TYPE_VIDEO -> selectedVideo == id - TRACK_TYPE_AUDIO -> selectedAudio == id - TRACK_TYPE_TEXT -> selectedSubtitle == id - else -> false - } + if (entries.isEmpty()) continue + val group = TrackGroup("mpv-$type", *entries.map { it.format }.toTypedArray()) + val selected = BooleanArray(entries.size) { idx -> + val id = entries[idx].id + when (type) { + TRACK_TYPE_VIDEO -> selectedVideo == id + TRACK_TYPE_AUDIO -> selectedAudio == id + TRACK_TYPE_TEXT -> selectedSubtitle == id + else -> false } - val support = IntArray(entries.size) { FORMAT_HANDLED } - result.add(Group(group, false, support, selected)) } + val support = IntArray(entries.size) { FORMAT_HANDLED } + result.add(Group(group, false, support, selected)) + } return Tracks(result) } - override fun getTrackSelectionParameters(): TrackSelectionParameters = currentTrackSelectionParameters + override fun getTrackSelectionParameters(): TrackSelectionParameters = + currentTrackSelectionParameters override fun setTrackSelectionParameters(trackSelectionParameters: TrackSelectionParameters) { currentTrackSelectionParameters = trackSelectionParameters @@ -301,7 +333,8 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { } } - override fun getMediaMetadata(): MediaMetadata = currentMediaItem?.mediaMetadata ?: MediaMetadata.EMPTY + override fun getMediaMetadata(): MediaMetadata = + currentMediaItem?.mediaMetadata ?: MediaMetadata.EMPTY override fun getPlaylistMetadata(): MediaMetadata = playlistMetadata @@ -315,15 +348,19 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { return object : Timeline() { override fun getWindowCount(): Int = 1 - override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long + ): Window { check(windowIndex == 0) return window.set( - Window.SINGLE_WINDOW_UID, + SINGLE_WINDOW_UID, item, null, - C.TIME_UNSET, - C.TIME_UNSET, - C.TIME_UNSET, + TIME_UNSET, + TIME_UNSET, + TIME_UNSET, true, false, item.liveConfiguration, @@ -339,26 +376,26 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { check(periodIndex == 0) - val uid = Window.SINGLE_WINDOW_UID + val uid = SINGLE_WINDOW_UID val id = if (setIds) uid else null return period.set(id, uid, 0, durationUs, 0L) } override fun getIndexOfPeriod(uid: Any): Int { - return if (uid == Window.SINGLE_WINDOW_UID) 0 else C.INDEX_UNSET + return if (uid == SINGLE_WINDOW_UID) 0 else INDEX_UNSET } override fun getUidOfPeriod(periodIndex: Int): Any { check(periodIndex == 0) - return Window.SINGLE_WINDOW_UID + return SINGLE_WINDOW_UID } } } - override fun getCurrentPeriodIndex() = if (currentMediaItem == null) C.INDEX_UNSET else 0 + override fun getCurrentPeriodIndex() = if (currentMediaItem == null) INDEX_UNSET else 0 override fun getCurrentMediaItemIndex() = - if (currentMediaItem == null) C.INDEX_UNSET else 0 + if (currentMediaItem == null) INDEX_UNSET else 0 override fun getDuration() = ((mpv.getPropertyDouble("duration") ?: 0.0).coerceAtLeast(0.0) * 1000.0).toLong() @@ -374,9 +411,9 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { override fun isPlayingAd(): Boolean = false - override fun getCurrentAdGroupIndex(): Int = C.INDEX_UNSET + override fun getCurrentAdGroupIndex(): Int = INDEX_UNSET - override fun getCurrentAdIndexInAdGroup(): Int = C.INDEX_UNSET + override fun getCurrentAdIndexInAdGroup(): Int = INDEX_UNSET override fun getMaxSeekToPreviousPosition() = 3_000L @@ -405,7 +442,7 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { } override fun setVideoSurface(surface: Surface?) { - mpv.attachSurface(surface ?: return clearVideoSurface() ) + mpv.attachSurface(surface ?: return clearVideoSurface()) mpv.setOptionString("force-window", "yes") mpv.setOptionString("vo", "gpu-next") } @@ -474,19 +511,24 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { override fun setDeviceMuted(muted: Boolean, flags: Int) = Unit - override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = Unit + override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = + Unit override fun event(eventId: Int) { when (eventId) { - MPVLib.MpvEvent.MPV_EVENT_START_FILE -> listeners.forEach { - it.onPlaybackStateChanged(STATE_BUFFERING) - } + MPVLib.MpvEvent.MPV_EVENT_START_FILE -> + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { + it.onPlaybackStateChanged(STATE_BUFFERING) + } - MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED -> - listeners.forEach { + MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED -> { + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { it.onPlaybackStateChanged(STATE_READY) + } + notifyListeners(EVENT_IS_PLAYING_CHANGED) { it.onIsPlayingChanged(playWhenReady) } + } MPVLib.MpvEvent.MPV_EVENT_SEEK, MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART -> { @@ -502,16 +544,15 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { INDEX_UNSET, INDEX_UNSET ) - listeners.forEach { - it.onPositionDiscontinuity(position, position, Player.DISCONTINUITY_REASON_SEEK) + notifyListeners(EVENT_POSITION_DISCONTINUITY) { + it.onPositionDiscontinuity(position, position, DISCONTINUITY_REASON_SEEK) } } - MPVLib.MpvEvent.MPV_EVENT_END_FILE -> listeners.forEach { - it.onPlaybackStateChanged( - STATE_ENDED - ) - } + MPVLib.MpvEvent.MPV_EVENT_END_FILE -> + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { + it.onPlaybackStateChanged(STATE_ENDED) + } MPVLib.MpvEvent.MPV_EVENT_QUEUE_OVERFLOW -> { playerError = PlaybackException( @@ -519,7 +560,7 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { null, PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK ) - listeners.forEach { + notifyListeners(EVENT_PLAYER_ERROR) { it.onPlayerErrorChanged(playerError) it.onPlayerError( playerError ?: PlaybackException( @@ -538,7 +579,7 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { override fun eventProperty(property: String, value: Long) { when (property) { "vid", "aid", "sid" -> { - listeners.forEach { it.onTracksChanged(getCurrentTracks()) } + notifyListeners(EVENT_TRACKS_CHANGED) { it.onTracksChanged(getCurrentTracks()) } } } } @@ -546,17 +587,21 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { override fun eventProperty(property: String, value: Double) { when (property) { "duration" -> - listeners.forEach { + notifyListeners(EVENT_TIMELINE_CHANGED) { it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) } "speed" -> - listeners.forEach { - it.onPlaybackParametersChanged(PlaybackParameters(value.toFloat().coerceAtLeast(0f))) + notifyListeners(EVENT_PLAYBACK_PARAMETERS_CHANGED) { + it.onPlaybackParametersChanged( + PlaybackParameters( + value.toFloat().coerceAtLeast(0f) + ) + ) } "volume" -> - listeners.forEach { + notifyListeners(EVENT_VOLUME_CHANGED) { it.onVolumeChanged((value.coerceIn(0.0, 100.0) / 100.0).toFloat()) } } @@ -564,23 +609,29 @@ class MpvPlayer(ctx: Context) : BasePlayer(), MPVLib.EventObserver { override fun eventProperty(property: String, value: Boolean) { when (property) { - "pause" -> - listeners.forEach { + "pause" -> { + notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) { it.onPlayWhenReadyChanged( !value, PLAY_WHEN_READY_CHANGE_REASON_REMOTE ) + } + notifyListeners(EVENT_IS_PLAYING_CHANGED) { it.onIsPlayingChanged(!value) } + } "core-idle", "eof-reached" -> - listeners.forEach { + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { it.onPlaybackStateChanged(getPlaybackState()) } "paused-for-cache" -> { - listeners.forEach { + notifyListeners(EVENT_IS_LOADING_CHANGED) { it.onIsLoadingChanged(value) + } + + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { it.onPlaybackStateChanged(getPlaybackState()) } } diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlaybackService.kt b/android/src/main/java/dev/zoriya/omni/OmniPlaybackService.kt new file mode 100644 index 0000000..42644d5 --- /dev/null +++ b/android/src/main/java/dev/zoriya/omni/OmniPlaybackService.kt @@ -0,0 +1,86 @@ +package dev.zoriya.omni + +import android.annotation.SuppressLint +import android.content.Intent +import androidx.core.content.ContextCompat +import androidx.media3.common.Player +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import com.margelo.nitro.NitroModules +import java.util.concurrent.atomic.AtomicReference + +@SuppressLint("UnsafeOptInUsageError") +class OmniPlaybackService : MediaSessionService() { + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + serviceRef.set(this) + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(this) + .setChannelId(NOTIFICATION_CHANNEL_ID) + .build() + ) + attachedPlayer.get()?.let { ensureSession(it) } + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + attachedPlayer.get()?.let { ensureSession(it) } + return mediaSession + } + + override fun onTaskRemoved(rootIntent: Intent?) { + if (!isPlaybackOngoing) { + pauseAllPlayersAndStopSelf() + } + } + + override fun onDestroy() { + mediaSession?.release() + mediaSession = null + serviceRef.compareAndSet(this, null) + super.onDestroy() + } + + private fun ensureSession(player: Player) { + if (mediaSession?.player === player) return + mediaSession?.release() + mediaSession = MediaSession.Builder(this, player) + .setId(SESSION_ID) + .build() + } + + private fun clearSessionIf(player: Player) { + if (mediaSession?.player !== player) return + mediaSession?.release() + mediaSession = null + stopSelf() + } + + companion object { + private const val SESSION_ID = "omni-session" + private const val NOTIFICATION_CHANNEL_ID = "omni_playback" + private val attachedPlayer = AtomicReference(null) + private val serviceRef = AtomicReference(null) + + fun attachPlayer(player: Player) { + attachedPlayer.set(player) + serviceRef.get()?.ensureSession(player) + } + + fun detachPlayer(player: Player) { + if (!attachedPlayer.compareAndSet(player, null)) return + serviceRef.get()?.clearSessionIf(player) + } + + fun ensureStarted(player: Player) { + val context = NitroModules.Companion.applicationContext ?: return + attachPlayer(player) + ContextCompat.startForegroundService( + context, + Intent(context, OmniPlaybackService::class.java) + ) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt index c9dd302..2e0f3f2 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt @@ -20,10 +20,15 @@ class OmniPlayer : HybridOmniPlayerSpec() { val player = MpvPlayer(ctx) override val eventMap = EventMap(player) + init { + OmniPlaybackService.attachPlayer(player) + } + override fun dispose() { super.dispose() eventMap.dispose() + OmniPlaybackService.detachPlayer(player) player.release() } @@ -99,6 +104,7 @@ class OmniPlayer : HybridOmniPlayerSpec() { override val rendition: Array get() = emptyArray() override fun play() { + OmniPlaybackService.ensureStarted(player) player.play() }