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
|
2026-05-24 10:41:51 +02:00
|
|
|
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. */
|
2026-05-24 10:41:51 +02:00
|
|
|
@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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-24 10:41:51 +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
|
2026-05-24 10:41:51 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 10:41:51 +02:00
|
|
|
private fun refreshCachedPlaybackPositionMs() {
|
2026-05-24 10:56:52 +02:00
|
|
|
val e = engine ?: return
|
2026-05-24 10:41:51 +02:00
|
|
|
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:41:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-24 10:56:52 +02:00
|
|
|
private fun updateProgressFromEngine() {
|
|
|
|
|
val e = engine ?: return
|
2026-05-24 10:41:51 +02:00
|
|
|
refreshCachedPlaybackPositionMs()
|
2026-05-15 09:03:20 +02:00
|
|
|
if (queue.isEmpty()) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-24 10:41:51 +02:00
|
|
|
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.
|
2026-05-24 10:41:51 +02:00
|
|
|
* 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
|
|
|
*/
|
2026-05-24 10:41:51 +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
|
2026-05-24 10:41:51 +02:00
|
|
|
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
|
2026-05-24 10:41:51 +02:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|