diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 679c0053f..b858d5377 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -5,6 +5,8 @@
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
index 1c10a2370..79c529397 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
@@ -360,6 +360,33 @@ private fun ExoPlayerSurface(
player
}
+ val nowPlayingController = remember(context, exoPlayer) {
+ AndroidPlayerNowPlayingController(
+ context = context,
+ controls = AndroidPlayerNowPlayingController.PlaybackControls(
+ play = {
+ exoPlayer.playWhenReady = true
+ exoPlayer.play()
+ },
+ pause = exoPlayer::pause,
+ seekTo = { positionMs -> exoPlayer.seekTo(positionMs.coerceAtLeast(0L)) },
+ seekBy = { offsetMs ->
+ exoPlayer.seekTo((exoPlayer.currentPosition + offsetMs).coerceAtLeast(0L))
+ },
+ ),
+ )
+ }
+
+ fun dispatchExoPlayerSnapshot() {
+ val snapshot = exoPlayer.snapshot()
+ latestOnSnapshot.value(snapshot)
+ nowPlayingController.syncPlayback(snapshot)
+ }
+
+ DisposableEffect(nowPlayingController) {
+ onDispose { nowPlayingController.release() }
+ }
+
LaunchedEffect(exoPlayer, resolvedMediaItem) {
val mediaItem = resolvedMediaItem ?: return@LaunchedEffect
exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs)
@@ -468,16 +495,16 @@ private fun ExoPlayerSurface(
exoPlayer.logCurrentTracks("STATE_READY")
}
syncPlayerViewKeepScreenOn()
- latestOnSnapshot.value(exoPlayer.snapshot())
+ dispatchExoPlayerSnapshot()
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
syncPlayerViewKeepScreenOn()
- latestOnSnapshot.value(exoPlayer.snapshot())
+ dispatchExoPlayerSnapshot()
}
override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) {
- latestOnSnapshot.value(exoPlayer.snapshot())
+ dispatchExoPlayerSnapshot()
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
@@ -501,7 +528,7 @@ private fun ExoPlayerSurface(
exoPlayer.selectTrackByIndex(C.TRACK_TYPE_TEXT, idx)
}
}
- latestOnSnapshot.value(exoPlayer.snapshot())
+ dispatchExoPlayerSnapshot()
}
}
@@ -523,7 +550,8 @@ private fun ExoPlayerSurface(
val isInPictureInPicture =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true
val isFinishing = activity?.isFinishing == true
- if (!isInPictureInPicture || isFinishing) {
+ val hasActiveNowPlayingSession = nowPlayingController.isActive
+ if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) {
exoPlayer.pause()
}
}
@@ -540,7 +568,7 @@ private fun ExoPlayerSurface(
LaunchedEffect(exoPlayer, playWhenReady) {
exoPlayer.playWhenReady = latestPlayWhenReady.value
syncPlayerViewKeepScreenOn()
- latestOnSnapshot.value(exoPlayer.snapshot())
+ dispatchExoPlayerSnapshot()
}
LaunchedEffect(exoPlayer) {
@@ -572,6 +600,14 @@ private fun ExoPlayerSurface(
exoPlayer.setPlaybackSpeed(speed)
}
+ override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {
+ nowPlayingController.updateMetadata(info)
+ }
+
+ override fun clearNowPlayingInfo() {
+ nowPlayingController.clear()
+ }
+
override fun getAudioTracks(): List =
exoPlayer.extractAudioTracks(context)
@@ -700,7 +736,7 @@ private fun ExoPlayerSurface(
LaunchedEffect(exoPlayer) {
while (isActive) {
- latestOnSnapshot.value(exoPlayer.snapshot())
+ dispatchExoPlayerSnapshot()
delay(250L)
}
}
@@ -766,8 +802,25 @@ private fun LibmpvPlayerSurface(
sanitizePlaybackHeaders(sourceHeaders)
}
var playerViewRef by remember { mutableStateOf(null) }
+ val nowPlayingController = remember(context, playerViewRef) {
+ playerViewRef?.let { view ->
+ AndroidPlayerNowPlayingController(
+ context = context,
+ controls = AndroidPlayerNowPlayingController.PlaybackControls(
+ play = { view.setPaused(false) },
+ pause = { view.setPaused(true) },
+ seekTo = { positionMs -> view.seekToMs(positionMs) },
+ seekBy = { offsetMs -> view.seekByMs(offsetMs) },
+ ),
+ )
+ }
+ }
- DisposableEffect(lifecycleOwner) {
+ DisposableEffect(nowPlayingController) {
+ onDispose { nowPlayingController?.release() }
+ }
+
+ DisposableEffect(lifecycleOwner, nowPlayingController) {
val activity = context.findActivity()
val observer = LifecycleEventObserver { _, event ->
val view = playerViewRef ?: return@LifecycleEventObserver
@@ -777,7 +830,8 @@ private fun LibmpvPlayerSurface(
val isInPictureInPicture =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true
val isFinishing = activity?.isFinishing == true
- if (!isInPictureInPicture || isFinishing) {
+ val hasActiveNowPlayingSession = nowPlayingController?.isActive == true
+ if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) {
view.setPaused(true)
}
}
@@ -790,11 +844,13 @@ private fun LibmpvPlayerSurface(
}
}
- DisposableEffect(playerViewRef) {
+ DisposableEffect(playerViewRef, nowPlayingController) {
val view = playerViewRef ?: return@DisposableEffect onDispose {}
fun dispatchSnapshot(updateKeepScreenOn: Boolean = false) {
coroutineScope.launch(Dispatchers.Main.immediate) {
- latestOnSnapshot.value(view.snapshot())
+ val snapshot = view.snapshot()
+ latestOnSnapshot.value(snapshot)
+ nowPlayingController?.syncPlayback(snapshot)
if (updateKeepScreenOn) {
view.keepScreenOn = view.shouldKeepScreenOn()
}
@@ -826,14 +882,18 @@ private fun LibmpvPlayerSurface(
MPV.mpvEvent.MPV_EVENT_START_FILE -> {
coroutineScope.launch(Dispatchers.Main.immediate) {
latestOnError.value(null)
- latestOnSnapshot.value(PlayerPlaybackSnapshot())
+ val snapshot = PlayerPlaybackSnapshot()
+ latestOnSnapshot.value(snapshot)
+ nowPlayingController?.syncPlayback(snapshot)
}
}
MPV.mpvEvent.MPV_EVENT_FILE_LOADED,
MPV.mpvEvent.MPV_EVENT_PLAYBACK_RESTART -> {
coroutineScope.launch(Dispatchers.Main.immediate) {
latestOnError.value(null)
- latestOnSnapshot.value(view.snapshot())
+ val snapshot = view.snapshot()
+ latestOnSnapshot.value(snapshot)
+ nowPlayingController?.syncPlayback(snapshot)
}
}
MPV.mpvEvent.MPV_EVENT_END_FILE -> dispatchSnapshot()
@@ -859,7 +919,9 @@ private fun LibmpvPlayerSurface(
LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) {
val view = playerViewRef ?: return@LaunchedEffect
- latestOnSnapshot.value(PlayerPlaybackSnapshot())
+ val snapshot = PlayerPlaybackSnapshot()
+ latestOnSnapshot.value(snapshot)
+ nowPlayingController?.syncPlayback(snapshot)
view.loadSource(
sourceUrl = sourceUrl,
sourceAudioUrl = sourceAudioUrl,
@@ -873,7 +935,9 @@ private fun LibmpvPlayerSurface(
val view = playerViewRef ?: return@LaunchedEffect
view.setPaused(!latestPlayWhenReady.value)
view.keepScreenOn = view.shouldKeepScreenOn()
- latestOnSnapshot.value(view.snapshot())
+ val snapshot = view.snapshot()
+ latestOnSnapshot.value(snapshot)
+ nowPlayingController?.syncPlayback(snapshot)
}
LaunchedEffect(playerViewRef, resizeMode) {
@@ -882,13 +946,15 @@ private fun LibmpvPlayerSurface(
LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) {
val view = playerViewRef ?: return@LaunchedEffect
- onControllerReady(view.controller(context))
+ onControllerReady(view.controller(context, nowPlayingController))
}
LaunchedEffect(playerViewRef) {
val view = playerViewRef ?: return@LaunchedEffect
while (isActive) {
- latestOnSnapshot.value(view.snapshot())
+ val snapshot = view.snapshot()
+ latestOnSnapshot.value(snapshot)
+ nowPlayingController?.syncPlayback(snapshot)
view.keepScreenOn = view.shouldKeepScreenOn()
delay(250L)
}
@@ -1071,19 +1137,26 @@ private class NuvioLibmpvView(
}
}
- fun controller(context: Context): PlayerEngineController =
+ fun seekToMs(positionMs: Long) {
+ mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute")
+ }
+
+ fun seekByMs(offsetMs: Long) {
+ mpv.command("seek", (offsetMs / 1000.0).toString(), "relative")
+ }
+
+ fun controller(
+ context: Context,
+ nowPlayingController: AndroidPlayerNowPlayingController?,
+ ): PlayerEngineController =
object : PlayerEngineController {
override fun play() = setPaused(false)
override fun pause() = setPaused(true)
- override fun seekTo(positionMs: Long) {
- mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute")
- }
+ override fun seekTo(positionMs: Long) = this@NuvioLibmpvView.seekToMs(positionMs)
- override fun seekBy(offsetMs: Long) {
- mpv.command("seek", (offsetMs / 1000.0).toString(), "relative")
- }
+ override fun seekBy(offsetMs: Long) = this@NuvioLibmpvView.seekByMs(offsetMs)
override fun retry() {
loadCurrentSource(playWhenReady = true)
@@ -1093,6 +1166,14 @@ private class NuvioLibmpvView(
mpv.setPropertyDouble("speed", speed.coerceIn(0.25f, 4f).toDouble())
}
+ override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {
+ nowPlayingController?.updateMetadata(info)
+ }
+
+ override fun clearNowPlayingInfo() {
+ nowPlayingController?.clear()
+ }
+
override fun setMuted(muted: Boolean) {
mpv.setPropertyBoolean("mute", muted)
}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt
new file mode 100644
index 000000000..b13954a07
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt
@@ -0,0 +1,551 @@
+package com.nuvio.app.features.player
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.media.MediaMetadata
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.os.Build
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.SystemClock
+import android.util.Log
+import com.nuvio.app.R
+import java.io.ByteArrayOutputStream
+import java.lang.ref.WeakReference
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.math.abs
+
+private const val NOW_PLAYING_TAG = "NuvioNowPlaying"
+private const val NOW_PLAYING_CHANNEL_ID = "nuvio_playback"
+private const val NOW_PLAYING_NOTIFICATION_ID = 0x4E55
+private const val SEEK_INTERVAL_MS = 10_000L
+private const val MAX_ARTWORK_DOWNLOAD_BYTES = 12 * 1024 * 1024
+private const val MAX_ARTWORK_EDGE_PX = 1_024
+
+private const val ACTION_PLAY = "com.nuvio.app.nowplaying.PLAY"
+private const val ACTION_PAUSE = "com.nuvio.app.nowplaying.PAUSE"
+private const val ACTION_REWIND = "com.nuvio.app.nowplaying.REWIND"
+private const val ACTION_FAST_FORWARD = "com.nuvio.app.nowplaying.FAST_FORWARD"
+private const val ACTION_START_FOREGROUND = "com.nuvio.app.nowplaying.START_FOREGROUND"
+
+private data class AndroidNowPlayingMetadata(
+ val title: String,
+ val subtitle: String?,
+ val artworkUrl: String?,
+)
+
+internal class AndroidPlayerNowPlayingController(
+ context: Context,
+ private val controls: PlaybackControls,
+) {
+ internal data class PlaybackControls(
+ val play: () -> Unit,
+ val pause: () -> Unit,
+ val seekTo: (Long) -> Unit,
+ val seekBy: (Long) -> Unit,
+ )
+
+ private val appContext = context.applicationContext
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val artworkExecutor = Executors.newSingleThreadExecutor { runnable ->
+ Thread(runnable, "NuvioNowPlayingArtwork").apply { isDaemon = true }
+ }
+ private val artworkGeneration = AtomicInteger(0)
+ private val mediaSession = MediaSession(appContext, NOW_PLAYING_TAG).apply {
+ setFlags(
+ MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or
+ MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS,
+ )
+ setCallback(
+ object : MediaSession.Callback() {
+ override fun onPlay() = controls.play()
+
+ override fun onPause() = controls.pause()
+
+ override fun onStop() = controls.pause()
+
+ override fun onSeekTo(pos: Long) = controls.seekTo(pos.coerceAtLeast(0L))
+
+ override fun onFastForward() = controls.seekBy(SEEK_INTERVAL_MS)
+
+ override fun onRewind() = controls.seekBy(-SEEK_INTERVAL_MS)
+ },
+ mainHandler,
+ )
+ buildContentIntent(appContext)?.let(::setSessionActivity)
+ }
+
+ private var metadata: AndroidNowPlayingMetadata? = null
+ private var snapshot = PlayerPlaybackSnapshot()
+ private var artworkBitmap: Bitmap? = null
+ private var released = false
+ private var lastPublishedPositionMs = Long.MIN_VALUE
+ private var lastPublishedDurationMs = Long.MIN_VALUE
+ private var lastPublishedPlaying: Boolean? = null
+ private var lastPublishedLoading: Boolean? = null
+ private var lastPublishedEnded: Boolean? = null
+ private var lastPublishedSpeed = Float.NaN
+
+ val isActive: Boolean
+ get() = !released && metadata != null
+
+ init {
+ createNotificationChannel(appContext)
+ AndroidNowPlayingActionDispatcher.register(this)
+ }
+
+ fun updateMetadata(info: PlayerNowPlayingInfo) {
+ runOnMain {
+ if (released) return@runOnMain
+
+ val normalized = AndroidNowPlayingMetadata(
+ title = info.title.trim(),
+ subtitle = info.subtitle?.trim()?.takeIf(String::isNotEmpty),
+ artworkUrl = info.artworkUrl?.trim()?.takeIf(String::isNotEmpty),
+ )
+ if (normalized.title.isEmpty()) {
+ clearInternal()
+ return@runOnMain
+ }
+
+ val artworkChanged = metadata?.artworkUrl != normalized.artworkUrl
+ metadata = normalized
+ mediaSession.isActive = true
+
+ if (artworkChanged) {
+ artworkBitmap = null
+ loadArtwork(normalized.artworkUrl)
+ }
+
+ publishMetadata()
+ publishPlaybackState(force = true)
+ publishNotification()
+ }
+ }
+
+ fun syncPlayback(nextSnapshot: PlayerPlaybackSnapshot) {
+ runOnMain {
+ if (released || metadata == null) return@runOnMain
+
+ val durationChanged = snapshot.durationMs != nextSnapshot.durationMs
+ val playingChanged = snapshot.isPlaying != nextSnapshot.isPlaying
+ val loadingChanged = snapshot.isLoading != nextSnapshot.isLoading
+ val endedChanged = snapshot.isEnded != nextSnapshot.isEnded
+ snapshot = nextSnapshot
+
+ if (durationChanged) publishMetadata()
+ publishPlaybackState(force = false)
+ if (playingChanged || loadingChanged || endedChanged) publishNotification()
+ }
+ }
+
+ fun clear() {
+ runOnMain {
+ if (released) return@runOnMain
+ clearInternal()
+ }
+ }
+
+ fun release() {
+ runOnMain {
+ if (released) return@runOnMain
+ released = true
+ clearInternal()
+ AndroidNowPlayingActionDispatcher.unregister(this)
+ mediaSession.release()
+ artworkExecutor.shutdownNow()
+ }
+ }
+
+ internal fun handleAction(action: String?) {
+ if (released) return
+ when (action) {
+ ACTION_PLAY -> controls.play()
+ ACTION_PAUSE -> controls.pause()
+ ACTION_REWIND -> controls.seekBy(-SEEK_INTERVAL_MS)
+ ACTION_FAST_FORWARD -> controls.seekBy(SEEK_INTERVAL_MS)
+ }
+ }
+
+ private fun clearInternal() {
+ artworkGeneration.incrementAndGet()
+ metadata = null
+ snapshot = PlayerPlaybackSnapshot()
+ artworkBitmap = null
+ resetPublishedPlaybackState()
+ mediaSession.setMetadata(null)
+ mediaSession.setPlaybackState(
+ PlaybackState.Builder()
+ .setState(PlaybackState.STATE_NONE, 0L, 0f)
+ .build(),
+ )
+ mediaSession.isActive = false
+ PlayerNowPlayingService.hide(appContext)
+ }
+
+ private fun publishMetadata() {
+ val currentMetadata = metadata ?: return
+ val builder = MediaMetadata.Builder()
+ .putString(MediaMetadata.METADATA_KEY_TITLE, currentMetadata.title)
+ .putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, currentMetadata.title)
+
+ currentMetadata.subtitle?.let { subtitle ->
+ builder.putString(MediaMetadata.METADATA_KEY_ARTIST, subtitle)
+ builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, subtitle)
+ }
+ snapshot.durationMs.takeIf { it > 0L }?.let { durationMs ->
+ builder.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs)
+ }
+ artworkBitmap?.let { artwork ->
+ builder.putBitmap(MediaMetadata.METADATA_KEY_ART, artwork)
+ builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, artwork)
+ builder.putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, artwork)
+ }
+
+ mediaSession.setMetadata(builder.build())
+ }
+
+ private fun publishPlaybackState(force: Boolean) {
+ val current = snapshot
+ val positionChanged = lastPublishedPositionMs == Long.MIN_VALUE ||
+ abs(current.positionMs - lastPublishedPositionMs) >= 1_000L
+ val durationChanged = lastPublishedDurationMs != current.durationMs
+ val playingChanged = lastPublishedPlaying != current.isPlaying
+ val loadingChanged = lastPublishedLoading != current.isLoading
+ val endedChanged = lastPublishedEnded != current.isEnded
+ val speedChanged = lastPublishedSpeed.isNaN() || lastPublishedSpeed != current.playbackSpeed
+
+ if (!force && !positionChanged && !durationChanged && !playingChanged && !loadingChanged && !endedChanged && !speedChanged) {
+ return
+ }
+
+ val state = when {
+ current.isEnded -> PlaybackState.STATE_STOPPED
+ current.isLoading -> PlaybackState.STATE_BUFFERING
+ current.isPlaying -> PlaybackState.STATE_PLAYING
+ else -> PlaybackState.STATE_PAUSED
+ }
+ val playbackSpeed = if (current.isPlaying) current.playbackSpeed.coerceAtLeast(0.1f) else 0f
+ val actions = PlaybackState.ACTION_PLAY or
+ PlaybackState.ACTION_PAUSE or
+ PlaybackState.ACTION_PLAY_PAUSE or
+ PlaybackState.ACTION_SEEK_TO or
+ PlaybackState.ACTION_FAST_FORWARD or
+ PlaybackState.ACTION_REWIND or
+ PlaybackState.ACTION_STOP
+
+ mediaSession.setPlaybackState(
+ PlaybackState.Builder()
+ .setActions(actions)
+ .setState(
+ state,
+ current.positionMs.coerceAtLeast(0L),
+ playbackSpeed,
+ SystemClock.elapsedRealtime(),
+ )
+ .setBufferedPosition(current.bufferedPositionMs.coerceAtLeast(0L))
+ .build(),
+ )
+
+ lastPublishedPositionMs = current.positionMs
+ lastPublishedDurationMs = current.durationMs
+ lastPublishedPlaying = current.isPlaying
+ lastPublishedLoading = current.isLoading
+ lastPublishedEnded = current.isEnded
+ lastPublishedSpeed = current.playbackSpeed
+ }
+
+ private fun publishNotification() {
+ val currentMetadata = metadata ?: return
+ val notification = buildNotification(
+ context = appContext,
+ sessionToken = mediaSession.sessionToken,
+ metadata = currentMetadata,
+ snapshot = snapshot,
+ artwork = artworkBitmap,
+ )
+ PlayerNowPlayingService.publish(appContext, notification)
+ }
+
+ private fun loadArtwork(urlString: String?) {
+ val generation = artworkGeneration.incrementAndGet()
+ if (urlString.isNullOrBlank()) return
+
+ artworkExecutor.execute {
+ val bitmap = runCatching { downloadArtwork(urlString) }
+ .onFailure { error -> Log.w(NOW_PLAYING_TAG, "Failed to load artwork", error) }
+ .getOrNull()
+
+ mainHandler.post {
+ if (released || generation != artworkGeneration.get() || metadata?.artworkUrl != urlString) {
+ bitmap?.recycle()
+ return@post
+ }
+ artworkBitmap = bitmap
+ publishMetadata()
+ publishNotification()
+ }
+ }
+ }
+
+ private fun resetPublishedPlaybackState() {
+ lastPublishedPositionMs = Long.MIN_VALUE
+ lastPublishedDurationMs = Long.MIN_VALUE
+ lastPublishedPlaying = null
+ lastPublishedLoading = null
+ lastPublishedEnded = null
+ lastPublishedSpeed = Float.NaN
+ }
+
+ private fun runOnMain(block: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ block()
+ } else {
+ mainHandler.post(block)
+ }
+ }
+}
+
+class PlayerNowPlayingActionReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ AndroidNowPlayingActionDispatcher.dispatch(intent.action)
+ }
+}
+
+class PlayerNowPlayingService : Service() {
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action != ACTION_START_FOREGROUND) {
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
+
+ val notification = PlayerNowPlayingServiceState.notification
+ if (notification == null) {
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
+
+ startForeground(NOW_PLAYING_NOTIFICATION_ID, notification)
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ @Suppress("DEPRECATION")
+ stopForeground(true)
+ }
+ super.onDestroy()
+ }
+
+ companion object {
+ internal fun publish(context: Context, notification: Notification) {
+ PlayerNowPlayingServiceState.notification = notification
+ val intent = Intent(context, PlayerNowPlayingService::class.java)
+ .setAction(ACTION_START_FOREGROUND)
+ runCatching {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ context.getSystemService(NotificationManager::class.java)
+ ?.notify(NOW_PLAYING_NOTIFICATION_ID, notification)
+ }.onFailure { error ->
+ Log.w(NOW_PLAYING_TAG, "Unable to publish playback notification", error)
+ }
+ }
+
+ internal fun hide(context: Context) {
+ PlayerNowPlayingServiceState.notification = null
+ runCatching { context.stopService(Intent(context, PlayerNowPlayingService::class.java)) }
+ context.getSystemService(NotificationManager::class.java)
+ ?.cancel(NOW_PLAYING_NOTIFICATION_ID)
+ }
+ }
+}
+
+private object PlayerNowPlayingServiceState {
+ @Volatile
+ var notification: Notification? = null
+}
+
+private object AndroidNowPlayingActionDispatcher {
+ @Volatile
+ private var controllerRef: WeakReference? = null
+
+ fun register(controller: AndroidPlayerNowPlayingController) {
+ controllerRef = WeakReference(controller)
+ }
+
+ fun unregister(controller: AndroidPlayerNowPlayingController) {
+ if (controllerRef?.get() === controller) controllerRef = null
+ }
+
+ fun dispatch(action: String?) {
+ controllerRef?.get()?.handleAction(action)
+ }
+}
+
+private fun createNotificationChannel(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
+ val channel = NotificationChannel(
+ NOW_PLAYING_CHANNEL_ID,
+ "Playback",
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "Media playback controls"
+ setSound(null, null)
+ enableVibration(false)
+ setShowBadge(false)
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
+ }
+ manager.createNotificationChannel(channel)
+}
+
+private fun buildNotification(
+ context: Context,
+ sessionToken: MediaSession.Token,
+ metadata: AndroidNowPlayingMetadata,
+ snapshot: PlayerPlaybackSnapshot,
+ artwork: Bitmap?,
+): Notification {
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(context, NOW_PLAYING_CHANNEL_ID)
+ } else {
+ @Suppress("DEPRECATION")
+ Notification.Builder(context)
+ }
+
+ val playPauseAction = if (snapshot.isPlaying) {
+ Notification.Action.Builder(
+ android.R.drawable.ic_media_pause,
+ "Pause",
+ buildActionIntent(context, ACTION_PAUSE, 2),
+ ).build()
+ } else {
+ Notification.Action.Builder(
+ android.R.drawable.ic_media_play,
+ "Play",
+ buildActionIntent(context, ACTION_PLAY, 2),
+ ).build()
+ }
+
+ return builder
+ .setSmallIcon(R.drawable.ic_notification_small)
+ .setContentTitle(metadata.title)
+ .setContentText(metadata.subtitle)
+ .setLargeIcon(artwork)
+ .setContentIntent(buildContentIntent(context))
+ .setCategory(Notification.CATEGORY_TRANSPORT)
+ .setVisibility(Notification.VISIBILITY_PUBLIC)
+ .setOnlyAlertOnce(true)
+ .setOngoing(snapshot.isPlaying)
+ .setShowWhen(false)
+ .addAction(
+ Notification.Action.Builder(
+ android.R.drawable.ic_media_rew,
+ "Rewind 10 seconds",
+ buildActionIntent(context, ACTION_REWIND, 1),
+ ).build(),
+ )
+ .addAction(playPauseAction)
+ .addAction(
+ Notification.Action.Builder(
+ android.R.drawable.ic_media_ff,
+ "Forward 10 seconds",
+ buildActionIntent(context, ACTION_FAST_FORWARD, 3),
+ ).build(),
+ )
+ .setStyle(
+ Notification.MediaStyle()
+ .setMediaSession(sessionToken)
+ .setShowActionsInCompactView(0, 1, 2),
+ )
+ .build()
+}
+
+private fun buildActionIntent(context: Context, action: String, requestCode: Int): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ requestCode,
+ Intent(context, PlayerNowPlayingActionReceiver::class.java).setAction(action),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+private fun buildContentIntent(context: Context): PendingIntent? {
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ ?.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ ?: return null
+ return PendingIntent.getActivity(
+ context,
+ 0,
+ launchIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+}
+
+private fun downloadArtwork(urlString: String): Bitmap? {
+ val connection = (URL(urlString).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 10_000
+ readTimeout = 15_000
+ instanceFollowRedirects = true
+ requestMethod = "GET"
+ setRequestProperty("Accept", "image/*")
+ }
+
+ return try {
+ connection.connect()
+ if (connection.responseCode !in 200..299) return null
+ val bytes = connection.inputStream.use { input ->
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var total = 0
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+ total += read
+ if (total > MAX_ARTWORK_DOWNLOAD_BYTES) return null
+ output.write(buffer, 0, read)
+ }
+ output.toByteArray()
+ }
+ decodeSampledBitmap(bytes, MAX_ARTWORK_EDGE_PX)
+ } finally {
+ connection.disconnect()
+ }
+}
+
+private fun decodeSampledBitmap(bytes: ByteArray, maxEdgePx: Int): Bitmap? {
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
+
+ var sampleSize = 1
+ while (bounds.outWidth / sampleSize > maxEdgePx || bounds.outHeight / sampleSize > maxEdgePx) {
+ sampleSize *= 2
+ }
+
+ val options = BitmapFactory.Options().apply {
+ inSampleSize = sampleSize
+ inPreferredConfig = Bitmap.Config.ARGB_8888
+ }
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
+}