Make PlaybackService persistent

Make PlaybackService solely rely on PlaybackStateManager, allowing the service to run beyond the main Auxio activity.
This commit is contained in:
OxygenCobalt 2020-10-26 16:13:20 -06:00
parent ac5e6ba140
commit 85475b5c61
6 changed files with 96 additions and 34 deletions

View file

@ -26,7 +26,6 @@
<service android:name=".playback.PlaybackService"
android:icon="@drawable/ic_launcher_foreground"
android:description="@string/description_service_playback"
android:stopWithTask="false"/>
android:stopWithTask="false" />
</application>
</manifest>

View file

@ -1,10 +1,15 @@
package org.oxycblt.auxio.playback
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
@ -17,11 +22,15 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toURI
import org.oxycblt.auxio.playback.state.PlaybackStateCallback
import org.oxycblt.auxio.playback.state.PlaybackStateManager
private const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK"
private const val NOTIF_ID = 0xA0A0
// A Service that manages the single ExoPlayer instance and [attempts] to keep
// persistence if the app closes.
class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
@ -34,7 +43,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
p
}
private val mBinder = LocalBinder()
private val playbackManager = PlaybackStateManager.getInstance()
private val serviceJob = Job()
@ -42,13 +50,25 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
serviceJob + Dispatchers.Main
)
override fun onBind(intent: Intent): IBinder {
return mBinder
private var isForeground = false
private lateinit var notification: Notification
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(this::class.simpleName, "Service is active.")
return START_STICKY
}
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onCreate() {
super.onCreate()
notification = createNotification()
playbackManager.addCallback(this)
}
@ -58,6 +78,8 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
player.release()
serviceJob.cancel()
playbackManager.removeCallback(this)
stopForeground(true)
}
override fun onPlaybackStateChanged(state: Int) {
@ -69,8 +91,14 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
}
override fun onSongUpdate(song: Song?) {
song?.let { song ->
val item = MediaItem.fromUri(song.id.toURI())
song?.let {
if (!isForeground) {
startForeground(NOTIF_ID, notification)
isForeground = true
}
val item = MediaItem.fromUri(it.id.toURI())
player.setMediaItem(item)
player.prepare()
player.play()
@ -87,13 +115,13 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
}
}
fun doSeek(position: Long) {
override fun onSeekConfirm(position: Long) {
player.seekTo(position * 1000)
}
// Awful Hack to get position polling to work, as exoplayer does not provide any
// onPositionChanged callback for some inane reason.
// FIXME: Consider using exoplayer UI elements here, don't be surprised if this causes problems.
// FIXME: Don't be surprised if this causes problems.
private fun pollCurrentPosition() = flow {
while (player.currentPosition <= player.duration) {
@ -110,7 +138,29 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
}
}
inner class LocalBinder : Binder() {
fun getService() = this@PlaybackService
private fun createNotification(): Notification {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.description_playback),
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(channel)
}
// TODO: Placeholder, implement proper media controls :)
val notif = NotificationCompat.Builder(
applicationContext,
CHANNEL_ID
)
.setSmallIcon(R.drawable.ic_song)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.description_playback))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setChannelId(CHANNEL_ID)
.build()
return notif
}
}

View file

@ -1,10 +1,7 @@
package org.oxycblt.auxio.playback
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
@ -68,25 +65,22 @@ class PlaybackViewModel(private val context: Context) : ViewModel(), PlaybackSta
// Service setup
private val playbackManager = PlaybackStateManager.getInstance()
private lateinit var playbackService: PlaybackService
private var playbackIntent: Intent
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
playbackService = (binder as PlaybackService.LocalBinder).getService()
}
override fun onServiceDisconnected(name: ComponentName) {
Log.d(this::class.simpleName, "Service disconnected.")
}
}
init {
playbackIntent = Intent(context, PlaybackService::class.java).also {
context.bindService(it, connection, Context.BIND_AUTO_CREATE)
// Start the service from the ViewModel.
// Yes, I know ViewModels aren't supposed to deal with this stuff but for some
// reason it only works here.
Intent(context, PlaybackService::class.java).also {
context.startService(it)
}
playbackManager.addCallback(this)
// If the PlaybackViewModel was cleared [signified by the PlaybackStateManager having a
// song and the fact that were are in the init function], then try to restore the playback
// state.
if (playbackManager.song != null) {
restorePlaybackState()
}
}
// --- PLAYING FUNCTIONS ---
@ -143,11 +137,8 @@ class PlaybackViewModel(private val context: Context) : ViewModel(), PlaybackSta
}
// Update the position and push the change the playbackManager.
// This is done when the seek is confirmed to make playbackService seek to the position.
fun updatePosition(progress: Int) {
playbackManager.setPosition(progress.toLong())
playbackService.doSeek(progress.toLong())
playbackManager.seekTo(progress.toLong())
}
// --- QUEUE FUNCTIONS ---
@ -241,6 +232,15 @@ class PlaybackViewModel(private val context: Context) : ViewModel(), PlaybackSta
mIsShuffling.value = isShuffling
}
private fun restorePlaybackState() {
mSong.value = playbackManager.song
mPosition.value = playbackManager.position
mQueue.value = playbackManager.queue
mIndex.value = playbackManager.index
mIsPlaying.value = playbackManager.isPlaying
mIsShuffling.value = playbackManager.isShuffling
}
class Factory(private val context: Context) : ViewModelProvider.Factory {
@Suppress("unchecked_cast")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {

View file

@ -9,4 +9,5 @@ interface PlaybackStateCallback {
fun onPlayingUpdate(isPlaying: Boolean) {}
fun onShuffleUpdate(isShuffling: Boolean) {}
fun onIndexUpdate(index: Int) {}
fun onSeekConfirm(position: Long) {}
}

View file

@ -52,6 +52,10 @@ class PlaybackStateManager {
}
private var mShuffleSeed = -1L
val song: Song? get() = mSong
val position: Long get() = mPosition
val queue: MutableList<Song> get() = mQueue
val index: Int get() = mIndex
val isPlaying: Boolean get() = mIsPlaying
val isShuffling: Boolean get() = mIsShuffling
@ -164,6 +168,12 @@ class PlaybackStateManager {
mPosition = position
}
fun seekTo(position: Long) {
mPosition = position
callbacks.forEach { it.onSeekConfirm(position) }
}
// --- QUEUE FUNCTIONS ---
fun next() {

View file

@ -48,6 +48,8 @@
<string name="description_shuffle_on">Turn shuffle on</string>
<string name="description_shuffle_off">Turn shuffle off</string>
<string name="description_service_playback">The music playback service for Auxio</string>
<string name="description_notif_playback">Music Playback</string>
<string name="description_playback">Auxio is playing music</string>
<!-- Placeholder Namespace | Placeholder values -->
<string name="placeholder_genre">Unknown Genre</string>