diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt index 6c0537d3..1c8e6440 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt @@ -11,9 +11,9 @@ data class Artist( override var coverArt: String? = null, override var albumCount: Long? = null, override var closeness: Int = 0 -) : ArtistOrIndex(id), Comparable { +) : ArtistOrIndex(id) { - override fun compareTo(other: Artist): Int { + fun compareTo(other: Artist): Int { when { this.closeness == other.closeness -> { return 0 @@ -26,4 +26,6 @@ data class Artist( } } } + + override fun compareTo(other: Identifiable) = compareTo(other as Artist) } diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/ArtistOrIndex.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/ArtistOrIndex.kt index 63decd3a..586f1dae 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/ArtistOrIndex.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/ArtistOrIndex.kt @@ -2,7 +2,7 @@ package org.moire.ultrasonic.domain import androidx.room.Ignore -open class ArtistOrIndex( +abstract class ArtistOrIndex( @Ignore override var id: String, @Ignore diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/GenericEntry.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/GenericEntry.kt deleted file mode 100644 index e731b769..00000000 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/GenericEntry.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.moire.ultrasonic.domain - -import androidx.room.Ignore - -open class GenericEntry { - // TODO Should be non-null! - @Ignore - open val id: String? = null - @Ignore - open val name: String? = null - - // These are just a formality and will never be called, - // because Kotlin data classes will have autogenerated equals() and hashCode() functions - override operator fun equals(other: Any?): Boolean { - return this === other - } - - override fun hashCode(): Int { - var result = id?.hashCode() ?: 0 - result = 31 * result + (name?.hashCode() ?: 0) - return result - } -} diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Genre.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Genre.kt index 49045571..d467eaaa 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Genre.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Genre.kt @@ -7,8 +7,8 @@ import java.io.Serializable @Entity data class Genre( @PrimaryKey val index: String, - override val name: String -) : Serializable, GenericEntry() { + val name: String +) : Serializable { companion object { private const val serialVersionUID = -3943025175219134028L } diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Identifiable.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Identifiable.kt new file mode 100644 index 00000000..b361d0b7 --- /dev/null +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Identifiable.kt @@ -0,0 +1,16 @@ +package org.moire.ultrasonic.domain + +import androidx.room.Ignore + +abstract class GenericEntry : Identifiable { + abstract override val id: String + @Ignore + open val name: String? = null + override fun compareTo(other: Identifiable): Int { + return this.id.toInt().compareTo(other.id.toInt()) + } +} + +interface Identifiable : Comparable { + val id: String +} diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt index 1d0bc146..a9c80a5c 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt @@ -69,7 +69,7 @@ class MusicDirectory { var bookmarkPosition: Int = 0, var userRating: Int? = null, var averageRating: Float? = null - ) : Serializable, GenericEntry(), Comparable { + ) : Serializable, GenericEntry() { fun setDuration(duration: Long) { this.duration = duration.toInt() } @@ -78,7 +78,7 @@ class MusicDirectory { private const val serialVersionUID = -3339106650010798108L } - override fun compareTo(other: Entry): Int { + fun compareTo(other: Entry): Int { when { this.closeness == other.closeness -> { return 0 @@ -91,5 +91,7 @@ class MusicDirectory { } } } + + override fun compareTo(other: Identifiable) = compareTo(other as Entry) } } diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt index adc8b080..f8d147b0 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt @@ -4,7 +4,7 @@ import java.io.Serializable import org.moire.ultrasonic.domain.MusicDirectory.Entry data class Share( - override var id: String? = null, + override var id: String, var url: String? = null, var description: String? = null, var username: String? = null, diff --git a/ultrasonic/lint-baseline.xml b/ultrasonic/lint-baseline.xml index d4b7fda1..d74e6655 100644 --- a/ultrasonic/lint-baseline.xml +++ b/ultrasonic/lint-baseline.xml @@ -106,7 +106,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -117,7 +117,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -128,7 +128,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -381,7 +381,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -440,7 +440,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -499,7 +499,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -558,7 +558,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + @@ -672,7 +676,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -731,7 +735,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -790,7 +794,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -849,7 +853,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> + @@ -915,7 +923,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -926,7 +934,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -985,7 +993,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + @@ -1099,7 +1111,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + @@ -1165,47 +1181,47 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - + @@ -1242,94 +1258,6 @@ column="6"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1368,7 +1296,7 @@ errorLine2=" ~~~~~~~~"> @@ -2138,7 +2066,7 @@ errorLine2=" ~~~~~~~~"> diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java index 3c14ad65..fc48490c 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java @@ -232,7 +232,7 @@ public class JukeboxMediaPlayer tasks.remove(Start.class); List ids = new ArrayList<>(); - for (DownloadFile file : downloader.getDownloads()) + for (DownloadFile file : downloader.getAll()) { ids.add(file.getSong().getId()); } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/Supplier.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/Supplier.java index 67ebea09..6137491b 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/Supplier.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/Supplier.java @@ -8,3 +8,5 @@ public abstract class Supplier { public abstract T get(); } + + diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java index 40a68a88..e3a48c70 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java @@ -200,7 +200,7 @@ public class CacheCleaner Lazy downloader = inject(Downloader.class); - for (DownloadFile downloadFile : downloader.getValue().getDownloads()) + for (DownloadFile downloadFile : downloader.getValue().getAll()) { filesToNotDelete.add(downloadFile.getPartialFile()); filesToNotDelete.add(downloadFile.getCompleteOrSaveFile()); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/SongListAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/SongListAdapter.java index 16faf837..32cae84d 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/SongListAdapter.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/SongListAdapter.java @@ -24,7 +24,7 @@ public class SongListAdapter extends ArrayAdapter public View getView(final int position, final View convertView, final ViewGroup parent) { DownloadFile downloadFile = getItem(position); - MusicDirectory.Entry entry = downloadFile.getSong(); + MusicDirectory.Entry entry = downloadFile.getSong(); SongView view; diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index 1af03dcc..d1ba3153 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -121,6 +121,7 @@ class NavigationActivity : AppCompatActivity() { R.id.mediaLibraryFragment, R.id.searchFragment, R.id.playlistsFragment, + R.id.downloadsFragment, R.id.sharesFragment, R.id.bookmarksFragment, R.id.chatFragment, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt index 9cd0a050..aafdea81 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt @@ -14,7 +14,7 @@ import org.moire.ultrasonic.util.Constants * Displays a list of Albums from the media library * TODO: Check refresh is working */ -class AlbumListFragment : GenericListFragment() { +class AlbumListFragment : EntryListFragment() { /** * The ViewModel to use to get the data diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt index c9dcbabe..e203a35e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt @@ -28,7 +28,7 @@ import timber.log.Timber * Creates a Row in a RecyclerView which contains the details of an Album */ class AlbumRowAdapter( - albumList: List, + itemList: List, onItemClick: (MusicDirectory.Entry) -> Unit, onContextMenuClick: (MenuItem, MusicDirectory.Entry) -> Boolean, private val imageLoader: ImageLoader, @@ -40,27 +40,23 @@ class AlbumRowAdapter( onMusicFolderUpdate ) { + init { + super.submitList(itemList) + } + private val starDrawable: Drawable = Util.getDrawableFromAttribute(context, R.attr.star_full) private val starHollowDrawable: Drawable = Util.getDrawableFromAttribute(context, R.attr.star_hollow) - override var itemList = albumList - // Set our layout files override val layout = R.layout.album_list_item override val contextMenuLayout = R.menu.artist_context_menu - // Sets the data to be displayed in the RecyclerView - override fun setData(data: List) { - itemList = data - super.notifyDataSetChanged() - } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (holder is ViewHolder) { val listPosition = if (selectFolderHeader != null) position - 1 else position - val entry = itemList[listPosition] + val entry = currentList[listPosition] holder.album.text = entry.title holder.artist.text = entry.artist holder.details.setOnClickListener { onItemClick(entry) } @@ -78,9 +74,9 @@ class AlbumRowAdapter( override fun getItemCount(): Int { if (selectFolderHeader != null) - return itemList.size + 1 + return currentList.size + 1 else - return itemList.size + return currentList.size } /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt index 51d42f65..58ee16a8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt @@ -10,7 +10,7 @@ import org.moire.ultrasonic.util.Constants /** * Displays the list of Artists from the media library */ -class ArtistListFragment : GenericListFragment() { +class ArtistListFragment : EntryListFragment() { /** * The ViewModel to use to get the data diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt index c014a059..e87477b2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt @@ -23,6 +23,7 @@ import android.os.Bundle import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import java.text.Collator import org.moire.ultrasonic.domain.ArtistOrIndex import org.moire.ultrasonic.service.MusicService @@ -63,6 +64,11 @@ class ArtistListModel(application: Application) : GenericListModel(application) result = musicService.getIndexes(musicFolderId, refresh) } - artists.postValue(result.toMutableList()) + artists.postValue(result.toMutableList().sortedWith(comparator)) + } + + companion object { + val comparator: Comparator = + compareBy(Collator.getInstance()) { t -> t.name } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt index 607eed30..d4079a73 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt @@ -11,7 +11,6 @@ import android.view.MenuItem import android.view.View import androidx.recyclerview.widget.RecyclerView import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter -import java.text.Collator import org.moire.ultrasonic.R import org.moire.ultrasonic.domain.ArtistOrIndex import org.moire.ultrasonic.imageloader.ImageLoader @@ -22,7 +21,7 @@ import org.moire.ultrasonic.util.Settings * Creates a Row in a RecyclerView which contains the details of an Artist */ class ArtistRowAdapter( - artistList: List, + itemList: List, onItemClick: (ArtistOrIndex) -> Unit, onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean, private val imageLoader: ImageLoader, @@ -34,32 +33,26 @@ class ArtistRowAdapter( ), SectionedAdapter { - override var itemList = artistList + init { + super.submitList(itemList) + } // Set our layout files override val layout = R.layout.artist_list_item override val contextMenuLayout = R.menu.artist_context_menu - /** - * Sets the data to be displayed in the RecyclerView - */ - override fun setData(data: List) { - itemList = data.sortedWith(compareBy(Collator.getInstance()) { t -> t.name }) - super.notifyDataSetChanged() - } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (holder is ViewHolder) { val listPosition = if (selectFolderHeader != null) position - 1 else position - holder.textView.text = itemList[listPosition].name + holder.textView.text = currentList[listPosition].name holder.section.text = getSectionForArtist(listPosition) - holder.layout.setOnClickListener { onItemClick(itemList[listPosition]) } + holder.layout.setOnClickListener { onItemClick(currentList[listPosition]) } holder.layout.setOnLongClickListener { view -> createPopupMenu(view, listPosition) } - holder.coverArtId = itemList[listPosition].coverArt + holder.coverArtId = currentList[listPosition].coverArt if (Settings.shouldShowArtistPicture) { holder.coverArt.visibility = View.VISIBLE - val key = FileUtil.getArtistArtKey(itemList[listPosition].name, false) + val key = FileUtil.getArtistArtKey(currentList[listPosition].name, false) imageLoader.loadImage( view = holder.coverArt, id = holder.coverArtId, @@ -81,18 +74,18 @@ class ArtistRowAdapter( // scrolled up to the "Select Folder" row if (listPosition < 0) listPosition = 0 - return getSectionFromName(itemList[listPosition].name ?: " ") + return getSectionFromName(currentList[listPosition].name ?: " ") } private fun getSectionForArtist(artistPosition: Int): String { if (artistPosition == 0) - return getSectionFromName(itemList[artistPosition].name ?: " ") + return getSectionFromName(currentList[artistPosition].name ?: " ") val previousArtistSection = getSectionFromName( - itemList[artistPosition - 1].name ?: " " + currentList[artistPosition - 1].name ?: " " ) val currentArtistSection = getSectionFromName( - itemList[artistPosition].name ?: " " + currentList[artistPosition].name ?: " " ) return if (previousArtistSection == currentArtistSection) "" else currentArtistSection diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt new file mode 100644 index 00000000..54d26ef9 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt @@ -0,0 +1,225 @@ +package org.moire.ultrasonic.fragment + +import android.app.Application +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.widget.CheckedTextView +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.recyclerview.widget.RecyclerView +import org.koin.core.component.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.service.DownloadFile +import org.moire.ultrasonic.service.DownloadStatus +import org.moire.ultrasonic.service.Downloader +import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.view.SongView + +class DownloadsFragment : GenericListFragment() { + + /** + * The ViewModel to use to get the data + */ + override val listModel: DownloadListModel by viewModels() + + /** + * The id of the main layout + */ + override val mainLayout: Int = R.layout.generic_list + + /** + * The id of the refresh view + */ + override val refreshListId: Int = R.id.generic_list_refresh + + /** + * The id of the RecyclerView + */ + override val recyclerViewId = R.id.generic_list_recycler + + /** + * The id of the target in the navigation graph where we should go, + * after the user has clicked on an item + */ + // FIXME + override val itemClickTarget: Int = R.id.trackCollectionFragment + + /** + * The central function to pass a query to the model and return a LiveData object + */ + override fun getLiveData(args: Bundle?): LiveData> { + return listModel.getList() + } + + /** + * Provide the Adapter for the RecyclerView with a lazy delegate + */ + override val viewAdapter: DownloadRowAdapter by lazy { + DownloadRowAdapter( + liveDataItems.value ?: listOf(), + { entry -> onItemClick(entry) }, + { menuItem, entry -> onContextMenuItemSelected(menuItem, entry) }, + onMusicFolderUpdate, + requireContext(), + viewLifecycleOwner + ) + } + + override fun onContextMenuItemSelected(menuItem: MenuItem, item: DownloadFile): Boolean { + // Do nothing + return true + } + + override fun onItemClick(item: DownloadFile) { + // Do nothing + } + + override fun setTitle(title: String?) { + FragmentTitle.setTitle(this, Util.appContext().getString(R.string.menu_downloads)) + } +} + +class DownloadRowAdapter( + itemList: List, + onItemClick: (DownloadFile) -> Unit, + onContextMenuClick: (MenuItem, DownloadFile) -> Boolean, + onMusicFolderUpdate: (String?) -> Unit, + context: Context, + val lifecycleOwner: LifecycleOwner +) : GenericRowAdapter( + onItemClick, + onContextMenuClick, + onMusicFolderUpdate +) { + + init { + super.submitList(itemList) + } + + private val starDrawable: Drawable = + Util.getDrawableFromAttribute(context, R.attr.star_full) + private val starHollowDrawable: Drawable = + Util.getDrawableFromAttribute(context, R.attr.star_hollow) + + // Set our layout files + override val layout = R.layout.song_list_item + override val contextMenuLayout = R.menu.artist_context_menu + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is ViewHolder) { + val downloadFile = currentList[position] + val entry = downloadFile.song + holder.title.text = entry.title + holder.artist.text = entry.artist + holder.star.setImageDrawable(if (entry.starred) starDrawable else starHollowDrawable) + + // Observe download status + downloadFile.status.observe( + lifecycleOwner, + { + updateDownloadStatus(downloadFile, holder) + } + ) + + downloadFile.progress.observe( + lifecycleOwner, + { + updateDownloadStatus(downloadFile, holder) + } + ) + } + } + + private fun updateDownloadStatus( + downloadFile: DownloadFile, + holder: ViewHolder + ) { + + var image: Drawable? = null + + when (downloadFile.status.value) { + DownloadStatus.DONE -> { + image = if (downloadFile.isSaved) SongView.pinImage else SongView.downloadedImage + holder.status.text = null + } + DownloadStatus.DOWNLOADING -> { + holder.status.text = Util.formatPercentage(downloadFile.progress.value!!) + image = SongView.downloadingImage + } + else -> { + holder.status.text = null + } + } + + // TODO: Migrate the image animation stuff from SongView into this class + // + // if (image != null) { + // holder.status.setCompoundDrawablesWithIntrinsicBounds( + // image, null, image, null + // ) + // } + // + // if (image === SongView.downloadingImage) { + // val frameAnimation = image as AnimationDrawable + // + // frameAnimation.setVisible(true, true) + // frameAnimation.start() + // } + } + + /** + * Holds the view properties of an Item row + */ + class ViewHolder( + view: View + ) : RecyclerView.ViewHolder(view) { + var check: CheckedTextView = view.findViewById(R.id.song_check) + var rating: LinearLayout = view.findViewById(R.id.song_rating) + var fiveStar1: ImageView = view.findViewById(R.id.song_five_star_1) + var fiveStar2: ImageView = view.findViewById(R.id.song_five_star_2) + var fiveStar3: ImageView = view.findViewById(R.id.song_five_star_3) + var fiveStar4: ImageView = view.findViewById(R.id.song_five_star_4) + var fiveStar5: ImageView = view.findViewById(R.id.song_five_star_5) + var star: ImageView = view.findViewById(R.id.song_star) + var drag: ImageView = view.findViewById(R.id.song_drag) + var track: TextView = view.findViewById(R.id.song_track) + var title: TextView = view.findViewById(R.id.song_title) + var artist: TextView = view.findViewById(R.id.song_artist) + var duration: TextView = view.findViewById(R.id.song_duration) + var status: TextView = view.findViewById(R.id.song_status) + + init { + drag.isVisible = false + star.isVisible = false + fiveStar1.isVisible = false + fiveStar2.isVisible = false + fiveStar3.isVisible = false + fiveStar4.isVisible = false + fiveStar5.isVisible = false + check.isVisible = false + } + } + + /** + * Creates an instance of our ViewHolder class + */ + override fun newViewHolder(view: View): RecyclerView.ViewHolder { + return ViewHolder(view) + } +} + +class DownloadListModel(application: Application) : GenericListModel(application) { + private val downloader by inject() + + fun getList(): LiveData> { + return downloader.observableList + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt index 8ac78dc8..1aa1f254 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt @@ -18,6 +18,7 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.GenericEntry +import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.MusicFolder import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.subsonic.ImageLoaderProvider @@ -31,7 +32,7 @@ import org.moire.ultrasonic.view.SelectMusicFolderView * @param T: The type of data which will be used (must extend GenericEntry) * @param TA: The Adapter to use (must extend GenericRowAdapter) */ -abstract class GenericListFragment> : Fragment() { +abstract class GenericListFragment> : Fragment() { internal val activeServerProvider: ActiveServerProvider by inject() internal val serverSettingsModel: ServerSettingsModel by viewModel() internal val imageLoaderProvider: ImageLoaderProvider by inject() @@ -90,7 +91,6 @@ abstract class GenericListFragment> @Suppress("CommentOverPrivateProperty") private val musicFolderObserver = { folders: List -> viewAdapter.setFolderList(folders, listModel.activeServer.musicFolderId) - Unit } /** @@ -114,7 +114,7 @@ abstract class GenericListFragment> !listModel.isOffline() && !Settings.shouldUseId3Tags } - fun setTitle(title: String?) { + open fun setTitle(title: String?) { if (title == null) { FragmentTitle.setTitle( this, @@ -143,7 +143,7 @@ abstract class GenericListFragment> liveDataItems = getLiveData(arguments) // Register an observer to update our UI when the data changes - liveDataItems.observe(viewLifecycleOwner, { newItems -> viewAdapter.setData(newItems) }) + liveDataItems.observe(viewLifecycleOwner, { newItems -> viewAdapter.submitList(newItems) }) // Setup the Music folder handling listModel.getMusicFolders().observe(viewLifecycleOwner, musicFolderObserver) @@ -176,8 +176,15 @@ abstract class GenericListFragment> return inflater.inflate(mainLayout, container, false) } + abstract fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean + + abstract fun onItemClick(item: T) +} + +abstract class EntryListFragment> : + GenericListFragment() { @Suppress("LongMethod") - fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean { + override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean { val isArtist = (item is Artist) when (menuItem.itemId) { @@ -263,7 +270,7 @@ abstract class GenericListFragment> return true } - open fun onItemClick(item: T) { + override fun onItemClick(item: T) { val bundle = Bundle() bundle.putString(Constants.INTENT_EXTRA_NAME_ID, item.id) bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, item.name) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt index b825fcac..33f3783e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt @@ -7,6 +7,7 @@ package org.moire.ultrasonic.fragment +import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.MenuInflater import android.view.MenuItem @@ -21,20 +22,19 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.moire.ultrasonic.R import org.moire.ultrasonic.data.ActiveServerProvider -import org.moire.ultrasonic.domain.GenericEntry +import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.MusicFolder import org.moire.ultrasonic.view.SelectMusicFolderView /* * An abstract Adapter, which can be extended to display a List of in a RecyclerView */ -abstract class GenericRowAdapter( +abstract class GenericRowAdapter( val onItemClick: (T) -> Unit, val onContextMenuClick: (MenuItem, T) -> Boolean, private val onMusicFolderUpdate: (String?) -> Unit ) : ListAdapter(GenericDiffCallback()) { - open var itemList: List = listOf() protected abstract val layout: Int protected abstract val contextMenuLayout: Int @@ -43,15 +43,6 @@ abstract class GenericRowAdapter( var musicFolders: List = listOf() var selectedFolder: String? = null - /** - * Sets the data to be displayed in the RecyclerView, - * using DiffUtil to efficiently calculate the minimum required changes.. - */ - open fun setData(data: List) { - submitList(data) - itemList = data - } - /** * Sets the content and state of the music folder selector row */ @@ -101,9 +92,9 @@ abstract class GenericRowAdapter( override fun getItemCount(): Int { if (selectFolderHeader != null) - return itemList.size + 1 + return currentList.size + 1 else - return itemList.size + return currentList.size } override fun getItemViewType(position: Int): Int { @@ -119,7 +110,7 @@ abstract class GenericRowAdapter( downloadMenuItem?.isVisible = !ActiveServerProvider.isOffline() popup.setOnMenuItemClickListener { menuItem -> - onContextMenuClick(menuItem, itemList[position]) + onContextMenuClick(menuItem, currentList[position]) } popup.show() return true @@ -145,7 +136,8 @@ abstract class GenericRowAdapter( /** * Calculates the differences between data sets */ - class GenericDiffCallback : DiffUtil.ItemCallback() { + class GenericDiffCallback : DiffUtil.ItemCallback() { + @SuppressLint("DiffUtilEquals") override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { return oldItem == newItem } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index b02372a4..31c61233 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -317,7 +317,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon repeatButton.setOnClickListener { val repeatMode = mediaPlayerController.repeatMode.next() mediaPlayerController.repeatMode = repeatMode - onDownloadListChanged() + onPlaylistChanged() when (repeatMode) { RepeatMode.OFF -> Util.toast( context, R.string.download_repeat_off @@ -435,7 +435,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon playlistFlipper.displayedChild = 1 } else { // Download list and Album art must be updated when Resumed - onDownloadListChanged() + onPlaylistChanged() onCurrentChanged() } val handler = Handler() @@ -642,7 +642,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon } R.id.menu_remove -> { mediaPlayerController.removeFromPlaylist(song!!) - onDownloadListChanged() + onPlaylistChanged() return true } R.id.menu_item_screen_on_off -> { @@ -697,7 +697,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon R.id.menu_item_clear_playlist -> { mediaPlayerController.isShufflePlayEnabled = false mediaPlayerController.clear() - onDownloadListChanged() + onPlaylistChanged() return true } R.id.menu_item_save_playlist -> { @@ -798,7 +798,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon if (cancel!!.isCancellationRequested) return val mediaPlayerController = mediaPlayerController if (currentRevision != mediaPlayerController.playListUpdateRevision) { - onDownloadListChanged() + onPlaylistChanged() } if (currentPlaying != mediaPlayerController.currentPlaying) { onCurrentChanged() @@ -874,7 +874,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon } } - private fun onDownloadListChanged() { + private fun onPlaylistChanged() { val mediaPlayerController = mediaPlayerController val list = mediaPlayerController.playList emptyTextView.setText(R.string.download_empty) @@ -907,7 +907,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon item.song.title ) Util.toast(context, songRemoved) - onDownloadListChanged() + onPlaylistChanged() onCurrentChanged() } }) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt index 2ddf1bc4..86b9aeab 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt @@ -19,6 +19,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Artist +import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.subsonic.ImageLoaderProvider @@ -35,7 +36,7 @@ import timber.log.Timber class DownloadFile( val song: MusicDirectory.Entry, private val save: Boolean -) : KoinComponent, Comparable { +) : KoinComponent, Identifiable { val partialFile: File val completeFile: File private val saveFile: File = FileUtil.getSongFile(song) @@ -61,6 +62,7 @@ class DownloadFile( private val activeServerProvider: ActiveServerProvider by inject() val progress: MutableLiveData = MutableLiveData(0) + val status: MutableLiveData = MutableLiveData(DownloadStatus.IDLE) init { partialFile = File(saveFile.parent, FileUtil.getPartialFile(saveFile.name)) @@ -204,11 +206,13 @@ class DownloadFile( val musicService = getMusicService() override fun execute() { + var inputStream: InputStream? = null var outputStream: FileOutputStream? = null try { if (saveFile.exists()) { Timber.i("%s already exists. Skipping.", saveFile) + status.postValue(DownloadStatus.DONE) return } @@ -222,9 +226,12 @@ class DownloadFile( } else { Timber.i("%s already exists. Skipping.", completeFile) } + status.postValue(DownloadStatus.DONE) return } + status.postValue(DownloadStatus.DOWNLOADING) + // Some devices seem to throw error on partial file which doesn't exist val needsDownloading: Boolean val duration = song.duration @@ -267,6 +274,7 @@ class DownloadFile( outputStream.close() if (isCancelled) { + status.postValue(DownloadStatus.ABORTED) throw Exception(String.format("Download of '%s' was cancelled", song)) } @@ -275,6 +283,8 @@ class DownloadFile( } downloadAndSaveCoverArt() + + status.postValue(DownloadStatus.DONE) } if (isPlaying) { @@ -293,7 +303,11 @@ class DownloadFile( Util.delete(saveFile) if (!isCancelled) { isFailed = true - if (retryCount > 0) { + if (retryCount > 1) { + status.postValue(DownloadStatus.RETRYING) + --retryCount + } else if (retryCount == 1) { + status.postValue(DownloadStatus.FAILED) --retryCount } Timber.w(all, "Failed to download '%s'.", song) @@ -389,11 +403,20 @@ class DownloadFile( } } - override fun compareTo(other: DownloadFile): Int { + override fun compareTo(other: Identifiable) = compareTo(other as DownloadFile) + + fun compareTo(other: DownloadFile): Int { return priority.compareTo(other.priority) } + override val id: String + get() = song.id + companion object { const val MAX_RETRIES = 5 } } + +enum class DownloadStatus { + IDLE, DOWNLOADING, RETRYING, FAILED, ABORTED, DONE +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt index 6d1527dc..3f27c85d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt @@ -1,9 +1,11 @@ package org.moire.ultrasonic.service import android.net.wifi.WifiManager +import androidx.lifecycle.MutableLiveData import java.util.ArrayList import java.util.PriorityQueue import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import org.koin.core.component.KoinComponent @@ -20,7 +22,6 @@ import timber.log.Timber * This class is responsible for maintaining the playlist and downloading * its items from the network to the filesystem. * - * TODO: Implement LiveData * TODO: Move away from managing the queue with scheduled checks, instead use callbacks when * Downloads are finished */ @@ -35,6 +36,8 @@ class Downloader( private val downloadQueue: PriorityQueue = PriorityQueue() private val activelyDownloading: MutableList = ArrayList() + val observableList: MutableLiveData> = MutableLiveData>() + private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() private val downloadFileCache = LRUCache(100) @@ -58,6 +61,7 @@ class Downloader( stop() clearPlaylist() clearBackground() + observableList.value = listOf() Timber.i("Downloader destroyed") } @@ -88,10 +92,21 @@ class Downloader( } fun checkDownloads() { - if (executorService == null || executorService!!.isTerminated) { + if ( + executorService == null || + executorService!!.isTerminated || + executorService!!.isShutdown + ) { start() } else { - executorService?.execute(downloadChecker) + try { + executorService?.execute(downloadChecker) + } catch (exception: RejectedExecutionException) { + Timber.w( + exception, + "checkDownloads() can't run, maybe the Downloader is shutting down..." + ) + } } } @@ -112,7 +127,8 @@ class Downloader( } // Check the active downloads for failures or completions and remove them - cleanupActiveDownloads() + // Store the result in a flag to know if changes have occurred + var listChanged = cleanupActiveDownloads() // Check if need to preload more from playlist val preloadCount = Settings.preloadCount @@ -134,6 +150,7 @@ class Downloader( !activelyDownloading.contains(download) && !downloadQueue.contains(download) ) { + listChanged = true downloadQueue.add(download) } } @@ -148,12 +165,21 @@ class Downloader( if (playlist.indexOf(task) == 1) { localMediaPlayer.setNextPlayerState(PlayerState.DOWNLOADING) } + listChanged = true } // Stop Executor service when done downloading if (activelyDownloading.size == 0) { stop() } + + if (listChanged) { + updateLiveData() + } + } + + private fun updateLiveData() { + observableList.postValue(downloads) } private fun startDownloadOnService(task: DownloadFile) { @@ -162,7 +188,12 @@ class Downloader( } } - private fun cleanupActiveDownloads() { + /** + * Return true if modifications were made + */ + private fun cleanupActiveDownloads(): Boolean { + val oldSize = activelyDownloading.size + activelyDownloading.retainAll { when { it.isDownloading -> true @@ -177,6 +208,8 @@ class Downloader( } } } + + return (oldSize != activelyDownloading.size) } @get:Synchronized @@ -201,13 +234,34 @@ class Downloader( } @get:Synchronized - val downloads: List + val all: List get() { - val temp: MutableList = ArrayList() - temp.addAll(playlist) + val temp: MutableList = ArrayList() temp.addAll(activelyDownloading) temp.addAll(downloadQueue) - return temp.distinct() + temp.addAll(playlist) + return temp.distinct().sorted() + } + + /* + * Returns a list of all DownloadFiles that are currently downloading or waiting for download, + * including undownloaded files from the playlist. + */ + @get:Synchronized + val downloads: List + get() { + val temp: MutableList = ArrayList() + temp.addAll(activelyDownloading) + temp.addAll(downloadQueue) + temp.addAll( + playlist.filter { + when (it.status.value) { + DownloadStatus.DOWNLOADING -> true + else -> false + } + } + ) + return temp.distinct().sorted() } @Synchronized @@ -216,11 +270,14 @@ class Downloader( // Cancel all active downloads with a high priority for (download in activelyDownloading) { - if (download.priority < 100) + if (download.priority < 100) { download.cancelDownload() + activelyDownloading.remove(download) + } } playlistUpdateRevision++ + updateLiveData() } @Synchronized @@ -230,17 +287,21 @@ class Downloader( // Cancel all active downloads with a low priority for (download in activelyDownloading) { - if (download.priority >= 100) + if (download.priority >= 100) { download.cancelDownload() + activelyDownloading.remove(download) + } } } @Synchronized fun clearActiveDownloads() { - // Cancel all active downloads with a low priority + // Cancel all active downloads for (download in activelyDownloading) { download.cancelDownload() } + activelyDownloading.clear() + updateLiveData() } @Synchronized @@ -250,6 +311,7 @@ class Downloader( } playlist.remove(downloadFile) playlistUpdateRevision++ + checkDownloads() } @Synchronized diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index 61a36389..2a96b79e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -421,14 +421,13 @@ class MediaPlayerController( } } - @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions val isJukeboxAvailable: Boolean get() { try { val username = activeServerProvider.getActiveServer().userName return getMusicService().getUser(username).jukeboxRole - } catch (e: Exception) { - Timber.w(e, "Error getting user information") + } catch (all: Exception) { + Timber.w(all, "Error getting user information") } return false } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index f86ce5b2..bd8eb3a5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -15,7 +15,9 @@ import android.app.Service import android.content.Context import android.content.Intent import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.support.v4.media.session.MediaSessionCompat import android.view.KeyEvent import androidx.core.app.NotificationCompat @@ -159,7 +161,10 @@ class MediaPlayerService : Service() { } fun notifyDownloaderStopped() { - stopIfIdle() + // TODO It would be nice to know if the service really can be stopped instead of just + // checking if it is idle once... + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed({ stopIfIdle() }, 1000) } @Synchronized @@ -356,13 +361,13 @@ class MediaPlayerService : Service() { Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying.song) Util.broadcastA2dpMetaDataChange( this@MediaPlayerService, playerPosition, currentPlaying, - downloader.downloads.size, downloader.currentPlayingIndex + 1 + downloader.all.size, downloader.currentPlayingIndex + 1 ) } else { Util.broadcastNewTrackInfo(this@MediaPlayerService, null) Util.broadcastA2dpMetaDataChange( this@MediaPlayerService, playerPosition, null, - downloader.downloads.size, downloader.currentPlayingIndex + 1 + downloader.all.size, downloader.currentPlayingIndex + 1 ) } @@ -740,14 +745,19 @@ class MediaPlayerService : Service() { private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" private const val NOTIFICATION_ID = 3033 + @Volatile private var instance: MediaPlayerService? = null private val instanceLock = Any() @JvmStatic fun getInstance(): MediaPlayerService? { val context = UApp.applicationContext() - synchronized(instanceLock) { - for (i in 0..19) { + // Try for twenty times to retrieve a running service, + // sleep 100 millis between each try, + // and run the block that creates a service only synchronized. + for (i in 0..19) { + if (instance != null) return instance + synchronized(instanceLock) { if (instance != null) return instance if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService( @@ -756,10 +766,10 @@ class MediaPlayerService : Service() { } else { context.startService(Intent(context, MediaPlayerService::class.java)) } - Util.sleepQuietly(50L) } - return instance + Util.sleepQuietly(100L) } + return instance } @JvmStatic diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongView.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongView.kt index 4a89c3c6..651926fe 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongView.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongView.kt @@ -351,9 +351,9 @@ class SongView(context: Context) : UpdateView(context), Checkable, KoinComponent companion object { private var starHollowDrawable: Drawable? = null private var starDrawable: Drawable? = null - private var pinImage: Drawable? = null - private var downloadedImage: Drawable? = null - private var downloadingImage: Drawable? = null + var pinImage: Drawable? = null + var downloadedImage: Drawable? = null + var downloadingImage: Drawable? = null private var playingImage: Drawable? = null private var theme: String? = null private var inflater: LayoutInflater? = null diff --git a/ultrasonic/src/main/res/menu/navigation.xml b/ultrasonic/src/main/res/menu/navigation.xml index 91de3df0..956f94ed 100644 --- a/ultrasonic/src/main/res/menu/navigation.xml +++ b/ultrasonic/src/main/res/menu/navigation.xml @@ -25,6 +25,11 @@ a:checkable="true" a:icon="?attr/playlists" a:title="@string/button_bar.playlists" /> + + diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 262b8d78..f9666771 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -121,6 +121,7 @@ Common Deleted playlist %s Failed to delete playlist %s + Downloads Exit Navigation Settings @@ -213,9 +214,9 @@ 1 hour Sort Songs By Disc Sort song list by disc number and track number - Display Bitrate And File Suffix + Display Bitrate and File Suffix Append artist name with bitrate and file suffix - Show Downloads On Play + Show Downloads on Play Transition to download activity when starting playback Gapless Playback Enable gapless playback