Add Download Fragment which show active downloads

This commit is contained in:
tzugen 2021-10-14 19:25:25 +02:00
parent 9bc19ec044
commit 1d5b335f97
No known key found for this signature in database
GPG Key ID: 61E9C34BC10EC930
29 changed files with 389 additions and 112 deletions

View File

@ -11,9 +11,9 @@ data class Artist(
override var coverArt: String? = null, override var coverArt: String? = null,
override var albumCount: Long? = null, override var albumCount: Long? = null,
override var closeness: Int = 0 override var closeness: Int = 0
) : ArtistOrIndex(id), Comparable<Artist> { ) : ArtistOrIndex(id) {
override fun compareTo(other: Artist): Int { fun compareTo(other: Artist): Int {
when { when {
this.closeness == other.closeness -> { this.closeness == other.closeness -> {
return 0 return 0
@ -26,4 +26,6 @@ data class Artist(
} }
} }
} }
override fun compareTo(other: Identifiable) = compareTo(other as Artist)
} }

View File

@ -2,7 +2,7 @@ package org.moire.ultrasonic.domain
import androidx.room.Ignore import androidx.room.Ignore
open class ArtistOrIndex( abstract class ArtistOrIndex(
@Ignore @Ignore
override var id: String, override var id: String,
@Ignore @Ignore
@ -15,4 +15,4 @@ open class ArtistOrIndex(
open var albumCount: Long? = null, open var albumCount: Long? = null,
@Ignore @Ignore
open var closeness: Int = 0 open var closeness: Int = 0
) : GenericEntry() ) : GenericEntry(id)

View File

@ -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
}
}

View File

@ -7,8 +7,8 @@ import java.io.Serializable
@Entity @Entity
data class Genre( data class Genre(
@PrimaryKey val index: String, @PrimaryKey val index: String,
override val name: String val name: String
) : Serializable, GenericEntry() { ) : Serializable {
companion object { companion object {
private const val serialVersionUID = -3943025175219134028L private const val serialVersionUID = -3943025175219134028L
} }

View File

@ -0,0 +1,17 @@
package org.moire.ultrasonic.domain
import androidx.room.Ignore
open class GenericEntry(
@Ignore override val id: String
) : Identifiable {
@Ignore
open val name: String? = null
override fun compareTo(other: Identifiable): Int {
return this.id.toInt().compareTo(other.id.toInt())
}
}
interface Identifiable : Comparable<Identifiable> {
val id: String
}

View File

@ -69,7 +69,7 @@ class MusicDirectory {
var bookmarkPosition: Int = 0, var bookmarkPosition: Int = 0,
var userRating: Int? = null, var userRating: Int? = null,
var averageRating: Float? = null var averageRating: Float? = null
) : Serializable, GenericEntry(), Comparable<Entry> { ) : Serializable, GenericEntry(id) {
fun setDuration(duration: Long) { fun setDuration(duration: Long) {
this.duration = duration.toInt() this.duration = duration.toInt()
} }
@ -78,7 +78,7 @@ class MusicDirectory {
private const val serialVersionUID = -3339106650010798108L private const val serialVersionUID = -3339106650010798108L
} }
override fun compareTo(other: Entry): Int { fun compareTo(other: Entry): Int {
when { when {
this.closeness == other.closeness -> { this.closeness == other.closeness -> {
return 0 return 0
@ -91,5 +91,7 @@ class MusicDirectory {
} }
} }
} }
override fun compareTo(other: Identifiable) = compareTo(other as Entry)
} }
} }

View File

@ -10,4 +10,4 @@ import androidx.room.PrimaryKey
data class MusicFolder( data class MusicFolder(
@PrimaryKey override val id: String, @PrimaryKey override val id: String,
override val name: String override val name: String
) : GenericEntry() ) : GenericEntry(id)

View File

