diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bd3c747..602a0c7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,7 +42,7 @@
-
+
when (command) {
+ is Command.StartService -> {
+ startService(Intent(this@MainActivity, PlayerService::class.java).apply {
+ putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString())
+ })
+ }
+
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
diff --git a/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt b/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt
index 38a5bcc..53cc04f 100644
--- a/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt
+++ b/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt
@@ -6,8 +6,6 @@ import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
-import android.media.MediaMetadata
-import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -18,9 +16,7 @@ import com.github.apognu.otter.utils.*
import com.squareup.picasso.Picasso
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Default
-import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
class MediaControlsManager(val context: Service, private val scope: CoroutineScope, private val mediaSession: MediaSessionCompat) {
companion object {
@@ -32,27 +28,6 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
private var notification: Notification? = null
- fun buildTrackMetadata(track: Track?): MediaMetadataCompat {
- track?.let {
- val coverUrl = maybeNormalizeUrl(track.album.cover.original)
-
- return MediaMetadataCompat.Builder().apply {
- putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.title)
- putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.artist.name)
- putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000)
-
- try {
- runBlocking(IO) {
- this@apply.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, Picasso.get().load(coverUrl).get())
- }
- } catch (e: Exception) {
- }
- }.build()
- }
-
- return MediaMetadataCompat.Builder().build()
- }
-
fun updateNotification(track: Track?, playing: Boolean) {
if (notification == null && !playing) return
@@ -82,7 +57,10 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
.setSmallIcon(R.drawable.ottershape)
.run {
coverUrl?.let {
- try { setLargeIcon(Picasso.get().load(coverUrl).get()) } catch (_: Exception) {}
+ try {
+ setLargeIcon(Picasso.get().load(coverUrl).get())
+ } catch (_: Exception) {
+ }
return@run this
}
@@ -114,20 +92,16 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
.build()
notification?.let {
- NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
+ if (playing) {
+ context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
+ } else {
+ NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
+ }
}
-
- if (playing) tick()
}
}
}
- fun tick() {
- notification?.let {
- context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
- }
- }
-
private fun action(icon: Int, title: String, id: Int): NotificationCompat.Action {
val intent = Intent(context, MediaControlActionReceiver::class.java).apply { action = id.toString() }
val pendingIntent = PendingIntent.getBroadcast(context, id, intent, 0)
@@ -138,16 +112,16 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
class MediaControlActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
- when (intent?.action) {
- MediaControlsManager.NOTIFICATION_ACTION_PREVIOUS.toString() -> CommandBus.send(
- Command.PreviousTrack
- )
- MediaControlsManager.NOTIFICATION_ACTION_TOGGLE.toString() -> CommandBus.send(
- Command.ToggleState
- )
- MediaControlsManager.NOTIFICATION_ACTION_NEXT.toString() -> CommandBus.send(
- Command.NextTrack
- )
+ val command = when (intent?.action) {
+ MediaControlsManager.NOTIFICATION_ACTION_PREVIOUS.toString() -> Command.PreviousTrack
+ MediaControlsManager.NOTIFICATION_ACTION_TOGGLE.toString() -> Command.ToggleState
+ MediaControlsManager.NOTIFICATION_ACTION_NEXT.toString() -> Command.NextTrack
+ else -> null
+ }
+
+ command?.let {
+ CommandBus.send(command)
+ CommandBus.send(Command.StartService(command))
}
}
}
diff --git a/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt b/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt
index dacfd9f..c76e57c 100644
--- a/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt
+++ b/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt
@@ -8,28 +8,32 @@ import android.content.IntentFilter
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
+import android.media.MediaMetadata
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.os.ResultReceiver
-import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.view.KeyEvent
+import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
-import kotlinx.coroutines.CoroutineScope
+import com.squareup.picasso.Picasso
+import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
class PlayerService : Service() {
+ companion object {
+ const val INITIAL_COMMAND_KEY = "start_command"
+ }
+
private var started = false
private val scope: CoroutineScope = CoroutineScope(Job() + Main)
@@ -40,9 +44,10 @@ class PlayerService : Service() {
private lateinit var queue: QueueManager
private lateinit var mediaControlsManager: MediaControlsManager
- private lateinit var mediaSession: MediaSessionCompat
private lateinit var player: SimpleExoPlayer
+ private val mediaMetadataBuilder = MediaMetadataCompat.Builder()
+
private lateinit var playerEventListener: PlayerEventListener
private val headphonesUnpluggedReceiver = HeadphonesUnpluggedReceiver()
@@ -51,7 +56,17 @@ class PlayerService : Service() {
private lateinit var radioPlayer: RadioPlayer
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- if (!started) watchEventBus()
+ if (!started) {
+ watchEventBus()
+
+ intent?.extras?.getString(INITIAL_COMMAND_KEY)?.let {
+ when (it) {
+ Command.ToggleState.toString() -> togglePlayback()
+ Command.NextTrack.toString() -> skipToNextTrack()
+ Command.PreviousTrack.toString() -> skipToPreviousTrack()
+ }
+ }
+ }
started = true
@@ -82,18 +97,7 @@ class PlayerService : Service() {
}
}
- mediaSession = MediaSessionCompat(this, applicationContext.packageName).apply {
- isActive = true
- setPlaybackState(PlaybackStateCompat.Builder()
- .setActions(
- PlaybackStateCompat.ACTION_PLAY_PAUSE or
- PlaybackStateCompat.ACTION_SEEK_TO or
- PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
- PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
- ).build())
- }
-
- mediaControlsManager = MediaControlsManager(this, scope, mediaSession)
+ mediaControlsManager = MediaControlsManager(this, scope, Otter.get().mediaSession)
player = SimpleExoPlayer.Builder(this).build().apply {
playWhenReady = false
@@ -102,40 +106,21 @@ class PlayerService : Service() {
addListener(it)
}
- MediaSessionConnector(mediaSession).also {
+ MediaSessionConnector(Otter.get().mediaSession).also {
it.setPlayer(this)
+ it.setQueueNavigator(OtterQueueNavigator())
it.setMediaMetadataProvider {
- mediaControlsManager.buildTrackMetadata(queue.current())
+ buildTrackMetadata(queue.current())
}
- it.setQueueNavigator(object : MediaSessionConnector.QueueNavigator {
- override fun onSkipToQueueItem(player: Player, controlDispatcher: ControlDispatcher, id: Long) {}
-
- override fun onCurrentWindowIndexChanged(player: Player) {}
-
- override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?) = true
-
- override fun getSupportedQueueNavigatorActions(player: Player): Long {
- return PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
- }
-
- override fun onSkipToNext(player: Player, controlDispatcher: ControlDispatcher) {}
-
- override fun getActiveQueueItemId(player: Player?) = 0L
-
- override fun onSkipToPrevious(player: Player, controlDispatcher: ControlDispatcher) {}
-
- override fun onTimelineChanged(player: Player) {}
- })
-
it.setMediaButtonEventHandler { player, _, mediaButtonEvent ->
mediaButtonEvent.extras?.getParcelable(Intent.EXTRA_KEY_EVENT)?.let { key ->
if (key.action == KeyEvent.ACTION_UP) {
when (key.keyCode) {
- KeyEvent.KEYCODE_MEDIA_PLAY -> state(true)
- KeyEvent.KEYCODE_MEDIA_PAUSE -> state(false)
+ KeyEvent.KEYCODE_MEDIA_PLAY -> setPlaybackState(true)
+ KeyEvent.KEYCODE_MEDIA_PAUSE -> setPlaybackState(false)
KeyEvent.KEYCODE_MEDIA_NEXT -> player.next()
- KeyEvent.KEYCODE_MEDIA_PREVIOUS -> previousTrack()
+ KeyEvent.KEYCODE_MEDIA_PREVIOUS -> skipToPreviousTrack()
}
}
}
@@ -146,12 +131,12 @@ class PlayerService : Service() {
}
if (queue.current > -1) {
- player.prepare(queue.datasources, true, true)
+ player.prepare(queue.datasources)
Cache.get(this, "progress")?.let { progress ->
player.seekTo(queue.current, progress.readLine().toLong())
- val (current, duration, percent) = progress(true)
+ val (current, duration, percent) = getProgress(true)
ProgressBus.send(current, duration, percent)
}
@@ -179,7 +164,7 @@ class PlayerService : Service() {
queue.replace(command.queue)
player.prepare(queue.datasources, true, true)
- state(true)
+ setPlaybackState(true)
CommandBus.send(Command.RefreshTrack(queue.current()))
}
@@ -193,22 +178,17 @@ class PlayerService : Service() {
queue.current = command.index
player.seekTo(command.index, C.TIME_UNSET)
- state(true)
+ setPlaybackState(true)
CommandBus.send(Command.RefreshTrack(queue.current()))
}
- is Command.ToggleState -> toggle()
- is Command.SetState -> state(command.state)
+ is Command.ToggleState -> togglePlayback()
+ is Command.SetState -> setPlaybackState(command.state)
- is Command.NextTrack -> {
- player.next()
-
- Cache.set(this@PlayerService, "progress", "0".toByteArray())
- ProgressBus.send(0, 0, 0)
- }
- is Command.PreviousTrack -> previousTrack()
- is Command.Seek -> progress(command.progress)
+ is Command.NextTrack -> skipToNextTrack()
+ is Command.PreviousTrack -> skipToPreviousTrack()
+ is Command.Seek -> seek(command.progress)
is Command.ClearQueue -> queue.clear()
@@ -222,10 +202,6 @@ class PlayerService : Service() {
is Command.PinTrack -> PinService.download(this@PlayerService, command.track)
is Command.PinTracks -> command.tracks.forEach { PinService.download(this@PlayerService, it) }
}
-
- if (player.playWhenReady) {
- mediaControlsManager.tick()
- }
}
}
@@ -243,7 +219,7 @@ class PlayerService : Service() {
while (true) {
delay(1000)
- val (current, duration, percent) = progress()
+ val (current, duration, percent) = getProgress()
if (player.playWhenReady) {
ProgressBus.send(current, duration, percent)
@@ -256,6 +232,8 @@ class PlayerService : Service() {
@SuppressLint("NewApi")
override fun onDestroy() {
+ scope.cancel()
+
try {
unregisterReceiver(headphonesUnpluggedReceiver)
} catch (_: Exception) {
@@ -272,11 +250,8 @@ class PlayerService : Service() {
audioManager.abandonAudioFocus(audioFocusChangeListener)
})
- mediaSession.isActive = false
- mediaSession.release()
-
player.removeListener(playerEventListener)
- state(false)
+ setPlaybackState(false)
player.release()
stopForeground(true)
@@ -286,9 +261,9 @@ class PlayerService : Service() {
}
@SuppressLint("NewApi")
- private fun state(state: Boolean) {
+ private fun setPlaybackState(state: Boolean) {
if (!state) {
- val (progress, _, _) = progress()
+ val (progress, _, _) = getProgress()
Cache.set(this@PlayerService, "progress", progress.toString().toByteArray())
}
@@ -329,11 +304,11 @@ class PlayerService : Service() {
}
}
- private fun toggle() {
- state(!player.playWhenReady)
+ private fun togglePlayback() {
+ setPlaybackState(!player.playWhenReady)
}
- private fun previousTrack() {
+ private fun skipToPreviousTrack() {
if (player.currentPosition > 5000) {
return player.seekTo(0)
}
@@ -341,7 +316,14 @@ class PlayerService : Service() {
player.previous()
}
- private fun progress(force: Boolean = false): Triple {
+ private fun skipToNextTrack() {
+ player.next()
+
+ Cache.set(this@PlayerService, "progress", "0".toByteArray())
+ ProgressBus.send(0, 0, 0)
+ }
+
+ private fun getProgress(force: Boolean = false): Triple {
if (!player.playWhenReady && !force) return progressCache
return queue.current()?.bestUpload()?.let { upload ->
@@ -354,7 +336,7 @@ class PlayerService : Service() {
} ?: Triple(0, 0, 0)
}
- private fun progress(value: Int) {
+ private fun seek(value: Int) {
val duration = ((queue.current()?.bestUpload()?.duration ?: 0) * (value.toFloat() / 100)) * 1000
progressCache = Triple(duration.toInt(), queue.current()?.bestUpload()?.duration ?: 0, value)
@@ -362,6 +344,28 @@ class PlayerService : Service() {
player.seekTo(duration.toLong())
}
+ private fun buildTrackMetadata(track: Track?): MediaMetadataCompat {
+ track?.let {
+ val coverUrl = maybeNormalizeUrl(track.album.cover.original)
+
+ return mediaMetadataBuilder.apply {
+ putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.title)
+ putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.artist.name)
+ putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000)
+
+ try {
+ runBlocking(IO) {
+ this@apply.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, Picasso.get().load(coverUrl).get())
+ }
+ } catch (e: Exception) {
+ }
+ }.build()
+ }
+
+ return mediaMetadataBuilder.build()
+ }
+
+ @SuppressLint("NewApi")
inner class PlayerEventListener : Player.EventListener {
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
super.onPlayerStateChanged(playWhenReady, playbackState)
@@ -388,7 +392,11 @@ class PlayerService : Service() {
if (playbackState == Player.STATE_READY) {
mediaControlsManager.updateNotification(queue.current(), false)
- stopForeground(false)
+
+ Build.VERSION_CODES.N.onApi(
+ { stopForeground(STOP_FOREGROUND_DETACH) },
+ { stopForeground(false) }
+ )
}
}
}
@@ -441,18 +449,18 @@ class PlayerService : Service() {
AudioManager.AUDIOFOCUS_GAIN -> {
player.volume = 1f
- state(stateWhenLostFocus)
+ setPlaybackState(stateWhenLostFocus)
stateWhenLostFocus = false
}
AudioManager.AUDIOFOCUS_LOSS -> {
stateWhenLostFocus = false
- state(false)
+ setPlaybackState(false)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
stateWhenLostFocus = player.playWhenReady
- state(false)
+ setPlaybackState(false)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
@@ -462,4 +470,33 @@ class PlayerService : Service() {
}
}
}
-}
+
+ inner class OtterQueueNavigator : MediaSessionConnector.QueueNavigator {
+ override fun onSkipToQueueItem(player: Player, controlDispatcher: ControlDispatcher, id: Long) {
+ CommandBus.send(Command.PlayTrack(id.toInt()))
+ }
+
+ override fun onCurrentWindowIndexChanged(player: Player) {}
+
+ override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?) = true
+
+ override fun getSupportedQueueNavigatorActions(player: Player): Long {
+ return PlaybackStateCompat.ACTION_PLAY_PAUSE or
+ PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
+ PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
+ PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
+ }
+
+ override fun onSkipToNext(player: Player, controlDispatcher: ControlDispatcher) {
+ skipToNextTrack()
+ }
+
+ override fun getActiveQueueItemId(player: Player?) = queue.current.toLong()
+
+ override fun onSkipToPrevious(player: Player, controlDispatcher: ControlDispatcher) {
+ skipToPreviousTrack()
+ }
+
+ override fun onTimelineChanged(player: Player) {}
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/apognu/otter/utils/Bus.kt b/app/src/main/java/com/github/apognu/otter/utils/Bus.kt
index 606b08f..6cf2dd2 100644
--- a/app/src/main/java/com/github/apognu/otter/utils/Bus.kt
+++ b/app/src/main/java/com/github/apognu/otter/utils/Bus.kt
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
sealed class Command {
+ class StartService(val command: Command) : Command()
object RefreshService : Command()
object ToggleState : Command()