feat: sync Playlists, wip: pair songs
This commit is contained in:
375
app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
Normal file
375
app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
Normal file
@@ -0,0 +1,375 @@
|
||||
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<PlaybackUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var queue: List<TrackQueueItem> = 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user