Make PlaybackService persistent
Make PlaybackService solely rely on PlaybackStateManager, allowing the service to run beyond the main Auxio activity.
This commit is contained in:
parent
ac5e6ba140
commit
85475b5c61
6 changed files with 96 additions and 34 deletions
|
@ -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>
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue