Add nice looking empty list view

Also fix shouldRetry() in the Downloader
This commit is contained in:
tzugen 2021-11-26 19:01:14 +01:00
parent 4e37a2483c
commit b33fe2d451
No known key found for this signature in database
GPG Key ID: 61E9C34BC10EC930
18 changed files with 281 additions and 150 deletions

View File

@ -59,15 +59,10 @@ class ArtistListFragment : EntryListFragment<ArtistOrIndex>() {
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, item.name)
bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, item.id)
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, (item is Artist))
bundle.putString(
Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE,
Constants.ALPHABETICAL_BY_NAME
)
bundle.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, Constants.ALBUMS_OF_ARTIST)
bundle.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE, item.name)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 1000)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0)
findNavController().navigate(itemClickTarget, bundle)
}
// Constants.ALPHABETICAL_BY_NAME
}

View File

@ -11,6 +11,7 @@ import android.app.Application
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import org.koin.core.component.inject
@ -76,6 +77,9 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
val liveDataList = listModel.getList()
emptyTextView.setText(R.string.download_empty)
emptyView.isVisible = liveDataList.value?.isEmpty() ?: true
viewAdapter.submitList(liveDataList.value)
}
}

View File

@ -7,8 +7,11 @@ import androidx.navigation.fragment.findNavController
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.GenericEntry
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.Constants
import androidx.fragment.app.Fragment
import org.moire.ultrasonic.util.Settings
/**
@ -27,91 +30,11 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
!listModel.isOffline() && !Settings.shouldUseId3Tags
}
@Suppress("LongMethod")
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
val isArtist = (item is Artist)
when (menuItem.itemId) {
R.id.menu_play_now ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_next ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = true,
background = false,
playNext = true,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_last ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_pin ->
downloadHandler.downloadRecursively(
this,
item.id,
save = true,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_unpin ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = true,
isArtist = isArtist
)
R.id.menu_download ->
downloadHandler.downloadRecursively(
this,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false,
isArtist = isArtist
)
}
return true
return handleContextMenu(menuItem, item, isArtist, downloadHandler, this)
}
override fun onItemClick(item: T) {
@ -137,4 +60,97 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
listModel.refresh(refreshListView!!, arguments)
}
}
companion object {
@Suppress("LongMethod")
internal fun handleContextMenu(
menuItem: MenuItem,
item: Identifiable,
isArtist: Boolean,
downloadHandler: DownloadHandler,
fragment: Fragment
): Boolean {
when (menuItem.itemId) {
R.id.menu_play_now ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_next ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = true,
background = false,
playNext = true,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_last ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_pin ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = true,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_unpin ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = true,
isArtist = isArtist
)
R.id.menu_download ->
downloadHandler.downloadRecursively(
fragment,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false,
isArtist = isArtist
)
}
return true
}
}
}

View File

@ -13,6 +13,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@ -46,6 +47,7 @@ abstract class MultiListFragment<T : Identifiable> : Fragment() {
protected var refreshListView: SwipeRefreshLayout? = null
internal var listView: RecyclerView? = null
internal lateinit var viewManager: LinearLayoutManager
internal lateinit var emptyView: ConstraintLayout
internal lateinit var emptyTextView: TextView
/**
@ -71,7 +73,7 @@ abstract class MultiListFragment<T : Identifiable> : Fragment() {
* The central function to pass a query to the model and return a LiveData object
*/
open fun getLiveData(args: Bundle? = null): LiveData<List<T>> {
return MutableLiveData(listOf())
return MutableLiveData()
}
/**
@ -90,7 +92,9 @@ abstract class MultiListFragment<T : Identifiable> : Fragment() {
*/
open val refreshListId = R.id.swipe_refresh_view
open val recyclerViewId = R.id.recycler_view
open val emptyTextViewId = R.id.empty_list_text
open val emptyViewId = R.id.empty_list_view
open val emptyTextId = R.id.empty_list_text
open fun setTitle(title: String?) {
if (title == null) {
@ -121,14 +125,14 @@ abstract class MultiListFragment<T : Identifiable> : Fragment() {
liveDataItems = getLiveData(arguments)
// Link view to display text if the list is empty
// FIXME: Hook this up globally.
emptyTextView = view.findViewById(emptyTextViewId)
emptyView = view.findViewById(emptyViewId)
emptyTextView = view.findViewById(emptyTextId)
// Register an observer to update our UI when the data changes
liveDataItems.observe(
viewLifecycleOwner,
{ newItems ->
emptyTextView.isVisible = newItems.isEmpty()
emptyView.isVisible = newItems.isEmpty()
viewAdapter.submitList(newItems)
}
)

View File

@ -28,6 +28,7 @@ import org.moire.ultrasonic.adapters.AlbumRowBinder
import org.moire.ultrasonic.adapters.ArtistRowBinder
import org.moire.ultrasonic.adapters.DividerBinder
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.SearchResult
@ -176,11 +177,11 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
return search(query, autoPlay)
}
}
// Fragment was started from the Menu, create empty list
// populateList(SearchResult())
}
/**
* This method create the search bar above the recycler view
*/
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
val activity = activity ?: return
val searchManager = activity.getSystemService(Context.SEARCH_SERVICE) as SearchManager
@ -191,8 +192,8 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
searchView.setSearchableInfo(searchableInfo)
val arguments = arguments
val autoPlay =
arguments != null && arguments.getBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false)
val autoPlay = arguments != null &&
arguments.getBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false)
val query = arguments?.getString(Constants.INTENT_EXTRA_NAME_QUERY)
// If started with a query, enter it to the searchView
@ -211,13 +212,13 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
val cursor = searchView.suggestionsAdapter.cursor
cursor.moveToPosition(position)
// TODO: Try to do something with this magic const:
// 2 is the index of col containing suggestion name.
// 2 is the index of col containing suggestion name.
val suggestion = cursor.getString(2)
searchView.setQuery(suggestion, true)
return true
}
})
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
Timber.d("onQueryTextSubmit: %s", query)
@ -230,6 +231,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
return true
}
})
searchView.setIconifiedByDefault(false)
searchItem.expandActionView()
}
@ -479,7 +481,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
}
// Show/hide the empty text view
emptyTextView.isVisible = list.isEmpty()
emptyView.isVisible = list.isEmpty()
viewAdapter.submitList(list)
}
@ -557,20 +559,15 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
var DEFAULT_SONGS = Settings.defaultSongs
}
// FIXME!!
override fun getLiveData(args: Bundle?): LiveData<List<Identifiable>> {
return MutableLiveData(listOf())
}
// FIXME
override val itemClickTarget: Int = 0
// FIXME
override fun onContextMenuItemSelected(menuItem: MenuItem, item: Identifiable): Boolean {
return true
}
// FIXME
override fun onItemClick(item: Identifiable) {
}
override fun onContextMenuItemSelected(menuItem: MenuItem, item: Identifiable): Boolean {
val isArtist = (item is Artist)
return EntryListFragment.handleContextMenu(menuItem, item, isArtist, downloadHandler, this)
}
}

View File

@ -57,7 +57,6 @@ import timber.log.Timber
open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Entry>() {
private var albumButtons: View? = null
private var emptyView: TextView? = null
internal var selectButton: ImageView? = null
internal var playNowButton: ImageView? = null
internal var playNextButton: ImageView? = null
@ -107,8 +106,6 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Entry>() {
setupButtons(view)
emptyView = view.findViewById(R.id.empty_list_text)
registerForContextMenu(listView!!)
setHasOptionsMenu(true)
@ -579,7 +576,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Entry>() {
}
// Show a text if we have no entries
emptyView?.isVisible = entryList.isEmpty()
emptyView.isVisible = entryList.isEmpty()
enableButtons()
@ -599,10 +596,8 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Entry>() {
val albumHeader = AlbumHeader(it, name ?: intentAlbumName)
val mixedList: MutableList<Identifiable> = mutableListOf(albumHeader)
mixedList.addAll(entryList)
Timber.e("SUBMITTING MIXED LIST")
viewAdapter.submitList(mixedList)
} else {
Timber.e("SUBMITTING ENTRY LIST")
viewAdapter.submitList(entryList)
}

View File

@ -13,7 +13,7 @@ import org.moire.ultrasonic.util.Settings
class AlbumListModel(application: Application) : GenericListModel(application) {
val list: MutableLiveData<List<MusicDirectory.Album>> = MutableLiveData(listOf())
val list: MutableLiveData<List<MusicDirectory.Album>> = MutableLiveData()
var lastType: String? = null
private var loadedUntil: Int = 0
@ -26,7 +26,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
// This way, we keep the scroll position
val albumListType = args.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE)!!
if (refresh || list.value!!.isEmpty() || albumListType != lastType) {
if (refresh || list.value?.isEmpty() != false || albumListType != lastType) {
lastType = albumListType
backgroundLoadFromServer(refresh, swipe, args)
}

View File

@ -31,7 +31,7 @@ import org.moire.ultrasonic.service.MusicService
* Provides ViewModel which contains the list of available Artists
*/
class ArtistListModel(application: Application) : GenericListModel(application) {
private val artists: MutableLiveData<List<ArtistOrIndex>> = MutableLiveData(listOf())
private val artists: MutableLiveData<List<ArtistOrIndex>> = MutableLiveData()
/**
* Retrieves all available Artists in a LiveData
@ -39,7 +39,7 @@ class ArtistListModel(application: Application) : GenericListModel(application)
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<ArtistOrIndex>> {
// Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position
if (artists.value!!.isEmpty() || refresh) {
if (artists.value?.isEmpty() != false || refresh) {
backgroundLoadFromServer(refresh, swipe)
}
return artists

View File

@ -14,7 +14,7 @@ import org.moire.ultrasonic.util.Settings
class SearchListModel(application: Application) : GenericListModel(application) {
var searchResult: MutableLiveData<SearchResult?> = MutableLiveData(null)
var searchResult: MutableLiveData<SearchResult?> = MutableLiveData()
override fun load(
isOffline: Boolean,

View File

@ -19,6 +19,9 @@ import org.moire.ultrasonic.util.Util
/*
* Model for retrieving different collections of tracks from the API
*
* TODO: Remove double data keeping in currentList/currentDirectory and use the base model liveData
* For this refactor MusicService to replace MusicDirectories with List<Album> or List<Track>
*/
class TrackCollectionModel(application: Application) : GenericListModel(application) {

View File

@ -157,7 +157,8 @@ class Downloader(
// Add file to queue if not in one of the queues already.
if (!download.isWorkDone &&
!activelyDownloading.contains(download) &&
!downloadQueue.contains(download)
!downloadQueue.contains(download) &&
download.shouldRetry()
) {
listChanged = true
downloadQueue.add(download)
@ -281,14 +282,18 @@ class Downloader(
fun clearPlaylist() {
playlist.clear()
val toRemove = mutableListOf<DownloadFile>()
// Cancel all active downloads with a high priority
for (download in activelyDownloading) {
if (download.priority < 100) {
download.cancelDownload()
activelyDownloading.remove(download)
toRemove.add(download)
}
}
activelyDownloading.removeAll(toRemove)
playlistUpdateRevision++
updateLiveData()
}

View File

@ -0,0 +1,91 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="100"
android:viewportHeight="100"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#000000"
android:pathData="M90.365,48.085l-3.83,0l-3.859,0l-3.83,0l-3.86,0l-3.83,0l-3.86,0l0,3.86l3.86,0l3.83,0l3.86,0l3.83,0l3.859,0l3.83,0l3.861,0l0,-3.86z" />
<path
android:fillColor="#000000"
android:pathData="M82.676,82.705h3.859v3.83h-3.859z" />
<path
android:fillColor="#000000"
android:pathData="M82.676,13.465h3.859v3.86h-3.859z" />
<path
android:fillColor="#000000"
android:pathData="M78.846,78.846h3.83v3.859h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M78.846,17.325h3.83v3.83h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M74.986,75.016h3.859v3.83h-3.859z" />
<path
android:fillColor="#000000"
android:pathData="M74.986,21.155h3.859v3.86h-3.859z" />
<path
android:fillColor="#000000"
android:pathData="M71.156,71.154h3.83v3.861h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M71.156,25.015h3.83v3.83h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M67.296,67.324h3.86v3.83h-3.86z" />
<path
android:fillColor="#000000"
android:pathData="M67.296,28.845h3.86v3.86h-3.86z" />
<path
android:fillColor="#000000"
android:pathData="M63.466,63.465h3.83v3.859h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M63.466,32.705h3.83v3.83h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M48.056,71.154l0,3.862l0,3.83l0,3.859l0,3.83l0,3.86l0,3.83l3.86,0l0,-3.83l0,-3.86l0,-3.83l0,-3.859l0,-3.83l0,-3.862l0,-3.83l-3.86,0z" />
<path
android:fillColor="#000000"
android:pathData="M48.056,9.635l0,3.83l0,3.86l0,3.83l0,3.86l0,3.83l0,3.86l3.86,0l0,-3.86l0,-3.83l0,-3.86l0,-3.83l0,-3.86l0,-3.83l0,-3.86l-3.86,0z" />
<path
android:fillColor="#000000"
android:pathData="M32.676,63.465h3.859v3.859h-3.859z" />
<path
android:fillColor="#000000"
android:pathData="M32.676,32.705h3.859v3.83h-3.859z" />
<path
android:fillColor="#000000"
android:pathData="M28.846,67.324h3.83v3.83h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M28.846,28.845h3.83v3.86h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M24.986,71.154h3.859v3.861h-3.859z" />
<path
android:fillColor="#000000"
android:pathData="M24.986,25.015h3.859v3.83h-3.859z" />
<path
android:fillColor="#000000"
android:pathData="M21.156,75.016h3.83v3.83h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M21.156,21.155h3.83v3.86h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M17.296,78.846h3.86v3.859h-3.86z" />
<path
android:fillColor="#000000"
android:pathData="M17.296,17.325h3.86v3.83h-3.86z" />
<path
android:fillColor="#000000"
android:pathData="M13.466,82.705h3.83v3.83h-3.83z" />
<path
android:fillColor="#000000"
android:pathData="M17.296,51.945l3.86,0l3.83,0l3.86,0l3.83,0l0,-3.86l-3.83,0l-3.86,0l-3.83,0l-3.86,0l-3.83,0l-3.861,0l-3.83,0l0,3.86l3.83,0l3.861,0z" />
<path
android:fillColor="#000000"
android:pathData="M13.466,13.465h3.83v3.86h-3.83z" />
</vector>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:a="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
a:layout_width="match_parent"
a:layout_height="match_parent"
a:visibility="gone"
a:id="@+id/empty_list_view"
>
<ImageView
a:layout_width="100dip"
a:layout_height="100dip"
a:layout_marginStart="50dp"
a:importantForAccessibility="no"
a:layout_marginEnd="50dp"
a:layout_marginBottom="8dp"
a:src="@drawable/ic_empty"
app:layout_constraintBottom_toTopOf="@+id/empty_list_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
a:id="@+id/empty_list_text"
a:layout_width="match_parent"
a:layout_height="wrap_content"
a:layout_marginTop="80dp"
a:layout_marginBottom="20dp"
a:drawablePadding="0dp"
a:gravity="center"
a:padding="12dp"
a:text="@string/search.no_match"
a:textAppearance="?android:attr/textAppearanceMedium"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -4,6 +4,7 @@
a:layout_height="fill_parent"
a:orientation="vertical">
<include layout="@layout/empty_view" />
<include layout="@layout/recycler_view" />
</LinearLayout>

View File

@ -1,12 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:a="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
a:id="@+id/empty_list_text"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:padding="10dip"
a:text="@string/select_album.empty"
a:visibility="gone" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
a:id="@+id/swipe_refresh_view"

View File

@ -1,29 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
a:layout_width="fill_parent"
a:layout_height="fill_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
a:layout_width="match_parent"
a:layout_height="match_parent"
a:orientation="vertical">
<TextView
a:id="@+id/empty_list_text"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:drawablePadding="0dp"
a:gravity="center"
a:padding="12dp"
a:text="@string/search.no_match"
a:textAppearance="?android:attr/textAppearanceMedium"
a:visibility="gone" />
<include layout="@layout/empty_view" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
a:id="@+id/swipe_refresh_view"
a:layout_width="fill_parent"
a:layout_width="match_parent"
a:layout_height="0dip"
a:layout_weight="1.0">
<androidx.recyclerview.widget.RecyclerView
a:id="@+id/recycler_view"
a:layout_width="fill_parent"
a:layout_width="match_parent"
a:layout_height="0dip"
a:layout_weight="1.0" />

View File

@ -4,11 +4,7 @@
a:layout_height="fill_parent"
a:orientation="vertical" >
<View
a:layout_width="fill_parent"
a:layout_height="1dp"
a:background="@color/dividerColor" />
<include layout="@layout/empty_view" />
<include layout="@layout/recycler_view" />
<include layout="@layout/album_buttons" />

View File

@ -57,6 +57,7 @@
<string name="delete_playlist">Do you want to delete %1$s</string>
<string name="download.bookmark_removed" formatted="false">Bookmark removed.</string>
<string name="download.bookmark_set_at_position" formatted="false">Bookmark set at %s.</string>
<string name="download.empty">Nothing is downloading</string>
<string name="playlist.empty">Playlist is empty</string>
<string name="download.jukebox_not_authorized">Remote control is not allowed. Please enable jukebox mode in <b>Users &gt; Settings</b> on your Subsonic server.</string>
<string name="download.jukebox_off">Turned off remote control. Music is played on phone.</string>