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 index b361d0b7..b316ce8e 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Identifiable.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Identifiable.kt @@ -9,8 +9,13 @@ abstract class GenericEntry : Identifiable { override fun compareTo(other: Identifiable): Int { return this.id.toInt().compareTo(other.id.toInt()) } + @delegate:Ignore + override val longId: Long by lazy { + id.hashCode().toLong() + } } interface Identifiable : Comparable { val id: String + val longId: Long } diff --git a/dependencies.gradle b/dependencies.gradle index 24bb2e52..33ad5dd2 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -45,6 +45,7 @@ ext.versions = [ colorPicker : "2.2.3", rxJava : "3.1.2", rxAndroid : "3.0.0", + multiType : "4.3.0", ] ext.gradlePlugins = [ @@ -95,6 +96,7 @@ ext.other = [ colorPickerView : "com.github.skydoves:colorpickerview:$versions.colorPicker", rxJava : "io.reactivex.rxjava3:rxjava:$versions.rxJava", rxAndroid : "io.reactivex.rxjava3:rxandroid:$versions.rxAndroid", + multiType : "com.drakeet.multitype:multitype:$versions.multiType", ] ext.testing = [ diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 27d06bc3..934e3b0d 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -108,6 +108,8 @@ dependencies { implementation other.colorPickerView implementation other.rxJava implementation other.rxAndroid + implementation other.multiType + implementation 'androidx.recyclerview:recyclerview-selection:1.1.0' kapt androidSupport.room diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java index f42771c0..16936c09 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java @@ -19,6 +19,7 @@ public abstract class LoadingTask extends BackgroundTask this.cancel = cancel; } + @Override public void execute() { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowAdapter.kt similarity index 99% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowAdapter.kt index e203a35e..61c79fea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowAdapter.kt @@ -5,7 +5,7 @@ * Distributed under terms of the GNU GPLv3 license. */ -package org.moire.ultrasonic.fragment +package org.moire.ultrasonic.adapters import android.content.Context import android.graphics.drawable.Drawable diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowAdapter.kt similarity index 98% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowAdapter.kt index d4079a73..88837b68 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowAdapter.kt @@ -5,7 +5,7 @@ * Distributed under terms of the GNU GPLv3 license. */ -package org.moire.ultrasonic.fragment +package org.moire.ultrasonic.adapters import android.view.MenuItem import android.view.View diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/GenericRowAdapter.kt similarity index 99% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/GenericRowAdapter.kt index 33f3783e..e95fb2c5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/GenericRowAdapter.kt @@ -5,7 +5,7 @@ * Distributed under terms of the GNU GPLv3 license. */ -package org.moire.ultrasonic.fragment +package org.moire.ultrasonic.adapters import android.annotation.SuppressLint import android.view.LayoutInflater diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ImageHelper.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ImageHelper.kt new file mode 100644 index 00000000..174c449c --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ImageHelper.kt @@ -0,0 +1,45 @@ +package org.moire.ultrasonic.adapters + +import android.content.Context +import android.graphics.drawable.Drawable +import org.moire.ultrasonic.R +import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Util + +/** + * Provides cached drawables for the UI + */ +class ImageHelper(context: Context) { + + lateinit var starHollowDrawable: Drawable + lateinit var starDrawable: Drawable + lateinit var pinImage: Drawable + lateinit var downloadedImage: Drawable + lateinit var downloadingImage: Drawable + lateinit var playingImage: Drawable + var theme: String + + fun rebuild(context: Context, force: Boolean = false) { + val currentTheme = Settings.theme!! + val themesMatch = theme == currentTheme + if (!themesMatch) theme = currentTheme + + if (!themesMatch || force ) { + getDrawables(context) + } + } + + init { + theme = Settings.theme!! + getDrawables(context) + } + + private fun getDrawables(context: Context) { + starHollowDrawable = Util.getDrawableFromAttribute(context, R.attr.star_hollow) + starDrawable = Util.getDrawableFromAttribute(context, R.attr.star_full) + pinImage = Util.getDrawableFromAttribute(context, R.attr.pin) + downloadedImage = Util.getDrawableFromAttribute(context, R.attr.downloaded) + downloadingImage = Util.getDrawableFromAttribute(context, R.attr.downloading) + playingImage = Util.getDrawableFromAttribute(context, R.attr.media_play_small) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/MultiTypeDiffAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/MultiTypeDiffAdapter.kt new file mode 100644 index 00000000..7bb0e251 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/MultiTypeDiffAdapter.kt @@ -0,0 +1,157 @@ +package org.moire.ultrasonic.adapters + +import android.annotation.SuppressLint +import android.view.MotionEvent +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.ItemKeyProvider +import androidx.recyclerview.selection.SelectionTracker +import androidx.recyclerview.widget.AdapterListUpdateCallback +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.AsyncListDiffer.ListListener +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.drakeet.multitype.MultiTypeAdapter +import org.moire.ultrasonic.domain.Identifiable +import timber.log.Timber + +class MultiTypeDiffAdapter : MultiTypeAdapter() { + + val diffCallback = GenericDiffCallback() + var tracker: SelectionTracker? = null + + init { + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long { + return getItem(position).longId + } + + override var items: List + get() = getCurrentList() + set(value) { + throw Exception("You must use submitList() to add data to the MultiTypeDiffAdapter") + } + + + var mDiffer: AsyncListDiffer = AsyncListDiffer( + AdapterListUpdateCallback(this), + AsyncDifferConfig.Builder(diffCallback).build() + ) + + private val mListener = + ListListener { previousList, currentList -> + this@MultiTypeDiffAdapter.onCurrentListChanged( + previousList, + currentList + ) + } + + init { + mDiffer.addListListener(mListener) + } + + + /** + * Submits a new list to be diffed, and displayed. + * + * + * If a list is already being displayed, a diff will be computed on a background thread, which + * will dispatch Adapter.notifyItem events on the main thread. + * + * @param list The new list to be displayed. + */ + fun submitList(list: List?) { + mDiffer.submitList(list) + } + + /** + * Set the new list to be displayed. + * + * + * If a List is already being displayed, a diff will be computed on a background thread, which + * will dispatch Adapter.notifyItem events on the main thread. + * + * + * The commit callback can be used to know when the List is committed, but note that it + * may not be executed. If List B is submitted immediately after List A, and is + * committed directly, the callback associated with List A will not be run. + * + * @param list The new list to be displayed. + * @param commitCallback Optional runnable that is executed when the List is committed, if + * it is committed. + */ + fun submitList(list: List?, commitCallback: Runnable?) { + mDiffer.submitList(list, commitCallback) + } + + protected fun getItem(position: Int): T { + return mDiffer.currentList[position] + } + + override fun getItemCount(): Int { + return mDiffer.currentList.size + } + + /** + * Get the current List - any diffing to present this list has already been computed and + * dispatched via the ListUpdateCallback. + * + * + * If a `null` List, or no List has been submitted, an empty list will be returned. + * + * + * The returned list may not be mutated - mutations to content must be done through + * [.submitList]. + * + * @return The list currently being displayed. + * + * @see .onCurrentListChanged + */ + fun getCurrentList(): List { + return mDiffer.currentList + } + + /** + * Called when the current List is updated. + * + * + * If a `null` List is passed to [.submitList], or no List has been + * submitted, the current List is represented as an empty List. + * + * @param previousList List that was displayed previously. + * @param currentList new List being displayed, will be empty if `null` was passed to + * [.submitList]. + * + * @see .getCurrentList + */ + fun onCurrentListChanged(previousList: List, currentList: List) { + // Void + } + + + + + + companion object { + /** + * Calculates the differences between data sets + */ + class GenericDiffCallback : DiffUtil.ItemCallback() { + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { + return oldItem == newItem + } + + override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { + return oldItem.id == newItem.id + } + } + + + + + } + +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt similarity index 99% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerRowAdapter.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt index 2b9e4be1..de8c1590 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt @@ -18,6 +18,7 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.util.ServerColor +import org.moire.ultrasonic.fragment.ServerSettingsModel import org.moire.ultrasonic.util.Util /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt new file mode 100644 index 00000000..e5e74d2d --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt @@ -0,0 +1,94 @@ +package org.moire.ultrasonic.adapters + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.Checkable +import androidx.recyclerview.selection.SelectionTracker +import com.drakeet.multitype.ItemViewBinder +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.domain.Identifiable +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.service.DownloadFile +import org.moire.ultrasonic.service.Downloader +import org.moire.ultrasonic.util.Settings + +class TrackViewBinder( + val selectedSet: MutableSet, + val checkable: Boolean, + val draggable: Boolean, + context: Context +) : ItemViewBinder(), KoinComponent { + + +// // +// onItemClick: (MusicDirectory.Entry) -> Unit, +// onContextMenuClick: (MenuItem, MusicDirectory.Entry) -> Boolean, +// onMusicFolderUpdate: (String?) -> Unit, +// context: Context, +// val lifecycleOwner: LifecycleOwner, +// init { +// super.submitList(itemList) +// } + + // Set our layout files + val layout = R.layout.song_list_item + val contextMenuLayout = R.menu.artist_context_menu + + private val downloader: Downloader by inject() + + private val imageHelper: ImageHelper = ImageHelper(context) + + + override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): TrackViewHolder { + return TrackViewHolder(inflater.inflate(layout, parent, false), selectedSet) + } + + override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) { + + val downloadFile: DownloadFile? + + when (item) { + is MusicDirectory.Entry -> { + downloadFile = downloader.getDownloadFileForSong(item) + } + is DownloadFile -> { + downloadFile = item + } + else -> { + return + } + } + + holder.imageHelper = imageHelper + + holder.setSong( + file = downloadFile, + checkable = checkable, + draggable = draggable + ) + + // Observe download status +// item.status.observe( +// lifecycleOwner, +// { +// holder.updateDownloadStatus(item) +// } +// ) +// +// item.progress.observe( +// lifecycleOwner, +// { +// holder.updateDownloadStatus(item) +// } +// ) + } + + +} + + + diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt new file mode 100644 index 00000000..116a2852 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -0,0 +1,306 @@ +package org.moire.ultrasonic.adapters + +import android.graphics.drawable.AnimationDrawable +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.Checkable +import android.widget.CheckedTextView +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.component.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.featureflags.Feature +import org.moire.ultrasonic.featureflags.FeatureStorage +import org.moire.ultrasonic.service.DownloadFile +import org.moire.ultrasonic.service.MediaPlayerController +import org.moire.ultrasonic.service.MusicServiceFactory +import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Util +import timber.log.Timber + +/** + * Used to display songs and videos in a `ListView`. + * TODO: Video List item + */ +class TrackViewHolder(val view: View, val selectedSet: MutableSet) : + RecyclerView.ViewHolder(view), Checkable, KoinComponent { + var check: CheckedTextView = view.findViewById(R.id.song_check) + var rating: LinearLayout = view.findViewById(R.id.song_rating) + private var fiveStar1: ImageView = view.findViewById(R.id.song_five_star_1) + private var fiveStar2: ImageView = view.findViewById(R.id.song_five_star_2) + private var fiveStar3: ImageView = view.findViewById(R.id.song_five_star_3) + private var fiveStar4: ImageView = view.findViewById(R.id.song_five_star_4) + private 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) + + var entry: MusicDirectory.Entry? = null + private set + var downloadFile: DownloadFile? = null + private set + + private var isMaximized = false + private var leftImage: Drawable? = null + private var previousLeftImageType: ImageType? = null + private var previousRightImageType: ImageType? = null + private var leftImageType: ImageType? = null + private var playing = false + + private val useFiveStarRating: Boolean by lazy { + val features: FeatureStorage = get() + features.isFeatureEnabled(Feature.FIVE_STAR_RATING) + } + + private val mediaPlayerController: MediaPlayerController by inject() + + lateinit var imageHelper: ImageHelper + + init { + itemView.setOnClickListener { + val nowChecked = !check.isChecked + isChecked = nowChecked + } + } + + fun setSong( + file: DownloadFile, + checkable: Boolean, + draggable: Boolean, + isSelected: Boolean = false + ) { + Timber.e("BINDING %s", isSelected) + val song = file.song + downloadFile = file + entry = song + + val entryDescription = Util.readableEntryDescription(song) + + artist.text = entryDescription.artist + title.text = entryDescription.title + duration.text = entryDescription.duration + + + if (Settings.shouldShowTrackNumber && song.track != null && song.track!! > 0) { + track.text = entryDescription.trackNumber + } else { + track.isVisible = false + } + + check.isVisible = (checkable && !song.isVideo) + drag.isVisible = draggable + + if (ActiveServerProvider.isOffline()) { + star.isVisible = false + rating.isVisible = false + } else { + setupStarButtons(song) + } + + update() + + isChecked = isSelected + + } + + private fun setupStarButtons(song: MusicDirectory.Entry) { + if (useFiveStarRating) { + // Hide single star + star.isVisible = false + val rating = if (song.userRating == null) 0 else song.userRating!! + setFiveStars(rating) + } else { + // Hide five stars + rating.isVisible = false + + setSingleStar(song.starred) + star.setOnClickListener { + val isStarred = song.starred + val id = song.id + + if (!isStarred) { + star.setImageDrawable(imageHelper.starDrawable) + song.starred = true + } else { + star.setImageDrawable(imageHelper.starHollowDrawable) + song.starred = false + } + Thread { + val musicService = MusicServiceFactory.getMusicService() + try { + if (!isStarred) { + musicService.star(id, null, null) + } else { + musicService.unstar(id, null, null) + } + } catch (all: Exception) { + Timber.e(all) + } + }.start() + } + } + } + + + + + @Synchronized + // TODO: Should be removed + fun update() { + + updateDownloadStatus(downloadFile!!) + + if (useFiveStarRating) { + val rating = entry?.userRating ?: 0 + setFiveStars(rating) + } else { + setSingleStar(entry!!.starred) + } + + val playing = mediaPlayerController.currentPlaying === downloadFile + + if (playing) { + if (!this.playing) { + this.playing = true + title.setCompoundDrawablesWithIntrinsicBounds( + imageHelper.playingImage, null, null, null + ) + } + } else { + if (this.playing) { + this.playing = false + title.setCompoundDrawablesWithIntrinsicBounds( + 0, 0, 0, 0 + ) + } + } + } + + @Suppress("MagicNumber") + private fun setFiveStars(rating: Int) { + fiveStar1.setImageDrawable( + if (rating > 0) imageHelper.starDrawable else imageHelper.starHollowDrawable + ) + fiveStar2.setImageDrawable( + if (rating > 1) imageHelper.starDrawable else imageHelper.starHollowDrawable + ) + fiveStar3.setImageDrawable( + if (rating > 2) imageHelper.starDrawable else imageHelper.starHollowDrawable + ) + fiveStar4.setImageDrawable( + if (rating > 3) imageHelper.starDrawable else imageHelper.starHollowDrawable + ) + fiveStar5.setImageDrawable( + if (rating > 4) imageHelper.starDrawable else imageHelper.starHollowDrawable + ) + } + + private fun setSingleStar(starred: Boolean) { + if (starred) { + if (star.drawable !== imageHelper.starDrawable) { + star.setImageDrawable(imageHelper.starDrawable) + } + } else { + if (star.drawable !== imageHelper.starHollowDrawable) { + star.setImageDrawable(imageHelper.starHollowDrawable) + } + } + } + + fun updateDownloadStatus(downloadFile: DownloadFile) { + + if (downloadFile.isWorkDone) { + val newLeftImageType = + if (downloadFile.isSaved) ImageType.Pin else ImageType.Downloaded + + if (leftImageType != newLeftImageType) { + leftImage = if (downloadFile.isSaved) { + imageHelper.pinImage + } else { + imageHelper.downloadedImage + } + leftImageType = newLeftImageType + } + } else { + leftImageType = ImageType.None + leftImage = null + } + + val rightImageType: ImageType + val rightImage: Drawable? + + if (downloadFile.isDownloading && !downloadFile.isDownloadCancelled) { + status.text = Util.formatPercentage(downloadFile.progress.value!!) + + rightImageType = ImageType.Downloading + rightImage = imageHelper.downloadingImage + } else { + rightImageType = ImageType.None + rightImage = null + + val statusText = status.text + if (!statusText.isNullOrEmpty()) status.text = null + } + + if (previousLeftImageType != leftImageType || previousRightImageType != rightImageType) { + previousLeftImageType = leftImageType + previousRightImageType = rightImageType + + status.setCompoundDrawablesWithIntrinsicBounds( + leftImage, null, rightImage, null + ) + + if (rightImage === imageHelper.downloadingImage) { + // FIXME + val frameAnimation = rightImage as AnimationDrawable? + + frameAnimation?.setVisible(true, true) + frameAnimation?.start() + } + } + } + + + override fun setChecked(newStatus: Boolean) { + if (newStatus) { + selectedSet.add(downloadFile!!.longId) + Timber.d("Selectedset %s", selectedSet.toString()) + } else { + selectedSet.remove(downloadFile!!.longId) + } + check.isChecked = newStatus + } + + override fun isChecked(): Boolean { + return check.isChecked + } + + override fun toggle() { + isChecked = isChecked + } + + fun maximizeOrMinimize() { + isMaximized = !isMaximized + + title.isSingleLine = !isMaximized + artist.isSingleLine = !isMaximized + } + + enum class ImageType { + None, Pin, Downloaded, Downloading + } + + + +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt index 5c4dcbc7..a902e443 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt @@ -43,9 +43,11 @@ internal class FilePickerAdapter(view: FilePickerView) : init { this.context = view.context - upIcon = Util.getDrawableFromAttribute(context, R.attr.filepicker_subdirectory_up) - folderIcon = Util.getDrawableFromAttribute(context, R.attr.filepicker_folder) - sdIcon = Util.getDrawableFromAttribute(context, R.attr.filepicker_sd_card) + listerView = view + + upIcon = Util.getDrawableFromAttribute(context!!, R.attr.filepicker_subdirectory_up) + folderIcon = Util.getDrawableFromAttribute(context!!, R.attr.filepicker_folder) + sdIcon = Util.getDrawableFromAttribute(context!!, R.attr.filepicker_sd_card) } fun start() { 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 aafdea81..2070278c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.LiveData import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView import org.moire.ultrasonic.R +import org.moire.ultrasonic.adapters.AlbumRowAdapter import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.util.Constants 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 58ee16a8..989c4b7b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.fragment.app.viewModels import androidx.lifecycle.LiveData import org.moire.ultrasonic.R +import org.moire.ultrasonic.adapters.ArtistRowAdapter import org.moire.ultrasonic.domain.ArtistOrIndex import org.moire.ultrasonic.util.Constants diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt index b9ca729a..a3a69fe2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt @@ -2,26 +2,20 @@ 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.adapters.GenericRowAdapter 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 +import org.moire.ultrasonic.view.SongViewHolder class DownloadsFragment : GenericListFragment() { @@ -92,7 +86,7 @@ class DownloadRowAdapter( onItemClick: (DownloadFile) -> Unit, onContextMenuClick: (MenuItem, DownloadFile) -> Boolean, onMusicFolderUpdate: (String?) -> Unit, - context: Context, + val context: Context, val lifecycleOwner: LifecycleOwner ) : GenericRowAdapter( onItemClick, @@ -104,116 +98,45 @@ class DownloadRowAdapter( 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) { + if (holder is SongViewHolder) { 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) + + holder.setSong(downloadFile, checkable = false, draggable = false) // Observe download status downloadFile.status.observe( lifecycleOwner, { - updateDownloadStatus(downloadFile, holder) + holder.updateDownloadStatus(downloadFile) } ) downloadFile.progress.observe( lifecycleOwner, { - updateDownloadStatus(downloadFile, holder) + holder.updateDownloadStatus(downloadFile) } ) } } - 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) + return SongViewHolder(view, context) } + + } class DownloadListModel(application: Application) : GenericListModel(application) { @@ -223,3 +146,6 @@ class DownloadListModel(application: Application) : GenericListModel(application return downloader.observableDownloads } } + + + 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 1aa1f254..c5df2b4f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt @@ -15,6 +15,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.R +import org.moire.ultrasonic.adapters.GenericRowAdapter import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.GenericEntry diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt new file mode 100644 index 00000000..e3604620 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt @@ -0,0 +1,284 @@ +package org.moire.ultrasonic.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.LiveData +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.drakeet.multitype.MultiTypeAdapter +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +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 +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.view.SelectMusicFolderView + +/** + * An abstract Model, which can be extended to display a list of items of type T from the API + * @param T: The type of data which will be used (must extend GenericEntry) + * @param TA: The Adapter to use (must extend GenericRowAdapter) + */ +abstract class MultiListFragment : Fragment() { + internal val activeServerProvider: ActiveServerProvider by inject() + internal val serverSettingsModel: ServerSettingsModel by viewModel() + internal val imageLoaderProvider: ImageLoaderProvider by inject() + protected val downloadHandler: DownloadHandler by inject() + protected var refreshListView: SwipeRefreshLayout? = null + internal var listView: RecyclerView? = null + internal lateinit var viewManager: LinearLayoutManager + internal var selectFolderHeader: SelectMusicFolderView? = null + + /** + * The Adapter for the RecyclerView + * Recommendation: Implement this as a lazy delegate + */ + internal abstract val viewAdapter: TA + + /** + * The ViewModel to use to get the data + */ + open val listModel: GenericListModel by viewModels() + + /** + * The LiveData containing the list provided by the model + * Implement this as a getter + */ + internal lateinit var liveDataItems: LiveData> + + /** + * The central function to pass a query to the model and return a LiveData object + */ + abstract fun getLiveData(args: Bundle? = null): LiveData> + + /** + * The id of the target in the navigation graph where we should go, + * after the user has clicked on an item + */ + protected abstract val itemClickTarget: Int + + /** + * The id of the RecyclerView + */ + protected abstract val recyclerViewId: Int + + /** + * The id of the main layout + */ + abstract val mainLayout: Int + + /** + * The id of the refresh view + */ + abstract val refreshListId: Int + + /** + * The observer to be called if the available music folders have changed + */ + @Suppress("CommentOverPrivateProperty") + private val musicFolderObserver = { folders: List -> + //viewAdapter.setFolderList(folders, listModel.activeServer.musicFolderId) + } + + /** + * What to do when the user has modified the folder filter + */ + val onMusicFolderUpdate = { selectedFolderId: String? -> + if (!listModel.isOffline()) { + val currentSetting = listModel.activeServer + currentSetting.musicFolderId = selectedFolderId + serverSettingsModel.updateItem(currentSetting) + } + viewAdapter.notifyDataSetChanged() + listModel.refresh(refreshListView!!, arguments) + } + + /** + * Whether to show the folder selector + */ + fun showFolderHeader(): Boolean { + return listModel.showSelectFolderHeader(arguments) && + !listModel.isOffline() && !Settings.shouldUseId3Tags + } + + open fun setTitle(title: String?) { + if (title == null) { + FragmentTitle.setTitle( + this, + if (listModel.isOffline()) + R.string.music_library_label_offline + else R.string.music_library_label + ) + } else { + FragmentTitle.setTitle(this, title) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Set the title if available + setTitle(arguments?.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE)) + + // Setup refresh handler + refreshListView = view.findViewById(refreshListId) + refreshListView?.setOnRefreshListener { + listModel.refresh(refreshListView!!, arguments) + } + + // Populate the LiveData. This starts an API request in most cases + liveDataItems = getLiveData(arguments) + + // Register an observer to update our UI when the data changes +// liveDataItems.observe(viewLifecycleOwner, { +// newItems -> viewAdapter.submitList(newItems) +// }) + + // Setup the Music folder handling + listModel.getMusicFolders().observe(viewLifecycleOwner, musicFolderObserver) + + // Create a View Manager + viewManager = LinearLayoutManager(this.context) + + // Hook up the view with the manager and the adapter + listView = view.findViewById(recyclerViewId).apply { + setHasFixedSize(true) + layoutManager = viewManager + adapter = viewAdapter + } + + // Configure whether to show the folder header + //viewAdapter.folderHeaderEnabled = showFolderHeader() + } + + @Override + override fun onCreate(savedInstanceState: Bundle?) { + Util.applyTheme(this.context) + super.onCreate(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + 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") +// 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 +// } +// +// 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) +// bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, item.id) +// bundle.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, (item is Artist)) +// findNavController().navigate(itemClickTarget, bundle) +// } +//} 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 7556d148..ef7da49b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -217,7 +217,7 @@ class PlayerFragment : val ratingLinearLayout = view.findViewById(R.id.song_rating) if (!useFiveStarRating) ratingLinearLayout.isVisible = false hollowStar = Util.getDrawableFromAttribute(view.context, R.attr.star_hollow) - fullStar = Util.getDrawableFromAttribute(context, R.attr.star_full) + fullStar = Util.getDrawableFromAttribute(context!!, R.attr.star_full) fiveStar1ImageView.setOnClickListener { setSongRating(1) } fiveStar2ImageView.setOnClickListener { setSongRating(2) } @@ -885,17 +885,17 @@ class PlayerFragment : when (mediaPlayerController.repeatMode) { RepeatMode.OFF -> repeatButton.setImageDrawable( Util.getDrawableFromAttribute( - context, R.attr.media_repeat_off + requireContext(), R.attr.media_repeat_off ) ) RepeatMode.ALL -> repeatButton.setImageDrawable( Util.getDrawableFromAttribute( - context, R.attr.media_repeat_all + requireContext(), R.attr.media_repeat_all ) ) RepeatMode.SINGLE -> repeatButton.setImageDrawable( Util.getDrawableFromAttribute( - context, R.attr.media_repeat_single + requireContext(), R.attr.media_repeat_single ) ) else -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt index 05d7b568..f06ed156 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.R +import org.moire.ultrasonic.adapters.ServerRowAdapter import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.fragment.EditServerFragment.Companion.EDIT_SERVER_INTENT_INDEX import org.moire.ultrasonic.service.MediaPlayerController diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 6394b08a..a2c379a7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -10,8 +10,6 @@ package org.moire.ultrasonic.fragment import android.os.Bundle import android.os.Handler import android.os.Looper -import android.view.ContextMenu -import android.view.ContextMenu.ContextMenuInfo import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -20,30 +18,30 @@ import android.view.View import android.view.ViewGroup import android.widget.AdapterView.AdapterContextMenuInfo import android.widget.ImageView -import android.widget.ListView import android.widget.TextView -import androidx.fragment.app.Fragment +import androidx.core.view.isVisible import androidx.fragment.app.viewModels +import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.viewModelScope import androidx.navigation.Navigation -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import java.util.Collections -import java.util.Random +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.moire.ultrasonic.R +import org.moire.ultrasonic.adapters.MultiTypeDiffAdapter +import org.moire.ultrasonic.adapters.TrackViewBinder +import org.moire.ultrasonic.adapters.TrackViewHolder import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline +import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.fragment.FragmentTitle.Companion.getTitle import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.service.MediaPlayerController -import org.moire.ultrasonic.subsonic.DownloadHandler -import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker import org.moire.ultrasonic.subsonic.ShareHandler -import org.moire.ultrasonic.subsonic.VideoPlayer import org.moire.ultrasonic.util.AlbumHeader import org.moire.ultrasonic.util.CancellationToken import org.moire.ultrasonic.util.CommunicationError @@ -51,19 +49,18 @@ import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util -import org.moire.ultrasonic.view.AlbumView -import org.moire.ultrasonic.view.EntryAdapter -import org.moire.ultrasonic.view.SongView import timber.log.Timber +import java.util.Collections +import java.util.Random +import java.util.TreeSet /** * Displays a group of tracks, eg. the songs of an album, of a playlist etc. * TODO: Refactor this fragment and model to extend the GenericListFragment */ -class TrackCollectionFragment : Fragment() { +class TrackCollectionFragment : + MultiListFragment>() { - private var refreshAlbumListView: SwipeRefreshLayout? = null - private var albumListView: ListView? = null private var header: View? = null private var albumButtons: View? = null private var emptyView: TextView? = null @@ -82,15 +79,38 @@ class TrackCollectionFragment : Fragment() { private var shareButton: MenuItem? = null private val mediaPlayerController: MediaPlayerController by inject() - private val downloadHandler: DownloadHandler by inject() private val networkAndStorageChecker: NetworkAndStorageChecker by inject() - private val imageLoaderProvider: ImageLoaderProvider by inject() private val shareHandler: ShareHandler by inject() private var cancellationToken: CancellationToken? = null - private val model: TrackCollectionModel by viewModels() + override val listModel: TrackCollectionModel by viewModels() private val random: Random = Random() + private var selectedSet: TreeSet = TreeSet() + + /** + * The id of the main layout + */ + override val mainLayout: Int = R.layout.track_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 + + override fun onCreate(savedInstanceState: Bundle?) { Util.applyTheme(this.context) super.onCreate(savedInstanceState) @@ -101,7 +121,7 @@ class TrackCollectionFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return inflater.inflate(R.layout.select_album, container, false) + return inflater.inflate(R.layout.track_list, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -110,53 +130,51 @@ class TrackCollectionFragment : Fragment() { albumButtons = view.findViewById(R.id.menu_album) - refreshAlbumListView = view.findViewById(R.id.select_album_entries_refresh) - albumListView = view.findViewById(R.id.select_album_entries_list) - - refreshAlbumListView!!.setOnRefreshListener { + // Setup refresh handler + refreshListView = view.findViewById(refreshListId) + refreshListView?.setOnRefreshListener { updateDisplay(true) } header = LayoutInflater.from(context).inflate( - R.layout.select_album_header, albumListView, + R.layout.select_album_header, listView, false ) - model.currentDirectory.observe(viewLifecycleOwner, defaultObserver) - model.songsForGenre.observe(viewLifecycleOwner, songsForGenreObserver) + listModel.currentList.observe(viewLifecycleOwner, defaultObserver) + listModel.songsForGenre.observe(viewLifecycleOwner, songsForGenreObserver) - albumListView!!.choiceMode = ListView.CHOICE_MODE_MULTIPLE - albumListView!!.setOnItemClickListener { parent, theView, position, _ -> - if (position >= 0) { - val entry = parent.getItemAtPosition(position) as MusicDirectory.Entry? - if (entry != null && entry.isDirectory) { - val bundle = Bundle() - bundle.putString(Constants.INTENT_EXTRA_NAME_ID, entry.id) - bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, entry.isDirectory) - bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.title) - bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, entry.parent) - Navigation.findNavController(theView).navigate( - R.id.trackCollectionFragment, - bundle - ) - } else if (entry != null && entry.isVideo) { - VideoPlayer.playVideo(requireContext(), entry) - } else { - enableButtons() - } - } - } - - albumListView!!.setOnItemLongClickListener { _, theView, _, _ -> - if (theView is AlbumView) { - return@setOnItemLongClickListener false - } - if (theView is SongView) { - theView.maximizeOrMinimize() - return@setOnItemLongClickListener true - } - return@setOnItemLongClickListener false - } +// listView!!.setOnItemClickListener { parent, theView, position, _ -> +// if (position >= 0) { +// val entry = parent.getItemAtPosition(position) as MusicDirectory.Entry? +// if (entry != null && entry.isDirectory) { +// val bundle = Bundle() +// bundle.putString(Constants.INTENT_EXTRA_NAME_ID, entry.id) +// bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, entry.isDirectory) +// bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, entry.title) +// bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, entry.parent) +// Navigation.findNavController(theView).navigate( +// R.id.trackCollectionFragment, +// bundle +// ) +// } else if (entry != null && entry.isVideo) { +// VideoPlayer.playVideo(requireContext(), entry) +// } else { +// enableButtons() +// } +// } +// } +// +// listView!!.setOnItemLongClickListener { _, theView, _, _ -> +// if (theView is AlbumView) { +// return@setOnItemLongClickListener false +// } +// if (theView is SongView) { +// theView.maximizeOrMinimize() +// return@setOnItemLongClickListener true +// } +// return@setOnItemLongClickListener false +// } selectButton = view.findViewById(R.id.select_album_select) playNowButton = view.findViewById(R.id.select_album_play_now) @@ -179,33 +197,51 @@ class TrackCollectionFragment : Fragment() { downloadHandler.download( this@TrackCollectionFragment, append = true, save = false, autoPlay = false, playNext = true, shuffle = false, - songs = getSelectedSongs(albumListView) + songs = getSelectedSongs() ) - selectAll(selected = false, toast = false) } playLastButton!!.setOnClickListener { playNow(true) } pinButton!!.setOnClickListener { downloadBackground(true) - selectAll(selected = false, toast = false) } unpinButton!!.setOnClickListener { unpin() - selectAll(selected = false, toast = false) } downloadButton!!.setOnClickListener { downloadBackground(false) - selectAll(selected = false, toast = false) } deleteButton!!.setOnClickListener { delete() - selectAll(selected = false, toast = false) } - registerForContextMenu(albumListView!!) + registerForContextMenu(listView!!) setHasOptionsMenu(true) + + + // Create a View Manager + viewManager = LinearLayoutManager(this.context) + + // Hook up the view with the manager and the adapter + listView = view.findViewById(recyclerViewId).apply { + setHasFixedSize(true) + layoutManager = viewManager + adapter = viewAdapter + } + + + viewAdapter.register( + TrackViewBinder( + selectedSet = selectedSet, + checkable = true, + draggable = false, + context = context!! + ) + ) + enableButtons() + updateDisplay(false) } @@ -213,177 +249,82 @@ class TrackCollectionFragment : Fragment() { Handler(Looper.getMainLooper()).post { CommunicationError.handleError(exception, context) } - refreshAlbumListView!!.isRefreshing = false + refreshListView!!.isRefreshing = false } private fun updateDisplay(refresh: Boolean) { - val args = requireArguments() - val id = args.getString(Constants.INTENT_EXTRA_NAME_ID) - val isAlbum = args.getBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, false) - val name = args.getString(Constants.INTENT_EXTRA_NAME_NAME) - val parentId = args.getString(Constants.INTENT_EXTRA_NAME_PARENT_ID) - val playlistId = args.getString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID) - val podcastChannelId = args.getString( - Constants.INTENT_EXTRA_NAME_PODCAST_CHANNEL_ID - ) - val playlistName = args.getString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME) - val shareId = args.getString(Constants.INTENT_EXTRA_NAME_SHARE_ID) - val shareName = args.getString(Constants.INTENT_EXTRA_NAME_SHARE_NAME) - val genreName = args.getString(Constants.INTENT_EXTRA_NAME_GENRE_NAME) - - val getStarredTracks = args.getInt(Constants.INTENT_EXTRA_NAME_STARRED, 0) - val getVideos = args.getInt(Constants.INTENT_EXTRA_NAME_VIDEOS, 0) - val getRandomTracks = args.getInt(Constants.INTENT_EXTRA_NAME_RANDOM, 0) - val albumListSize = args.getInt( - Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0 - ) - val albumListOffset = args.getInt( - Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0 - ) - - fun setTitle(name: String?) { - setTitle(this@TrackCollectionFragment, name) - } - - fun setTitle(name: Int) { - setTitle(this@TrackCollectionFragment, name) - } - - model.viewModelScope.launch(handler) { - refreshAlbumListView!!.isRefreshing = true - - model.getMusicFolders(refresh) - - if (playlistId != null) { - setTitle(playlistName!!) - model.getPlaylist(playlistId, playlistName) - } else if (podcastChannelId != null) { - setTitle(getString(R.string.podcasts_label)) - model.getPodcastEpisodes(podcastChannelId) - } else if (shareId != null) { - setTitle(shareName) - model.getShare(shareId) - } else if (genreName != null) { - setTitle(genreName) - model.getSongsForGenre(genreName, albumListSize, albumListOffset) - } else if (getStarredTracks != 0) { - setTitle(getString(R.string.main_songs_starred)) - model.getStarred() - } else if (getVideos != 0) { - setTitle(R.string.main_videos) - model.getVideos(refresh) - } else if (getRandomTracks != 0) { - setTitle(R.string.main_songs_random) - model.getRandom(albumListSize) - } else { - setTitle(name) - if (!isOffline() && Settings.shouldUseId3Tags) { - if (isAlbum) { - model.getAlbum(refresh, id!!, name, parentId) - } else { - model.getArtist(refresh, id!!, name) - } - } else { - model.getMusicDirectory(refresh, id!!, name, parentId) - } - } - - refreshAlbumListView!!.isRefreshing = false - } - } - - override fun onCreateContextMenu(menu: ContextMenu, view: View, menuInfo: ContextMenuInfo?) { - super.onCreateContextMenu(menu, view, menuInfo) - val info = menuInfo as AdapterContextMenuInfo? - - val entry = albumListView!!.getItemAtPosition(info!!.position) as MusicDirectory.Entry? - - if (entry != null && entry.isDirectory) { - val inflater = requireActivity().menuInflater - inflater.inflate(R.menu.generic_context_menu, menu) - } - - shareButton = menu.findItem(R.id.menu_item_share) - - if (shareButton != null) { - shareButton!!.isVisible = !isOffline() - } - - val downloadMenuItem = menu.findItem(R.id.menu_download) - if (downloadMenuItem != null) { - downloadMenuItem.isVisible = !isOffline() - } + getLiveData(requireArguments()) } override fun onContextItemSelected(menuItem: MenuItem): Boolean { Timber.d("onContextItemSelected") val info = menuItem.menuInfo as AdapterContextMenuInfo? ?: return true - val entry = albumListView!!.getItemAtPosition(info.position) as MusicDirectory.Entry? - ?: return true - - val entryId = entry.id - - when (menuItem.itemId) { - R.id.menu_play_now -> { - downloadHandler.downloadRecursively( - this, entryId, save = false, append = false, - autoPlay = true, shuffle = false, background = false, - playNext = false, unpin = false, isArtist = false - ) - } - R.id.menu_play_next -> { - downloadHandler.downloadRecursively( - this, entryId, save = false, append = false, - autoPlay = false, shuffle = false, background = false, - playNext = true, unpin = false, isArtist = false - ) - } - R.id.menu_play_last -> { - downloadHandler.downloadRecursively( - this, entryId, save = false, append = true, - autoPlay = false, shuffle = false, background = false, - playNext = false, unpin = false, isArtist = false - ) - } - R.id.menu_pin -> { - downloadHandler.downloadRecursively( - this, entryId, save = true, append = true, - autoPlay = false, shuffle = false, background = false, - playNext = false, unpin = false, isArtist = false - ) - } - R.id.menu_unpin -> { - downloadHandler.downloadRecursively( - this, entryId, save = false, append = false, - autoPlay = false, shuffle = false, background = false, - playNext = false, unpin = true, isArtist = false - ) - } - R.id.menu_download -> { - downloadHandler.downloadRecursively( - this, entryId, save = false, append = false, - autoPlay = false, shuffle = false, background = true, - playNext = false, unpin = false, isArtist = false - ) - } - R.id.select_album_play_all -> { - // TODO: Why is this being handled here?! - playAll() - } - R.id.menu_item_share -> { - val entries: MutableList = ArrayList(1) - entries.add(entry) - shareHandler.createShare( - this, entries, refreshAlbumListView, - cancellationToken!! - ) - return true - } - else -> { - return super.onContextItemSelected(menuItem) - } - } +// val entry = listView!!.getItemAtPosition(info.position) as MusicDirectory.Entry? +// ?: return true +// +// val entryId = entry.id +// +// when (menuItem.itemId) { +// R.id.menu_play_now -> { +// downloadHandler.downloadRecursively( +// this, entryId, save = false, append = false, +// autoPlay = true, shuffle = false, background = false, +// playNext = false, unpin = false, isArtist = false +// ) +// } +// R.id.menu_play_next -> { +// downloadHandler.downloadRecursively( +// this, entryId, save = false, append = false, +// autoPlay = false, shuffle = false, background = false, +// playNext = true, unpin = false, isArtist = false +// ) +// } +// R.id.menu_play_last -> { +// downloadHandler.downloadRecursively( +// this, entryId, save = false, append = true, +// autoPlay = false, shuffle = false, background = false, +// playNext = false, unpin = false, isArtist = false +// ) +// } +// R.id.menu_pin -> { +// downloadHandler.downloadRecursively( +// this, entryId, save = true, append = true, +// autoPlay = false, shuffle = false, background = false, +// playNext = false, unpin = false, isArtist = false +// ) +// } +// R.id.menu_unpin -> { +// downloadHandler.downloadRecursively( +// this, entryId, save = false, append = false, +// autoPlay = false, shuffle = false, background = false, +// playNext = false, unpin = true, isArtist = false +// ) +// } +// R.id.menu_download -> { +// downloadHandler.downloadRecursively( +// this, entryId, save = false, append = false, +// autoPlay = false, shuffle = false, background = true, +// playNext = false, unpin = false, isArtist = false +// ) +// } +// R.id.select_album_play_all -> { +// // TODO: Why is this being handled here?! +// playAll() +// } +// R.id.menu_item_share -> { +// val entries: MutableList = ArrayList(1) +// entries.add(entry) +// shareHandler.createShare( +// this, entries, refreshListView, +// cancellationToken!! +// ) +// return true +// } +// else -> { +// return super.onContextItemSelected(menuItem) +// } +// } return true } @@ -414,8 +355,8 @@ class TrackCollectionFragment : Fragment() { return true } else if (itemId == R.id.menu_item_share) { shareHandler.createShare( - this, getSelectedSongs(albumListView), - refreshAlbumListView, cancellationToken!! + this, getSelectedSongs(), + refreshListView, cancellationToken!! ) return true } @@ -429,7 +370,7 @@ class TrackCollectionFragment : Fragment() { } private fun playNow(append: Boolean) { - val selectedSongs = getSelectedSongs(albumListView) + val selectedSongs = getSelectedSongs() if (selectedSongs.isNotEmpty()) { downloadHandler.download( @@ -445,8 +386,9 @@ class TrackCollectionFragment : Fragment() { private fun playAll(shuffle: Boolean = false, append: Boolean = false) { var hasSubFolders = false - for (i in 0 until albumListView!!.count) { - val entry = albumListView!!.getItemAtPosition(i) as MusicDirectory.Entry? + for (i in 0 until listView!!.childCount) { + val vh = listView!!.findViewHolderForAdapterPosition(i) as TrackViewHolder? + val entry = vh?.entry if (entry != null && entry.isDirectory) { hasSubFolders = true break @@ -458,46 +400,56 @@ class TrackCollectionFragment : Fragment() { if (hasSubFolders && id != null) { downloadHandler.downloadRecursively( - this, id, false, append, !append, - shuffle, background = false, playNext = false, unpin = false, isArtist = isArtist + fragment = this, + id = id, + save = false, + append = append, + autoPlay = !append, + shuffle = shuffle, + background = false, + playNext = false, + unpin = false, + isArtist = isArtist ) } else { selectAll(selected = true, toast = false) downloadHandler.download( - this, append, false, !append, false, - shuffle, getSelectedSongs(albumListView) + fragment = this, + append = append, + save = false, + autoPlay = !append, + playNext = false, + shuffle = shuffle, + songs = getSelectedSongs() ) selectAll(selected = false, toast = false) } } private fun selectAllOrNone() { - var someUnselected = false - val count = albumListView!!.count + val someUnselected = selectedSet.size < listView!!.childCount - for (i in 0 until count) { - if (!albumListView!!.isItemChecked(i) && - albumListView!!.getItemAtPosition(i) is MusicDirectory.Entry - ) { - someUnselected = true - break - } - } selectAll(someUnselected, true) + } private fun selectAll(selected: Boolean, toast: Boolean) { - val count = albumListView!!.count + val count = listView!!.childCount var selectedCount = 0 + listView!! + for (i in 0 until count) { - val entry = albumListView!!.getItemAtPosition(i) as MusicDirectory.Entry? + val vh = listView!!.findViewHolderForAdapterPosition(i) as TrackViewHolder + val entry = vh.entry + if (entry != null && !entry.isDirectory && !entry.isVideo) { - albumListView!!.setItemChecked(i, selected) + vh.isChecked = selected selectedCount++ } } + // Display toast: N tracks selected / N tracks unselected if (toast) { val toastResId = if (selected) @@ -506,11 +458,12 @@ class TrackCollectionFragment : Fragment() { R.string.select_album_n_unselected Util.toast(activity, getString(toastResId, selectedCount)) } + enableButtons() } private fun enableButtons() { - val selection = getSelectedSongs(albumListView) + val selection = getSelectedSongs() val enabled = selection.isNotEmpty() var unpinEnabled = false var deleteEnabled = false @@ -518,7 +471,6 @@ class TrackCollectionFragment : Fragment() { var pinnedCount = 0 for (song in selection) { - if (song == null) continue val downloadFile = mediaPlayerController.getDownloadFileForSong(song) if (downloadFile.isWorkDone) { deleteEnabled = true @@ -529,27 +481,21 @@ class TrackCollectionFragment : Fragment() { } } - playNowButton!!.visibility = if (enabled) View.VISIBLE else View.GONE - playNextButton!!.visibility = if (enabled) View.VISIBLE else View.GONE - playLastButton!!.visibility = if (enabled) View.VISIBLE else View.GONE - pinButton!!.visibility = if (enabled && !isOffline() && selection.size > pinnedCount) - View.VISIBLE - else - View.GONE - unpinButton!!.visibility = if (enabled && unpinEnabled) View.VISIBLE else View.GONE - downloadButton!!.visibility = if (enabled && !deleteEnabled && !isOffline()) - View.VISIBLE - else - View.GONE - deleteButton!!.visibility = if (enabled && deleteEnabled) View.VISIBLE else View.GONE + playNowButton?.isVisible = enabled + playNextButton?.isVisible = enabled + playLastButton?.isVisible = enabled + pinButton?.isVisible = (enabled && !isOffline() && selection.size > pinnedCount) + unpinButton?.isVisible = (enabled && unpinEnabled) + downloadButton?.isVisible = (enabled && !deleteEnabled && !isOffline()) + deleteButton?.isVisible = (enabled && deleteEnabled) } private fun downloadBackground(save: Boolean) { - var songs = getSelectedSongs(albumListView) + var songs = getSelectedSongs() if (songs.isEmpty()) { selectAll(selected = true, toast = false) - songs = getSelectedSongs(albumListView) + songs = getSelectedSongs() } downloadBackground(save, songs) @@ -580,18 +526,18 @@ class TrackCollectionFragment : Fragment() { } private fun delete() { - var songs = getSelectedSongs(albumListView) + var songs = getSelectedSongs() if (songs.isEmpty()) { selectAll(selected = true, toast = false) - songs = getSelectedSongs(albumListView) + songs = getSelectedSongs() } mediaPlayerController.delete(songs) } private fun unpin() { - val songs = getSelectedSongs(albumListView) + val songs = getSelectedSongs() Util.toast( context, resources.getQuantityString( @@ -605,8 +551,8 @@ class TrackCollectionFragment : Fragment() { // Hide more button when results are less than album list size if (musicDirectory.getChildren().size < requireArguments().getInt( - Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0 - ) + Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0 + ) ) { moreButton!!.visibility = View.GONE } else { @@ -628,22 +574,21 @@ class TrackCollectionFragment : Fragment() { .navigate(R.id.trackCollectionFragment, bundle) } - updateInterfaceWithEntries(musicDirectory) + //updateInterfaceWithEntries(musicDirectory) } private val defaultObserver = Observer(this::updateInterfaceWithEntries) - private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory) { - val entries = musicDirectory.getChildren() + private fun updateInterfaceWithEntries(list: List) { - if (model.currentListIsSortable && Settings.shouldSortByDisc) { - Collections.sort(entries, EntryByDiscAndTrackComparator()) + if (listModel.currentListIsSortable && Settings.shouldSortByDisc) { + Collections.sort(list, EntryByDiscAndTrackComparator()) } var allVideos = true var songCount = 0 - for (entry in entries) { + for (entry in list) { if (!entry.isVideo) { allVideos = false } @@ -655,17 +600,17 @@ class TrackCollectionFragment : Fragment() { val listSize = requireArguments().getInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0) if (songCount > 0) { - if (model.showHeader) { - val intentAlbumName = requireArguments().getString(Constants.INTENT_EXTRA_NAME_NAME) - val directoryName = musicDirectory.name - val header = createHeader( - entries, intentAlbumName ?: directoryName, - songCount - ) - if (header != null && albumListView!!.headerViewsCount == 0) { - albumListView!!.addHeaderView(header, null, false) - } - } +// if (listModel.showHeader) { +// val intentAlbumName = requireArguments().getString(Constants.INTENT_EXTRA_NAME_NAME) +// val directoryName = musicDirectory.name +// val header = createHeader( +// entries, intentAlbumName ?: directoryName, +// songCount +// ) +//// if (header != null && listView!!.headerViewsCount == 0) { +//// listView!!.addHeaderView(header, null, false) +//// } +// } pinButton!!.visibility = View.VISIBLE unpinButton!!.visibility = View.VISIBLE @@ -708,7 +653,7 @@ class TrackCollectionFragment : Fragment() { playNextButton!!.visibility = View.GONE playLastButton!!.visibility = View.GONE - if (listSize == 0 || musicDirectory.getChildren().size < listSize) { + if (listSize == 0 || list.size < listSize) { albumButtons!!.visibility = View.GONE } else { moreButton!!.visibility = View.VISIBLE @@ -721,15 +666,15 @@ class TrackCollectionFragment : Fragment() { Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE ) - playAllButtonVisible = !(isAlbumList || entries.isEmpty()) && !allVideos + playAllButtonVisible = !(isAlbumList || list.isEmpty()) && !allVideos shareButtonVisible = !isOffline() && songCount > 0 - albumListView!!.removeHeaderView(emptyView!!) - if (entries.isEmpty()) { - emptyView!!.text = getString(R.string.select_album_empty) - emptyView!!.setPadding(10, 10, 10, 10) - albumListView!!.addHeaderView(emptyView, null, false) - } +// listView!!.removeHeaderView(emptyView!!) +// if (entries.isEmpty()) { +// emptyView!!.text = getString(R.string.select_album_empty) +// emptyView!!.setPadding(10, 10, 10, 10) +// listView!!.addHeaderView(emptyView, null, false) +// } if (playAllButton != null) { playAllButton!!.isVisible = playAllButtonVisible @@ -739,10 +684,7 @@ class TrackCollectionFragment : Fragment() { shareButton!!.isVisible = shareButtonVisible } - albumListView!!.adapter = EntryAdapter( - context, - imageLoaderProvider.getImageLoader(), entries, true - ) + viewAdapter.submitList(list) val playAll = requireArguments().getBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, false) if (playAll && songCount > 0) { @@ -752,7 +694,9 @@ class TrackCollectionFragment : Fragment() { ) } - model.currentListIsSortable = true + listModel.currentListIsSortable = true + + } private fun createHeader( @@ -820,18 +764,122 @@ class TrackCollectionFragment : Fragment() { return header } - private fun getSelectedSongs(albumListView: ListView?): List { - val songs: MutableList = ArrayList(10) + private fun getSelectedSongs(): MutableList { + val songs: MutableList = mutableListOf() - if (albumListView != null) { - val count = albumListView.count - for (i in 0 until count) { - if (albumListView.isItemChecked(i)) { - songs.add(albumListView.getItemAtPosition(i) as MusicDirectory.Entry?) - } + for (i in 0 until listView!!.childCount) { + val vh = listView!!.findViewHolderForAdapterPosition(i) as TrackViewHolder? ?: continue + + if (vh.isChecked) { + songs.add(vh.entry!!) } } + for (key in selectedSet) { + songs.add(viewAdapter.getCurrentList().findLast { + it.longId == key + } as MusicDirectory.Entry) + } return songs } + + override val viewAdapter: MultiTypeDiffAdapter by lazy { + MultiTypeDiffAdapter() + } + + override fun setTitle(title: String?) { + setTitle(this@TrackCollectionFragment, title) + } + + fun setTitle(id: Int) { + setTitle(this@TrackCollectionFragment, id) + } + + @Suppress("LongMethod") + override fun getLiveData(args: Bundle?): LiveData> { + if (args == null) return listModel.currentList + val id = args.getString(Constants.INTENT_EXTRA_NAME_ID) + val isAlbum = args.getBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, false) + val name = args.getString(Constants.INTENT_EXTRA_NAME_NAME) + val parentId = args.getString(Constants.INTENT_EXTRA_NAME_PARENT_ID) + val playlistId = args.getString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID) + val podcastChannelId = args.getString( + Constants.INTENT_EXTRA_NAME_PODCAST_CHANNEL_ID + ) + val playlistName = args.getString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME) + val shareId = args.getString(Constants.INTENT_EXTRA_NAME_SHARE_ID) + val shareName = args.getString(Constants.INTENT_EXTRA_NAME_SHARE_NAME) + val genreName = args.getString(Constants.INTENT_EXTRA_NAME_GENRE_NAME) + + val getStarredTracks = args.getInt(Constants.INTENT_EXTRA_NAME_STARRED, 0) + val getVideos = args.getInt(Constants.INTENT_EXTRA_NAME_VIDEOS, 0) + val getRandomTracks = args.getInt(Constants.INTENT_EXTRA_NAME_RANDOM, 0) + val albumListSize = args.getInt( + Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0 + ) + val albumListOffset = args.getInt( + Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0 + ) + val refresh = args.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH, true) + + listModel.viewModelScope.launch(handler) { + refreshListView!!.isRefreshing = true + + listModel.getMusicFolders(refresh) + + if (playlistId != null) { + setTitle(playlistName!!) + listModel.getPlaylist(playlistId, playlistName) + } else if (podcastChannelId != null) { + setTitle(getString(R.string.podcasts_label)) + listModel.getPodcastEpisodes(podcastChannelId) + } else if (shareId != null) { + setTitle(shareName) + listModel.getShare(shareId) + } else if (genreName != null) { + setTitle(genreName) + listModel.getSongsForGenre(genreName, albumListSize, albumListOffset) + } else if (getStarredTracks != 0) { + setTitle(getString(R.string.main_songs_starred)) + listModel.getStarred() + } else if (getVideos != 0) { + setTitle(R.string.main_videos) + listModel.getVideos(refresh) + } else if (getRandomTracks != 0) { + setTitle(R.string.main_songs_random) + listModel.getRandom(albumListSize) + } else { + setTitle(name) + if (!isOffline() && Settings.shouldUseId3Tags) { + if (isAlbum) { + listModel.getAlbum(refresh, id!!, name, parentId) + } else { + listModel.getArtist(refresh, id!!, name) + } + } else { + listModel.getMusicDirectory(refresh, id!!, name, parentId) + } + } + + refreshListView!!.isRefreshing = false + } + return listModel.currentList + } + + override fun onContextMenuItemSelected( + menuItem: MenuItem, + item: MusicDirectory.Entry + ): Boolean { + //TODO + return false + } + + override fun onItemClick(item: MusicDirectory.Entry) { + // nothing + } + + } + + + diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt index ab5ce052..0c547c20 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt @@ -13,8 +13,11 @@ import androidx.lifecycle.MutableLiveData import java.util.LinkedList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.service.DownloadFile +import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.util.Settings @@ -29,7 +32,9 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat private val allSongsId = "-1" val currentDirectory: MutableLiveData = MutableLiveData() + val currentList: MutableLiveData> = MutableLiveData() val songsForGenre: MutableLiveData = MutableLiveData() + private val downloader: Downloader by inject() suspend fun getMusicFolders(refresh: Boolean) { withContext(Dispatchers.IO) { @@ -89,9 +94,14 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat } currentDirectory.postValue(root) + updateList(root) } } + private fun updateList(root: MusicDirectory) { + currentList.postValue(root.getChildren()) + } + // Given a Music directory "songs" it recursively adds all children to "songs" private fun getSongsRecursively( parent: MusicDirectory, @@ -148,6 +158,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat root = musicDirectory } currentDirectory.postValue(root) + updateList(root) } } @@ -190,6 +201,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat } currentDirectory.postValue(musicDirectory) + updateList(musicDirectory) } } @@ -215,6 +227,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat } currentDirectory.postValue(musicDirectory) + updateList(musicDirectory) } } @@ -223,7 +236,11 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat withContext(Dispatchers.IO) { val service = MusicServiceFactory.getMusicService() - currentDirectory.postValue(service.getVideos(refresh)) + val videos = service.getVideos(refresh) + currentDirectory.postValue(videos) + if (videos != null) { + updateList(videos) + } } } @@ -235,6 +252,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat currentListIsSortable = false currentDirectory.postValue(musicDirectory) + updateList(musicDirectory) } } @@ -245,6 +263,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat val musicDirectory = service.getPlaylist(playlistId, playlistName) currentDirectory.postValue(musicDirectory) + updateList(musicDirectory) } } @@ -254,6 +273,9 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat val service = MusicServiceFactory.getMusicService() val musicDirectory = service.getPodcastEpisodes(podcastChannelId) currentDirectory.postValue(musicDirectory) + if (musicDirectory != null) { + updateList(musicDirectory) + } } } @@ -274,6 +296,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat } } currentDirectory.postValue(musicDirectory) + updateList(musicDirectory) } } 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 86b9aeab..4f0260eb 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt @@ -411,6 +411,9 @@ class DownloadFile( override val id: String get() = song.id + override val longId: Long by lazy { + id.hashCode().toLong() + } companion object { const val MAX_RETRIES = 5 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 a1aa4bec..51db86d1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt @@ -474,4 +474,10 @@ class Downloader( const val CHECK_INTERVAL = 5L const val SHUFFLE_BUFFER_LIMIT = 4 } + + // Extension function + fun MusicDirectory.Entry.downloadFile(): DownloadFile { + return getDownloadFileForSong(this) + } } + diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index baf983a4..d84fb080 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -461,9 +461,9 @@ object Util { } @JvmStatic - fun getDrawableFromAttribute(context: Context?, attr: Int): Drawable { + fun getDrawableFromAttribute(context: Context, attr: Int): Drawable { val attrs = intArrayOf(attr) - val ta = context!!.obtainStyledAttributes(attrs) + val ta = context.obtainStyledAttributes(attrs) val drawableFromTheme: Drawable? = ta.getDrawable(0) ta.recycle() return drawableFromTheme!! @@ -747,7 +747,8 @@ object Util { } @JvmOverloads - fun formatTotalDuration(totalDuration: Long, inMilliseconds: Boolean = false): String { + fun formatTotalDuration(totalDuration: Long?, inMilliseconds: Boolean = false): String { + if (totalDuration == null) return "" var millis = totalDuration if (!inMilliseconds) { millis = totalDuration * 1000 @@ -852,7 +853,16 @@ object Util { ) } - @Suppress("ComplexMethod", "LongMethod") + data class ReadableEntryDescription( + var artist: String, + var title: String, + val trackNumber: String, + val duration: String, + var bitrate: String?, + var fileFormat: String?, + ) + + fun getMediaDescriptionForEntry( song: MusicDirectory.Entry, mediaId: String? = null, @@ -860,15 +870,39 @@ object Util { ): MediaDescriptionCompat { val descriptionBuilder = MediaDescriptionCompat.Builder() + val desc = readableEntryDescription(song) + var title = "" + + if (groupNameId != null) + descriptionBuilder.setExtras( + Bundle().apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + appContext().getString(groupNameId) + ) + } + ) + + if (desc.trackNumber.isNotEmpty()) { + title = "${desc.trackNumber} - ${desc.title}" + } else { + title = desc.title + } + + descriptionBuilder.setTitle(title) + descriptionBuilder.setSubtitle(desc.artist) + descriptionBuilder.setMediaId(mediaId) + + return descriptionBuilder.build() + } + + @Suppress("ComplexMethod", "LongMethod") + fun readableEntryDescription(song: MusicDirectory.Entry): ReadableEntryDescription { val artist = StringBuilder(LINE_LENGTH) var bitRate: String? = null + var trackText = "" val duration = song.duration - if (duration != null) { - artist.append( - String.format(Locale.ROOT, "%s ", formatTotalDuration(duration.toLong())) - ) - } if (song.bitRate != null && song.bitRate!! > 0) bitRate = String.format( @@ -887,8 +921,8 @@ object Util { if (artistName != null) { if (Settings.shouldDisplayBitrateWithArtist && ( - !bitRate.isNullOrBlank() || !fileFormat.isNullOrBlank() - ) + !bitRate.isNullOrBlank() || !fileFormat.isNullOrBlank() + ) ) { artist.append(artistName).append(" (").append( String.format( @@ -905,9 +939,11 @@ object Util { val trackNumber = song.track ?: 0 + val title = StringBuilder(LINE_LENGTH) - if (Settings.shouldShowTrackNumber && trackNumber > 0) - title.append(String.format(Locale.ROOT, "%02d - ", trackNumber)) + if (Settings.shouldShowTrackNumber && trackNumber > 0) { + trackText = String.format(Locale.ROOT, "%02d.", trackNumber) + } title.append(song.title) @@ -922,21 +958,14 @@ object Util { ).append(')') } - if (groupNameId != null) - descriptionBuilder.setExtras( - Bundle().apply { - putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - appContext().getString(groupNameId) - ) - } - ) - - descriptionBuilder.setTitle(title) - descriptionBuilder.setSubtitle(artist) - descriptionBuilder.setMediaId(mediaId) - - return descriptionBuilder.build() + return ReadableEntryDescription( + artist = artist.toString(), + title = title.toString(), + trackNumber = trackText, + duration = formatTotalDuration(duration?.toLong()), + bitrate = bitRate, + fileFormat = fileFormat, + ) } fun getPendingIntentForMediaAction( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongViewHolder.kt new file mode 100644 index 00000000..020b9ac9 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/SongViewHolder.kt @@ -0,0 +1,316 @@ +package org.moire.ultrasonic.view + +import android.content.Context +import android.graphics.drawable.AnimationDrawable +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.Checkable +import android.widget.CheckedTextView +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.component.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.featureflags.Feature +import org.moire.ultrasonic.featureflags.FeatureStorage +import org.moire.ultrasonic.fragment.DownloadRowAdapter +import org.moire.ultrasonic.service.DownloadFile +import org.moire.ultrasonic.service.MediaPlayerController +import org.moire.ultrasonic.service.MusicServiceFactory +import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Util +import timber.log.Timber + +/** + * Used to display songs and videos in a `ListView`. + * TODO: Video List item + */ +class SongViewHolder(view: View, context: Context) : RecyclerView.ViewHolder(view), Checkable, KoinComponent { + 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) + + var entry: MusicDirectory.Entry? = null + private set + var downloadFile: DownloadFile? = null + private set + + private var isMaximized = false + private var leftImage: Drawable? = null + private var previousLeftImageType: ImageType? = null + private var previousRightImageType: ImageType? = null + private var leftImageType: ImageType? = null + private var playing = false + + private val features: FeatureStorage = get() + private val useFiveStarRating: Boolean = features.isFeatureEnabled(Feature.FIVE_STAR_RATING) + private val mediaPlayerController: MediaPlayerController by inject() + + fun setSong(file: DownloadFile, checkable: Boolean, draggable: Boolean) { + val song = file.song + downloadFile = file + entry = song + + val entryDescription = Util.readableEntryDescription(song) + + artist.text = entryDescription.artist + title.text = entryDescription.title + duration.text = entryDescription.duration + + + if (Settings.shouldShowTrackNumber && song.track != null && song.track!! > 0) { + track.text = entryDescription.trackNumber + } else { + track.isVisible = false + } + + check.isVisible = (checkable && !song.isVideo) + drag.isVisible = draggable + + if (ActiveServerProvider.isOffline()) { + star.isVisible = false + rating.isVisible = false + } else { + setupStarButtons(song) + } + update() + } + + private fun setupStarButtons(song: MusicDirectory.Entry) { + if (useFiveStarRating) { + star.isVisible = false + val rating = if (song.userRating == null) 0 else song.userRating!! + fiveStar1.setImageDrawable( + if (rating > 0) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + fiveStar2.setImageDrawable( + if (rating > 1) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + fiveStar3.setImageDrawable( + if (rating > 2) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + fiveStar4.setImageDrawable( + if (rating > 3) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + fiveStar5.setImageDrawable( + if (rating > 4) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + } else { + rating.isVisible = false + star.setImageDrawable( + if (song.starred) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + + star.setOnClickListener { + val isStarred = song.starred + val id = song.id + + if (!isStarred) { + star.setImageDrawable(DownloadRowAdapter.starDrawable) + song.starred = true + } else { + star.setImageDrawable(DownloadRowAdapter.starHollowDrawable) + song.starred = false + } + Thread { + val musicService = MusicServiceFactory.getMusicService() + try { + if (!isStarred) { + musicService.star(id, null, null) + } else { + musicService.unstar(id, null, null) + } + } catch (all: Exception) { + Timber.e(all) + } + }.start() + } + } + } + + + @Synchronized + // TDOD: Should be removed + fun update() { + val song = entry ?: return + + updateDownloadStatus(downloadFile!!) + + if (entry?.starred != true) { + if (star.drawable !== DownloadRowAdapter.starHollowDrawable) { + star.setImageDrawable(DownloadRowAdapter.starHollowDrawable) + } + } else { + if (star.drawable !== DownloadRowAdapter.starDrawable) { + star.setImageDrawable(DownloadRowAdapter.starDrawable) + } + } + + val rating = entry?.userRating ?: 0 + fiveStar1.setImageDrawable( + if (rating > 0) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + fiveStar2.setImageDrawable( + if (rating > 1) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + fiveStar3.setImageDrawable( + if (rating > 2) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + fiveStar4.setImageDrawable( + if (rating > 3) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + fiveStar5.setImageDrawable( + if (rating > 4) DownloadRowAdapter.starDrawable else DownloadRowAdapter.starHollowDrawable + ) + + val playing = mediaPlayerController.currentPlaying === downloadFile + + if (playing) { + if (!this.playing) { + this.playing = true + title.setCompoundDrawablesWithIntrinsicBounds( + DownloadRowAdapter.playingImage, null, null, null + ) + } + } else { + if (this.playing) { + this.playing = false + title.setCompoundDrawablesWithIntrinsicBounds( + 0, 0, 0, 0 + ) + } + } + } + + fun updateDownloadStatus(downloadFile: DownloadFile) { + + if (downloadFile.isWorkDone) { + val newLeftImageType = + if (downloadFile.isSaved) ImageType.Pin else ImageType.Downloaded + + if (leftImageType != newLeftImageType) { + leftImage = if (downloadFile.isSaved) { + DownloadRowAdapter.pinImage + } else { + DownloadRowAdapter.downloadedImage + } + leftImageType = newLeftImageType + } + } else { + leftImageType = ImageType.None + leftImage = null + } + + val rightImageType: ImageType + val rightImage: Drawable? + + if (downloadFile.isDownloading && !downloadFile.isDownloadCancelled) { + status.text = Util.formatPercentage(downloadFile.progress.value!!) + + rightImageType = ImageType.Downloading + rightImage = DownloadRowAdapter.downloadingImage + } else { + rightImageType = ImageType.None + rightImage = null + + val statusText = status.text + if (!statusText.isNullOrEmpty()) status.text = null + } + + if (previousLeftImageType != leftImageType || previousRightImageType != rightImageType) { + previousLeftImageType = leftImageType + previousRightImageType = rightImageType + + status.setCompoundDrawablesWithIntrinsicBounds( + leftImage, null, rightImage, null + ) + + if (rightImage === DownloadRowAdapter.downloadingImage) { + // FIXME + val frameAnimation = rightImage as AnimationDrawable? + + frameAnimation?.setVisible(true, true) + frameAnimation?.start() + } + } + } + +// fun updateDownloadStatus2( +// downloadFile: DownloadFile, +// ) { +// +// var image: Drawable? = null +// +// when (downloadFile.status.value) { +// DownloadStatus.DONE -> { +// image = if (downloadFile.isSaved) DownloadRowAdapter.pinImage else DownloadRowAdapter.downloadedImage +// status.text = null +// } +// DownloadStatus.DOWNLOADING -> { +// status.text = Util.formatPercentage(downloadFile.progress.value!!) +// image = DownloadRowAdapter.downloadingImage +// } +// else -> { +// status.text = null +// } +// } +// +// // TODO: Migrate the image animation stuff from SongView into this class +// +// if (image != null) { +// status.setCompoundDrawablesWithIntrinsicBounds( +// image, null, null, null +// ) +// } +// +// if (image === DownloadRowAdapter.downloadingImage) { +// // FIXME +//// val frameAnimation = image as AnimationDrawable +//// +//// frameAnimation.setVisible(true, true) +//// frameAnimation.start() +// } +// } + + override fun setChecked(newStatus: Boolean) { + check.isChecked = newStatus + } + + override fun isChecked(): Boolean { + return check.isChecked + } + + override fun toggle() { + check.toggle() + } + + fun maximizeOrMinimize() { + isMaximized = !isMaximized + + title.isSingleLine = !isMaximized + artist.isSingleLine = !isMaximized + } + + enum class ImageType { + None, Pin, Downloaded, Downloading + } + + +} \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/track_list.xml b/ultrasonic/src/main/res/layout/track_list.xml new file mode 100644 index 00000000..76a702c4 --- /dev/null +++ b/ultrasonic/src/main/res/layout/track_list.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/ultrasonic/src/main/res/navigation/navigation_graph.xml b/ultrasonic/src/main/res/navigation/navigation_graph.xml index 4f4bd844..c76a0720 100644 --- a/ultrasonic/src/main/res/navigation/navigation_graph.xml +++ b/ultrasonic/src/main/res/navigation/navigation_graph.xml @@ -38,6 +38,13 @@ + +