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 var positionCachePollJob: Job? = null private val _uiState = MutableStateFlow(PlaybackUiState.initial()) val uiState: StateFlow = _uiState.asStateFlow() private val _trackBoundary = MutableSharedFlow( extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) val trackBoundaryEvents: SharedFlow = _trackBoundary.asSharedFlow() private var queue: List = emptyList() private var index: Int = 0 /** Updated on the main thread whenever progress is read from ExoPlayer — safe for sensor threads. */ @Volatile private var cachedPlaybackPositionMs: Long = 0L 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() } } } positionCachePollJob?.cancel() positionCachePollJob = scope.launch { while (isActive) { delay(POSITION_CACHE_INTERVAL_MS) refreshCachedPlaybackPositionMs() } } } private fun releasePlayer() { positionPollJob?.cancel() positionPollJob = null positionCachePollJob?.cancel() positionCachePollJob = 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 refreshCachedPlaybackPositionMs() { val p = player ?: return if (queue.isEmpty()) { cachedPlaybackPositionMs = 0L return } val durationMs = currentDurationMs().coerceAtLeast(1L) cachedPlaybackPositionMs = p.currentPosition.coerceIn(0L, durationMs) } private fun updateProgressFromPlayer() { val p = player ?: 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, 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]. * 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 onDestroy() { positionPollJob?.cancel() positionCachePollJob?.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 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" } }