Minor playback improvements
Improve the code design and fix some bugs with PlaybackService and friends.
This commit is contained in:
parent
2ded706445
commit
6fc034e376
6 changed files with 109 additions and 62 deletions
|
@ -105,9 +105,13 @@ class MainFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the playback service.
|
// Start the playback service [If not already]
|
||||||
Intent(requireContext(), PlaybackService::class.java).also {
|
if (!playbackModel.serviceStarted) {
|
||||||
requireContext().startService(it)
|
Intent(requireContext(), PlaybackService::class.java).also {
|
||||||
|
requireContext().startService(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackModel.setServiceStatus(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(this::class.simpleName, "Fragment Created.")
|
Log.d(this::class.simpleName, "Fragment Created.")
|
||||||
|
|
|
@ -72,10 +72,16 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
|
||||||
// --- VIEWMODEL SETUP --
|
// --- VIEWMODEL SETUP --
|
||||||
|
|
||||||
playbackModel.song.observe(viewLifecycleOwner) {
|
playbackModel.song.observe(viewLifecycleOwner) {
|
||||||
Log.d(this::class.simpleName, "Updating song display to ${it.name}.")
|
if (it != null) {
|
||||||
|
Log.d(this::class.simpleName, "Updating song display to ${it.name}.")
|
||||||
|
|
||||||
binding.song = it
|
binding.song = it
|
||||||
binding.playbackSeekBar.max = it.seconds.toInt()
|
binding.playbackSeekBar.max = it.seconds.toInt()
|
||||||
|
} else {
|
||||||
|
Log.d(this::class.simpleName, "No song played anymore, leaving.")
|
||||||
|
|
||||||
|
findNavController().navigateUp()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackModel.index.observe(viewLifecycleOwner) {
|
playbackModel.index.observe(viewLifecycleOwner) {
|
||||||
|
|
|
@ -39,6 +39,7 @@ 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 {
|
||||||
|
// TODO: Use the ExoPlayer queue functionality [To an extent]? Could make things faster.
|
||||||
private val player: SimpleExoPlayer by lazy {
|
private val player: SimpleExoPlayer by lazy {
|
||||||
val p = SimpleExoPlayer.Builder(applicationContext).build()
|
val p = SimpleExoPlayer.Builder(applicationContext).build()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
@ -50,13 +51,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
|
||||||
|
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
private val playbackManager = PlaybackStateManager.getInstance()
|
||||||
private lateinit var mediaSession: MediaSessionCompat
|
private lateinit var mediaSession: MediaSessionCompat
|
||||||
private val buttonMediaCallback = object : MediaSessionCompat.Callback() {
|
|
||||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
|
||||||
Log.d(this::class.simpleName, "Hello?")
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val serviceJob = Job()
|
private val serviceJob = Job()
|
||||||
private val serviceScope = CoroutineScope(
|
private val serviceScope = CoroutineScope(
|
||||||
|
@ -67,19 +61,20 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
|
||||||
|
|
||||||
private lateinit var notification: Notification
|
private lateinit var notification: Notification
|
||||||
|
|
||||||
|
// --- SERVICE OVERRIDES ---
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
Log.d(this::class.simpleName, "Service is active.")
|
Log.d(this::class.simpleName, "Service is active.")
|
||||||
|
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
override fun onBind(intent: Intent): IBinder? = null
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
// Set up the media button callbacks
|
||||||
mediaSession = MediaSessionCompat(this, packageName).apply {
|
mediaSession = MediaSessionCompat(this, packageName).apply {
|
||||||
isActive = true
|
isActive = true
|
||||||
}
|
}
|
||||||
|
@ -87,30 +82,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
|
||||||
val connector = MediaSessionConnector(mediaSession)
|
val connector = MediaSessionConnector(mediaSession)
|
||||||
connector.setPlayer(player)
|
connector.setPlayer(player)
|
||||||
connector.setMediaButtonEventHandler { _, _, mediaButtonEvent ->
|
connector.setMediaButtonEventHandler { _, _, mediaButtonEvent ->
|
||||||
val item = mediaButtonEvent
|
handleMediaButtonEvent(mediaButtonEvent)
|
||||||
.getParcelableExtra<Parcelable>(Intent.EXTRA_KEY_EVENT) as KeyEvent
|
|
||||||
|
|
||||||
if (item.action == KeyEvent.ACTION_DOWN) {
|
|
||||||
when (item.keyCode) {
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_HEADSETHOOK -> {
|
|
||||||
playbackManager.setPlayingStatus(!playbackManager.isPlaying)
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
|
||||||
playbackManager.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
|
||||||
playbackManager.prev()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement the other callbacks for
|
|
||||||
// CLOSE/STOP & REWIND
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notification = createNotification()
|
notification = createNotification()
|
||||||
|
@ -121,14 +93,19 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
||||||
|
stopForeground(true)
|
||||||
|
|
||||||
|
// Release everything that could cause a memory leak if left around
|
||||||
player.release()
|
player.release()
|
||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
serviceJob.cancel()
|
serviceJob.cancel()
|
||||||
playbackManager.removeCallback(this)
|
playbackManager.removeCallback(this)
|
||||||
|
|
||||||
stopForeground(true)
|
Log.d(this::class.simpleName, "Service destroyed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- PLAYER EVENT LISTENER OVERRIDES ---
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(state: Int) {
|
override fun onPlaybackStateChanged(state: Int) {
|
||||||
if (state == Player.STATE_ENDED) {
|
if (state == Player.STATE_ENDED) {
|
||||||
playbackManager.next()
|
playbackManager.next()
|
||||||
|
@ -137,28 +114,31 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- PLAYBACK STATE CALLBACK OVERRIDES ---
|
||||||
|
|
||||||
override fun onSongUpdate(song: Song?) {
|
override fun onSongUpdate(song: Song?) {
|
||||||
song?.let {
|
song?.let {
|
||||||
if (!isForeground) {
|
|
||||||
startForeground(NOTIF_ID, notification)
|
|
||||||
|
|
||||||
isForeground = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val item = MediaItem.fromUri(it.id.toURI())
|
val item = MediaItem.fromUri(it.id.toURI())
|
||||||
player.setMediaItem(item)
|
player.setMediaItem(item)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.play()
|
player.play()
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
player.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayingUpdate(isPlaying: Boolean) {
|
override fun onPlayingUpdate(isPlaying: Boolean) {
|
||||||
if (isPlaying) {
|
if (isPlaying && !player.isPlaying) {
|
||||||
player.play()
|
player.play()
|
||||||
|
|
||||||
|
startForeground(NOTIF_ID, notification)
|
||||||
startPollingPosition()
|
startPollingPosition()
|
||||||
} else {
|
} else {
|
||||||
player.pause()
|
player.pause()
|
||||||
|
|
||||||
|
stopForeground(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -166,25 +146,63 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
|
||||||
player.seekTo(position * 1000)
|
player.seekTo(position * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- OTHER FUNCTIONS ---
|
||||||
|
|
||||||
// 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: Don't be surprised if this causes problems.
|
// FIXME: There has to be a better way of polling positions.
|
||||||
|
|
||||||
private fun pollCurrentPosition() = flow {
|
private fun pollCurrentPosition() = flow {
|
||||||
while (player.currentPosition <= player.duration) {
|
while (player.isPlaying) {
|
||||||
emit(player.currentPosition)
|
emit(player.currentPosition)
|
||||||
delay(500)
|
delay(250)
|
||||||
}
|
}
|
||||||
}.conflate()
|
}.conflate()
|
||||||
|
|
||||||
private fun startPollingPosition() {
|
private fun startPollingPosition() {
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
pollCurrentPosition().takeWhile { true }.collect {
|
pollCurrentPosition().takeWhile { player.isPlaying }.collect {
|
||||||
playbackManager.setPosition(it / 1000)
|
playbackManager.setPosition(it / 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle a media button event.
|
||||||
|
private fun handleMediaButtonEvent(event: Intent): Boolean {
|
||||||
|
val item = event
|
||||||
|
.getParcelableExtra<Parcelable>(Intent.EXTRA_KEY_EVENT) as KeyEvent
|
||||||
|
|
||||||
|
if (item.action == KeyEvent.ACTION_DOWN) {
|
||||||
|
return when (item.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY,
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_HEADSETHOOK -> {
|
||||||
|
playbackManager.setPlayingStatus(!playbackManager.isPlaying)
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||||
|
playbackManager.next()
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||||
|
playbackManager.prev()
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement the other callbacks for
|
||||||
|
// CLOSE/STOP & REWIND
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a notification
|
||||||
|
// TODO: Spin this off into its own object!
|
||||||
private fun createNotification(): Notification {
|
private fun createNotification(): Notification {
|
||||||
val notificationManager =
|
val notificationManager =
|
||||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
@ -197,7 +215,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateCallback {
|
||||||
)
|
)
|
||||||
notificationManager.createNotificationChannel(channel)
|
notificationManager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
// TODO: Placeholder, implement proper media controls :)
|
// TODO: Placeholder, implement proper media controls.
|
||||||
val notif = NotificationCompat.Builder(
|
val notif = NotificationCompat.Builder(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
CHANNEL_ID
|
CHANNEL_ID
|
||||||
|
|
|
@ -18,10 +18,10 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
// TODO: Implement Looping Modes
|
// TODO: Implement Looping Modes
|
||||||
// TODO: Implement User Queue
|
// TODO: Implement User Queue
|
||||||
// TODO: Implement Persistence through Bundles/Databases/Idk
|
// TODO: Implement Persistence through Bundles/Databases/Idk
|
||||||
class PlaybackViewModel() : ViewModel(), PlaybackStateCallback {
|
class PlaybackViewModel : ViewModel(), PlaybackStateCallback {
|
||||||
// Playback
|
// Playback
|
||||||
private val mSong = MutableLiveData<Song>()
|
private val mSong = MutableLiveData<Song?>()
|
||||||
val song: LiveData<Song> get() = mSong
|
val song: LiveData<Song?> get() = mSong
|
||||||
|
|
||||||
private val mPosition = MutableLiveData(0L)
|
private val mPosition = MutableLiveData(0L)
|
||||||
val position: LiveData<Long> get() = mPosition
|
val position: LiveData<Long> get() = mPosition
|
||||||
|
@ -44,6 +44,9 @@ class PlaybackViewModel() : ViewModel(), PlaybackStateCallback {
|
||||||
private val mIsSeeking = MutableLiveData(false)
|
private val mIsSeeking = MutableLiveData(false)
|
||||||
val isSeeking: LiveData<Boolean> get() = mIsSeeking
|
val isSeeking: LiveData<Boolean> get() = mIsSeeking
|
||||||
|
|
||||||
|
private var mServiceStarted = false
|
||||||
|
val serviceStarted: Boolean get() = mServiceStarted
|
||||||
|
|
||||||
val formattedPosition = Transformations.map(mPosition) {
|
val formattedPosition = Transformations.map(mPosition) {
|
||||||
it.toDuration()
|
it.toDuration()
|
||||||
}
|
}
|
||||||
|
@ -179,6 +182,10 @@ class PlaybackViewModel() : ViewModel(), PlaybackStateCallback {
|
||||||
mIsSeeking.value = value
|
mIsSeeking.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setServiceStatus(value: Boolean) {
|
||||||
|
mServiceStarted = value
|
||||||
|
}
|
||||||
|
|
||||||
// --- OVERRIDES ---
|
// --- OVERRIDES ---
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
@ -186,9 +193,7 @@ class PlaybackViewModel() : ViewModel(), PlaybackStateCallback {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSongUpdate(song: Song?) {
|
override fun onSongUpdate(song: Song?) {
|
||||||
song?.let {
|
mSong.value = song
|
||||||
mSong.value = it
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPositionUpdate(position: Long) {
|
override fun onPositionUpdate(position: Long) {
|
||||||
|
|
|
@ -9,5 +9,7 @@ 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) {}
|
||||||
|
|
||||||
|
// Service callbacks
|
||||||
fun onSeekConfirm(position: Long) {}
|
fun onSeekConfirm(position: Long) {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -165,7 +165,13 @@ class PlaybackStateManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPosition(position: Long) {
|
fun setPosition(position: Long) {
|
||||||
mPosition = position
|
// Due to the hacky way I poll ExoPlayer positions, don't accept any bugged positions
|
||||||
|
// that are over the duration of the song.
|
||||||
|
mSong?.let {
|
||||||
|
if (position <= it.seconds) {
|
||||||
|
mPosition = position
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekTo(position: Long) {
|
fun seekTo(position: Long) {
|
||||||
|
@ -177,8 +183,14 @@ class PlaybackStateManager {
|
||||||
// --- QUEUE FUNCTIONS ---
|
// --- QUEUE FUNCTIONS ---
|
||||||
|
|
||||||
fun next() {
|
fun next() {
|
||||||
if (mIndex < mQueue.size) {
|
if (mIndex < mQueue.lastIndex) {
|
||||||
mIndex = mIndex.inc()
|
mIndex = mIndex.inc()
|
||||||
|
} else {
|
||||||
|
// TODO: Implement option so that the playlist loops instead of stops
|
||||||
|
mQueue = mutableListOf()
|
||||||
|
mSong = null
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePlayback(mQueue[mIndex])
|
updatePlayback(mQueue[mIndex])
|
||||||
|
|
Loading…
Reference in a new issue