Compare commits

..

15 Commits

Author SHA1 Message Date
tzugen
b7889b6324
Clean Timber 2023-05-19 23:30:39 +02:00
tzugen
7c325f3ffb
Cleanup 2023-05-19 22:30:22 +02:00
tzugen
b48e82171a
Working :) 2023-05-19 22:21:43 +02:00
tzugen
e64b4ab486
Small 2023-05-19 21:57:41 +02:00
tzugen
16ea8e6e24
Kind of ok 2023-05-19 21:06:47 +02:00
tzugen
33933c788b
Try volume again 2023-05-19 20:47:41 +02:00
tzugen
2993c63a16
Clean 2023-05-19 20:42:59 +02:00
tzugen
7a39ad3a6d
Clean 2023-05-19 19:18:59 +02:00
tzugen
5e09364d9f
Format 2023-05-19 19:16:10 +02:00
tzugen
a3d9e35199
Nicer volume setter 2023-05-19 18:38:12 +02:00
tzugen
ce3ad45364
Finish 2023-05-19 18:01:31 +02:00
tzugen
67ad135590
Finish 2023-05-19 15:59:44 +02:00
tzugen
8650023013
Finish 2023-05-19 15:58:56 +02:00
tzugen
554172a3f3
Use native EventList 2023-05-19 15:06:52 +02:00
tzugen
16e40518ef
Formatting 2023-05-19 13:09:22 +02:00
31 changed files with 191 additions and 424 deletions

View File

@ -1,27 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 7.4.0" type="baseline" client="gradle" dependencies="true" name="AGP (7.4.0)" variant="all" version="7.4.0">
<issue
id="MissingPermission"
message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
errorLine1=" manager.notify(NOTIFICATION_ID, notification)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt"
line="260"
column="17"/>
</issue>
<issue
id="MissingPermission"
message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
errorLine1=" notificationManagerCompat.notify(notification.notificationId, notification.notification)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt"
line="194"
column="9"/>
</issue>
<issues format="6" by="lint 8.0.1" type="baseline" client="gradle" dependencies="true" name="AGP (8.0.1)" variant="all" version="8.0.1">
<issue
id="PluralsCandidate"
@ -30,7 +8,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="152"
line="151"
column="5"/>
</issue>
@ -48,50 +26,6 @@
file="../core/subsonic-api/build/libs/subsonic-api.jar"/>
</issue>
<issue
id="ExportedContentProvider"
message="Exported content providers can provide access to potentially sensitive data"
errorLine1=" &lt;provider"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="128"
column="10"/>
</issue>
<issue
id="ExportedContentProvider"
message="Exported content providers can provide access to potentially sensitive data"
errorLine1=" &lt;provider"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="133"
column="10"/>
</issue>
<issue
id="ExportedReceiver"
message="Exported receiver does not require permission"
errorLine1=" &lt;receiver android:name=&quot;.receiver.UltrasonicIntentReceiver&quot;"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="88"
column="10"/>
</issue>
<issue
id="ExportedService"
message="Exported service does not require permission"
errorLine1=" &lt;service android:name=&quot;.playback.PlaybackService&quot;"
errorLine2=" ~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="77"
column="10"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_pause` appears to be unused"
@ -136,17 +70,6 @@
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_small_icon` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_small_icon.xml"
line="1"
column="1"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"

View File

@ -66,13 +66,6 @@
android:exported="false">
</service>
<service
android:name=".service.JukeboxMediaPlayer"
android:label="Ultrasonic Jukebox Media Player Service"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
</service>
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
<service android:name=".playback.PlaybackService"
android:label="@string/common.appname"

View File

@ -122,6 +122,7 @@ private fun Intent.getBluetoothDevice(): BluetoothDevice? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
}

View File