@ -10,7 +10,7 @@ data class Playlist @JvmOverloads constructor(
val songCount: String = "", val songCount: String = "",
val created: String = "", val created: String = "",
val public: Boolean? = null val public: Boolean? = null
) : Serializable, GenericEntry() { ) : Serializable, GenericEntry(id) {
companion object { companion object {
private const val serialVersionUID = -4160515427075433798L private const val serialVersionUID = -4160515427075433798L
} }

View File

@ -8,7 +8,7 @@ data class PodcastsChannel(
val url: String?, val url: String?,
val description: String?, val description: String?,
val status: String? val status: String?
) : Serializable, GenericEntry() { ) : Serializable, GenericEntry(id) {
companion object { companion object {
private const val serialVersionUID = -4160515427075433798L private const val serialVersionUID = -4160515427075433798L
} }

View File

@ -4,7 +4,7 @@ import java.io.Serializable
import org.moire.ultrasonic.domain.MusicDirectory.Entry import org.moire.ultrasonic.domain.MusicDirectory.Entry
data class Share( data class Share(
override var id: String? = null, override var id: String,
var url: String? = null, var url: String? = null,
var description: String? = null, var description: String? = null,
var username: String? = null, var username: String? = null,
@ -13,7 +13,7 @@ data class Share(
var expires: String? = null, var expires: String? = null,
var visitCount: Long? = null, var visitCount: Long? = null,
private val entries: MutableList<Entry> = mutableListOf() private val entries: MutableList<Entry> = mutableListOf()
) : Serializable, GenericEntry() { ) : Serializable, GenericEntry(id) {
override val name: String? override val name: String?
get() { get() {
if (url != null) { if (url != null) {

View File

@ -232,7 +232,7 @@ public class JukeboxMediaPlayer
tasks.remove(Start.class); tasks.remove(Start.class);
List<String> ids = new ArrayList<>(); List<String> ids = new ArrayList<>();
for (DownloadFile file : downloader.getDownloads()) for (DownloadFile file : downloader.getAll())
{ {
ids.add(file.getSong().getId()); ids.add(file.getSong().getId());
} }

View File

@ -8,3 +8,5 @@ public abstract class Supplier<T>
{ {
public abstract T get(); public abstract T get();
} }

View File

@ -200,7 +200,7 @@ public class CacheCleaner
Lazy<Downloader> downloader = inject(Downloader.class); Lazy<Downloader> downloader = inject(Downloader.class);
for (DownloadFile downloadFile : downloader.getValue().getDownloads()) for (DownloadFile downloadFile : downloader.getValue().getAll())
{ {
filesToNotDelete.add(downloadFile.getPartialFile()); filesToNotDelete.add(downloadFile.getPartialFile());
filesToNotDelete.add(downloadFile.getCompleteOrSaveFile()); filesToNotDelete.add(downloadFile.getCompleteOrSaveFile());

View File

@ -108,6 +108,7 @@ class NavigationActivity : AppCompatActivity() {
R.id.mediaLibraryFragment, R.id.mediaLibraryFragment,
R.id.searchFragment, R.id.searchFragment,
R.id.playlistsFragment, R.id.playlistsFragment,
R.id.downloadsFragment,
R.id.sharesFragment, R.id.sharesFragment,
R.id.bookmarksFragment, R.id.bookmarksFragment,
R.id.chatFragment, R.id.chatFragment,

View File

@ -14,7 +14,7 @@ import org.moire.ultrasonic.util.Constants
* Displays a list of Albums from the media library * Displays a list of Albums from the media library
* TODO: Check refresh is working * TODO: Check refresh is working
*/ */
class AlbumListFragment : GenericListFragment<MusicDirectory.Entry, AlbumRowAdapter>() { class AlbumListFragment : EntryListFragment<MusicDirectory.Entry, AlbumRowAdapter>() {
/** /**
* The ViewModel to use to get the data * The ViewModel to use to get the data

View File

@ -28,7 +28,7 @@ import timber.log.Timber
* Creates a Row in a RecyclerView which contains the details of an Album * Creates a Row in a RecyclerView which contains the details of an Album
*/ */
class AlbumRowAdapter( class AlbumRowAdapter(
albumList: List<MusicDirectory.Entry>, itemList: List<MusicDirectory.Entry>,
onItemClick: (MusicDirectory.Entry) -> Unit, onItemClick: (MusicDirectory.Entry) -> Unit,
onContextMenuClick: (MenuItem, MusicDirectory.Entry) -> Boolean, onContextMenuClick: (MenuItem, MusicDirectory.Entry) -> Boolean,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
@ -40,27 +40,23 @@ class AlbumRowAdapter(
onMusicFolderUpdate onMusicFolderUpdate
) { ) {
init {
super.submitList(itemList)
}
private val starDrawable: Drawable = private val starDrawable: Drawable =
Util.getDrawableFromAttribute(context, R.attr.star_full) Util.getDrawableFromAttribute(context, R.attr.star_full)
private val starHollowDrawable: Drawable = private val starHollowDrawable: Drawable =
Util.getDrawableFromAttribute(context, R.attr.star_hollow) Util.getDrawableFromAttribute(context, R.attr.star_hollow)
override var itemList = albumList
// Set our layout files // Set our layout files
override val layout = R.layout.album_list_item override val layout = R.layout.album_list_item
override val contextMenuLayout = R.menu.artist_context_menu override val contextMenuLayout = R.menu.artist_context_menu
// Sets the data to be displayed in the RecyclerView
override fun setData(data: List<MusicDirectory.Entry>) {
itemList = data
super.notifyDataSetChanged()
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) { if (holder is ViewHolder) {
val listPosition = if (selectFolderHeader != null) position - 1 else position val listPosition = if (selectFolderHeader != null) position - 1 else position
val entry = itemList[listPosition] val entry = currentList[listPosition]
holder.album.text = entry.title holder.album.text = entry.title
holder.artist.text = entry.artist holder.artist.text = entry.artist
holder.details.setOnClickListener { onItemClick(entry) } holder.details.setOnClickListener { onItemClick(entry) }
@ -78,9 +74,9 @@ class AlbumRowAdapter(
override fun getItemCount(): Int { override fun getItemCount(): Int {
if (selectFolderHeader != null) if (selectFolderHeader != null)
return itemList.size + 1 return currentList.size + 1
else else
return itemList.size return currentList.size
} }
/** /**

View File

@ -10,7 +10,7 @@ import org.moire.ultrasonic.util.Constants
/** /**
* Displays the list of Artists from the media library * Displays the list of Artists from the media library
*/ */
class ArtistListFragment : GenericListFragment<ArtistOrIndex, ArtistRowAdapter>() { class ArtistListFragment : EntryListFragment<ArtistOrIndex, ArtistRowAdapter>() {
/** /**
* The ViewModel to use to get the data * The ViewModel to use to get the data

View File

@ -23,6 +23,7 @@ import android.os.Bundle
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.text.Collator
import org.moire.ultrasonic.domain.ArtistOrIndex import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.MusicService
@ -63,6 +64,11 @@ class ArtistListModel(application: Application) : GenericListModel(application)
result = musicService.getIndexes(musicFolderId, refresh) result = musicService.getIndexes(musicFolderId, refresh)
} }
artists.postValue(result.toMutableList()) artists.postValue(result.toMutableList().sortedWith(comparator))
}
companion object {
val comparator: Comparator<ArtistOrIndex> =
compareBy(Collator.getInstance()) { t -> t.name }
} }
} }

View File

@ -11,7 +11,6 @@ import android.view.MenuItem
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter
import java.text.Collator
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.ArtistOrIndex import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.imageloader.ImageLoader 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 * Creates a Row in a RecyclerView which contains the details of an Artist
*/ */
class ArtistRowAdapter( class ArtistRowAdapter(
artistList: List<ArtistOrIndex>, itemList: List<ArtistOrIndex>,
onItemClick: (ArtistOrIndex) -> Unit, onItemClick: (ArtistOrIndex) -> Unit,
onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean, onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
@ -34,32 +33,26 @@ class ArtistRowAdapter(
), ),
SectionedAdapter { SectionedAdapter {
override var itemList = artistList init {
super.submitList(itemList)
}
// Set our layout files // Set our layout files
override val layout = R.layout.artist_list_item override val layout = R.layout.artist_list_item
override val contextMenuLayout = R.menu.artist_context_menu override val contextMenuLayout = R.menu.artist_context_menu
/**
* Sets the data to be displayed in the RecyclerView
*/
override fun setData(data: List<ArtistOrIndex>) {
itemList = data.sortedWith(compareBy(Collator.getInstance()) { t -> t.name })
super.notifyDataSetChanged()
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) { if (holder is ViewHolder) {
val listPosition = if (selectFolderHeader != null) position - 1 else position 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.section.text = getSectionForArtist(listPosition)
holder.layout.setOnClickListener { onItemClick(itemList[listPosition]) } holder.layout.setOnClickListener { onItemClick(currentList[listPosition]) }
holder.layout.setOnLongClickListener { view -> createPopupMenu(view, listPosition) } holder.layout.setOnLongClickListener { view -> createPopupMenu(view, listPosition) }
holder.coverArtId = itemList[listPosition].coverArt holder.coverArtId = currentList[listPosition].coverArt
if (Settings.shouldShowArtistPicture) { if (Settings.shouldShowArtistPicture) {
holder.coverArt.visibility = View.VISIBLE holder.coverArt.visibility = View.VISIBLE
val key = FileUtil.getArtistArtKey(itemList[listPosition].name, false) val key = FileUtil.getArtistArtKey(currentList[listPosition].name, false)
imageLoader.loadImage( imageLoader.loadImage(
view = holder.coverArt, view = holder.coverArt,
id = holder.coverArtId, id = holder.coverArtId,
@ -81,18 +74,18 @@ class ArtistRowAdapter(
// scrolled up to the "Select Folder" row // scrolled up to the "Select Folder" row
if (listPosition < 0) listPosition = 0 if (listPosition < 0) listPosition = 0
return getSectionFromName(itemList[listPosition].name ?: " ") return getSectionFromName(currentList[listPosition].name ?: " ")
} }
private fun getSectionForArtist(artistPosition: Int): String { private fun getSectionForArtist(artistPosition: Int): String {
if (artistPosition == 0) if (artistPosition == 0)
return getSectionFromName(itemList[artistPosition].name ?: " ") return getSectionFromName(currentList[artistPosition].name ?: " ")
val previousArtistSection = getSectionFromName( val previousArtistSection = getSectionFromName(
itemList[artistPosition - 1].name ?: " " currentList[artistPosition - 1].name ?: " "
) )
val currentArtistSection = getSectionFromName( val currentArtistSection = getSectionFromName(
itemList[artistPosition].name ?: " " currentList[artistPosition].name ?: " "
) )
return if (previousArtistSection == currentArtistSection) "" else currentArtistSection return if (previousArtistSection == currentArtistSection) "" else currentArtistSection

View File

@ -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<DownloadFile, DownloadRowAdapter>() {
/**
* 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<List<DownloadFile>> {
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<DownloadFile>,
onItemClick: (DownloadFile) -> Unit,
onContextMenuClick: (MenuItem, DownloadFile) -> Boolean,
onMusicFolderUpdate: (String?) -> Unit,
context: Context,
val lifecycleOwner: LifecycleOwner
) : GenericRowAdapter<DownloadFile>(
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<Downloader>()
fun getList(): LiveData<List<DownloadFile>> {
return downloader.observableList
}
}

View File

@ -18,6 +18,7 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.GenericEntry import org.moire.ultrasonic.domain.GenericEntry
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicFolder import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.ImageLoaderProvider 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 T: The type of data which will be used (must extend GenericEntry)
* @param TA: The Adapter to use (must extend GenericRowAdapter) * @param TA: The Adapter to use (must extend GenericRowAdapter)
*/ */
abstract class GenericListFragment<T : GenericEntry, TA : GenericRowAdapter<T>> : Fragment() { abstract class GenericListFragment<T : Identifiable, TA : GenericRowAdapter<T>> : Fragment() {
internal val activeServerProvider: ActiveServerProvider by inject() internal val activeServerProvider: ActiveServerProvider by inject()
internal val serverSettingsModel: ServerSettingsModel by viewModel() internal val serverSettingsModel: ServerSettingsModel by viewModel()
internal val imageLoaderProvider: ImageLoaderProvider by inject() internal val imageLoaderProvider: ImageLoaderProvider by inject()
@ -90,7 +91,6 @@ abstract class GenericListFragment<T : GenericEntry, TA : GenericRowAdapter<T>>
@Suppress("CommentOverPrivateProperty") @Suppress("CommentOverPrivateProperty")
private val musicFolderObserver = { folders: List<MusicFolder> -> private val musicFolderObserver = { folders: List<MusicFolder> ->
viewAdapter.setFolderList(folders, listModel.activeServer.musicFolderId) viewAdapter.setFolderList(folders, listModel.activeServer.musicFolderId)
Unit
} }
/** /**
@ -114,7 +114,7 @@ abstract class GenericListFragment<T : GenericEntry, TA : GenericRowAdapter<T>>
!listModel.isOffline() && !Settings.shouldUseId3Tags !listModel.isOffline() && !Settings.shouldUseId3Tags
} }
fun setTitle(title: String?) { open fun setTitle(title: String?) {
if (title == null) { if (title == null) {
FragmentTitle.setTitle( FragmentTitle.setTitle(
this, this,
@ -143,7 +143,7 @@ abstract class GenericListFragment<T : GenericEntry, TA : GenericRowAdapter<T>>
liveDataItems = getLiveData(arguments) liveDataItems = getLiveData(arguments)
// Register an observer to update our UI when the data changes // 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 // Setup the Music folder handling
listModel.getMusicFolders().observe(viewLifecycleOwner, musicFolderObserver) listModel.getMusicFolders().observe(viewLifecycleOwner, musicFolderObserver)
@ -176,8 +176,15 @@ abstract class GenericListFragment<T : GenericEntry, TA : GenericRowAdapter<T>>
return inflater.inflate(mainLayout, container, false) return inflater.inflate(mainLayout, container, false)
} }
abstract fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean
abstract fun onItemClick(item: T)
}
abstract class EntryListFragment<T : GenericEntry, TA : GenericRowAdapter<T>> :
GenericListFragment<T, TA>() {
@Suppress("LongMethod") @Suppress("LongMethod")
fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean { override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
val isArtist = (item is Artist) val isArtist = (item is Artist)
when (menuItem.itemId) { when (menuItem.itemId) {
@ -263,7 +270,7 @@ abstract class GenericListFragment<T : GenericEntry, TA : GenericRowAdapter<T>>
return true return true
} }
open fun onItemClick(item: T) { override fun onItemClick(item: T) {
val bundle = Bundle() val bundle = Bundle()
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, item.id) bundle.putString(Constants.INTENT_EXTRA_NAME_ID, item.id)
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, item.name) bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, item.name)

View File

@ -7,6 +7,7 @@
package org.moire.ultrasonic.fragment package org.moire.ultrasonic.fragment
import android.annotation.SuppressLint
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
@ -21,20 +22,19 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider 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.domain.MusicFolder
import org.moire.ultrasonic.view.SelectMusicFolderView import org.moire.ultrasonic.view.SelectMusicFolderView
/* /*
* An abstract Adapter, which can be extended to display a List of <T> in a RecyclerView * An abstract Adapter, which can be extended to display a List of <T> in a RecyclerView
*/ */
abstract class GenericRowAdapter<T : GenericEntry>( abstract class GenericRowAdapter<T : Identifiable>(
val onItemClick: (T) -> Unit, val onItemClick: (T) -> Unit,
val onContextMenuClick: (MenuItem, T) -> Boolean, val onContextMenuClick: (MenuItem, T) -> Boolean,
private val onMusicFolderUpdate: (String?) -> Unit private val onMusicFolderUpdate: (String?) -> Unit
) : ListAdapter<T, RecyclerView.ViewHolder>(GenericDiffCallback()) { ) : ListAdapter<T, RecyclerView.ViewHolder>(GenericDiffCallback()) {
open var itemList: List<T> = listOf()
protected abstract val layout: Int protected abstract val layout: Int
protected abstract val contextMenuLayout: Int protected abstract val contextMenuLayout: Int
@ -43,15 +43,6 @@ abstract class GenericRowAdapter<T : GenericEntry>(
var musicFolders: List<MusicFolder> = listOf() var musicFolders: List<MusicFolder> = listOf()
var selectedFolder: String? = null 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<T>) {
submitList(data)
itemList = data
}
/** /**
* Sets the content and state of the music folder selector row * Sets the content and state of the music folder selector row
*/ */
@ -101,9 +92,9 @@ abstract class GenericRowAdapter<T : GenericEntry>(
override fun getItemCount(): Int { override fun getItemCount(): Int {
if (selectFolderHeader != null) if (selectFolderHeader != null)
return itemList.size + 1 return currentList.size + 1
else else
return itemList.size return currentList.size
} }
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
@ -119,7 +110,7 @@ abstract class GenericRowAdapter<T : GenericEntry>(
downloadMenuItem?.isVisible = !ActiveServerProvider.isOffline() downloadMenuItem?.isVisible = !ActiveServerProvider.isOffline()
popup.setOnMenuItemClickListener { menuItem -> popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick(menuItem, itemList[position]) onContextMenuClick(menuItem, currentList[position])
} }
popup.show() popup.show()
return true return true
@ -145,7 +136,8 @@ abstract class GenericRowAdapter<T : GenericEntry>(
/** /**
* Calculates the differences between data sets * Calculates the differences between data sets
*/ */
class GenericDiffCallback<T : GenericEntry> : DiffUtil.ItemCallback<T>() { class GenericDiffCallback<T : Identifiable> : DiffUtil.ItemCallback<T>() {
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem return oldItem == newItem
} }

View File

@ -19,6 +19,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider
@ -35,7 +36,7 @@ import timber.log.Timber
class DownloadFile( class DownloadFile(
val song: MusicDirectory.Entry, val song: MusicDirectory.Entry,
private val save: Boolean private val save: Boolean
) : KoinComponent, Comparable<DownloadFile> { ) : KoinComponent, Identifiable {
val partialFile: File val partialFile: File
val completeFile: File val completeFile: File
private val saveFile: File = FileUtil.getSongFile(song) private val saveFile: File = FileUtil.getSongFile(song)
@ -61,6 +62,7 @@ class DownloadFile(
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
val progress: MutableLiveData<Int> = MutableLiveData(0) val progress: MutableLiveData<Int> = MutableLiveData(0)
val status: MutableLiveData<DownloadStatus> = MutableLiveData(DownloadStatus.IDLE)
init { init {
partialFile = File(saveFile.parent, FileUtil.getPartialFile(saveFile.name)) partialFile = File(saveFile.parent, FileUtil.getPartialFile(saveFile.name))
@ -204,11 +206,13 @@ class DownloadFile(
val musicService = getMusicService() val musicService = getMusicService()
override fun execute() { override fun execute() {
var inputStream: InputStream? = null var inputStream: InputStream? = null
var outputStream: FileOutputStream? = null var outputStream: FileOutputStream? = null
try { try {
if (saveFile.exists()) { if (saveFile.exists()) {
Timber.i("%s already exists. Skipping.", saveFile) Timber.i("%s already exists. Skipping.", saveFile)
status.postValue(DownloadStatus.DONE)
return return
} }
@ -222,9 +226,12 @@ class DownloadFile(
} else { } else {
Timber.i("%s already exists. Skipping.", completeFile) Timber.i("%s already exists. Skipping.", completeFile)
} }
status.postValue(DownloadStatus.DONE)
return return
} }
status.postValue(DownloadStatus.DOWNLOADING)
// Some devices seem to throw error on partial file which doesn't exist // Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean val needsDownloading: Boolean
val duration = song.duration val duration = song.duration
@ -267,6 +274,7 @@ class DownloadFile(
outputStream.close() outputStream.close()
if (isCancelled) { if (isCancelled) {
status.postValue(DownloadStatus.ABORTED)
throw Exception(String.format("Download of '%s' was cancelled", song)) throw Exception(String.format("Download of '%s' was cancelled", song))
} }
@ -275,6 +283,8 @@ class DownloadFile(
} }
downloadAndSaveCoverArt() downloadAndSaveCoverArt()
status.postValue(DownloadStatus.DONE)
} }
if (isPlaying) { if (isPlaying) {
@ -293,7 +303,11 @@ class DownloadFile(
Util.delete(saveFile) Util.delete(saveFile)
if (!isCancelled) { if (!isCancelled) {
isFailed = true isFailed = true
if (retryCount > 0) { if (retryCount > 1) {
status.postValue(DownloadStatus.RETRYING)
--retryCount
} else if (retryCount == 1) {
status.postValue(DownloadStatus.FAILED)
--retryCount --retryCount
} }
Timber.w(all, "Failed to download '%s'.", song) 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) return priority.compareTo(other.priority)
} }
override val id: String
get() = song.id
companion object { companion object {
const val MAX_RETRIES = 5 const val MAX_RETRIES = 5
} }
} }
enum class DownloadStatus {
IDLE, DOWNLOADING, RETRYING, FAILED, ABORTED, DONE
}

View File

@ -1,7 +1,6 @@
package org.moire.ultrasonic.service package org.moire.ultrasonic.service
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import java.util.ArrayList import java.util.ArrayList
import java.util.PriorityQueue import java.util.PriorityQueue
@ -115,11 +114,9 @@ class Downloader(
return return
} }
// Flag to know if changes have occured
var listChanged = false
// Check the active downloads for failures or completions and remove them // 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 // Check if need to preload more from playlist
val preloadCount = Settings.preloadCount val preloadCount = Settings.preloadCount
@ -165,7 +162,7 @@ class Downloader(
} }
if (listChanged) { if (listChanged) {
observableList.value = downloads observableList.postValue(downloads)
} }
} }
@ -175,7 +172,12 @@ class Downloader(
} }
} }
private fun cleanupActiveDownloads() { /**
* Return true if modifications were made
*/
private fun cleanupActiveDownloads(): Boolean {
val oldSize = activelyDownloading.size
activelyDownloading.retainAll { activelyDownloading.retainAll {
when { when {
it.isDownloading -> true it.isDownloading -> true
@ -190,6 +192,8 @@ class Downloader(
} }
} }
} }
return (oldSize != activelyDownloading.size)
} }
@get:Synchronized @get:Synchronized
@ -214,7 +218,7 @@ class Downloader(
} }
@get:Synchronized @get:Synchronized
val downloads: List<DownloadFile> val all: List<DownloadFile>
get() { get() {
val temp: MutableList<DownloadFile> = ArrayList() val temp: MutableList<DownloadFile> = ArrayList()
temp.addAll(activelyDownloading) temp.addAll(activelyDownloading)
@ -223,6 +227,27 @@ class Downloader(
return temp.distinct().sorted() 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<DownloadFile>
get() {
val temp: MutableList<DownloadFile> = 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 @Synchronized
fun clearPlaylist() { fun clearPlaylist() {
playlist.clear() playlist.clear()

View File

@ -356,13 +356,13 @@ class MediaPlayerService : Service() {
Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying.song) Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying.song)
Util.broadcastA2dpMetaDataChange( Util.broadcastA2dpMetaDataChange(
this@MediaPlayerService, playerPosition, currentPlaying, this@MediaPlayerService, playerPosition, currentPlaying,
downloader.downloads.size, downloader.currentPlayingIndex + 1 downloader.all.size, downloader.currentPlayingIndex + 1
) )
} else { } else {
Util.broadcastNewTrackInfo(this@MediaPlayerService, null) Util.broadcastNewTrackInfo(this@MediaPlayerService, null)
Util.broadcastA2dpMetaDataChange( Util.broadcastA2dpMetaDataChange(
this@MediaPlayerService, playerPosition, null, this@MediaPlayerService, playerPosition, null,
downloader.downloads.size, downloader.currentPlayingIndex + 1 downloader.all.size, downloader.currentPlayingIndex + 1
) )
} }

View File

@ -351,9 +351,9 @@ class SongView(context: Context) : UpdateView(context), Checkable, KoinComponent
companion object { companion object {
private var starHollowDrawable: Drawable? = null private var starHollowDrawable: Drawable? = null
private var starDrawable: Drawable? = null private var starDrawable: Drawable? = null
private var pinImage: Drawable? = null var pinImage: Drawable? = null
private var downloadedImage: Drawable? = null var downloadedImage: Drawable? = null
private var downloadingImage: Drawable? = null var downloadingImage: Drawable? = null
private var playingImage: Drawable? = null private var playingImage: Drawable? = null
private var theme: String? = null private var theme: String? = null
private var inflater: LayoutInflater? = null private var inflater: LayoutInflater? = null

View File

@ -25,6 +25,11 @@
a:checkable="true" a:checkable="true"
a:icon="?attr/playlists" a:icon="?attr/playlists"
a:title="@string/button_bar.playlists" /> a:title="@string/button_bar.playlists" />
<item
a:id="@+id/downloadsFragment"
a:checkable="true"
a:icon="?attr/downloaded"
a:title="@string/menu.downloads" />
<item <item
a:id="@+id/sharesFragment" a:id="@+id/sharesFragment"
a:checkable="true" a:checkable="true"

View File

@ -60,6 +60,9 @@
android:id="@+id/playlistsToSelectAlbum" android:id="@+id/playlistsToSelectAlbum"
app:destination="@id/trackCollectionFragment" /> app:destination="@id/trackCollectionFragment" />
</fragment> </fragment>
<fragment
android:id="@+id/downloadsFragment"
android:name="org.moire.ultrasonic.fragment.DownloadsFragment" />
<fragment <fragment
android:id="@+id/sharesFragment" android:id="@+id/sharesFragment"
android:name="org.moire.ultrasonic.fragment.SharesFragment" > android:name="org.moire.ultrasonic.fragment.SharesFragment" >

View File

@ -121,6 +121,7 @@
<string name="menu.common">Common</string> <string name="menu.common">Common</string>
<string name="menu.deleted_playlist">Deleted playlist %s</string> <string name="menu.deleted_playlist">Deleted playlist %s</string>
<string name="menu.deleted_playlist_error">Failed to delete playlist %s</string> <string name="menu.deleted_playlist_error">Failed to delete playlist %s</string>
<string name="menu.downloads">Downloads</string>
<string name="menu.exit">Exit</string> <string name="menu.exit">Exit</string>
<string name="menu.navigation">Navigation</string> <string name="menu.navigation">Navigation</string>
<string name="menu.settings">Settings</string> <string name="menu.settings">Settings</string>
@ -213,9 +214,9 @@
<string name="settings.directory_cache_time_60">1 hour</string> <string name="settings.directory_cache_time_60">1 hour</string>
<string name="settings.disc_sort">Sort Songs By Disc</string> <string name="settings.disc_sort">Sort Songs By Disc</string>
<string name="settings.disc_sort_summary">Sort song list by disc number and track number</string> <string name="settings.disc_sort_summary">Sort song list by disc number and track number</string>
<string name="settings.display_bitrate">Display Bitrate And File Suffix</string> <string name="settings.display_bitrate">Display Bitrate and File Suffix</string>
<string name="settings.display_bitrate_summary">Append artist name with bitrate and file suffix</string> <string name="settings.display_bitrate_summary">Append artist name with bitrate and file suffix</string>
<string name="settings.download_transition">Show Downloads On Play</string> <string name="settings.download_transition">Show Downloads on Play</string>
<string name="settings.download_transition_summary">Transition to download activity when starting playback</string> <string name="settings.download_transition_summary">Transition to download activity when starting playback</string>
<string name="settings.gapless_playback">Gapless Playback</string> <string name="settings.gapless_playback">Gapless Playback</string>
<string name="settings.gapless_playback_summary">Enable gapless playback</string> <string name="settings.gapless_playback_summary">Enable gapless playback</string>