mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Merge branch 'cmp-rewrite' of https://github.com/NuvioMedia/NuvioMobile into cmp-rewrite
This commit is contained in:
commit
d2a6206357
3 changed files with 668 additions and 24 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in a new issue