Merge branch 'cast' into 'develop'

Use the JukeboxPlayer as a Player instead of an Controller

See merge request ultrasonic/ultrasonic!1019
This commit is contained in:
birdbird 2023-05-19 21:37:31 +00:00
commit 71168983b6
41 changed files with 470 additions and 758 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

@ -13,7 +13,7 @@ import static org.koin.java.KoinJavaComponent.inject;
public class UltrasonicIntentReceiver extends BroadcastReceiver
{
private final Lazy<MediaPlayerLifecycleSupport> lifecycleSupport = inject(MediaPlayerLifecycleSupport.class);
private Lazy<MediaPlayerLifecycleSupport> lifecycleSupport = inject(MediaPlayerLifecycleSupport.class);
@Override
public void onReceive(Context context, Intent intent)

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.MediaPlayerController
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
@ -98,7 +97,7 @@ class NavigationActivity : AppCompatActivity() {
private val serverSettingsModel: ServerSettingsModel by viewModel()
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private val serverRepository: ServerSettingDao by inject()
@ -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 = mediaPlayerController.isJukeboxEnabled
if (isVolumeAdjust && isJukebox) {
mediaPlayerController.adjustVolume(isVolumeUp)
return true
}
return super.onKeyDown(keyCode, event)
}
private fun setupNavigationMenu(navController: NavController) {
navigationView?.setupWithNavController(navController)
@ -308,7 +295,7 @@ class NavigationActivity : AppCompatActivity() {
}
R.id.menu_exit -> {
setResult(Constants.RESULT_CLOSE_ALL)
mediaPlayerController.onDestroy()
mediaPlayerManager.onDestroy()
finish()
exit()
}
@ -475,9 +462,9 @@ class NavigationActivity : AppCompatActivity() {
}
if (nowPlayingView != null) {
val playerState: Int = mediaPlayerController.playbackState
val playerState: Int = mediaPlayerManager.playbackState
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
val item: MediaItem? = mediaPlayerController.currentMediaItem
val item: MediaItem? = mediaPlayerManager.currentMediaItem
if (item != null) {
nowPlayingView?.visibility = View.VISIBLE
}

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.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.PlaybackStateSerializer
/**
@ -15,5 +15,5 @@ val mediaPlayerModule = module {
single { ExternalStorageMonitor() }
// TODO Ideally this can be cleaned up when all circular references are removed.
single { MediaPlayerController(get(), get(), get()) }
single { MediaPlayerManager(get(), get(), get()) }
}

View File

@ -72,7 +72,7 @@ class BookmarksFragment : TrackCollectionFragment() {
currentPlayingPosition = songs[0].bookmarkPosition
)
mediaPlayerController.restore(
mediaPlayerManager.restore(
state = state,
autoPlay = true,
newPlaylist = true

View File

@ -25,7 +25,7 @@ import kotlin.math.abs
import org.koin.android.ext.android.inject
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Settings
@ -48,7 +48,7 @@ class NowPlayingFragment : Fragment() {
private var nowPlayingArtist: TextView? = null
private var rxBusSubscription: Disposable? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
override fun onCreate(savedInstanceState: Bundle?) {
@ -85,13 +85,13 @@ class NowPlayingFragment : Fragment() {
@SuppressLint("ClickableViewAccessibility")
private fun update() {
try {
if (mediaPlayerController.isPlaying) {
if (mediaPlayerManager.isPlaying) {
playButton!!.setIconResource(R.drawable.media_pause)
} else {
playButton!!.setIconResource(R.drawable.media_start)
}
val file = mediaPlayerController.currentMediaItem?.toTrack()
val file = mediaPlayerManager.currentMediaItem?.toTrack()
if (file != null) {
val title = file.title
@ -127,7 +127,7 @@ class NowPlayingFragment : Fragment() {
// This empty onClickListener is necessary for the onTouchListener to work
requireView().setOnClickListener { }
playButton!!.setOnClickListener { mediaPlayerController.togglePlayPause() }
playButton!!.setOnClickListener { mediaPlayerManager.togglePlayPause() }
} catch (all: Exception) {
Timber.w(all, "Failed to get notification cover art")
}
@ -149,10 +149,10 @@ class NowPlayingFragment : Fragment() {
if (abs(deltaX) > MIN_DISTANCE) {
// left or right
if (deltaX < 0) {
mediaPlayerController.seekToPrevious()
mediaPlayerManager.seekToPrevious()
}
if (deltaX > 0) {
mediaPlayerController.seekToNext()
mediaPlayerManager.seekToNext()
}
} else if (abs(deltaY) > MIN_DISTANCE) {
if (deltaY < 0) {

View File

@ -87,7 +87,7 @@ import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
@ -128,7 +128,7 @@ class PlayerFragment :
// Data & Services
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val shareHandler: ShareHandler by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private var currentSong: Track? = null
@ -263,8 +263,8 @@ class PlayerFragment :
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
val nextButton: AutoRepeatButton = view.findViewById(R.id.button_next)
shuffleButton = view.findViewById(R.id.button_shuffle)
updateShuffleButtonState(mediaPlayerController.isShufflePlayEnabled)
updateRepeatButtonState(mediaPlayerController.repeatMode)
updateShuffleButtonState(mediaPlayerManager.isShufflePlayEnabled)
updateRepeatButtonState(mediaPlayerManager.repeatMode)
val ratingLinearLayout = view.findViewById<LinearLayout>(R.id.song_rating)
if (!useFiveStarRating) ratingLinearLayout.isVisible = false
@ -286,7 +286,7 @@ class PlayerFragment :
previousButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.seekToPrevious()
mediaPlayerManager.seekToPrevious()
}
}
@ -297,7 +297,7 @@ class PlayerFragment :
nextButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.seekToNext()
mediaPlayerManager.seekToNext()
}
}
@ -307,22 +307,22 @@ class PlayerFragment :
pauseButton.setOnClickListener {
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.pause()
mediaPlayerManager.pause()
}
}
stopButton.setOnClickListener {
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.reset()
mediaPlayerManager.reset()
}
}
playButton.setOnClickListener {
if (!mediaPlayerController.isJukeboxEnabled)
if (!mediaPlayerManager.isJukeboxEnabled)
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.play()
mediaPlayerManager.play()
}
}
@ -331,12 +331,12 @@ class PlayerFragment :
}
repeatButton.setOnClickListener {
var newRepeat = mediaPlayerController.repeatMode + 1
var newRepeat = mediaPlayerManager.repeatMode + 1
if (newRepeat == 3) {
newRepeat = 0
}
mediaPlayerController.repeatMode = newRepeat
mediaPlayerManager.repeatMode = newRepeat
onPlaylistChanged()
@ -358,7 +358,7 @@ class PlayerFragment :
progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onStopTrackingTouch(seekBar: SeekBar) {
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.seekTo(progressBar.progress)
mediaPlayerManager.seekTo(progressBar.progress)
}
}
@ -395,12 +395,19 @@ class PlayerFragment :
// Query the Jukebox state in an IO Context
ioScope.launch(CommunicationError.getHandler(context)) {
try {
jukeboxAvailable = mediaPlayerController.isJukeboxAvailable
jukeboxAvailable = mediaPlayerManager.isJukeboxAvailable
} catch (all: Exception) {
Timber.e(all)
}
}
// Subscribe to change in command availability
mediaPlayerManager.addListener(object : Player.Listener {
override fun onAvailableCommandsChanged(availableCommands: Player.Commands) {
updateMediaButtonActivationState()
}
})
view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) }
}
@ -432,7 +439,7 @@ class PlayerFragment :
}
private fun toggleShuffle() {
val isEnabled = mediaPlayerController.toggleShuffle()
val isEnabled = mediaPlayerManager.toggleShuffle()
if (isEnabled) {
Util.toast(activity, R.string.download_menu_shuffle_on)
@ -445,7 +452,7 @@ class PlayerFragment :
override fun onResume() {
super.onResume()
if (mediaPlayerController.currentMediaItem == null) {
if (mediaPlayerManager.currentMediaItem == null) {
playlistFlipper.displayedChild = 1
} else {
// Download list and Album art must be updated when resumed
@ -458,7 +465,7 @@ class PlayerFragment :
executorService = Executors.newSingleThreadScheduledExecutor()
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
if (mediaPlayerController.keepScreenOn) {
if (mediaPlayerManager.keepScreenOn) {
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@ -469,7 +476,7 @@ class PlayerFragment :
// Scroll to current playing.
private fun scrollToCurrent() {
val index = mediaPlayerController.currentMediaItemIndex
val index = mediaPlayerManager.currentMediaItemIndex
if (index != -1) {
val smoothScroller = LinearSmoothScroller(context)
@ -557,7 +564,7 @@ class PlayerFragment :
equalizerMenuItem.isVisible = isEqualizerAvailable
}
val mediaPlayerController = mediaPlayerController
val mediaPlayerController = mediaPlayerManager
val track = mediaPlayerController.currentMediaItem?.toTrack()
if (track != null) {
@ -666,12 +673,12 @@ class PlayerFragment :
}
R.id.menu_item_screen_on_off -> {
val window = requireActivity().window
if (mediaPlayerController.keepScreenOn) {
if (mediaPlayerManager.keepScreenOn) {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
mediaPlayerController.keepScreenOn = false
mediaPlayerManager.keepScreenOn = false
} else {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
mediaPlayerController.keepScreenOn = true
mediaPlayerManager.keepScreenOn = true
}
return true
}
@ -684,8 +691,8 @@ class PlayerFragment :
return true
}
R.id.menu_item_jukebox -> {
val jukeboxEnabled = !mediaPlayerController.isJukeboxEnabled
mediaPlayerController.isJukeboxEnabled = jukeboxEnabled
val jukeboxEnabled = !mediaPlayerManager.isJukeboxEnabled
mediaPlayerManager.isJukeboxEnabled = jukeboxEnabled
Util.toast(
context,
if (jukeboxEnabled) R.string.download_jukebox_on
@ -699,13 +706,13 @@ class PlayerFragment :
return true
}
R.id.menu_item_clear_playlist -> {
mediaPlayerController.isShufflePlayEnabled = false
mediaPlayerController.clear()
mediaPlayerManager.isShufflePlayEnabled = false
mediaPlayerManager.clear()
onPlaylistChanged()
return true
}
R.id.menu_item_save_playlist -> {
if (mediaPlayerController.playlistSize > 0) {
if (mediaPlayerManager.playlistSize > 0) {
showSavePlaylistDialog()
}
return true
@ -724,7 +731,7 @@ class PlayerFragment :
if (track == null) return true
val songId = track.id
val playerPosition = mediaPlayerController.playerPosition
val playerPosition = mediaPlayerManager.playerPosition
track.bookmarkPosition = playerPosition
val bookmarkTime = Util.formatTotalDuration(playerPosition.toLong(), true)
Thread {
@ -759,7 +766,7 @@ class PlayerFragment :
return true
}
R.id.menu_item_share -> {
val mediaPlayerController = mediaPlayerController
val mediaPlayerController = mediaPlayerManager
val tracks: MutableList<Track?> = ArrayList()
val playlist = mediaPlayerController.playlist
for (item in playlist) {
@ -794,8 +801,7 @@ class PlayerFragment :
private fun update(cancel: CancellationToken? = null) {
if (cancel?.isCancellationRequested == true) return
val mediaPlayerController = mediaPlayerController
if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
if (currentSong?.id != mediaPlayerManager.currentMediaItem?.mediaId) {
onTrackChanged()
}
updateSeekBar()
@ -803,10 +809,10 @@ class PlayerFragment :
private fun savePlaylistInBackground(playlistName: String) {
Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName))
mediaPlayerController.suggestedPlaylistName = playlistName
mediaPlayerManager.suggestedPlaylistName = playlistName
// The playlist can be acquired only from the main thread
val entries = mediaPlayerController.playlist.map {
val entries = mediaPlayerManager.playlist.map {
it.toTrack()
}
@ -859,8 +865,8 @@ class PlayerFragment :
// Create listener
val clickHandler: ((Track, Int) -> Unit) = { _, listPos ->
val mediaIndex = mediaPlayerController.getUnshuffledIndexOf(listPos)
mediaPlayerController.play(mediaIndex)
val mediaIndex = mediaPlayerManager.getUnshuffledIndexOf(listPos)
mediaPlayerManager.play(mediaIndex)
}
viewAdapter.register(
@ -924,7 +930,7 @@ class PlayerFragment :
@SuppressLint("NotifyDataSetChanged")
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val pos = viewHolder.bindingAdapterPosition
val item = mediaPlayerController.getMediaItemAt(pos)
val item = mediaPlayerManager.getMediaItemAt(pos)
// Remove the item from the list quickly
val items = viewAdapter.getCurrentList().toMutableList()
@ -940,7 +946,7 @@ class PlayerFragment :
Util.toast(context, songRemoved)
// Remove the item from the playlist
mediaPlayerController.removeFromPlaylist(pos)
mediaPlayerManager.removeFromPlaylist(pos)
}
override fun onSelectedChanged(
@ -960,7 +966,7 @@ class PlayerFragment :
dragging = false
// Move the item in the playlist separately
Timber.i("Moving item %s to %s", startPosition, endPosition)
mediaPlayerController.moveItemInPlaylist(startPosition, endPosition)
mediaPlayerManager.moveItemInPlaylist(startPosition, endPosition)
}
}
@ -1038,7 +1044,7 @@ class PlayerFragment :
}
private fun onPlaylistChanged() {
val mediaPlayerController = mediaPlayerController
val mediaPlayerController = mediaPlayerManager
// Try to display playlist in play order
val list = mediaPlayerController.playlistInPlayOrder
emptyTextView.setText(R.string.playlist_empty)
@ -1050,12 +1056,12 @@ class PlayerFragment :
}
private fun onTrackChanged() {
currentSong = mediaPlayerController.currentMediaItem?.toTrack()
currentSong = mediaPlayerManager.currentMediaItem?.toTrack()
scrollToCurrent()
val totalDuration = mediaPlayerController.playListDuration
val totalSongs = mediaPlayerController.playlistSize
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
val totalDuration = mediaPlayerManager.playListDuration
val totalSongs = mediaPlayerManager.playlistSize
val currentSongIndex = mediaPlayerManager.currentMediaItemIndex + 1
val duration = Util.formatTotalDuration(totalDuration)
val trackFormat =
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
@ -1110,23 +1116,27 @@ class PlayerFragment :
updateSongRating()
nextButton.isEnabled = mediaPlayerController.canSeekToNext()
previousButton.isEnabled = mediaPlayerController.canSeekToPrevious()
updateMediaButtonActivationState()
}
private fun updateMediaButtonActivationState() {
nextButton.isEnabled = mediaPlayerManager.canSeekToNext()
previousButton.isEnabled = mediaPlayerManager.canSeekToPrevious()
}
@Synchronized
private fun updateSeekBar() {
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
val duration: Int = mediaPlayerController.playerDuration
val playbackState: Int = mediaPlayerController.playbackState
val isJukeboxEnabled: Boolean = mediaPlayerManager.isJukeboxEnabled
val millisPlayed: Int = max(0, mediaPlayerManager.playerPosition)
val duration: Int = mediaPlayerManager.playerDuration
val playbackState: Int = mediaPlayerManager.playbackState
if (currentSong != null) {
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
durationTextView.text = Util.formatTotalDuration(duration.toLong(), true)
progressBar.max = if (duration == 0) 100 else duration // Work-around for apparent bug.
progressBar.progress = millisPlayed
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
progressBar.isEnabled = mediaPlayerManager.isPlaying || isJukeboxEnabled
} else {
positionTextView.setText(R.string.util_zero_time)
durationTextView.setText(R.string.util_no_time)
@ -1135,7 +1145,7 @@ class PlayerFragment :
progressBar.isEnabled = false
}
val progress = mediaPlayerController.bufferedPercentage
val progress = mediaPlayerManager.bufferedPercentage
updateBufferProgress(playbackState, progress)
}
@ -1148,7 +1158,7 @@ class PlayerFragment :
setTitle(this@PlayerFragment, downloadStatus)
}
Player.STATE_READY -> {
if (mediaPlayerController.isShufflePlayEnabled) {
if (mediaPlayerManager.isShufflePlayEnabled) {
setTitle(
this@PlayerFragment,
R.string.download_playerstate_playing_shuffle
@ -1172,7 +1182,7 @@ class PlayerFragment :
}
private fun updateButtonStates(playbackState: Int) {
val isPlaying = mediaPlayerController.isPlaying
val isPlaying = mediaPlayerManager.isPlaying
when (playbackState) {
Player.STATE_READY -> {
pauseButton.isVisible = isPlaying
@ -1195,9 +1205,9 @@ class PlayerFragment :
private fun seek(forward: Boolean) {
launch(CommunicationError.getHandler(context)) {
if (forward) {
mediaPlayerController.seekForward()
mediaPlayerManager.seekForward()
} else {
mediaPlayerController.seekBack()
mediaPlayerManager.seekBack()
}
}
}
@ -1223,28 +1233,28 @@ class PlayerFragment :
// Right to Left swipe
if (e1X - e2X > swipeDistance && absX > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekToNext()
mediaPlayerManager.seekToNext()
return true
}
// Left to Right swipe
if (e2X - e1X > swipeDistance && absX > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekToPrevious()
mediaPlayerManager.seekToPrevious()
return true
}
// Top to Bottom swipe
if (e2Y - e1Y > swipeDistance && absY > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekTo(mediaPlayerController.playerPosition + 30000)
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition + 30000)
return true
}
// Bottom to Top swipe
if (e1Y - e2Y > swipeDistance && absY > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekTo(mediaPlayerController.playerPosition - 8000)
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition - 8000)
return true
}
return false
@ -1309,7 +1319,7 @@ class PlayerFragment :
builder.setView(layout)
builder.setCancelable(true)
val dialog = builder.create()
val playlistName = mediaPlayerController.suggestedPlaylistName
val playlistName = mediaPlayerManager.suggestedPlaylistName
if (playlistName != null) {
playlistNameView.setText(playlistName)
} else {

View File

@ -42,7 +42,7 @@ import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.model.SearchListModel
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo
@ -63,7 +63,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
private var searchRefresh: SwipeRefreshLayout? = null
private var searchView: SearchView? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val shareHandler: ShareHandler by inject()
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
@ -305,15 +305,15 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
private fun onSongSelected(song: Track, append: Boolean) {
if (!append) {
mediaPlayerController.clear()
mediaPlayerManager.clear()
}
mediaPlayerController.addToPlaylist(
mediaPlayerManager.addToPlaylist(
listOf(song),
autoPlay = false,
shuffle = false,
insertionMode = MediaPlayerController.InsertionMode.APPEND
insertionMode = MediaPlayerManager.InsertionMode.APPEND
)
mediaPlayerController.play(mediaPlayerController.mediaItemCount - 1)
mediaPlayerManager.play(mediaPlayerManager.mediaItemCount - 1)
toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1))
}

View File

@ -28,7 +28,7 @@ import org.moire.ultrasonic.log.FileLoggerTree.Companion.getLogFileSizes
import org.moire.ultrasonic.log.FileLoggerTree.Companion.plantToTimberForest
import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest
import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.util.ConfirmationDialog
import org.moire.ultrasonic.util.Constants
@ -62,7 +62,7 @@ class SettingsFragment :
private var debugLogToFile: CheckBoxPreference? = null
private var customCacheLocation: CheckBoxPreference? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
@ -342,7 +342,7 @@ class SettingsFragment :
Settings.cacheLocationUri = path
// Clear download queue.
mediaPlayerController.clear()
mediaPlayerManager.clear()
Storage.reset()
Storage.ensureRootIsAvailable()
}

View File

@ -40,7 +40,7 @@ import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.model.TrackCollectionModel
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadAction
@ -82,7 +82,7 @@ open class TrackCollectionFragment(
private var playAllButton: MenuItem? = null
private var shareButton: MenuItem? = null
internal val mediaPlayerController: MediaPlayerController by inject()
internal val mediaPlayerManager: MediaPlayerManager by inject()
private val shareHandler: ShareHandler by inject()
internal var cancellationToken: CancellationToken? = null

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

@ -19,7 +19,6 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
import androidx.media3.common.Player
import androidx.media3.common.Rating
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
@ -47,7 +46,7 @@ import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RatingManager
import org.moire.ultrasonic.util.MainThreadExecutor
@ -101,10 +100,10 @@ const val PLAY_COMMAND = "play "
*/
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
@SuppressLint("UnsafeOptInUsageError")
class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) :
class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
private val mediaPlayerController by inject<MediaPlayerController>()
private val mediaPlayerManager by inject<MediaPlayerManager>()
private val activeServerProvider: ActiveServerProvider by inject()
private val serviceJob = SupervisorJob()
@ -241,7 +240,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
* is stored in the track.starred value
* See https://github.com/androidx/media/issues/33
*/
val track = mediaPlayerController.currentMediaItem?.toTrack()
val track = mediaPlayerManager.currentMediaItem?.toTrack()
if (track != null) {
customCommandFuture = onSetRating(
session,
@ -254,12 +253,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
override fun onSuccess(result: SessionResult) {
track.starred = !track.starred
// This needs to be called on the main Thread
// TODO: This is a looping reference
libraryService.onUpdateNotification(session)
}
override fun onFailure(t: Throwable) {
Toast.makeText(
mediaPlayerController.context,
mediaPlayerManager.context,
"There was an error updating the rating",
LENGTH_SHORT
).show()

View File

@ -20,7 +20,7 @@ import com.google.common.collect.ImmutableList
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.util.toTrack
@UnstableApi
@ -33,7 +33,7 @@ class CustomNotificationProvider(ctx: Context) :
* is stored in the track.starred value. See https://github.com/androidx/media/issues/33
* TODO: Once the bug is fixed remove this circular reference!
*/
private val mediaPlayerController by inject<MediaPlayerController>()
private val mediaPlayerManager by inject<MediaPlayerManager>()
override fun addNotificationActions(
mediaSession: MediaSession,
@ -48,7 +48,7 @@ class CustomNotificationProvider(ctx: Context) :
* is stored in the track.starred value
* See https://github.com/androidx/media/issues/33
*/
val rating = mediaPlayerController.currentMediaItem?.toTrack()?.starred?.let {
val rating = mediaPlayerManager.currentMediaItem?.toTrack()?.starred?.let {
HeartRating(
it
)

View File

@ -26,8 +26,7 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
import androidx.media3.exoplayer.source.ShuffleOrder.UnshuffledShuffleOrder
import androidx.media3.exoplayer.source.ShuffleOrder
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -37,6 +36,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.activity.NavigationActivity
import org.moire.ultrasonic.app.UApp
@ -46,6 +46,8 @@ import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.JukeboxMediaPlayer
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
@ -61,9 +63,10 @@ class PlaybackService :
MediaLibraryService(),
KoinComponent,
CoroutineScope by CoroutineScope(Dispatchers.IO) {
private lateinit var player: ExoPlayer
private lateinit var player: Player
private lateinit var mediaLibrarySession: MediaLibrarySession
private var equalizer: EqualizerController? = null
private val activeServerProvider: ActiveServerProvider by inject()
private lateinit var librarySessionCallback: MediaLibrarySession.Callback
@ -76,6 +79,7 @@ class PlaybackService :
super.onCreate()
initializeSessionAndPlayer()
setListener(MediaSessionServiceListener())
instance = this
}
private fun getWakeModeFlag(): Int {
@ -99,6 +103,7 @@ class PlaybackService :
}
private fun releasePlayerAndSession() {
Timber.i("Releasing player and session")
// Broadcast that the service is being shutdown
RxBus.stopServiceCommandPublisher.onNext(Unit)
@ -127,6 +132,91 @@ class PlaybackService :
setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext()))
player = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
Timber.i("Jukebox enabled by default")
getJukeboxPlayer()
} else {
getLocalPlayer()
}
// Create browser interface
librarySessionCallback = AutoMediaBrowserCallback(this)
// This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(getPendingIntentForContent())
.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
(player as? ExoPlayer)?.setWakeMode(getWakeModeFlag())
}
// Set a listener to reset the ShuffleOrder
rxBusSubscription += RxBus.shufflePlayObservable.subscribe { shuffle ->
// This only applies for local playback
val exo = if (player is ExoPlayer) {
player as ExoPlayer
} else {
return@subscribe
}
val len = player.currentTimeline.windowCount
Timber.i("Resetting shuffle order, isShuffled: %s", shuffle)
// If disabling Shuffle return early
if (!shuffle) {
return@subscribe exo.setShuffleOrder(
ShuffleOrder.UnshuffledShuffleOrder(len)
)
}
// Get the position of the current track in the unshuffled order
val cur = player.currentMediaItemIndex
val seed = System.currentTimeMillis()
val random = Random(seed)
val list = createShuffleListFromCurrentIndex(cur, len, random)
Timber.i("New Shuffle order: %s", list.joinToString { it.toString() })
exo.setShuffleOrder(ShuffleOrder.DefaultShuffleOrder(list, seed))
}
// Listen to the shutdown command
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
Timber.i("Received destroy command via Rx")
onDestroy()
}
player.addListener(listener)
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
}
private fun getJukeboxPlayer(): Player {
return JukeboxMediaPlayer()
}
private fun getLocalPlayer(): Player {
// Create a new plain OkHttpClient
val builder = OkHttpClient.Builder()
val client = builder.build()
@ -147,7 +237,7 @@ class PlaybackService :
renderer.setEnableAudioOffload(true)
// Create the player
player = ExoPlayer.Builder(this)
val player = ExoPlayer.Builder(this)
.setAudioAttributes(getAudioAttributes(), true)
.setWakeMode(getWakeModeFlag())
.setHandleAudioBecomingNoisy(true)
@ -157,59 +247,17 @@ class PlaybackService :
.setSeekForwardIncrementMs(Settings.seekInterval.toLong())
.build()
// Setup Equalizer
equalizer = EqualizerController.create(player.audioSessionId)
// Enable audio offload
if (Settings.useHwOffload)
player.experimentalSetOffloadSchedulingEnabled(true)
// Create browser interface
librarySessionCallback = AutoMediaBrowserCallback(player, this)
// This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(getPendingIntentForContent())
.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
player.setWakeMode(getWakeModeFlag())
}
// Set a listener to reset the ShuffleOrder
rxBusSubscription += RxBus.shufflePlayObservable.subscribe { shuffle ->
val len = player.currentTimeline.windowCount
Timber.i("Resetting shuffle order, isShuffled: %s", shuffle)
// If disabling Shuffle return early
if (!shuffle) {
return@subscribe player.setShuffleOrder(UnshuffledShuffleOrder(len))
}
// Get the position of the current track in the unshuffled order
val cur = player.currentMediaItemIndex
val seed = System.currentTimeMillis()
val random = Random(seed)
val list = createShuffleListFromCurrentIndex(cur, len, random)
Timber.i("New Shuffle order: %s", list.joinToString { it.toString() })
player.setShuffleOrder(DefaultShuffleOrder(list, seed))
}
// Listen to the shutdown command
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
Timber.i("Received destroy command via Rx")
onDestroy()
}
player.addListener(listener)
isStarted = true
return player
}
fun createShuffleListFromCurrentIndex(
private fun createShuffleListFromCurrentIndex(
currentIndex: Int,
length: Int,
random: Random
@ -244,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,
@ -333,6 +382,16 @@ class PlaybackService :
}
companion object {
var actualBackend: MediaPlayerManager.PlayerBackend? = null
private var desiredBackend: MediaPlayerManager.PlayerBackend? = null
fun setBackend(playerBackend: MediaPlayerManager.PlayerBackend) {
desiredBackend = playerBackend
instance?.updateBackend(playerBackend)
}
var instance: PlaybackService? = null
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic.error"
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic error messages"
private const val NOTIFICATION_ID = 3009

View File

@ -7,29 +7,12 @@
package org.moire.ultrasonic.service
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.view.Gravity
import android.view.KeyEvent
import android.view.KeyEvent.KEYCODE_MEDIA_NEXT
import android.view.KeyEvent.KEYCODE_MEDIA_PAUSE
import android.view.KeyEvent.KEYCODE_MEDIA_PLAY
import android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
import android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS
import android.view.KeyEvent.KEYCODE_MEDIA_STOP
import android.view.LayoutInflater
import android.view.View
import android.widget.ProgressBar
import android.widget.Toast
import androidx.core.app.NotificationManagerCompat
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
@ -39,34 +22,27 @@ 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 androidx.media3.session.MediaSession
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
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
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
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.playback.CustomNotificationProvider
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Util.getPendingIntentToShowPlayer
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util.sleepQuietly
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
import timber.log.Timber
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
/**
@ -86,135 +62,64 @@ 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()
private var currentIndex: Int = 0
private val notificationProvider = CustomNotificationProvider(applicationContext())
private lateinit var mediaSession: MediaSession
private lateinit var notificationManagerCompat: NotificationManagerCompat
@Suppress("MagicNumber")
override fun onCreate() {
super.onCreate()
if (running.get()) return
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 {
running.set(true)
listeners = ListenerSet(
applicationLooper,
Clock.DEFAULT
) { listener: Player.Listener, flags: FlagSet? ->
listener.onEvents(
this,
Player.Events(
flags!!
)
)
}
tasks.clear()
updatePlaylist()
stop()
startFuture?.set(this)
startProcessTasks()
notificationManagerCompat = NotificationManagerCompat.from(this)
mediaSession = MediaSession.Builder(applicationContext(), this)
.setId("jukebox")
.setSessionActivity(getPendingIntentToShowPlayer(this))
.build()
val notification = notificationProvider.createNotification(
mediaSession,
ImmutableList.of(),
JukeboxNotificationActionFactory()
) {}
if (Build.VERSION.SDK_INT >= 29) {
startForeground(
notification.notificationId,
notification.notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
startForeground(
notification.notificationId, notification.notification
)
}
Timber.d("Started Jukebox Service")
}
@Suppress("MagicNumber")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
if (Intent.ACTION_MEDIA_BUTTON != intent?.action) return START_STICKY
val extras = intent.extras
if ((extras != null) && extras.containsKey(Intent.EXTRA_KEY_EVENT)) {
val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
extras.getParcelable(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
} else {
@Suppress("DEPRECATION")
extras.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)
}
when (event?.keyCode) {
KEYCODE_MEDIA_PLAY -> play()
KEYCODE_MEDIA_PAUSE -> stop()
KEYCODE_MEDIA_STOP -> stop()
KEYCODE_MEDIA_PLAY_PAUSE -> if (isPlaying) stop() else play()
KEYCODE_MEDIA_PREVIOUS -> seekToPrevious()
KEYCODE_MEDIA_NEXT -> seekToNext()
}
}
return START_STICKY
}
override fun onDestroy() {
override fun release() {
tasks.clear()
stop()
if (!running.get()) return
running.set(false)
serviceThread!!.join()
serviceThread?.join()
stopForegroundRemoveNotification()
mediaSession.release()
super.onDestroy()
Timber.d("Stopped Jukebox Service")
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
fun requestStop() {
stopSelf()
}
private fun updateNotification() {
val notification = notificationProvider.createNotification(
mediaSession,
ImmutableList.of(),
JukeboxNotificationActionFactory()
) {}
notificationManagerCompat.notify(notification.notificationId, notification.notification)
}
companion object {
val running = AtomicBoolean()
private var startFuture: SettableFuture<JukeboxMediaPlayer>? = null
@JvmStatic
fun requestStart(): ListenableFuture<JukeboxMediaPlayer>? {
if (running.get()) return null
startFuture = SettableFuture.create()
val context = applicationContext()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(
Intent(context, JukeboxMediaPlayer::class.java)
)
} else {
context.startService(Intent(context, JukeboxMediaPlayer::class.java))
}
Timber.i("JukeboxMediaPlayer starting...")
return startFuture
}
}
override fun addListener(listener: Player.Listener) {
listeners.add(listener)
}
@ -263,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
@ -292,8 +203,11 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
override fun getAvailableCommands(): Player.Commands {
val commandsBuilder = Player.Commands.Builder().addAll(
Player.COMMAND_SET_VOLUME,
Player.COMMAND_GET_VOLUME
Player.COMMAND_CHANGE_MEDIA_ITEMS,
Player.COMMAND_GET_TIMELINE,
Player.COMMAND_GET_DEVICE_VOLUME,
Player.COMMAND_ADJUST_DEVICE_VOLUME,
Player.COMMAND_SET_DEVICE_VOLUME
)
if (isPlaying) commandsBuilder.add(Player.COMMAND_STOP)
if (playlist.isNotEmpty()) {
@ -306,8 +220,7 @@ 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
)
@ -323,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
}
@ -358,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>) {
@ -444,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
}
@ -499,51 +446,63 @@ 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()) 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")
}
// Jukebox status contains data received from the server, we need to validate it!
private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) {
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
if (jukeboxStatus.currentPlayingIndex != null && jukeboxStatus.currentPlayingIndex!! < 0) {
jukeboxStatus.currentPlayingIndex = 0
jukeboxStatus.isPlaying = false
}
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
@ -552,44 +511,39 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
}
updateNotification()
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 {
@ -608,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
)
@ -719,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
@ -748,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 {
@ -779,6 +716,8 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
return AudioAttributes.DEFAULT
}
override fun setVolume(volume: Float) {}
override fun getVideoSize(): VideoSize {
return VideoSize(0, 0)
}
@ -824,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
@ -26,7 +25,7 @@ import androidx.media3.common.Tracks
*/
@Suppress("TooManyFunctions")
@SuppressLint("UnsafeOptInUsageError")
abstract class JukeboxUnimplementedFunctions : Service(), Player {
abstract class JukeboxUnimplementedFunctions : Player {
override fun setMediaItems(mediaItems: MutableList<MediaItem>) {
TODO("Not yet implemented")
@ -140,10 +139,6 @@ abstract class JukeboxUnimplementedFunctions : Service(), 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 : Service(), 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

@ -32,7 +32,7 @@ import timber.log.Timber
class MediaPlayerLifecycleSupport : KoinComponent {
private lateinit var ratingManager: RatingManager
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
private val mediaPlayerController by inject<MediaPlayerController>()
private val mediaPlayerManager by inject<MediaPlayerManager>()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private var created = false
@ -64,7 +64,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
return
}
mediaPlayerController.onCreate {
mediaPlayerManager.onCreate {
restoreLastSession(autoPlay, afterRestore)
}
@ -81,7 +81,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
Timber.i("Restoring %s songs", it!!.songs.size)
mediaPlayerController.restore(
mediaPlayerManager.restore(
it,
autoPlay,
false
@ -110,7 +110,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
if (intent == null) return
val intentAction = intent.action
if (intentAction == null || intentAction.isEmpty()) return
if (intentAction.isNullOrEmpty()) return
Timber.i("Received intent: %s", intentAction)
@ -146,15 +146,15 @@ class MediaPlayerLifecycleSupport : KoinComponent {
val state = extras.getInt("state")
if (state == 0) {
if (!mediaPlayerController.isJukeboxEnabled) {
mediaPlayerController.pause()
if (!mediaPlayerManager.isJukeboxEnabled) {
mediaPlayerManager.pause()
}
} else if (state == 1) {
if (!mediaPlayerController.isJukeboxEnabled &&
Settings.resumePlayOnHeadphonePlug && !mediaPlayerController.isPlaying
if (!mediaPlayerManager.isJukeboxEnabled &&
Settings.resumePlayOnHeadphonePlug && !mediaPlayerManager.isPlaying
) {
mediaPlayerController.prepare()
mediaPlayerController.play()
mediaPlayerManager.prepare()
mediaPlayerManager.play()
}
}
}
@ -183,18 +183,18 @@ class MediaPlayerLifecycleSupport : KoinComponent {
onCreate(autoStart) {
when (keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerController.togglePlayPause()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.seekToPrevious()
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.seekToNext()
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
KeyEvent.KEYCODE_1 -> mediaPlayerController.legacySetRating(1)
KeyEvent.KEYCODE_2 -> mediaPlayerController.legacySetRating(2)
KeyEvent.KEYCODE_3 -> mediaPlayerController.legacySetRating(3)
KeyEvent.KEYCODE_4 -> mediaPlayerController.legacySetRating(4)
KeyEvent.KEYCODE_5 -> mediaPlayerController.legacySetRating(5)
KeyEvent.KEYCODE_STAR -> mediaPlayerController.legacyToggleStar()
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerManager.togglePlayPause()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerManager.seekToPrevious()
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerManager.seekToNext()
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerManager.stop()
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerManager.play()
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerManager.pause()
KeyEvent.KEYCODE_1 -> mediaPlayerManager.legacySetRating(1)
KeyEvent.KEYCODE_2 -> mediaPlayerManager.legacySetRating(2)
KeyEvent.KEYCODE_3 -> mediaPlayerManager.legacySetRating(3)
KeyEvent.KEYCODE_4 -> mediaPlayerManager.legacySetRating(4)
KeyEvent.KEYCODE_5 -> mediaPlayerManager.legacySetRating(5)
KeyEvent.KEYCODE_STAR -> mediaPlayerManager.legacyToggleStar()
else -> {
}
}
@ -222,17 +222,17 @@ class MediaPlayerLifecycleSupport : KoinComponent {
// We can receive intents when everything is stopped, so we need to start
onCreate(autoStart) {
when (action) {
Constants.CMD_PLAY -> mediaPlayerController.play()
Constants.CMD_PLAY -> mediaPlayerManager.play()
Constants.CMD_RESUME_OR_PLAY ->
// If Ultrasonic wasn't running, the autoStart is enough to resume,
// no need to call anything
if (isRunning) mediaPlayerController.resumeOrPlay()
if (isRunning) mediaPlayerManager.resumeOrPlay()
Constants.CMD_NEXT -> mediaPlayerController.seekToNext()
Constants.CMD_PREVIOUS -> mediaPlayerController.seekToPrevious()
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
Constants.CMD_STOP -> mediaPlayerController.stop()
Constants.CMD_PAUSE -> mediaPlayerController.pause()
Constants.CMD_NEXT -> mediaPlayerManager.seekToNext()
Constants.CMD_PREVIOUS -> mediaPlayerManager.seekToPrevious()
Constants.CMD_TOGGLEPAUSE -> mediaPlayerManager.togglePlayPause()
Constants.CMD_STOP -> mediaPlayerManager.stop()
Constants.CMD_PAUSE -> mediaPlayerManager.pause()
}
}
}

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
@ -50,12 +48,13 @@ private const val CONTROLLER_SWITCH_DELAY = 500L
private const val VOLUME_DELTA = 0.05f
/**
* The implementation of the Media Player Controller.
* The Media Player Manager can forward commands to the Media3 controller as
* well as switch between different player interfaces (local, remote, cast etc).
* This class contains everything that is necessary for the Application UI
* to control the Media Player implementation.
*/
@Suppress("TooManyFunctions")
class MediaPlayerController(
class MediaPlayerManager(
private val playbackStateSerializer: PlaybackStateSerializer,
private val externalStorageMonitor: ExternalStorageMonitor,
val context: Context
@ -97,15 +96,15 @@ class MediaPlayerController(
* We run the event through RxBus in order to throttle them
*/
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled)
val start = timeline.getFirstWindowIndex(isShufflePlayEnabled)
Timber.w("On timeline changed. First shuffle play at index: %s", start)
deferredPlay?.let {
Timber.w("Executing deferred shuffle play")
it()
deferredPlay = null
}
RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack))
val playlist = Util.getPlayListFromTimeline(timeline, false).map(MediaItem::toTrack)
RxBus.playlistPublisher.onNext(playlist)
}
override fun onPlaybackStateChanged(playbackState: Int) {
@ -179,11 +178,8 @@ class MediaPlayerController(
fun onCreate(onCreated: () -> Unit) {
if (created) return
externalStorageMonitor.onCreate { reset() }
if (activeServerProvider.getActiveServer().jukeboxByDefault) {
switchToJukebox(onCreated)
} else {
switchToLocalPlayer(onCreated)
}
createMediaController(onCreated)
rxBusSubscription += RxBus.activeServerChangingObservable.subscribe { oldServer ->
if (oldServer != OFFLINE_DB_ID) {
@ -195,8 +191,7 @@ class MediaPlayerController(
if (controller is JukeboxMediaPlayer) {
// When the server changes, the Jukebox should be released.
// The new server won't understand the jukebox requests of the old one.
releaseJukebox(controller)
controller = null
switchToLocalPlayer()
}
}
@ -246,6 +241,22 @@ class MediaPlayerController(
Timber.i("MediaPlayerController started")
}
private fun createMediaController(onCreated: () -> Unit) {
mediaControllerFuture = MediaController.Builder(
context,
sessionToken
).buildAsync()
mediaControllerFuture?.addListener({
controller = mediaControllerFuture?.get()
Timber.i("MediaController Instance received")
controller?.addListener(listeners)
onCreated()
Timber.i("MediaPlayerController creation complete")
}, MoreExecutors.directExecutor())
}
private fun playerStateChangedHandler() {
val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
@ -262,6 +273,10 @@ class MediaPlayerController(
}
}
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.
@ -336,7 +351,6 @@ class MediaPlayerController(
@Synchronized
fun play(index: Int) {
controller?.seekTo(index, 0L)
// FIXME CHECK ITS NOT MAKING PROBLEMS
controller?.prepare()
controller?.play()
}
@ -538,7 +552,7 @@ class MediaPlayerController(
@Synchronized
fun canSeekToPrevious(): Boolean {
return controller?.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS) == true
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS) == true
}
@Synchronized
@ -548,7 +562,7 @@ class MediaPlayerController(
@Synchronized
fun canSeekToNext(): Boolean {
return controller?.isCommandAvailable(COMMAND_SEEK_TO_NEXT) == true
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT) == true
}
@Synchronized
@ -580,102 +594,49 @@ class MediaPlayerController(
@set:Synchronized
var isJukeboxEnabled: Boolean
get() = controller is JukeboxMediaPlayer
set(jukeboxEnabled) {
if (jukeboxEnabled) {
switchToJukebox {}
get() = PlaybackService.actualBackend == PlayerBackend.JUKEBOX
set(shouldEnable) {
if (shouldEnable) {
switchToJukebox()
} else {
switchToLocalPlayer {}
switchToLocalPlayer()
}
}
private fun switchToJukebox(onCreated: () -> Unit) {
if (controller is JukeboxMediaPlayer) return
val currentPlaylist = playlist
val currentIndex = controller?.currentMediaItemIndex ?: 0
val currentPosition = controller?.currentPosition ?: 0
private fun switchToJukebox() {
if (isJukeboxEnabled) return
scheduleSwitchTo(PlayerBackend.JUKEBOX)
DownloadService.requestStop()
controller?.pause()
controller?.stop()
val oldController = controller
controller = null // While we switch, the controller shouldn't be available
// Stop() won't work if we don't give it time to be processed
Handler(Looper.getMainLooper()).postDelayed({
if (oldController != null) releaseLocalPlayer(oldController)
setupJukebox {
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
onCreated()
}
}, CONTROLLER_SWITCH_DELAY)
}
private fun switchToLocalPlayer(onCreated: () -> Unit) {
if (controller is MediaController) return
val currentPlaylist = playlist
private fun switchToLocalPlayer() {
if (!isJukeboxEnabled) return
scheduleSwitchTo(PlayerBackend.LOCAL)
controller?.stop()
}
private fun scheduleSwitchTo(newBackend: PlayerBackend) {
val currentPlaylist = playlist.toList()
val currentIndex = controller?.currentMediaItemIndex ?: 0
val currentPosition = controller?.currentPosition ?: 0
controller?.stop()
val oldController = controller
controller = null // While we switch, the controller shouldn't be available
Handler(Looper.getMainLooper()).postDelayed({
if (oldController != null) releaseJukebox(oldController)
setupLocalPlayer {
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
onCreated()
}
// Change the backend
PlaybackService.setBackend(newBackend)
// Restore the media items
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
}, CONTROLLER_SWITCH_DELAY)
}
private fun releaseController() {
when (controller) {
null -> return
is JukeboxMediaPlayer -> releaseJukebox(controller)
is MediaController -> releaseLocalPlayer(controller)
}
}
private fun setupLocalPlayer(onCreated: () -> Unit) {
mediaControllerFuture = MediaController.Builder(
context,
sessionToken
).buildAsync()
mediaControllerFuture?.addListener({
controller = mediaControllerFuture?.get()
Timber.i("MediaController Instance received")
controller?.addListener(listeners)
onCreated()
Timber.i("MediaPlayerController creation complete")
}, MoreExecutors.directExecutor())
}
private fun releaseLocalPlayer(player: Player?) {
player?.removeListener(listeners)
player?.release()
controller?.removeListener(listeners)
controller?.release()
if (mediaControllerFuture != null) MediaController.releaseFuture(mediaControllerFuture!!)
Timber.i("MediaPlayerController released")
}
private fun setupJukebox(onCreated: () -> Unit) {
val jukeboxFuture = JukeboxMediaPlayer.requestStart()
jukeboxFuture?.addListener({
controller = jukeboxFuture.get()
onCreated()
controller?.addListener(listeners)
Timber.i("JukeboxService creation complete")
}, MoreExecutors.directExecutor())
}
private fun releaseJukebox(player: Player?) {
val jukebox = player as JukeboxMediaPlayer?
jukebox?.removeListener(listeners)
jukebox?.requestStop()
Timber.i("JukeboxService released")
}
/**
* This function calls the music service directly and
* therefore can't be called from the main thread
@ -700,10 +661,6 @@ class MediaPlayerController(
controller?.volume = gain
}
fun setVolume(volume: Float) {
controller?.volume = volume
}
/*
* Sets the rating of the current track
*/
@ -841,4 +798,6 @@ class MediaPlayerController(
enum class InsertionMode {
CLEAR, APPEND, AFTER_CURRENT
}
enum class PlayerBackend { JUKEBOX, LOCAL }
}

View File

@ -18,7 +18,7 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.executeTaskWithToast
@ -28,7 +28,7 @@ import org.moire.ultrasonic.util.executeTaskWithToast
*/
@Suppress("LongParameterList")
class DownloadHandler(
val mediaPlayerController: MediaPlayerController,
val mediaPlayerManager: MediaPlayerManager,
private val networkAndStorageChecker: NetworkAndStorageChecker
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private val maxSongs = 500
@ -150,16 +150,16 @@ class DownloadHandler(
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
val insertionMode = when {
append -> MediaPlayerController.InsertionMode.APPEND
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
else -> MediaPlayerController.InsertionMode.CLEAR
append -> MediaPlayerManager.InsertionMode.APPEND
playNext -> MediaPlayerManager.InsertionMode.AFTER_CURRENT
else -> MediaPlayerManager.InsertionMode.CLEAR
}
if (playlistName != null) {
mediaPlayerController.suggestedPlaylistName = playlistName
mediaPlayerManager.suggestedPlaylistName = playlistName
}
mediaPlayerController.addToPlaylist(
mediaPlayerManager.addToPlaylist(
songs,
autoPlay,
shuffle,

View File

@ -19,7 +19,7 @@ import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
@ -235,8 +235,8 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo
private fun findFilesToNotDelete(): Set<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
val mediaController = inject<MediaPlayerController>(
MediaPlayerController::class.java
val mediaController = inject<MediaPlayerManager>(
MediaPlayerManager::class.java
)
val playlist = mainScope.future { mediaController.value.playlist }.get()

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>