Add media session

This commit is contained in:
2026-04-22 13:21:02 +02:00
parent e4d70467c6
commit 125e8a323f
4 changed files with 241 additions and 84 deletions
+14
View File
@@ -1,6 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-feature
android:name="android.software.picture_in_picture"
android:required="false" />
<application>
<service
android:name=".OmniPlaybackService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -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<Player.Listener>()
private val applicationHandler = Handler(applicationLooper)
private val listeners = ListenerSet<Player.Listener>(
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<MediaItem>, 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<MediaItem>, startIndex: Int, startPositionMs: Long) {
override fun setMediaItems(
mediaItems: List<MediaItem>,
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<Int, MutableList<Entry>>()
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<Int, MutableList<Entry>>(type) { mutableListOf<Entry>() }
.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<Int, MutableList<Entry>>(type) { mutableListOf<Entry>() }
.add(Entry(id, format))
}
val selectedVideo = mpv.getPropertyInt("vid")
val selectedAudio = mpv.getPropertyInt("aid")
val selectedSubtitle = mpv.getPropertyInt("sid")
val result = ArrayList<Group>()
for ((type, entries) in grouped) {
if (entries.isEmpty()) continue
val group = TrackGroup("mpv-$type", *entries.map { it.format }.toTypedArray<Format>())
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<Format>())
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())
}
}
@@ -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<Player?>(null)
private val serviceRef = AtomicReference<OmniPlaybackService?>(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)
)
}
}
}
@@ -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<Rendition> get() = emptyArray()
override fun play() {
OmniPlaybackService.ensureStarted(player)
player.play()
}