Files
lockstep-player/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
2026-05-15 09:03:20 +02:00

550 lines
18 KiB
Kotlin

package at.lockstep.player.playback
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import at.lockstep.player.LockstepApplication
import at.lockstep.player.MainActivity
import at.lockstep.player.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Local MP3 playback via Media3 [ExoPlayer]. UI progress and beat taps use
* [ExoPlayer.getCurrentPosition] (milliseconds from the start of the current media item), which is
* the same timeline used for seeking and end-of-track reporting — see Media3
* [Player] / [ExoPlayer] documentation.
*/
class PlaybackService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var mediaSession: MediaSessionCompat
private lateinit var app: LockstepApplication
private val binder = LocalBinder()
private var player: ExoPlayer? = null
private var positionPollJob: Job? = null
private val _uiState = MutableStateFlow(PlaybackUiState.initial())
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
private val _trackBoundary =
MutableSharedFlow<TrackBoundaryEvent>(
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val trackBoundaryEvents: SharedFlow<TrackBoundaryEvent> = _trackBoundary.asSharedFlow()
private var queue: List<TrackQueueItem> = emptyList()
private var index: Int = 0
private val playerListener =
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
if (queue.isEmpty()) {
return
}
if (index < queue.lastIndex) {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK,
)
index++
publishCurrentTrack()
} else {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.LAST_TRACK_FINISHED,
)
setPlaying(false)
}
}
}
}
private fun emitTrackBoundaryForQueueIndex(
i: Int,
reason: TrackBoundaryReason,
) {
val item = queue.getOrNull(i) ?: return
scope.launch {
_trackBoundary.emit(
TrackBoundaryEvent(
trackId = item.id,
title = item.title,
artist = item.artist,
queueIndex = i,
queueSize = queue.size,
reason = reason,
),
)
}
}
inner class LocalBinder : Binder() {
fun getService(): PlaybackService = this@PlaybackService
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onCreate() {
super.onCreate()
app = application as LockstepApplication
mediaSession = MediaSessionCompat(this, "LockstepPlayback")
mediaSession.setCallback(
object : MediaSessionCompat.Callback() {
override fun onPlay() {
setPlaying(true)
}
override fun onPause() {
setPlaying(false)
}
override fun onSkipToNext() {
skipDelta(1)
}
override fun onSkipToPrevious() {
skipDelta(-1)
}
override fun onSeekTo(pos: Long) {
val p = player ?: return
p.seekTo(pos)
updateProgressFromPlayer()
}
},
)
mediaSession.isActive = true
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
when (intent?.action) {
ACTION_START_PLAYLIST -> {
val pid = intent.getStringExtra(EXTRA_PLAYLIST_ID)
if (pid != null) {
goForegroundPlaceholder()
startPlaylist(pid)
}
}
ACTION_TOGGLE_PAUSE -> togglePlaying()
ACTION_SKIP_NEXT -> skipDelta(1)
ACTION_SKIP_PREVIOUS -> skipDelta(-1)
}
return START_STICKY
}
private fun goForegroundPlaceholder() {
val notification =
NotificationCompat.Builder(
this,
LockstepApplication.PLAYBACK_NOTIFICATION_CHANNEL_ID,
).setContentTitle(getString(R.string.notification_loading_playlist))
.setContentText(getString(R.string.app_name))
.setSmallIcon(R.drawable.ic_play_arrow_24)
.setContentIntent(contentActivityIntent())
.setOngoing(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
LockstepApplication.PLAYBACK_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(
LockstepApplication.PLAYBACK_NOTIFICATION_ID,
notification,
)
}
}
private fun ensurePlayer(): ExoPlayer {
player?.let {
return it
}
return ExoPlayer.Builder(this)
.build()
.also {
it.addListener(playerListener)
player = it
startPositionPolling()
}
}
private fun startPositionPolling() {
positionPollJob?.cancel()
positionPollJob =
scope.launch {
while (isActive) {
delay(UPDATE_INTERVAL_MS)
if (player != null && queue.isNotEmpty()) {
updateProgressFromPlayer()
}
}
}
}
private fun releasePlayer() {
positionPollJob?.cancel()
positionPollJob = null
player?.removeListener(playerListener)
player?.release()
player = null
}
private fun startPlaylist(pid: String) {
scope.launch(Dispatchers.IO) {
val rows = app.playlistRepository.getTracks(pid)
val pairingByTrackId =
app.database.pairingDao().listForPlaylist(pid).associateBy { it.trackId }
queue =
rows.mapNotNull { row ->
val tid = row.trackId ?: return@mapNotNull null
val pairing = pairingByTrackId[tid] ?: return@mapNotNull null
val uriStr = pairing.localUri
if (uriStr.isNullOrBlank() || pairing.pairingError != null) {
return@mapNotNull null
}
val hint =
row.durationMs?.takeIf { it > 0 }
?: DEFAULT_DURATION_HINT_MS
TrackQueueItem(
id = tid,
title = row.trackName ?: "",
artist = row.artistName ?: "",
localUri = Uri.parse(uriStr),
durationMsHint = hint,
)
}
index = 0
if (queue.isEmpty()) {
withContext(Dispatchers.Main) {
releasePlayer()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
return@launch
}
withContext(Dispatchers.Main) {
ensurePlayer()
setPlaying(true)
publishCurrentTrack()
refreshForegroundNotification()
}
}
}
private fun publishCurrentTrack() {
val item = queue.getOrNull(index) ?: return
applyCurrentMediaItem(item)
val durationSec =
(player?.duration?.takeIf { it > 0 }?.div(1000)?.toInt())
?: (item.durationMsHint / 1000).coerceAtLeast(1)
_uiState.value =
PlaybackUiState(
title = item.title,
artist = item.artist,
progress = 0f,
durationSeconds = durationSec,
isPlaying = _uiState.value.isPlaying,
currentTrackId = item.id,
currentQueueIndex = index,
queueSize = queue.size,
)
updateProgressFromPlayer()
updateSessionMetadata(item, durationSec)
updatePlaybackStateFromPlayer()
refreshForegroundNotification()
}
private fun applyCurrentMediaItem(item: TrackQueueItem) {
val uri = item.localUri ?: return
val p = ensurePlayer()
p.setMediaItem(MediaItem.fromUri(uri))
p.prepare()
p.playWhenReady = _uiState.value.isPlaying
}
private fun updateSessionMetadata(
item: TrackQueueItem,
durationSec: Int,
) {
mediaSession.setMetadata(
MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.title)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.artist)
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationSec * 1000L)
.build(),
)
}
private fun currentDurationMs(): Long {
val p = player
val fromPlayer = p?.duration?.takeIf { it > 0 }
if (fromPlayer != null) {
return fromPlayer
}
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
return hint
}
private fun updateProgressFromPlayer() {
val p = player ?: return
val durationMs = currentDurationMs().coerceAtLeast(1L)
val positionMs = p.currentPosition.coerceIn(0L, durationMs)
if (queue.isEmpty()) {
return
}
val progress = (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
val durationSec = (durationMs / 1000L).toInt().coerceAtLeast(1)
_uiState.value =
_uiState.value.copy(
progress = progress,
durationSeconds = durationSec,
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
currentQueueIndex = index,
queueSize = queue.size,
)
updatePlaybackStateFromPlayer()
}
private fun updatePlaybackStateFromPlayer() {
val p = player
val positionMs = p?.currentPosition ?: 0L
val actions =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO
val state =
if (_uiState.value.isPlaying && p?.isPlaying == true) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
}
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(actions)
.setState(state, positionMs, 1f)
.build(),
)
}
private fun setPlaying(playing: Boolean) {
_uiState.value = _uiState.value.copy(isPlaying = playing)
player?.playWhenReady = playing
updatePlaybackStateFromPlayer()
refreshForegroundNotification()
}
private fun togglePlaying() {
if (queue.isEmpty()) return
setPlaying(!_uiState.value.isPlaying)
}
private fun skipDelta(delta: Int) {
if (queue.isEmpty()) {
return
}
val oldIndex = index
val newIndex = (oldIndex + delta).coerceIn(0, queue.lastIndex)
if (newIndex == oldIndex) {
return
}
emitTrackBoundaryForQueueIndex(oldIndex, TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK)
index = newIndex
publishCurrentTrack()
}
fun requestTogglePause() {
togglePlaying()
}
fun requestSkipNext() {
skipDelta(1)
}
fun requestSkipPrevious() {
skipDelta(-1)
}
fun requestSeek(fraction: Float) {
if (queue.isEmpty()) return
val p = player ?: return
val durationMs = currentDurationMs()
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
p.seekTo(positionMs)
updateProgressFromPlayer()
}
/**
* Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition].
* May be called from any thread per Media3 [Player] contract.
*/
fun getPlaybackPositionMs(): Long {
return player?.currentPosition ?: 0L
}
private fun refreshForegroundNotification() {
if (queue.isEmpty()) return
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
LockstepApplication.PLAYBACK_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(
LockstepApplication.PLAYBACK_NOTIFICATION_ID,
notification,
)
}
}
private fun contentActivityIntent(): PendingIntent =
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun buildNotification(): android.app.Notification {
val prev =
PendingIntent.getService(
this,
1,
Intent(this, PlaybackService::class.java).setAction(ACTION_SKIP_PREVIOUS),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val next =
PendingIntent.getService(
this,
2,
Intent(this, PlaybackService::class.java).setAction(ACTION_SKIP_NEXT),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val toggle =
PendingIntent.getService(
this,
3,
Intent(this, PlaybackService::class.java).setAction(ACTION_TOGGLE_PAUSE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val playing = _uiState.value.isPlaying
val style =
MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0, 1, 2)
return NotificationCompat.Builder(
this,
LockstepApplication.PLAYBACK_NOTIFICATION_CHANNEL_ID,
).setContentTitle(_uiState.value.title)
.setContentText(_uiState.value.artist)
.setSmallIcon(R.drawable.ic_play_arrow_24)
.setContentIntent(contentActivityIntent())
.setOnlyAlertOnce(true)
.addAction(R.drawable.ic_skip_previous_24, getString(R.string.notification_prev), prev)
.addAction(
if (playing) R.drawable.ic_pause_24 else R.drawable.ic_play_arrow_24,
getString(R.string.notification_play_pause),
toggle,
)
.addAction(R.drawable.ic_skip_next_24, getString(R.string.notification_next), next)
.setStyle(style)
.build()
}
override fun onDestroy() {
positionPollJob?.cancel()
releasePlayer()
mediaSession.run {
isActive = false
release()
}
super.onDestroy()
}
data class PlaybackUiState(
val title: String,
val artist: String,
val progress: Float,
val durationSeconds: Int,
val isPlaying: Boolean,
val currentTrackId: String?,
val currentQueueIndex: Int,
val queueSize: Int,
) {
companion object {
fun initial() =
PlaybackUiState(
title = "",
artist = "",
progress = 0f,
durationSeconds = 180,
isPlaying = false,
currentTrackId = null,
currentQueueIndex = 0,
queueSize = 0,
)
}
}
private data class TrackQueueItem(
val id: String,
val title: String,
val artist: String,
val localUri: Uri?,
/** Fallback when [ExoPlayer] has not reported duration yet (from jukebox or default). */
val durationMsHint: Int,
)
companion object {
private const val UPDATE_INTERVAL_MS = 250L
private const val DEFAULT_DURATION_HINT_MS = 180_000
const val ACTION_START_PLAYLIST = "at.lockstep.player.action.START_PLAYLIST"
const val ACTION_TOGGLE_PAUSE = "at.lockstep.player.action.TOGGLE_PAUSE"
const val ACTION_SKIP_NEXT = "at.lockstep.player.action.SKIP_NEXT"
const val ACTION_SKIP_PREVIOUS = "at.lockstep.player.action.SKIP_PREVIOUS"
const val EXTRA_PLAYLIST_ID = "playlist_id"
}
}