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) +}