playback: migrate media session to android 13
Migrate MediaSessionComponent to android 13. This was primarily implementing custom actions with the media session and adding some extra bug fixes that I was already planning. I was really hoping that google fixes the nightmarish mess that was the previous MediaStyle notification, where I had to update the session and then the notification in a tight dance with clever tricks to not get rate-limited, but nope. I still have to do exactly the same thing as beforehand, but with even extra insanity due to the custom actions.
This commit is contained in:
parent
3cef088d12
commit
40a34f0596
5 changed files with 141 additions and 83 deletions
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
## dev
|
## dev
|
||||||
|
|
||||||
|
#### What's New
|
||||||
|
- Added Android 13 support [#129]
|
||||||
|
- Switch to new storage permissions
|
||||||
|
- Add monochrome icon
|
||||||
|
- Fix issue where widget covers would not load
|
||||||
|
|
||||||
#### What's Improved
|
#### What's Improved
|
||||||
- Playback bar now has a marquee effect
|
- Playback bar now has a marquee effect
|
||||||
- Added a way to access the system equalizer from the playback menu.
|
- Added a way to access the system equalizer from the playback menu.
|
||||||
|
|
|
@ -21,6 +21,7 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
@ -40,15 +41,20 @@ import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The component managing the [MediaSessionCompat] instance.
|
* The component managing the [MediaSessionCompat] instance, alongside the [NotificationComponent]
|
||||||
*
|
*
|
||||||
* Media3 is a joke. It tries so hard to be "hElpfUl" and implement so many fundamental behaviors
|
* MediaSession is easily one of the most poorly thought out APIs in Android. It tries to hard to be
|
||||||
* into a one-size-fits-all package that it only ends up causing unending bugs and frustration. The
|
* hElpfUl and implement so many fundamental behaviors into a one-size-fits-all package that it only
|
||||||
* queue system is horribly designed, the notification code is outdated, and the overstretched
|
* ends up causing unending bugs and frustrating. The queue system is horribly designed, the
|
||||||
* abstractions result in terrible performance bottlenecks and insane state bugs..
|
* playback state system has unending coherency bugs, and the overstretched abstractions result in
|
||||||
|
* god-awful performance bottlenecks and insane state bugs.
|
||||||
*
|
*
|
||||||
* Show me a way to adapt my internal queue into the new system and I will change my mind, but
|
* The sheer absurdity of the hoops we have jump through to get this working in an okay manner is
|
||||||
* otherwise, I will stick with my normal system that works correctly.
|
* the reason why Auxio only mirrors a saner playback state to the media session instead of relying
|
||||||
|
* on it. I thought that Android 13 would at least try to make the state more coherent, but NOPE.
|
||||||
|
* You still have to do a delicate dance of posting notifications and updating the session state
|
||||||
|
* while also keeping in mind the absurd rate limiting system in place just to have a sort-of
|
||||||
|
* coherent state. And even then it will break if you skip too much.
|
||||||
*
|
*
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
*
|
*
|
||||||
|
@ -64,7 +70,13 @@ class MediaSessionComponent(
|
||||||
PlaybackStateManager.Callback,
|
PlaybackStateManager.Callback,
|
||||||
Settings.Callback {
|
Settings.Callback {
|
||||||
interface Callback {
|
interface Callback {
|
||||||
fun onPostNotification(notification: NotificationComponent?, reason: String)
|
fun onPostNotification(notification: NotificationComponent?, reason: PostingReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class PostingReason {
|
||||||
|
METADATA,
|
||||||
|
ACTIONS,
|
||||||
|
POSITION
|
||||||
}
|
}
|
||||||
|
|
||||||
private val mediaSession =
|
private val mediaSession =
|
||||||
|
@ -126,7 +138,7 @@ class MediaSessionComponent(
|
||||||
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
|
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
|
||||||
if (song == null) {
|
if (song == null) {
|
||||||
mediaSession.setMetadata(emptyMetadata)
|
mediaSession.setMetadata(emptyMetadata)
|
||||||
callback.onPostNotification(null, "song update")
|
callback.onPostNotification(null, PostingReason.METADATA)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,7 +196,7 @@ class MediaSessionComponent(
|
||||||
val metadata = builder.build()
|
val metadata = builder.build()
|
||||||
mediaSession.setMetadata(metadata)
|
mediaSession.setMetadata(metadata)
|
||||||
notification.updateMetadata(metadata)
|
notification.updateMetadata(metadata)
|
||||||
callback.onPostNotification(notification, "song update")
|
callback.onPostNotification(notification, PostingReason.METADATA)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -211,7 +223,7 @@ class MediaSessionComponent(
|
||||||
|
|
||||||
override fun onPlayingChanged(isPlaying: Boolean) {
|
override fun onPlayingChanged(isPlaying: Boolean) {
|
||||||
invalidateSessionState()
|
invalidateSessionState()
|
||||||
invalidateNotificationActions()
|
invalidateActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRepeatChanged(repeatMode: RepeatMode) {
|
override fun onRepeatChanged(repeatMode: RepeatMode) {
|
||||||
|
@ -222,7 +234,7 @@ class MediaSessionComponent(
|
||||||
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
|
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
|
||||||
})
|
})
|
||||||
|
|
||||||
invalidateNotificationActions()
|
invalidateActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onShuffledChanged(isShuffled: Boolean) {
|
override fun onShuffledChanged(isShuffled: Boolean) {
|
||||||
|
@ -233,7 +245,7 @@ class MediaSessionComponent(
|
||||||
PlaybackStateCompat.SHUFFLE_MODE_NONE
|
PlaybackStateCompat.SHUFFLE_MODE_NONE
|
||||||
})
|
})
|
||||||
|
|
||||||
invalidateNotificationActions()
|
invalidateActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SETTINGSMANAGER CALLBACKS ---
|
// --- SETTINGSMANAGER CALLBACKS ---
|
||||||
|
@ -243,7 +255,7 @@ class MediaSessionComponent(
|
||||||
context.getString(R.string.set_key_show_covers),
|
context.getString(R.string.set_key_show_covers),
|
||||||
context.getString(R.string.set_key_quality_covers) ->
|
context.getString(R.string.set_key_quality_covers) ->
|
||||||
updateMediaMetadata(playbackManager.song, playbackManager.parent)
|
updateMediaMetadata(playbackManager.song, playbackManager.parent)
|
||||||
context.getString(R.string.set_key_alt_notif_action) -> invalidateNotificationActions()
|
context.getString(R.string.set_key_alt_notif_action) -> invalidateActions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -262,7 +274,7 @@ class MediaSessionComponent(
|
||||||
// when we invalidate the state (that will cause us to be rate-limited), and also not
|
// when we invalidate the state (that will cause us to be rate-limited), and also not
|
||||||
// always when we seek (that will also cause us to be rate-limited). Someone looked at
|
// always when we seek (that will also cause us to be rate-limited). Someone looked at
|
||||||
// this system and said it was well-designed.
|
// this system and said it was well-designed.
|
||||||
callback.onPostNotification(notification, "position discontinuity")
|
callback.onPostNotification(notification, PostingReason.POSITION)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -280,7 +292,7 @@ class MediaSessionComponent(
|
||||||
|
|
||||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||||
super.onPlayFromSearch(query, extras)
|
super.onPlayFromSearch(query, extras)
|
||||||
// STUB: Unimplemented
|
// STUB: Unimplemented, no media browser
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAddQueueItem(description: MediaDescriptionCompat?) {
|
override fun onAddQueueItem(description: MediaDescriptionCompat?) {
|
||||||
|
@ -345,6 +357,14 @@ class MediaSessionComponent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||||
|
super.onCustomAction(action, extras)
|
||||||
|
|
||||||
|
// Service already handles intents from the old notification actions, easier to
|
||||||
|
// plug into that system.
|
||||||
|
context.sendBroadcast(Intent(action))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
// Get the service to shut down with the ACTION_EXIT intent
|
// Get the service to shut down with the ACTION_EXIT intent
|
||||||
context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT))
|
context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT))
|
||||||
|
@ -360,7 +380,6 @@ class MediaSessionComponent(
|
||||||
// aggressively batch notification updates to prevent rate-limiting.
|
// aggressively batch notification updates to prevent rate-limiting.
|
||||||
// Android 13 seems to resolve these, but I'm still stuck with these issues below that
|
// Android 13 seems to resolve these, but I'm still stuck with these issues below that
|
||||||
// version.
|
// version.
|
||||||
// TODO: Add the custom actions for Android 13
|
|
||||||
val state =
|
val state =
|
||||||
PlaybackStateCompat.Builder()
|
PlaybackStateCompat.Builder()
|
||||||
.setActions(ACTIONS)
|
.setActions(ACTIONS)
|
||||||
|
@ -376,10 +395,42 @@ class MediaSessionComponent(
|
||||||
|
|
||||||
state.setState(playerState, player.currentPosition, 1.0f, SystemClock.elapsedRealtime())
|
state.setState(playerState, player.currentPosition, 1.0f, SystemClock.elapsedRealtime())
|
||||||
|
|
||||||
|
// Android 13+ leverages custom actions.
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
val extraAction =
|
||||||
|
if (settings.useAltNotifAction) {
|
||||||
|
PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
PlaybackService.ACTION_INVERT_SHUFFLE,
|
||||||
|
context.getString(R.string.desc_change_repeat),
|
||||||
|
if (playbackManager.isShuffled) {
|
||||||
|
R.drawable.ic_shuffle_on_24
|
||||||
|
} else {
|
||||||
|
R.drawable.ic_shuffle_off_24
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
PlaybackService.ACTION_INC_REPEAT_MODE,
|
||||||
|
context.getString(R.string.desc_change_repeat),
|
||||||
|
playbackManager.repeatMode.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
val exitAction =
|
||||||
|
PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
PlaybackService.ACTION_EXIT,
|
||||||
|
context.getString(R.string.desc_exit),
|
||||||
|
R.drawable.ic_close_24)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
state.addCustomAction(extraAction.build())
|
||||||
|
state.addCustomAction(exitAction)
|
||||||
|
}
|
||||||
|
|
||||||
mediaSession.setPlaybackState(state.build())
|
mediaSession.setPlaybackState(state.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun invalidateNotificationActions() {
|
private fun invalidateActions() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
notification.updatePlaying(playbackManager.isPlaying)
|
notification.updatePlaying(playbackManager.isPlaying)
|
||||||
|
|
||||||
if (settings.useAltNotifAction) {
|
if (settings.useAltNotifAction) {
|
||||||
|
@ -387,9 +438,13 @@ class MediaSessionComponent(
|
||||||
} else {
|
} else {
|
||||||
notification.updateRepeatMode(playbackManager.repeatMode)
|
notification.updateRepeatMode(playbackManager.repeatMode)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Update custom actions instead of the notification
|
||||||
|
invalidateSessionState()
|
||||||
|
}
|
||||||
|
|
||||||
if (!provider.isBusy) {
|
if (!provider.isBusy) {
|
||||||
callback.onPostNotification(notification, "new notification actions")
|
callback.onPostNotification(notification, PostingReason.ACTIONS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -106,15 +106,40 @@ class PlaybackService :
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
foregroundManager = ForegroundManager(this)
|
|
||||||
|
|
||||||
// --- PLAYER SETUP ---
|
|
||||||
|
|
||||||
replayGainProcessor = ReplayGainAudioProcessor(this)
|
replayGainProcessor = ReplayGainAudioProcessor(this)
|
||||||
|
|
||||||
player = newPlayer()
|
// Since Auxio is a music player, only specify an audio renderer to save
|
||||||
|
// battery/apk size/cache size
|
||||||
|
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
||||||
|
arrayOf(
|
||||||
|
MediaCodecAudioRenderer(
|
||||||
|
this,
|
||||||
|
MediaCodecSelector.DEFAULT,
|
||||||
|
handler,
|
||||||
|
audioListener,
|
||||||
|
AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
|
||||||
|
replayGainProcessor),
|
||||||
|
LibflacAudioRenderer(handler, audioListener, replayGainProcessor))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable
|
||||||
|
val extractorsFactory = DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)
|
||||||
|
|
||||||
|
player =
|
||||||
|
ExoPlayer.Builder(this, audioRenderer)
|
||||||
|
.setMediaSourceFactory(DefaultMediaSourceFactory(this, extractorsFactory))
|
||||||
|
.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
|
.build(),
|
||||||
|
true)
|
||||||
|
.build()
|
||||||
|
|
||||||
player.addListener(this)
|
player.addListener(this)
|
||||||
|
|
||||||
|
playbackManager.registerController(this)
|
||||||
positionScope.launch {
|
positionScope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
playbackManager.synchronizePosition(this@PlaybackService, player.currentPosition)
|
playbackManager.synchronizePosition(this@PlaybackService, player.currentPosition)
|
||||||
|
@ -122,7 +147,8 @@ class PlaybackService :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SYSTEM SETUP ---
|
settings = Settings(this, this)
|
||||||
|
foregroundManager = ForegroundManager(this)
|
||||||
|
|
||||||
widgetComponent = WidgetComponent(this)
|
widgetComponent = WidgetComponent(this)
|
||||||
mediaSessionComponent = MediaSessionComponent(this, player, this)
|
mediaSessionComponent = MediaSessionComponent(this, player, this)
|
||||||
|
@ -144,9 +170,6 @@ class PlaybackService :
|
||||||
|
|
||||||
// --- PLAYBACKSTATEMANAGER SETUP ---
|
// --- PLAYBACKSTATEMANAGER SETUP ---
|
||||||
|
|
||||||
settings = Settings(this, this)
|
|
||||||
playbackManager.registerController(this)
|
|
||||||
|
|
||||||
logD("Service created")
|
logD("Service created")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -281,6 +304,26 @@ class PlaybackService :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun broadcastAudioEffectAction(event: String) {
|
||||||
|
sendBroadcast(
|
||||||
|
Intent(event)
|
||||||
|
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
|
||||||
|
.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
|
||||||
|
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the foreground state and hide the notification */
|
||||||
|
private fun stopAndSave() {
|
||||||
|
hasPlayed = false
|
||||||
|
|
||||||
|
if (foregroundManager.tryStopForeground()) {
|
||||||
|
logD("Saving playback state")
|
||||||
|
saveScope.launch {
|
||||||
|
playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
logD("Seeking to ${positionMs}ms")
|
logD("Seeking to ${positionMs}ms")
|
||||||
player.seekTo(positionMs)
|
player.seekTo(positionMs)
|
||||||
|
@ -293,7 +336,10 @@ class PlaybackService :
|
||||||
player.playWhenReady = isPlaying
|
player.playWhenReady = isPlaying
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPostNotification(notification: NotificationComponent?, reason: String) {
|
override fun onPostNotification(
|
||||||
|
notification: NotificationComponent?,
|
||||||
|
reason: MediaSessionComponent.PostingReason
|
||||||
|
) {
|
||||||
if (notification == null) {
|
if (notification == null) {
|
||||||
// This case is only here if I ever need to move foreground stopping from
|
// This case is only here if I ever need to move foreground stopping from
|
||||||
// the player code to the notification code.
|
// the player code to the notification code.
|
||||||
|
@ -321,55 +367,6 @@ class PlaybackService :
|
||||||
|
|
||||||
// --- OTHER FUNCTIONS ---
|
// --- OTHER FUNCTIONS ---
|
||||||
|
|
||||||
/** Create the [ExoPlayer] instance. */
|
|
||||||
private fun newPlayer(): ExoPlayer {
|
|
||||||
// Since Auxio is a music player, only specify an audio renderer to save
|
|
||||||
// battery/apk size/cache size
|
|
||||||
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
|
||||||
arrayOf(
|
|
||||||
MediaCodecAudioRenderer(
|
|
||||||
this,
|
|
||||||
MediaCodecSelector.DEFAULT,
|
|
||||||
handler,
|
|
||||||
audioListener,
|
|
||||||
AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
|
|
||||||
replayGainProcessor),
|
|
||||||
LibflacAudioRenderer(handler, audioListener, replayGainProcessor))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable
|
|
||||||
val extractorsFactory = DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)
|
|
||||||
|
|
||||||
return ExoPlayer.Builder(this, audioRenderer)
|
|
||||||
.setMediaSourceFactory(DefaultMediaSourceFactory(this, extractorsFactory))
|
|
||||||
.setWakeMode(C.WAKE_MODE_LOCAL)
|
|
||||||
.setAudioAttributes(
|
|
||||||
AudioAttributes.Builder()
|
|
||||||
.setUsage(C.USAGE_MEDIA)
|
|
||||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
|
||||||
.build(),
|
|
||||||
true)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun broadcastAudioEffectAction(event: String) {
|
|
||||||
sendBroadcast(
|
|
||||||
Intent(event)
|
|
||||||
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
|
|
||||||
.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
|
|
||||||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop the foreground state and hide the notification */
|
|
||||||
private fun stopAndSave() {
|
|
||||||
if (foregroundManager.tryStopForeground()) {
|
|
||||||
logD("Saving playback state")
|
|
||||||
saveScope.launch {
|
|
||||||
playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A [BroadcastReceiver] for receiving general playback events from the system. */
|
/** A [BroadcastReceiver] for receiving general playback events from the system. */
|
||||||
private inner class PlaybackReceiver : BroadcastReceiver() {
|
private inner class PlaybackReceiver : BroadcastReceiver() {
|
||||||
private var initialHeadsetPlugEventHandled = false
|
private var initialHeadsetPlugEventHandled = false
|
||||||
|
|
|
@ -111,7 +111,6 @@ class WidgetComponent(private val context: Context) :
|
||||||
RoundedCornersTransformation(cornerRadius.toFloat()))
|
RoundedCornersTransformation(cornerRadius.toFloat()))
|
||||||
} else {
|
} else {
|
||||||
// Divide by two to really make sure we aren't hitting the memory limit.
|
// Divide by two to really make sure we aren't hitting the memory limit.
|
||||||
|
|
||||||
builder.size(computeSize(sw, sh, 2f))
|
builder.size(computeSize(sw, sh, 2f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -250,6 +250,7 @@
|
||||||
<string name="desc_change_repeat">Change repeat mode</string>
|
<string name="desc_change_repeat">Change repeat mode</string>
|
||||||
<string name="desc_shuffle">Turn shuffle on or off</string>
|
<string name="desc_shuffle">Turn shuffle on or off</string>
|
||||||
<string name="desc_shuffle_all">Shuffle all songs</string>
|
<string name="desc_shuffle_all">Shuffle all songs</string>
|
||||||
|
<string name="desc_exit">Stop playback</string>
|
||||||
|
|
||||||
<string name="desc_clear_queue_item">Remove this queue song</string>
|
<string name="desc_clear_queue_item">Remove this queue song</string>
|
||||||
<string name="desc_queue_handle">Move this queue song</string>
|
<string name="desc_queue_handle">Move this queue song</string>
|
||||||
|
|
Loading…
Reference in a new issue