Merge branch 'cmp-rewrite' of https://github.com/NuvioMedia/NuvioMobile into cmp-rewrite

This commit is contained in:
tapframe 2026-07-14 16:36:19 +05:30
commit d2a6206357
3 changed files with 668 additions and 24 deletions

View file

@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application
android:allowBackup="false"
@ -60,6 +62,16 @@
</intent-filter>
</activity>
<service
android:name="com.nuvio.app.features.player.PlayerNowPlayingService"
android:exported="false"
android:foregroundServiceType="mediaPlayback"
android:stopWithTask="false" />
<receiver
android:name="com.nuvio.app.features.player.PlayerNowPlayingActionReceiver"
android:exported="false" />
<receiver
android:name="com.nuvio.app.features.downloads.DownloadsNotificationActionReceiver"
android:exported="false" />

View file

@ -362,6 +362,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)
@ -482,7 +509,7 @@ private fun ExoPlayerSurface(
exoPlayer.logCurrentTracks("STATE_READY")
}
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot())
dispatchExoPlayerSnapshot()
}
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
@ -491,11 +518,11 @@ private fun ExoPlayerSurface(
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) {
@ -519,7 +546,7 @@ private fun ExoPlayerSurface(
exoPlayer.selectTrackByIndex(C.TRACK_TYPE_TEXT, idx)
}
}
latestOnSnapshot.value(exoPlayer.snapshot())
dispatchExoPlayerSnapshot()
}
}
@ -542,7 +569,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()
}
}
@ -559,7 +587,7 @@ private fun ExoPlayerSurface(
LaunchedEffect(exoPlayer, playWhenReady) {
exoPlayer.playWhenReady = latestPlayWhenReady.value
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot())
dispatchExoPlayerSnapshot()
}
LaunchedEffect(exoPlayer) {
@ -591,6 +619,14 @@ private fun ExoPlayerSurface(
exoPlayer.setPlaybackSpeed(speed)
}
override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {
nowPlayingController.updateMetadata(info)
}
override fun clearNowPlayingInfo() {
nowPlayingController.clear()
}
override fun getAudioTracks(): List<AudioTrack> =
exoPlayer.extractAudioTracks(context)
@ -719,7 +755,7 @@ private fun ExoPlayerSurface(
LaunchedEffect(exoPlayer) {
while (isActive) {
latestOnSnapshot.value(exoPlayer.snapshot())
dispatchExoPlayerSnapshot()
delay(250L)
}
}
@ -785,8 +821,25 @@ private fun LibmpvPlayerSurface(
sanitizePlaybackHeaders(sourceHeaders)
}
var playerViewRef by remember { mutableStateOf<NuvioLibmpvView?>(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
@ -796,7 +849,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)
}
}
@ -809,11 +863,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()
}
@ -845,14 +901,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()
@ -890,7 +950,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,
@ -904,7 +966,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) {
@ -913,13 +977,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)
}
@ -1116,19 +1182,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)
@ -1138,6 +1211,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)
}

View file

@ -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<AndroidPlayerNowPlayingController>? = 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)
}