diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3b33222b0..d69adda0c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,7 +26,6 @@ + android:stopWithTask="false" /> - \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt index bd0592657..a326598c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackService.kt @@ -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 } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index d25e146c9..b4fb6a574 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -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 create(modelClass: Class): T { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateCallback.kt index 94ed7eebb..e2272741b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateCallback.kt @@ -9,4 +9,5 @@ interface PlaybackStateCallback { fun onPlayingUpdate(isPlaying: Boolean) {} fun onShuffleUpdate(isShuffling: Boolean) {} fun onIndexUpdate(index: Int) {} + fun onSeekConfirm(position: Long) {} } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index cff57269f..c8fbde9b1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -52,6 +52,10 @@ class PlaybackStateManager { } private var mShuffleSeed = -1L + val song: Song? get() = mSong + val position: Long get() = mPosition + val queue: MutableList 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() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3006f204..0a0c72017 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,6 +48,8 @@ Turn shuffle on Turn shuffle off The music playback service for Auxio + Music Playback + Auxio is playing music Unknown Genre