Files
lockstep-player/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt

607 lines
20 KiB
Kotlin
Raw Normal View History

2026-05-14 02:43:49 +02:00
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 at.lockstep.player.LockstepApplication
import at.lockstep.player.MainActivity
import at.lockstep.player.R
2026-05-24 10:56:52 +02:00
import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
import at.lockstep.player.playback.engine.MusicPlayerEngine
2026-05-14 02:43:49 +02:00
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
2026-05-15 09:03:20 +02:00
import kotlinx.coroutines.Job
2026-05-14 02:43:49 +02:00
import kotlinx.coroutines.SupervisorJob
2026-05-15 09:03:20 +02:00
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
2026-05-14 02:43:49 +02:00
import kotlinx.coroutines.flow.MutableStateFlow
2026-05-15 09:03:20 +02:00
import kotlinx.coroutines.flow.SharedFlow
2026-05-14 02:43:49 +02:00
import kotlinx.coroutines.flow.StateFlow
2026-05-15 09:03:20 +02:00
import kotlinx.coroutines.flow.asSharedFlow
2026-05-14 02:43:49 +02:00
import kotlinx.coroutines.flow.asStateFlow
2026-05-15 09:03:20 +02:00
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.isActive
2026-05-14 02:43:49 +02:00
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
2026-05-15 09:03:20 +02:00
/**
2026-05-24 10:56:52 +02:00
* Local MP3 playback via [MusicPlayerEngine] (ExoPlayer today, libpasada later). UI progress and
* beat taps use [MusicPlayerEngine.getCurrentPositionMs] (milliseconds from the start of the
* current media item).
2026-05-15 09:03:20 +02:00
*/
2026-05-14 02:43:49 +02:00
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()
2026-05-24 10:56:52 +02:00
private var engine: MusicPlayerEngine? = null
2026-05-15 09:03:20 +02:00
private var positionPollJob: Job? = null
private var positionCachePollJob: Job? = null
2026-05-15 09:03:20 +02:00
2026-05-14 02:43:49 +02:00
private val _uiState = MutableStateFlow(PlaybackUiState.initial())
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
2026-05-15 09:03:20 +02:00
private val _trackBoundary =
MutableSharedFlow<TrackBoundaryEvent>(
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val trackBoundaryEvents: SharedFlow<TrackBoundaryEvent> = _trackBoundary.asSharedFlow()
2026-05-14 02:43:49 +02:00
private var queue: List<TrackQueueItem> = emptyList()
private var index: Int = 0
2026-05-30 20:21:50 +02:00
private var tornDown = false
2026-05-14 02:43:49 +02:00
2026-05-24 10:56:52 +02:00
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
@Volatile
private var cachedPlaybackPositionMs: Long = 0L
2026-05-24 10:56:52 +02:00
private val engineListener =
object : MusicPlayerEngine.Listener {
override fun onPlaybackEnded() {
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)
2026-05-15 09:03:20 +02:00
}
}
2026-05-24 10:56:52 +02:00
override fun onError(
errorCode: Int,
message: String,
) {
setPlaying(false)
}
2026-05-15 09:03:20 +02:00
}
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,
),
)
}
}
2026-05-14 02:43:49 +02:00
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)
}
2026-05-15 09:03:20 +02:00
override fun onSeekTo(pos: Long) {
2026-05-24 10:56:52 +02:00
engine?.seekTo(pos)
updateProgressFromEngine()
2026-05-15 09:03:20 +02:00
}
2026-05-14 02:43:49 +02:00
},
)
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,
)
}
}
2026-05-24 10:56:52 +02:00
private fun ensureEngine(): MusicPlayerEngine {
engine?.let {
2026-05-15 09:03:20 +02:00
return it
}
2026-05-24 10:56:52 +02:00
return ExoPlayerMusicPlayerEngine(this)
2026-05-15 09:03:20 +02:00
.also {
2026-05-24 10:56:52 +02:00
it.setListener(engineListener)
it.initSession()
engine = it
2026-05-15 09:03:20 +02:00
startPositionPolling()
}
}
private fun startPositionPolling() {
positionPollJob?.cancel()
positionPollJob =
scope.launch {
while (isActive) {
delay(UPDATE_INTERVAL_MS)
2026-05-24 10:56:52 +02:00
if (engine != null && queue.isNotEmpty()) {
updateProgressFromEngine()
2026-05-15 09:03:20 +02:00
}
}
}
positionCachePollJob?.cancel()
positionCachePollJob =
scope.launch {
while (isActive) {
delay(POSITION_CACHE_INTERVAL_MS)
refreshCachedPlaybackPositionMs()
}
}
2026-05-15 09:03:20 +02:00
}
2026-05-24 10:56:52 +02:00
private fun releaseEngine() {
2026-05-15 09:03:20 +02:00
positionPollJob?.cancel()
positionPollJob = null
positionCachePollJob?.cancel()
positionCachePollJob = null
2026-05-24 10:56:52 +02:00
engine?.setListener(null)
engine?.releaseSession()
engine = null
2026-05-15 09:03:20 +02:00
}
2026-05-14 02:43:49 +02:00
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
}
2026-05-15 09:03:20 +02:00
val hint =
row.durationMs?.takeIf { it > 0 }
?: DEFAULT_DURATION_HINT_MS
2026-05-14 02:43:49 +02:00
TrackQueueItem(
id = tid,
title = row.trackName ?: "",
artist = row.artistName ?: "",
localUri = Uri.parse(uriStr),
2026-05-15 09:03:20 +02:00
durationMsHint = hint,
2026-05-14 02:43:49 +02:00
)
}
index = 0
if (queue.isEmpty()) {
2026-05-15 09:03:20 +02:00
withContext(Dispatchers.Main) {
2026-05-24 10:56:52 +02:00
releaseEngine()
2026-05-15 09:03:20 +02:00
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
2026-05-14 02:43:49 +02:00
return@launch
}
withContext(Dispatchers.Main) {
2026-05-24 10:56:52 +02:00
ensureEngine()
2026-05-14 02:43:49 +02:00
setPlaying(true)
publishCurrentTrack()
2026-05-15 09:03:20 +02:00
refreshForegroundNotification()
2026-05-14 02:43:49 +02:00
}
}
}
private fun publishCurrentTrack() {
val item = queue.getOrNull(index) ?: return
2026-05-15 09:03:20 +02:00
applyCurrentMediaItem(item)
val durationSec =
2026-05-24 10:56:52 +02:00
(engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
2026-05-15 09:03:20 +02:00
?: (item.durationMsHint / 1000).coerceAtLeast(1)
2026-05-14 02:43:49 +02:00
_uiState.value =
PlaybackUiState(
title = item.title,
artist = item.artist,
2026-05-15 09:03:20 +02:00
progress = 0f,
2026-05-14 02:43:49 +02:00
durationSeconds = durationSec,
isPlaying = _uiState.value.isPlaying,
2026-05-15 09:03:20 +02:00
currentTrackId = item.id,
currentQueueIndex = index,
queueSize = queue.size,
2026-05-14 02:43:49 +02:00
)
2026-05-24 10:56:52 +02:00
updateProgressFromEngine()
2026-05-15 09:03:20 +02:00
updateSessionMetadata(item, durationSec)
2026-05-24 10:56:52 +02:00
updatePlaybackStateFromEngine()
2026-05-14 02:43:49 +02:00
refreshForegroundNotification()
}
2026-05-15 09:03:20 +02:00
private fun applyCurrentMediaItem(item: TrackQueueItem) {
val uri = item.localUri ?: return
2026-05-24 10:56:52 +02:00
val e = ensureEngine()
e.prepareTrack(uri)
if (_uiState.value.isPlaying) {
e.play()
} else {
e.pause()
}
2026-05-15 09:03:20 +02:00
}
2026-05-14 02:43:49 +02:00
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(),
)
}
2026-05-15 09:03:20 +02:00
private fun currentDurationMs(): Long {
2026-05-24 10:56:52 +02:00
val fromEngine = engine?.getDurationMs()?.takeIf { it > 0 }
if (fromEngine != null) {
return fromEngine
2026-05-15 09:03:20 +02:00
}
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
return hint
}
private fun refreshCachedPlaybackPositionMs() {
2026-05-24 10:56:52 +02:00
val e = engine ?: return
if (queue.isEmpty()) {
cachedPlaybackPositionMs = 0L
return
}
2026-05-15 09:03:20 +02:00
val durationMs = currentDurationMs().coerceAtLeast(1L)
2026-05-24 10:56:52 +02:00
cachedPlaybackPositionMs = e.getCurrentPositionMs().coerceIn(0L, durationMs)
}
2026-05-24 10:56:52 +02:00
private fun updateProgressFromEngine() {
val e = engine ?: return
refreshCachedPlaybackPositionMs()
2026-05-15 09:03:20 +02:00
if (queue.isEmpty()) {
return
}
val durationMs = currentDurationMs().coerceAtLeast(1L)
val positionMs = cachedPlaybackPositionMs
2026-05-15 09:03:20 +02:00
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,
)
2026-05-24 10:56:52 +02:00
updatePlaybackStateFromEngine()
2026-05-15 09:03:20 +02:00
}
2026-05-24 10:56:52 +02:00
private fun updatePlaybackStateFromEngine() {
val e = engine
val positionMs = e?.getCurrentPositionMs() ?: 0L
2026-05-14 02:43:49 +02:00
val actions =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
2026-05-15 09:03:20 +02:00
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO
2026-05-14 02:43:49 +02:00
val state =
2026-05-24 10:56:52 +02:00
if (_uiState.value.isPlaying && e?.isPlaying() == true) {
2026-05-14 02:43:49 +02:00
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
}
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(actions)
2026-05-15 09:03:20 +02:00
.setState(state, positionMs, 1f)
2026-05-14 02:43:49 +02:00
.build(),
)
}
private fun setPlaying(playing: Boolean) {
_uiState.value = _uiState.value.copy(isPlaying = playing)
2026-05-24 10:56:52 +02:00
if (playing) {
engine?.play()
} else {
engine?.pause()
}
updatePlaybackStateFromEngine()
2026-05-14 02:43:49 +02:00
refreshForegroundNotification()
}
private fun togglePlaying() {
if (queue.isEmpty()) return
setPlaying(!_uiState.value.isPlaying)
}
private fun skipDelta(delta: Int) {
2026-05-15 09:03:20 +02:00
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
2026-05-14 02:43:49 +02:00
publishCurrentTrack()
}
fun requestTogglePause() {
togglePlaying()
}
fun requestSkipNext() {
skipDelta(1)
}
fun requestSkipPrevious() {
skipDelta(-1)
}
fun requestSeek(fraction: Float) {
if (queue.isEmpty()) return
2026-05-24 10:56:52 +02:00
val e = engine ?: return
2026-05-15 09:03:20 +02:00
val durationMs = currentDurationMs()
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
2026-05-24 10:56:52 +02:00
e.seekTo(positionMs)
updateProgressFromEngine()
2026-05-15 09:03:20 +02:00
}
/**
2026-05-24 10:56:52 +02:00
* Milliseconds from the start of the current track same timebase as the playback engine.
* Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe.
2026-05-15 09:03:20 +02:00
*/
fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
2026-05-14 02:43:49 +02:00
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()
}
2026-05-30 20:21:50 +02:00
override fun onTaskRemoved(rootIntent: Intent?) {
stopPlaybackAndTeardown()
stopSelf()
super.onTaskRemoved(rootIntent)
}
2026-05-14 02:43:49 +02:00
override fun onDestroy() {
2026-05-30 20:21:50 +02:00
stopPlaybackAndTeardown()
super.onDestroy()
}
/** Stops audio, clears queue state, and removes the foreground notification. Idempotent. */
private fun stopPlaybackAndTeardown() {
if (tornDown) return
tornDown = true
2026-05-15 09:03:20 +02:00
positionPollJob?.cancel()
2026-05-30 20:21:50 +02:00
positionPollJob = null
positionCachePollJob?.cancel()
2026-05-30 20:21:50 +02:00
positionCachePollJob = null
2026-05-24 10:56:52 +02:00
releaseEngine()
2026-05-30 20:21:50 +02:00
queue = emptyList()
index = 0
cachedPlaybackPositionMs = 0L
_uiState.value = PlaybackUiState.initial()
if (::mediaSession.isInitialized) {
mediaSession.run {
isActive = false
release()
}
2026-05-14 02:43:49 +02:00
}
2026-05-30 20:21:50 +02:00
stopForeground(STOP_FOREGROUND_REMOVE)
2026-05-14 02:43:49 +02:00
}
data class PlaybackUiState(
val title: String,
val artist: String,
val progress: Float,
val durationSeconds: Int,
val isPlaying: Boolean,
2026-05-15 09:03:20 +02:00
val currentTrackId: String?,
val currentQueueIndex: Int,
val queueSize: Int,
2026-05-14 02:43:49 +02:00
) {
companion object {
fun initial() =
PlaybackUiState(
title = "",
artist = "",
progress = 0f,
durationSeconds = 180,
isPlaying = false,
2026-05-15 09:03:20 +02:00
currentTrackId = null,
currentQueueIndex = 0,
queueSize = 0,
2026-05-14 02:43:49 +02:00
)
}
}
private data class TrackQueueItem(
val id: String,
val title: String,
val artist: String,
val localUri: Uri?,
2026-05-24 10:56:52 +02:00
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
2026-05-15 09:03:20 +02:00
val durationMsHint: Int,
2026-05-14 02:43:49 +02:00
)
companion object {
2026-05-15 09:03:20 +02:00
private const val UPDATE_INTERVAL_MS = 250L
private const val POSITION_CACHE_INTERVAL_MS = 20L
2026-05-15 09:03:20 +02:00
private const val DEFAULT_DURATION_HINT_MS = 180_000
2026-05-14 02:43:49 +02:00
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"
}
}