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

617 lines
21 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 at.lockstep.player.LockstepApplication
import at.lockstep.player.MainActivity
import at.lockstep.player.R
import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
import at.lockstep.player.playback.engine.MusicPlayerEngine
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 [MusicPlayerEngine] (ExoPlayer today, libpasada later). UI progress and
* beat taps use [MusicPlayerEngine.getCurrentPositionMs] (milliseconds from the start of the
* current media item).
*/
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 engine: MusicPlayerEngine? = null
private var positionPollJob: Job? = null
private var positionCachePollJob: 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 var tornDown = false
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
@Volatile
private var cachedPlaybackPositionMs: Long = 0L
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)
}
}
override fun onError(
errorCode: Int,
message: String,
) {
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,
playlistPosition = item.playlistPosition,
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) {
engine?.seekTo(pos)
updateProgressFromEngine()
}
},
)
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 ensureEngine(): MusicPlayerEngine {
engine?.let {
return it
}
return ExoPlayerMusicPlayerEngine(this)
.also {
it.setListener(engineListener)
it.initSession()
engine = it
startPositionPolling()
}
}
private fun startPositionPolling() {
positionPollJob?.cancel()
positionPollJob =
scope.launch {
while (isActive) {
delay(UPDATE_INTERVAL_MS)
if (engine != null && queue.isNotEmpty()) {
updateProgressFromEngine()
}
}
}
positionCachePollJob?.cancel()
positionCachePollJob =
scope.launch {
while (isActive) {
delay(POSITION_CACHE_INTERVAL_MS)
refreshCachedPlaybackPositionMs()
}
}
}
private fun releaseEngine() {
positionPollJob?.cancel()
positionPollJob = null
positionCachePollJob?.cancel()
positionCachePollJob = null
engine?.setListener(null)
engine?.releaseSession()
engine = 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,
playlistPosition = row.position,
)
}
index = 0
if (queue.isEmpty()) {
withContext(Dispatchers.Main) {
releaseEngine()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
return@launch
}
withContext(Dispatchers.Main) {
ensureEngine()
setPlaying(true)
publishCurrentTrack()
refreshForegroundNotification()
}
}
}
private fun publishCurrentTrack() {
val item = queue.getOrNull(index) ?: return
applyCurrentMediaItem(item)
val durationSec =
(engine?.getDurationMs()?.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,
currentPlaylistPosition = item.playlistPosition,
queueSize = queue.size,
)
updateProgressFromEngine()
updateSessionMetadata(item, durationSec)
updatePlaybackStateFromEngine()
refreshForegroundNotification()
}
private fun applyCurrentMediaItem(item: TrackQueueItem) {
val uri = item.localUri ?: return
val e = ensureEngine()
e.prepareTrack(uri)
if (_uiState.value.isPlaying) {
e.play()
} else {
e.pause()
}
}
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 fromEngine = engine?.getDurationMs()?.takeIf { it > 0 }
if (fromEngine != null) {
return fromEngine
}
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
return hint
}
private fun refreshCachedPlaybackPositionMs() {
val e = engine ?: return
if (queue.isEmpty()) {
cachedPlaybackPositionMs = 0L
return
}
val durationMs = currentDurationMs().coerceAtLeast(1L)
cachedPlaybackPositionMs = e.getCurrentPositionMs().coerceIn(0L, durationMs)
}
private fun updateProgressFromEngine() {
val e = engine ?: return
refreshCachedPlaybackPositionMs()
if (queue.isEmpty()) {
return
}
val durationMs = currentDurationMs().coerceAtLeast(1L)
val positionMs = cachedPlaybackPositionMs
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,
currentPlaylistPosition =
queue.getOrNull(index)?.playlistPosition ?: _uiState.value.currentPlaylistPosition,
queueSize = queue.size,
)
updatePlaybackStateFromEngine()
}
private fun updatePlaybackStateFromEngine() {
val e = engine
val positionMs = e?.getCurrentPositionMs() ?: 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 && e?.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)
if (playing) {
engine?.play()
} else {
engine?.pause()
}
updatePlaybackStateFromEngine()
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 e = engine ?: return
val durationMs = currentDurationMs()
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
e.seekTo(positionMs)
updateProgressFromEngine()
}
/**
* 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.
*/
fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
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 onTaskRemoved(rootIntent: Intent?) {
stopPlaybackAndTeardown()
stopSelf()
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
stopPlaybackAndTeardown()
super.onDestroy()
}
/** Stops audio, clears queue state, and removes the foreground notification. Idempotent. */
private fun stopPlaybackAndTeardown() {
if (tornDown) return
tornDown = true
positionPollJob?.cancel()
positionPollJob = null
positionCachePollJob?.cancel()
positionCachePollJob = null
releaseEngine()
queue = emptyList()
index = 0
cachedPlaybackPositionMs = 0L
_uiState.value = PlaybackUiState.initial()
if (::mediaSession.isInitialized) {
mediaSession.run {
isActive = false
release()
}
}
stopForeground(STOP_FOREGROUND_REMOVE)
}
data class PlaybackUiState(
val title: String,
val artist: String,
val progress: Float,
val durationSeconds: Int,
val isPlaying: Boolean,
val currentTrackId: String?,
val currentQueueIndex: Int,
/** 0-based position in the full Spotify playlist for the current track. */
val currentPlaylistPosition: Int,
val queueSize: Int,
) {
companion object {
fun initial() =
PlaybackUiState(
title = "",
artist = "",
progress = 0f,
durationSeconds = 180,
isPlaying = false,
currentTrackId = null,
currentQueueIndex = 0,
currentPlaylistPosition = 0,
queueSize = 0,
)
}
}
private data class TrackQueueItem(
val id: String,
val title: String,
val artist: String,
val localUri: Uri?,
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
val durationMsHint: Int,
/** 0-based position in the full Spotify playlist. */
val playlistPosition: Int,
)
companion object {
private const val UPDATE_INTERVAL_MS = 250L
private const val POSITION_CACHE_INTERVAL_MS = 20L
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"
}
}