@ -17,7 +17,6 @@ import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.provider.SearchRecentSuggestions
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -55,8 +54,8 @@ import org.moire.ultrasonic.data.ServerSettingDao
import org.moire.ultrasonic.fragment.OnBackPressedHandler
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.util.Constants
@ -274,18 +273,6 @@ class NavigationActivity : AppCompatActivity() {
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val isVolumeDown = keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
val isVolumeUp = keyCode == KeyEvent.KEYCODE_VOLUME_UP
val isVolumeAdjust = isVolumeDown || isVolumeUp
val isJukebox = mediaPlayerManager.isJukeboxEnabled
if (isVolumeAdjust && isJukebox) {
mediaPlayerManager.adjustVolume(isVolumeUp)
return true
}
return super.onKeyDown(keyCode, event)
}
private fun setupNavigationMenu(navController: NavController) {
navigationView?.setupWithNavController(navController)

View File

@ -2,8 +2,8 @@ package org.moire.ultrasonic.di
import org.koin.dsl.module
import org.moire.ultrasonic.service.ExternalStorageMonitor
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.PlaybackStateSerializer
/**

View File

@ -401,6 +401,13 @@ class PlayerFragment :
}
}
// Subscribe to change in command availability
mediaPlayerManager.addListener(object : Player.Listener {
override fun onAvailableCommandsChanged(availableCommands: Player.Commands) {
updateMediaButtonActivationState()
}
})
view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) }
}
@ -794,8 +801,7 @@ class PlayerFragment :
private fun update(cancel: CancellationToken? = null) {
if (cancel?.isCancellationRequested == true) return
val mediaPlayerController = mediaPlayerManager
if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
if (currentSong?.id != mediaPlayerManager.currentMediaItem?.mediaId) {
onTrackChanged()
}
updateSeekBar()
@ -1110,6 +1116,10 @@ class PlayerFragment :
updateSongRating()
updateMediaButtonActivationState()
}
private fun updateMediaButtonActivationState() {
nextButton.isEnabled = mediaPlayerManager.canSeekToNext()
previousButton.isEnabled = mediaPlayerManager.canSeekToPrevious()
}

View File

@ -10,7 +10,7 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import java.io.IOException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flatMapMerge
@ -90,7 +90,7 @@ class EditServerModel(val app: Application) : AndroidViewModel(app), KoinCompone
}
}
@OptIn(FlowPreview::class)
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun queryFeatureSupport(currentServerSetting: ServerSetting): Flow<FeatureSupport> {
val client = buildTestClient(currentServerSetting)
// One line of magic:

View File

@ -253,7 +253,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
override fun onSuccess(result: SessionResult) {
track.starred = !track.starred
// This needs to be called on the main Thread
// FIXME: This is a looping reference
// TODO: This is a looping reference
libraryService.onUpdateNotification(session)
}

View File

@ -82,11 +82,6 @@ class PlaybackService :
instance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Timber.i("onStartCommand called: $intent")
return super.onStartCommand(intent, flags, startId)
}
private fun getWakeModeFlag(): Int {
return if (ActiveServerProvider.isOffline()) C.WAKE_MODE_LOCAL else C.WAKE_MODE_NETWORK
}
@ -153,7 +148,6 @@ class PlaybackService :
.setBitmapLoader(ArtworkBitmapLoader())
.build()
// Set a listener to update the API client when the active server has changed
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
// Set the player wake mode
@ -164,7 +158,7 @@ class PlaybackService :
rxBusSubscription += RxBus.shufflePlayObservable.subscribe { shuffle ->
// This only applies for local playback
val exo = if (player is ExoPlayer) {
player as ExoPlayer
player as ExoPlayer
} else {
return@subscribe
}
@ -199,14 +193,21 @@ class PlaybackService :
isStarted = true
}
private fun updateBackend(newBackend: MediaPlayerManager.PlayerBackend) {
Timber.i("Switching player backends")
// Remove old listeners
player.removeListener(listener)
player.release()
player = if (newBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) {
getJukeboxPlayer()
} else {
getLocalPlayer()
}
// Add fresh listeners
player.addListener(listener)
mediaLibrarySession.player = player
actualBackend = newBackend
}
@ -291,6 +292,7 @@ class PlaybackService :
}
private fun cacheNextSongs() {
if (actualBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) return
Timber.d("PlaybackService caching the next songs")
val nextSongs = Util.getPlayListFromTimeline(
player.currentTimeline,
@ -382,7 +384,6 @@ class PlaybackService :
companion object {
var actualBackend: MediaPlayerManager.PlayerBackend? = null
// FIXME with by default stuff
private var desiredBackend: MediaPlayerManager.PlayerBackend? = null
fun setBackend(playerBackend: MediaPlayerManager.PlayerBackend) {
desiredBackend = playerBackend
@ -395,7 +396,4 @@ class PlaybackService :
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic error messages"
private const val NOTIFICATION_ID = 3009
}
}

View File

@ -7,17 +7,12 @@
package org.moire.ultrasonic.service
import android.annotation.SuppressLint
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.ProgressBar
import android.widget.Toast
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.DeviceInfo
import androidx.media3.common.FlagSet
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
@ -27,7 +22,15 @@ import androidx.media3.common.Timeline
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.Clock
import androidx.media3.common.util.ListenerSet
import androidx.media3.common.util.Size
import java.util.concurrent.Executors
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
@ -35,19 +38,11 @@ import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util.sleepQuietly
import timber.log.Timber
import java.util.concurrent.Executors
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.roundToInt
private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L
private const val SEEK_INCREMENT_SECONDS = 5L
private const val SEEK_START_AFTER_SECONDS = 5
private const val QUEUE_POLL_INTERVAL_SECONDS = 1L
/**
@ -67,22 +62,45 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
private val timeOfLastUpdate = AtomicLong()
private var jukeboxStatus: JukeboxStatus? = null
private var previousJukeboxStatus: JukeboxStatus? = null
private var gain = 0.5f
private var volumeToast: VolumeToast? = null
private var gain = (MAX_GAIN / 3)
private val floatGain: Float
get() = gain.toFloat() / MAX_GAIN
private var serviceThread: Thread? = null
private var listeners: MutableList<Player.Listener> = mutableListOf()
private var listeners: ListenerSet<Player.Listener>
private val playlist: MutableList<MediaItem> = mutableListOf()
// This must never be smaller 0
private var currentIndex: Int = 0
private var _currentIndex: Int = 0
private var currentIndex: Int
get() = _currentIndex
set(value) {
// This must never be smaller 0
_currentIndex = if (value >= 0) value else 0
}
companion object {
// This is quite important, by setting the DeviceInfo the player is recognized by
// Android as being a remote playback surface
val DEVICE_INFO = DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 10)
val running = AtomicBoolean()
const val MAX_GAIN = 10
}
init {
// FIXME: Adapt
//if (running.get()) return
running.set(true)
listeners = ListenerSet(
applicationLooper,
Clock.DEFAULT
) { listener: Player.Listener, flags: FlagSet? ->
listener.onEvents(
this,
Player.Events(
flags!!
)
)
}
tasks.clear()
updatePlaylist()
stop()
@ -90,23 +108,18 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
@Suppress("MagicNumber")
fun onDestroy() {
override fun release() {
tasks.clear()
stop()
if (!running.get()) return
running.set(false)
serviceThread!!.join()
serviceThread?.join()
Timber.d("Stopped Jukebox Service")
}
companion object {
val running = AtomicBoolean()
}
override fun addListener(listener: Player.Listener) {
listeners.add(listener)
}
@ -155,14 +168,20 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
tasks.add(Skip(mediaItemIndex, positionSeconds))
currentIndex = mediaItemIndex
updateAvailableCommands()
}
override fun seekBack() {
seekTo(0L.coerceAtMost((jukeboxStatus?.positionSeconds ?: 0) - SEEK_INCREMENT_SECONDS))
seekTo(
0L.coerceAtMost(
(jukeboxStatus?.positionSeconds ?: 0) -
Settings.seekIntervalMillis
)
)
}
override fun seekForward() {
seekTo((jukeboxStatus?.positionSeconds ?: 0) + SEEK_INCREMENT_SECONDS)
seekTo((jukeboxStatus?.positionSeconds ?: 0) + Settings.seekIntervalMillis)
}
override fun isCurrentMediaItemSeekable() = true
@ -183,18 +202,15 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
override fun getAvailableCommands(): Player.Commands {
// FIXME Stale ness problem!!
Timber.i("adding new commands")
val commandsBuilder = Player.Commands.Builder().addAll(
Player.COMMAND_SET_VOLUME,
Player.COMMAND_GET_VOLUME,
Player.COMMAND_CHANGE_MEDIA_ITEMS,
Player.COMMAND_GET_TIMELINE,
// TEST
Player.COMMAND_SEEK_TO_NEXT
Player.COMMAND_GET_DEVICE_VOLUME,
Player.COMMAND_ADJUST_DEVICE_VOLUME,
Player.COMMAND_SET_DEVICE_VOLUME
)
if (isPlaying) commandsBuilder.add(Player.COMMAND_STOP)
if (true || playlist.isNotEmpty()) {
if (playlist.isNotEmpty()) {
commandsBuilder.addAll(
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
Player.COMMAND_GET_MEDIA_ITEMS_METADATA,
@ -204,19 +220,14 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
Player.COMMAND_SEEK_FORWARD,
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
)
if (currentIndex > 0) commandsBuilder.addAll(
// Seeking back is always available
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
) else {
Timber.i("Not adding prev command")
}
)
if (currentIndex < playlist.size - 1) commandsBuilder.addAll(
Player.COMMAND_SEEK_TO_NEXT,
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
) else {
Timber.i("Not adding seek command")
}
)
}
return commandsBuilder.build()
}
@ -225,6 +236,18 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
return availableCommands.contains(command)
}
private fun updateAvailableCommands() {
Handler(Looper.getMainLooper()).post {
listeners.sendEvent(
Player.EVENT_AVAILABLE_COMMANDS_CHANGED
) { listener: Player.Listener ->
listener.onAvailableCommandsChanged(
availableCommands
)
}
}
}
override fun getPlayWhenReady(): Boolean {
return isPlaying
}
@ -241,7 +264,6 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
override fun getCurrentTimeline(): Timeline {
Timber.i("getCurrentTimeline was requested")
return PlaylistTimeline(playlist)
}
@ -261,21 +283,43 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
override fun setVolume(volume: Float) {
override fun setDeviceVolume(volume: Int) {
gain = volume
tasks.remove(SetGain::class.java)
tasks.add(SetGain(volume))
val context = applicationContext()
if (volumeToast == null) volumeToast = VolumeToast(context)
volumeToast!!.setVolume(volume)
tasks.add(SetGain(floatGain))
// We must trigger an event so that the Controller knows the new volume
Handler(Looper.getMainLooper()).post {
listeners.queueEvent(Player.EVENT_DEVICE_VOLUME_CHANGED) {
it.onDeviceVolumeChanged(
gain,
false
)
}
}
}
override fun increaseDeviceVolume() {
gain = (gain + 1).coerceAtMost(MAX_GAIN)
deviceVolume = gain
}
override fun decreaseDeviceVolume() {
gain = (gain - 1).coerceAtLeast(0)
deviceVolume = gain
}
override fun setDeviceMuted(muted: Boolean) {
gain = 0
deviceVolume = gain
}
override fun getVolume(): Float {
return gain
return floatGain
}
override fun getDeviceVolume(): Int {
return (gain * 100).toInt()
return gain
}
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
@ -315,9 +359,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
return Player.REPEAT_MODE_OFF
}
override fun setRepeatMode(repeatMode: Int) {
// TODO
}
override fun setRepeatMode(repeatMode: Int) {}
override fun getCurrentPosition(): Long {
return positionSeconds * 1000L
@ -349,7 +391,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
override fun seekToPrevious() {
if ((jukeboxStatus?.positionSeconds ?: 0) > SEEK_START_AFTER_SECONDS) {
if ((jukeboxStatus?.positionSeconds ?: 0) > (Settings.seekIntervalMillis)) {
seekTo(currentIndex, 0)
return
}
@ -382,8 +424,6 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
private fun startStatusUpdate() {
stopStatusUpdate()
val updateTask = Runnable {
Timber.i("UpdateTask")
Timber.i("playlist: ${playlist.size}")
tasks.remove(GetStatus::class.java)
tasks.add(GetStatus())
}
@ -406,25 +446,19 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
@Suppress("LoopWithTooManyJumpStatements")
private fun processTasks() {
Timber.d("JukeboxMediaPlayer processTasks starting")
while (true) {
while (running.get()) {
// Sleep a bit to spare processor time if we loop a lot
sleepQuietly(10)
// This is only necessary if Ultrasonic goes offline sooner than the thread stops
if (isOffline()) continue
var task: JukeboxTask? = null
try {
task = tasks.poll()
// If running is false, exit when the queue is empty
if (task == null && !running.get()) {
Timber.d("JukeboxMediaPlayer processTasks exiting because not running")
break
}
if (task == null) continue
task = tasks.poll() ?: continue
Timber.v("JukeBoxMediaPlayer processTasks processes Task %s", task::class)
val status = task.execute()
onStatusUpdate(status)
} catch (x: Throwable) {
onError(task, x)
} catch (all: Throwable) {
onError(task, all)
}
}
Timber.d("JukeboxMediaPlayer processTasks stopped")
@ -435,6 +469,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
timeOfLastUpdate.set(System.currentTimeMillis())
previousJukeboxStatus = this.jukeboxStatus
this.jukeboxStatus = jukeboxStatus
var shouldUpdateCommands = false
// Ensure that the index is never smaller than 0
// If -1 assume that this means we are not playing
@ -445,23 +480,29 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
currentIndex = jukeboxStatus.currentPlayingIndex ?: currentIndex
if (jukeboxStatus.isPlaying != previousJukeboxStatus?.isPlaying) {
shouldUpdateCommands = true
Handler(Looper.getMainLooper()).post {
listeners.forEach {
listeners.queueEvent(Player.EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(
if (jukeboxStatus.isPlaying) Player.STATE_READY else Player.STATE_IDLE
)
}
listeners.queueEvent(Player.EVENT_IS_PLAYING_CHANGED) {
it.onIsPlayingChanged(jukeboxStatus.isPlaying)
}
}
}
if (jukeboxStatus.currentPlayingIndex != previousJukeboxStatus?.currentPlayingIndex) {
shouldUpdateCommands = true
currentIndex = jukeboxStatus.currentPlayingIndex ?: 0
val currentMedia =
if (currentIndex > 0 && currentIndex < playlist.size) playlist[currentIndex]
else MediaItem.EMPTY
Handler(Looper.getMainLooper()).post {
listeners.forEach {
listeners.queueEvent(Player.EVENT_MEDIA_ITEM_TRANSITION) {
it.onMediaItemTransition(
currentMedia,
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
@ -469,43 +510,40 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
}
}
if (shouldUpdateCommands) updateAvailableCommands()
Handler(Looper.getMainLooper()).post {
listeners.flushEvents()
}
}
private fun onError(task: JukeboxTask?, x: Throwable) {
var exception: PlaybackException? = null
if (x is ApiNotSupportedException && task !is Stop) {
Handler(Looper.getMainLooper()).post {
listeners.forEach {
it.onPlayerError(
PlaybackException(
"Jukebox server too old",
null,
R.string.download_jukebox_server_too_old
)
)
}
}
exception = PlaybackException(
"Jukebox server too old",
null,
R.string.download_jukebox_server_too_old
)
} else if (x is OfflineException && task !is Stop) {
Handler(Looper.getMainLooper()).post {
listeners.forEach {
it.onPlayerError(
PlaybackException(
"Jukebox offline",
null,
R.string.download_jukebox_offline
)
)
}
}
exception = PlaybackException(
"Jukebox offline",
null,
R.string.download_jukebox_offline
)
} else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) {
exception = PlaybackException(
"Jukebox not authorized",
null,
R.string.download_jukebox_not_authorized
)
}
if (exception != null) {
Handler(Looper.getMainLooper()).post {
listeners.forEach {
it.onPlayerError(
PlaybackException(
"Jukebox not authorized",
null,
R.string.download_jukebox_not_authorized
)
)
listeners.sendEvent(Player.EVENT_PLAYER_ERROR) {
it.onPlayerError(exception)
}
}
} else {
@ -524,8 +562,10 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
tasks.add(SetPlaylist(ids))
Handler(Looper.getMainLooper()).post {
listeners.forEach {
it.onTimelineChanged(
listeners.sendEvent(
Player.EVENT_TIMELINE_CHANGED
) { listener: Player.Listener ->
listener.onTimelineChanged(
PlaylistTimeline(playlist),
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED
)
@ -635,25 +675,6 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
}
@SuppressLint("InflateParams")
private class VolumeToast(context: Context) : Toast(context) {
private val progressBar: ProgressBar
fun setVolume(volume: Float) {
progressBar.progress = (100 * volume).roundToInt()
show()
}
init {
duration = LENGTH_SHORT
val inflater =
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val view = inflater.inflate(R.layout.jukebox_volume, null)
progressBar = view.findViewById<View>(R.id.jukebox_volume_progress_bar) as ProgressBar
setView(view)
setGravity(Gravity.TOP, 0, 0)
}
}
// The constants below are necessary so a MediaSession can be built from the Jukebox Service
override fun isCurrentMediaItemDynamic(): Boolean {
return false
@ -664,15 +685,15 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
override fun getMaxSeekToPreviousPosition(): Long {
return SEEK_START_AFTER_SECONDS * 1000L
return Settings.seekInterval.toLong()
}
override fun getSeekBackIncrement(): Long {
return SEEK_INCREMENT_SECONDS * 1000L
return Settings.seekInterval.toLong()
}
override fun getSeekForwardIncrement(): Long {
return SEEK_INCREMENT_SECONDS * 1000L
return Settings.seekInterval.toLong()
}
override fun isLoading(): Boolean {
@ -695,6 +716,8 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
return AudioAttributes.DEFAULT
}
override fun setVolume(volume: Float) {}
override fun getVideoSize(): VideoSize {
return VideoSize(0, 0)
}
@ -740,7 +763,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
override fun getDeviceInfo(): DeviceInfo {
return DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 1)
return DEVICE_INFO
}
override fun getPlayerError(): PlaybackException? {

View File

@ -1,97 +0,0 @@
/*
* JukeboxNotificationActionFactory.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.media3.common.Player
import androidx.media3.common.util.Util
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession
import org.moire.ultrasonic.app.UApp
/**
* This class creates Intents and Actions to be used with the Media Notification
* of the Jukebox Service
*/
@SuppressLint("UnsafeOptInUsageError")
class JukeboxNotificationActionFactory : MediaNotification.ActionFactory {
override fun createMediaAction(
mediaSession: MediaSession,
icon: IconCompat,
title: CharSequence,
command: Int
): NotificationCompat.Action {
return NotificationCompat.Action(
icon, title, createMediaActionPendingIntent(mediaSession, command.toLong())
)
}
override fun createCustomAction(
mediaSession: MediaSession,
icon: IconCompat,
title: CharSequence,
customAction: String,
extras: Bundle
): NotificationCompat.Action {
return NotificationCompat.Action(
icon, title, null
)
}
override fun createCustomActionFromCustomCommandButton(
mediaSession: MediaSession,
customCommandButton: CommandButton
): NotificationCompat.Action {
return NotificationCompat.Action(null, null, null)
}
@Suppress("MagicNumber")
override fun createMediaActionPendingIntent(
mediaSession: MediaSession,
command: Long
): PendingIntent {
val keyCode: Int = toKeyCode(command)
val intent = Intent(Intent.ACTION_MEDIA_BUTTON)
intent.component = ComponentName(UApp.applicationContext(), JukeboxMediaPlayer::class.java)
intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
return if (Util.SDK_INT >= 26 && command == Player.COMMAND_PLAY_PAUSE.toLong()) {
return PendingIntent.getForegroundService(
UApp.applicationContext(), keyCode, intent, PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getService(
UApp.applicationContext(),
keyCode,
intent,
if (Util.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
)
}
}
private fun toKeyCode(action: @Player.Command Long): Int {
return when (action.toInt()) {
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_NEXT -> KeyEvent.KEYCODE_MEDIA_NEXT
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_PREVIOUS -> KeyEvent.KEYCODE_MEDIA_PREVIOUS
Player.COMMAND_STOP -> KeyEvent.KEYCODE_MEDIA_STOP
Player.COMMAND_SEEK_FORWARD -> KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
Player.COMMAND_SEEK_BACK -> KeyEvent.KEYCODE_MEDIA_REWIND
Player.COMMAND_PLAY_PAUSE -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
else -> KeyEvent.KEYCODE_UNKNOWN
}
}
}

View File

@ -8,7 +8,6 @@
package org.moire.ultrasonic.service
import android.annotation.SuppressLint
import android.app.Service
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
@ -140,10 +139,6 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented")
}
override fun release() {
TODO("Not yet implemented")
}
override fun getCurrentTracks(): Tracks {
// TODO Dummy information is returned for now, this seems to work
return Tracks.EMPTY
@ -228,20 +223,4 @@ abstract class JukeboxUnimplementedFunctions : Player {
override fun clearVideoTextureView(textureView: TextureView?) {
TODO("Not yet implemented")
}
override fun setDeviceVolume(volume: Int) {
TODO("Not yet implemented")
}
override fun increaseDeviceVolume() {
TODO("Not yet implemented")
}
override fun decreaseDeviceVolume() {
TODO("Not yet implemented")
}
override fun setDeviceMuted(muted: Boolean) {
TODO("Not yet implemented")
}
}

View File

@ -16,8 +16,6 @@ import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT
import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
import androidx.media3.common.Player.REPEAT_MODE_OFF
import androidx.media3.common.Rating
@ -105,8 +103,8 @@ class MediaPlayerManager(
it()
deferredPlay = null
}
RxBus.playlistPublisher.onNext(Util.getPlayListFromTimeline(timeline, false).map(MediaItem::toTrack))
val playlist = Util.getPlayListFromTimeline(timeline, false).map(MediaItem::toTrack)
RxBus.playlistPublisher.onNext(playlist)
}
override fun onPlaybackStateChanged(playbackState: Int) {
@ -275,6 +273,10 @@ class MediaPlayerManager(
}
}
fun addListener(listener: Player.Listener) {
controller?.addListener(listener)
}
private fun clearBookmark() {
// This method is called just before we update the cachedMediaItem,
// so in fact cachedMediaItem will refer to the track that has just finished.
@ -349,7 +351,6 @@ class MediaPlayerManager(
@Synchronized
fun play(index: Int) {
controller?.seekTo(index, 0L)
// FIXME CHECK ITS NOT MAKING PROBLEMS
controller?.prepare()
controller?.play()
}
@ -358,8 +359,6 @@ class MediaPlayerManager(
fun play() {
controller?.prepare()
controller?.play()
val time = controller?.currentTimeline
Timber.i("timeline: ${time?.windowCount}")
}
@Synchronized
@ -553,9 +552,7 @@ class MediaPlayerManager(
@Synchronized
fun canSeekToPrevious(): Boolean {
val can = controller?.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS) == true
Timber.i("can prev command: $can")
return can
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS) == true
}
@Synchronized
@ -565,9 +562,7 @@ class MediaPlayerManager(
@Synchronized
fun canSeekToNext(): Boolean {
val can = controller?.isCommandAvailable(COMMAND_SEEK_TO_NEXT) == true
Timber.i("can seek command: $can")
return can
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT) == true
}
@Synchronized
@ -666,10 +661,6 @@ class MediaPlayerManager(
controller?.volume = gain
}
fun setVolume(volume: Float) {
controller?.volume = volume
}
/*
* Sets the rating of the current track
*/

View File

@ -128,6 +128,9 @@ object Settings {
var seekInterval
by StringIntSetting(getKey(R.string.setting_key_increment_time), 5000)
val seekIntervalMillis: Long
get() = (seekInterval / 1000).toLong()
@JvmStatic
var mediaButtonsEnabled
by BooleanSetting(getKey(R.string.setting_key_media_buttons), true)

View File

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
a:id="@+id/toast_layout_root"
a:orientation="vertical"
a:layout_width="fill_parent"
a:layout_height="fill_parent"
a:background="@android:drawable/toast_frame">
<TextView
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:text="@string/download.jukebox_volume"
a:textAppearance="?android:attr/textAppearanceMedium"
a:textColor="#ffffffff"
a:shadowColor="#bb000000"
a:shadowRadius="2.75"
a:paddingStart="32dp"
a:paddingEnd="32dp"
a:paddingBottom="12dp"
/>
<ProgressBar a:id="@+id/jukebox_volume_progress_bar"
style="@android:style/Widget.Holo.Light.ProgressBar.Horizontal"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:paddingBottom="3dp" />
</LinearLayout>

View File

@ -48,7 +48,6 @@
<string name="download.jukebox_offline">Vzdálené ovládání není dostupné v offline módu.</string>
<string name="download.jukebox_on">Vzdálené ovládání zapnuto. Hudba přehrávána na serveru.</string>
<string name="download.jukebox_server_too_old">Vzdálené ovládání není podporováno. Aktualizujte svůj Subsonic server.</string>
<string name="download.jukebox_volume">Hlasitost vzdáleného přístroje</string>
<string name="download.menu_equalizer">Ekvalizér</string>
<string name="download.menu_jukebox_off">Jukebox vypnut</string>
<string name="download.menu_jukebox_on">Jukebox zapnut</string>

View File

@ -61,7 +61,6 @@
<string name="download.jukebox_offline">Fernbedienungs-Modus is Offline nicht verfügbar.</string>
<string name="download.jukebox_on">Fernbedienung ausgeschaltet. Musik wird auf dem Server wiedergegeben.</string>
<string name="download.jukebox_server_too_old">Fernbedienungs Modus wird nicht unterstützt. Bitte den Subsonic Server aktualisieren.</string>
<string name="download.jukebox_volume">Entfernte Lautstärke</string>
<string name="download.menu_equalizer">Equalizer</string>
<string name="download.menu_jukebox_off">Jukebox Aus</string>
<string name="download.menu_jukebox_on">Jukebox An</string>

View File

@ -62,7 +62,6 @@
<string name="download.jukebox_offline">Control remoto no disponible en modo fuera de línea.</string>
<string name="download.jukebox_on">Control remoto encendido. La música se reproduce en el servidor.</string>
<string name="download.jukebox_server_too_old">Control remoto no soportado. Por favor actualiza tu servidor de Subsonic.</string>
<string name="download.jukebox_volume">Volumen remoto</string>
<string name="download.menu_equalizer">Ecualizador</string>
<string name="download.menu_jukebox_off">Apagar Jukebox</string>
<string name="download.menu_jukebox_on">Encender Jukebox</string>

View File

@ -61,7 +61,6 @@
<string name="download.jukebox_offline">Le mode jukebox n\'est pas disponible en mode déconnecté.</string>
<string name="download.jukebox_on">Mode jukebox activé. La musique est jouée sur le serveur</string>
<string name="download.jukebox_server_too_old">Le mode jukebox n\'est pas pris en charge. Mise à jour du serveur Subsonic requise.</string>
<string name="download.jukebox_volume">Volume sur serveur distant</string>
<string name="download.menu_equalizer">Égaliseur</string>
<string name="download.menu_jukebox_off">Désactiver le mode jukebox</string>
<string name="download.menu_jukebox_on">Activer le mode jukebox</string>

View File

@ -54,7 +54,6 @@
<string name="download.jukebox_offline">A távvezérlés nem lehetséges kapcsolat nélküli módban!</string>
<string name="download.jukebox_on">Távvezérlés bekapcsolása. A zenelejátszás a kiszolgálón történik.</string>
<string name="download.jukebox_server_too_old">A távvezérlés nem támogatott. Kérjük, frissítse a Subsonic kiszolgálót!</string>
<string name="download.jukebox_volume">Hangerő távvezérlése</string>
<string name="download.menu_equalizer">Equalizer</string>
<string name="download.menu_jukebox_off">Jukebox ki</string>
<string name="download.menu_jukebox_on">Jukebox be</string>

View File

@ -45,7 +45,6 @@
<string name="download.jukebox_offline">Il controllo remoto non è disponibile nella modalità offline. </string>
<string name="download.jukebox_on">Controllo remoto abilitato. La musica verrà riprodotta sul server.</string>
<string name="download.jukebox_server_too_old">Il controllo remoto non è supportato. Per favore aggiorna la versione del server Airsonic.</string>
<string name="download.jukebox_volume">Volume remoto</string>
<string name="download.menu_equalizer">Equalizzatore</string>
<string name="download.menu_jukebox_off">Jukebox spento</string>
<string name="download.menu_jukebox_on">Jukebox acceso</string>

View File

@ -42,7 +42,6 @@
<string name="download.jukebox_on">リモートコントロールがオンになりました。サーバーで音楽が再生されます。</string>
<string name="download.jukebox_off">リモートコントロールがオフになりました。モバイル端末で音楽が再生されます。</string>
<string name="download.jukebox_server_too_old">リモートコントロールがサポートされていません。Subsonicサーバーをアップグレードしてください。</string>
<string name="download.jukebox_volume">リモート音量</string>
<string name="download.menu_jukebox_on">ジュークボックス ON</string>
<string name="download.menu_lyrics">歌詞</string>
<string name="download.menu_show_album">アルバムを表示</string>

View File

@ -339,7 +339,6 @@
<string name="download.jukebox_not_authorized">Fjernkontroll er avskrudd. Skru på jukebox-modus i <b>Brukere &gt; Innstillinger</b> på din Subsonic-tjener.</string>
<string name="download.jukebox_off">Fjernkontroll avskrudd. Musikk spilles på enheten.</string>
<string name="download.jukebox_server_too_old">Fjernkontroll støttes ikke. Oppgrader din Subsonic-tjener.</string>
<string name="download.jukebox_volume">Fjernkontroll</string>
<string name="download.menu_jukebox_off">Jukebox avslått</string>
<string name="download.menu_jukebox_on">Jukebox påslått</string>
<string name="download.menu_shuffle">Omstokking</string>

View File

@ -63,7 +63,6 @@
<string name="download.jukebox_offline">Afstandsbediening is niet beschikbaar in offline-modus.</string>
<string name="download.jukebox_on">Afstandsbediening ingeschakeld; muziek wordt afgespeeld op de server.</string>
<string name="download.jukebox_server_too_old">Afstandsbediening wordt niet ondersteund. Werk je Subsonic-server bij.</string>
<string name="download.jukebox_volume">Afstandsbedieningvolume</string>
<string name="download.menu_equalizer">Equalizer</string>
<string name="download.menu_jukebox_off">Jukebox uitgeschakeld</string>
<string name="download.menu_jukebox_on">Jukebox ingeschakeld</string>

View File

@ -47,7 +47,6 @@
<string name="download.jukebox_offline">Pilot jest niedostępny w trybie offline.</string>
<string name="download.jukebox_on">Tryb pilota jest włączony. Muzyka jest odtwarzana na serwerze.</string>
<string name="download.jukebox_server_too_old">Tryb pilota jest niedostępny. Proszę uaktualnić serwer Subsonic.</string>
<string name="download.jukebox_volume">Zdalna głośność</string>
<string name="download.menu_equalizer">Korektor dźwięku</string>
<string name="download.menu_jukebox_off">Jukebox wyłączony</string>
<string name="download.menu_jukebox_on">Jukebox włączony</string>

View File

@ -62,7 +62,6 @@
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
<string name="download.jukebox_volume">Volume Remoto</string>
<string name="download.menu_equalizer">Equalizador</string>
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
<string name="download.menu_jukebox_on">Jukebox Ligado</string>

View File

@ -47,7 +47,6 @@
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
<string name="download.jukebox_volume">Volume Remoto</string>
<string name="download.menu_equalizer">Equalizador</string>
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
<string name="download.menu_jukebox_on">Jukebox Ligado</string>

View File

@ -59,7 +59,6 @@
<string name="download.jukebox_offline">Пульт дистанционного управления недоступен в автономном режиме.</string>
<string name="download.jukebox_on">Включен пульт управления. Музыка играет на сервере.</string>
<string name="download.jukebox_server_too_old">Пульт дистанционного управления не поддерживается. Пожалуйста, обновите ваш дозвуковой сервер.</string>
<string name="download.jukebox_volume">Удаленная громкость</string>
<string name="download.menu_equalizer">Эквалайзер</string>
<string name="download.menu_jukebox_off">Jukebox выключен</string>
<string name="download.menu_jukebox_on">Jukebox включен</string>

View File

@ -60,7 +60,6 @@
<string name="download.jukebox_offline">离线模式不支持远程控制。</string>
<string name="download.jukebox_on">已打开远程控制,音乐将在服务端播放。</string>
<string name="download.jukebox_server_too_old">远程控制不支持,请升级您的 Subsonic 服务器。</string>
<string name="download.jukebox_volume">远程音量</string>
<string name="download.menu_equalizer">均衡器</string>
<string name="download.menu_jukebox_off">关闭点唱机</string>
<string name="download.menu_jukebox_on">开启点唱机</string>

View File

@ -141,7 +141,6 @@
<string name="common.pin">固定</string>
<string name="chat.send_button">傳送</string>
<string name="button_bar.chat">聊天</string>
<string name="download.jukebox_volume">遠端音量</string>
<string name="chat.user_avatar">頭像</string>
<string name="common.delete_selection_confirmation">您真的要刪除目前選取的項目嗎?</string>
<string name="background_task.parse_error">無法理解答覆,請檢查伺服器位址。</string>

View File

@ -63,7 +63,6 @@
<string name="download.jukebox_offline">Remote control is not available in offline mode.</string>
<string name="download.jukebox_on">Turned on remote control. Music is played on server.</string>
<string name="download.jukebox_server_too_old">Remote control is not supported. Please upgrade your Subsonic server.</string>
<string name="download.jukebox_volume">Remote Volume</string>
<string name="download.menu_equalizer">Equalizer</string>
<string name="download.menu_jukebox_off">Jukebox Off</string>
<string name="download.menu_jukebox_on">Jukebox On</string>