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

@ -28,5 +28,4 @@
android:description="@string/description_service_playback" android:description="@string/description_service_playback"
android:stopWithTask="false" /> android:stopWithTask="false" />
</application> </application>
</manifest> </manifest>

View file

@ -1,10 +1,15 @@
package org.oxycblt.auxio.playback package org.oxycblt.auxio.playback
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Binder
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.SimpleExoPlayer
@ -17,11 +22,15 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toURI import org.oxycblt.auxio.music.toURI
import org.oxycblt.auxio.playback.state.PlaybackStateCallback import org.oxycblt.auxio.playback.state.PlaybackStateCallback
import org.oxycblt.auxio.playback.state.PlaybackStateManager 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 // A Service that manages the single ExoPlayer instance and [attempts] to keep
// persistence if the app closes. // persistence if the app closes.
class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback { class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
@ -34,7 +43,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
p p
} }
private val mBinder = LocalBinder()
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private val serviceJob = Job() private val serviceJob = Job()
@ -42,13 +50,25 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
serviceJob + Dispatchers.Main serviceJob + Dispatchers.Main
) )
override fun onBind(intent: Intent): IBinder { private var isForeground = false
return mBinder
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() { override fun onCreate() {
super.onCreate() super.onCreate()
notification = createNotification()
playbackManager.addCallback(this) playbackManager.addCallback(this)
} }
@ -58,6 +78,8 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
player.release() player.release()
serviceJob.cancel() serviceJob.cancel()
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
stopForeground(true)
} }
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
@ -69,8 +91,14 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
} }
override fun onSongUpdate(song: Song?) { override fun onSongUpdate(song: Song?) {
song?.let { song -> song?.let {
val item = MediaItem.fromUri(song.id.toURI()) if (!isForeground) {
startForeground(NOTIF_ID, notification)
isForeground = true
}
val item = MediaItem.fromUri(it.id.toURI())
player.setMediaItem(item) player.setMediaItem(item)
player.prepare() player.prepare()
player.play() 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) player.seekTo(position * 1000)
} }
// Awful Hack to get position polling to work, as exoplayer does not provide any // Awful Hack to get position polling to work, as exoplayer does not provide any
// onPositionChanged callback for some inane reason. // 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 { private fun pollCurrentPosition() = flow {
while (player.currentPosition <= player.duration) { while (player.currentPosition <= player.duration) {
@ -110,7 +138,29 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
} }
} }
inner class LocalBinder : Binder() { private fun createNotification(): Notification {
fun getService() = this@PlaybackService 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 package org.oxycblt.auxio.playback
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -68,25 +65,22 @@ class PlaybackViewModel(private val context: Context) : ViewModel(), PlaybackSta
// Service setup // Service setup
private val playbackManager = PlaybackStateManager.getInstance() 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 { init {
playbackIntent = Intent(context, PlaybackService::class.java).also { // Start the service from the ViewModel.
context.bindService(it, connection, Context.BIND_AUTO_CREATE) // 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) 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 --- // --- PLAYING FUNCTIONS ---
@ -143,11 +137,8 @@ class PlaybackViewModel(private val context: Context) : ViewModel(), PlaybackSta
} }
// Update the position and push the change the playbackManager. // 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) { fun updatePosition(progress: Int) {
playbackManager.setPosition(progress.toLong()) playbackManager.seekTo(progress.toLong())
playbackService.doSeek(progress.toLong())
} }
// --- QUEUE FUNCTIONS --- // --- QUEUE FUNCTIONS ---
@ -241,6 +232,15 @@ class PlaybackViewModel(private val context: Context) : ViewModel(), PlaybackSta
mIsShuffling.value = isShuffling 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 { class Factory(private val context: Context) : ViewModelProvider.Factory {
@Suppress("unchecked_cast") @Suppress("unchecked_cast")
override fun <T : ViewModel?> create(modelClass: Class<T>): T { override fun <T : ViewModel?> create(modelClass: Class<T>): T {

View file

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

View file

@ -52,6 +52,10 @@ class PlaybackStateManager {
} }
private var mShuffleSeed = -1L 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 isPlaying: Boolean get() = mIsPlaying
val isShuffling: Boolean get() = mIsShuffling val isShuffling: Boolean get() = mIsShuffling
@ -164,6 +168,12 @@ class PlaybackStateManager {
mPosition = position mPosition = position
} }
fun seekTo(position: Long) {
mPosition = position
callbacks.forEach { it.onSeekConfirm(position) }
}
// --- QUEUE FUNCTIONS --- // --- QUEUE FUNCTIONS ---
fun next() { fun next() {

View file

@ -48,6 +48,8 @@
<string name="description_shuffle_on">Turn shuffle on</string> <string name="description_shuffle_on">Turn shuffle on</string>
<string name="description_shuffle_off">Turn shuffle off</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_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 --> <!-- Placeholder Namespace | Placeholder values -->
<string name="placeholder_genre">Unknown Genre</string> <string name="placeholder_genre">Unknown Genre</string>