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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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 val _uiState = MutableStateFlow(PlaybackUiState.initial()) val uiState: StateFlow = _uiState.asStateFlow() private var queue: List = emptyList() private var index: Int = 0 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) } }, ) 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 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 } TrackQueueItem( id = tid, title = row.trackName ?: "—", artist = row.artistName ?: "—", localUri = Uri.parse(uriStr), ) } index = 0 if (queue.isEmpty()) { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() return@launch } withContext(Dispatchers.Main) { setPlaying(true) publishCurrentTrack() } } } private fun publishCurrentTrack() { val item = queue.getOrNull(index) ?: return val durationSec = 180 _uiState.value = PlaybackUiState( title = item.title, artist = item.artist, progress = _uiState.value.progress, durationSeconds = durationSec, isPlaying = _uiState.value.isPlaying, ) updateSessionMetadata( item, durationSec, ) updatePlaybackState() refreshForegroundNotification() } 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 updatePlaybackState() { val actions = PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS val state = if (_uiState.value.isPlaying) { PlaybackStateCompat.STATE_PLAYING } else { PlaybackStateCompat.STATE_PAUSED } mediaSession.setPlaybackState( PlaybackStateCompat.Builder() .setActions(actions) .setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f) .build(), ) } private fun setPlaying(playing: Boolean) { _uiState.value = _uiState.value.copy(isPlaying = playing) updatePlaybackState() refreshForegroundNotification() } private fun togglePlaying() { if (queue.isEmpty()) return setPlaying(!_uiState.value.isPlaying) } private fun skipDelta(delta: Int) { if (queue.isEmpty()) return index = (index + delta).coerceIn(0, queue.lastIndex) publishCurrentTrack() } fun requestTogglePause() { togglePlaying() } fun requestSkipNext() { skipDelta(1) } fun requestSkipPrevious() { skipDelta(-1) } fun requestSeek(fraction: Float) { if (queue.isEmpty()) return _uiState.value = _uiState.value.copy( progress = fraction.coerceIn(0f, 1f), ) mediaSession.setPlaybackState( PlaybackStateCompat.Builder() .setActions( PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SEEK_TO, ) .setState( if (_uiState.value.isPlaying) { PlaybackStateCompat.STATE_PLAYING } else { PlaybackStateCompat.STATE_PAUSED }, (_uiState.value.progress * _uiState.value.durationSeconds * 1000).toLong(), 1f, ) .build(), ) refreshForegroundNotification() } 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() { 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, ) { companion object { fun initial() = PlaybackUiState( title = "—", artist = "—", progress = 0f, durationSeconds = 180, isPlaying = false, ) } } private data class TrackQueueItem( val id: String, val title: String, val artist: String, val localUri: Uri?, ) companion object { 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" } }