ultrasonic-app-subsonic-and.../ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt

709 lines
24 KiB
Kotlin
Raw Normal View History

2021-04-21 22:31:56 +02:00
/*
* TrackCollectionFragment.kt
2022-07-06 11:14:46 +02:00
* Copyright (C) 2009-2022 Ultrasonic developers
2021-04-21 22:31:56 +02:00
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.core.view.isVisible
2021-04-21 22:31:56 +02:00
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import androidx.navigation.Navigation
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections
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.AlbumHeader
import org.moire.ultrasonic.adapters.AlbumRowBinder
2021-10-18 12:57:21 +02:00
import org.moire.ultrasonic.adapters.HeaderViewBinder
import org.moire.ultrasonic.adapters.TrackViewBinder
2022-07-06 11:14:46 +02:00
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.model.TrackCollectionModel
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.subsonic.VideoPlayer
import org.moire.ultrasonic.util.CancellationToken
import org.moire.ultrasonic.util.CommunicationError
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 timber.log.Timber
/**
* Displays a group of tracks, eg. the songs of an album, of a playlist etc.
*
* In most cases the data should be just a list of Entries, but there are some cases
* where the list can contain Albums as well. This happens especially when having ID3 tags disabled,
* or using Offline mode, both in which Indexes instead of Artists are being used.
*
* TODO: Remove more button and introduce endless scrolling
*/
@Suppress("TooManyFunctions")
open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
private var albumButtons: View? = null
private var selectButton: ImageView? = null
internal var playNowButton: ImageView? = null
private var playNextButton: ImageView? = null
private var playLastButton: ImageView? = null
private var pinButton: ImageView? = null
private var unpinButton: ImageView? = null
private var downloadButton: ImageView? = null
private var deleteButton: ImageView? = null
private var moreButton: ImageView? = null
private var playAllButtonVisible = false
private var shareButtonVisible = false
private var playAllButton: MenuItem? = null
private var shareButton: MenuItem? = null
internal val mediaPlayerController: MediaPlayerController by inject()
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
private val shareHandler: ShareHandler by inject()
internal var cancellationToken: CancellationToken? = null
override val listModel: TrackCollectionModel by viewModels()
2021-04-21 22:31:56 +02:00
/**
* The id of the main layout
*/
override val mainLayout: Int = R.layout.list_layout_track
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
cancellationToken = CancellationToken()
albumButtons = view.findViewById(R.id.menu_album)
// Setup refresh handler
refreshListView = view.findViewById(refreshListId)
refreshListView?.setOnRefreshListener {
getLiveData(arguments, true)
}
setupButtons(view)
registerForContextMenu(listView!!)
setHasOptionsMenu(true)
// Create a View Manager
viewManager = LinearLayoutManager(this.context)
2021-04-21 21:55:55 +02:00
// Hook up the view with the manager and the adapter
listView = view.findViewById<RecyclerView>(recyclerViewId).apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
2021-04-21 22:55:58 +02:00
}
2021-10-18 12:57:21 +02:00
viewAdapter.register(
HeaderViewBinder(
context = requireContext()
)
)
viewAdapter.register(
TrackViewBinder(
2022-04-03 23:57:50 +02:00
onItemClick = { file, _ -> onItemClick(file.track) },
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
checkable = true,
draggable = false,
context = requireContext(),
lifecycleOwner = viewLifecycleOwner
)
)
2021-04-21 21:55:55 +02:00
viewAdapter.register(
AlbumRowBinder(
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider.getImageLoader(),
context = requireContext()
)
)
enableButtons()
2021-04-21 21:55:55 +02:00
// Update the buttons when the selection has changed
2021-11-15 20:01:04 +01:00
viewAdapter.selectionRevision.observe(
viewLifecycleOwner
) {
enableButtons()
}
}
internal open fun setupButtons(view: View) {
selectButton = view.findViewById(R.id.select_album_select)
playNowButton = view.findViewById(R.id.select_album_play_now)
playNextButton = view.findViewById(R.id.select_album_play_next)
playLastButton = view.findViewById(R.id.select_album_play_last)
pinButton = view.findViewById(R.id.select_album_pin)
unpinButton = view.findViewById(R.id.select_album_unpin)
downloadButton = view.findViewById(R.id.select_album_download)
deleteButton = view.findViewById(R.id.select_album_delete)
moreButton = view.findViewById(R.id.select_album_more)
selectButton?.setOnClickListener {
selectAllOrNone()
}
playNowButton?.setOnClickListener {
playNow(false)
}
playNextButton?.setOnClickListener {
downloadHandler.download(
this@TrackCollectionFragment, append = true,
save = false, autoPlay = false, playNext = true, shuffle = false,
songs = getSelectedSongs()
)
}
playLastButton!!.setOnClickListener {
playNow(true)
}
pinButton?.setOnClickListener {
downloadBackground(true)
}
unpinButton?.setOnClickListener {
unpin()
}
downloadButton?.setOnClickListener {
downloadBackground(false)
}
deleteButton?.setOnClickListener {
delete()
}
}
val handler = CoroutineExceptionHandler { _, exception ->
Handler(Looper.getMainLooper()).post {
CommunicationError.handleError(exception, context)
}
refreshListView?.isRefreshing = false
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
playAllButton = menu.findItem(R.id.select_album_play_all)
if (playAllButton != null) {
playAllButton!!.isVisible = playAllButtonVisible
}
shareButton = menu.findItem(R.id.menu_item_share)
if (shareButton != null) {
shareButton!!.isVisible = shareButtonVisible
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.select_album, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val itemId = item.itemId
if (itemId == R.id.select_album_play_all) {
playAll()
return true
} else if (itemId == R.id.menu_item_share) {
shareHandler.createShare(
this, getSelectedSongs(),
refreshListView, cancellationToken!!
)
return true
}
return false
}
override fun onDestroyView() {
cancellationToken!!.cancel()
super.onDestroyView()
}
private fun playNow(
append: Boolean,
selectedSongs: List<Track> = getSelectedSongs()
) {
if (selectedSongs.isNotEmpty()) {
downloadHandler.download(
2021-04-21 22:42:52 +02:00
this, append, false, !append, playNext = false,
2021-04-21 22:55:58 +02:00
shuffle = false, songs = selectedSongs
)
} else {
playAll(false, append)
}
}
/**
* Get the size of the underlying list
*/
2021-10-18 12:57:21 +02:00
private val childCount: Int
get() {
val count = viewAdapter.getCurrentList().count()
return if (listModel.showHeader) {
count - 1
2021-10-18 12:57:21 +02:00
} else {
count
2021-10-18 12:57:21 +02:00
}
}
private fun playAll(shuffle: Boolean = false, append: Boolean = false) {
var hasSubFolders = false
for (item in viewAdapter.getCurrentList()) {
if (item is MusicDirectory.Child && item.isDirectory) {
hasSubFolders = true
break
}
}
2021-11-30 21:21:50 +01:00
val isArtist = arguments?.getBoolean(Constants.INTENT_ARTIST, false) ?: false
val id = arguments?.getString(Constants.INTENT_ID)
if (hasSubFolders && id != null) {
downloadHandler.downloadRecursively(
fragment = this,
id = id,
save = false,
append = append,
autoPlay = !append,
shuffle = shuffle,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
} else {
downloadHandler.download(
fragment = this,
append = append,
save = false,
autoPlay = !append,
playNext = false,
shuffle = shuffle,
songs = getAllSongs()
)
}
}
@Suppress("UNCHECKED_CAST")
private fun getAllSongs(): List<Track> {
return viewAdapter.getCurrentList().filter {
it is Track && !it.isDirectory
} as List<Track>
}
private fun selectAllOrNone() {
val someUnselected = viewAdapter.selectedSet.size < childCount
selectAll(someUnselected, true)
}
private fun selectAll(selected: Boolean, toast: Boolean) {
var selectedCount = viewAdapter.selectedSet.size * -1
2021-10-18 12:57:21 +02:00
selectedCount += viewAdapter.setSelectionStatusOfAll(selected)
// Display toast: N tracks selected
if (toast) {
val toastResId = R.string.select_album_n_selected
Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0)))
}
}
internal open fun enableButtons(selection: List<Track> = getSelectedSongs()) {
val enabled = selection.isNotEmpty()
var unpinEnabled = false
var deleteEnabled = false
val multipleSelection = viewAdapter.hasMultipleSelection()
var pinnedCount = 0
for (song in selection) {
val downloadFile = mediaPlayerController.getDownloadFileForSong(song)
if (downloadFile.isWorkDone) {
deleteEnabled = true
}
if (downloadFile.isSaved) {
pinnedCount++
unpinEnabled = true
}
}
playNowButton?.isVisible = enabled
playNextButton?.isVisible = enabled && multipleSelection
playLastButton?.isVisible = enabled && multipleSelection
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()
if (songs.isEmpty()) {
songs = getAllSongs()
}
downloadBackground(save, songs)
}
private fun downloadBackground(
save: Boolean,
songs: List<Track?>
) {
val onValid = Runnable {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.downloadBackground(songs, save)
if (save) {
Util.toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_pinned, songs.size, songs.size
)
)
} else {
Util.toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_downloaded, songs.size, songs.size
)
)
}
}
onValid.run()
}
internal fun delete(songs: List<Track> = getSelectedSongs()) {
2021-11-15 20:01:04 +01:00
Util.toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_deleted, songs.size, songs.size
)
)
mediaPlayerController.delete(songs)
}
internal fun unpin(songs: List<Track> = getSelectedSongs()) {
Util.toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_unpinned, songs.size, songs.size
)
)
mediaPlayerController.unpin(songs)
}
override val defaultObserver: (List<MusicDirectory.Child>) -> Unit = {
2021-10-18 12:57:21 +02:00
Timber.i("Received list")
val entryList: MutableList<MusicDirectory.Child> = it.toMutableList()
2021-04-21 21:55:55 +02:00
if (listModel.currentListIsSortable && Settings.shouldSortByDisc) {
2021-10-18 12:57:21 +02:00
Collections.sort(entryList, EntryByDiscAndTrackComparator())
2021-04-21 21:55:55 +02:00
}
var allVideos = true
var songCount = 0
2021-10-18 12:57:21 +02:00
for (entry in entryList) {
2021-04-21 21:55:55 +02:00
if (!entry.isVideo) {
allVideos = false
}
if (!entry.isDirectory) {
songCount++
}
2021-04-21 21:55:55 +02:00
}
2021-11-30 21:21:50 +01:00
val listSize = arguments?.getInt(Constants.INTENT_ALBUM_LIST_SIZE, 0) ?: 0
// Hide select button for video lists and singular selection lists
selectButton!!.isVisible = !allVideos && viewAdapter.hasMultipleSelection() && songCount > 0
2021-04-21 21:55:55 +02:00
if (songCount > 0) {
2021-04-21 21:55:55 +02:00
if (listSize == 0 || songCount < listSize) {
moreButton!!.visibility = View.GONE
} else {
moreButton!!.visibility = View.VISIBLE
if ((arguments?.getInt(Constants.INTENT_RANDOM, 0) ?: 0) > 0) {
moreRandomTracks()
} else if ((arguments?.getString(Constants.INTENT_GENRE_NAME, "") ?: "") != "") {
moreSongsForGenre()
}
2021-04-21 21:55:55 +02:00
}
}
// Show a text if we have no entries
emptyView.isVisible = entryList.isEmpty()
2021-04-21 21:55:55 +02:00
enableButtons()
val isAlbumList = arguments?.containsKey(
2021-11-30 21:21:50 +01:00
Constants.INTENT_ALBUM_LIST_TYPE
) ?: false
2021-04-21 22:55:58 +02:00
2021-10-18 12:57:21 +02:00
playAllButtonVisible = !(isAlbumList || entryList.isEmpty()) && !allVideos
2021-05-09 10:25:04 +02:00
shareButtonVisible = !isOffline() && songCount > 0
playAllButton?.isVisible = playAllButtonVisible
shareButton?.isVisible = shareButtonVisible
2021-10-18 12:57:21 +02:00
if (songCount > 0 && listModel.showHeader) {
2021-11-30 21:21:50 +01:00
val intentAlbumName = arguments?.getString(Constants.INTENT_NAME, "")
2021-11-29 19:00:28 +01:00
val albumHeader = AlbumHeader(it, intentAlbumName)
2021-10-18 12:57:21 +02:00
val mixedList: MutableList<Identifiable> = mutableListOf(albumHeader)
mixedList.addAll(entryList)
viewAdapter.submitList(mixedList)
} else {
viewAdapter.submitList(entryList)
}
2021-11-30 21:21:50 +01:00
val playAll = arguments?.getBoolean(Constants.INTENT_AUTOPLAY, false) ?: false
2021-04-21 21:55:55 +02:00
if (playAll && songCount > 0) {
playAll(
2021-11-30 21:21:50 +01:00
arguments?.getBoolean(Constants.INTENT_SHUFFLE, false) ?: false,
2021-04-21 22:55:58 +02:00
false
2021-04-21 21:55:55 +02:00
)
}
listModel.currentListIsSortable = true
Timber.i("Processed list")
2021-04-21 21:55:55 +02:00
}
private fun moreSongsForGenre(args: Bundle = requireArguments()) {
moreButton!!.setOnClickListener {
val theGenre = args.getString(Constants.INTENT_GENRE_NAME)
val size = args.getInt(Constants.INTENT_ALBUM_LIST_SIZE, 0)
val theOffset = args.getInt(
Constants.INTENT_ALBUM_LIST_OFFSET, 0
) + size
val bundle = Bundle()
bundle.putString(Constants.INTENT_GENRE_NAME, theGenre)
bundle.putInt(Constants.INTENT_ALBUM_LIST_SIZE, size)
bundle.putInt(Constants.INTENT_ALBUM_LIST_OFFSET, theOffset)
Navigation.findNavController(requireView())
.navigate(R.id.trackCollectionFragment, bundle)
}
}
private fun moreRandomTracks() {
val listSize = arguments?.getInt(Constants.INTENT_ALBUM_LIST_SIZE, 0) ?: 0
moreButton!!.setOnClickListener {
val offset = requireArguments().getInt(
Constants.INTENT_ALBUM_LIST_OFFSET, 0
) + listSize
val bundle = Bundle()
bundle.putInt(Constants.INTENT_RANDOM, 1)
bundle.putInt(Constants.INTENT_ALBUM_LIST_SIZE, listSize)
bundle.putInt(Constants.INTENT_ALBUM_LIST_OFFSET, offset)
Navigation.findNavController(requireView()).navigate(
R.id.trackCollectionFragment, bundle
)
}
}
internal fun getSelectedSongs(): List<Track> {
// Walk through selected set and get the Entries based on the saved ids.
return viewAdapter.getCurrentList().mapNotNull {
if (it is Track && viewAdapter.isSelected(it.longId))
it
else
null
}
}
override fun setTitle(title: String?) {
setTitle(this@TrackCollectionFragment, title)
}
fun setTitle(id: Int) {
setTitle(this@TrackCollectionFragment, id)
}
@Suppress("LongMethod")
override fun getLiveData(
args: Bundle?,
refresh: Boolean
): LiveData<List<MusicDirectory.Child>> {
Timber.i("Starting gathering track collection data...")
if (args == null) return listModel.currentList
2021-11-30 21:21:50 +01:00
val id = args.getString(Constants.INTENT_ID)
val isAlbum = args.getBoolean(Constants.INTENT_IS_ALBUM, false)
val name = args.getString(Constants.INTENT_NAME)
val playlistId = args.getString(Constants.INTENT_PLAYLIST_ID)
val podcastChannelId = args.getString(Constants.INTENT_PODCAST_CHANNEL_ID)
val playlistName = args.getString(Constants.INTENT_PLAYLIST_NAME)
val shareId = args.getString(Constants.INTENT_SHARE_ID)
val shareName = args.getString(Constants.INTENT_SHARE_NAME)
val genreName = args.getString(Constants.INTENT_GENRE_NAME)
val getStarredTracks = args.getInt(Constants.INTENT_STARRED, 0)
val getVideos = args.getInt(Constants.INTENT_VIDEOS, 0)
val getRandomTracks = args.getInt(Constants.INTENT_RANDOM, 0)
val albumListSize = args.getInt(Constants.INTENT_ALBUM_LIST_SIZE, 0)
val albumListOffset = args.getInt(Constants.INTENT_ALBUM_LIST_OFFSET, 0)
val refresh2 = args.getBoolean(Constants.INTENT_REFRESH, true) || refresh
listModel.viewModelScope.launch(handler) {
refreshListView?.isRefreshing = true
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(refresh2)
} else if (getRandomTracks != 0) {
setTitle(R.string.main_songs_random)
listModel.getRandom(albumListSize)
} else {
setTitle(name)
2022-07-06 11:14:46 +02:00
if (ActiveServerProvider.isID3Enabled()) {
if (isAlbum) {
listModel.getAlbum(refresh2, id!!, name)
} else {
throw IllegalAccessException("Use AlbumFragment instead!")
}
} else {
listModel.getMusicDirectory(refresh2, id!!, name)
}
}
refreshListView?.isRefreshing = false
}
return listModel.currentList
}
@Suppress("LongMethod")
override fun onContextMenuItemSelected(
menuItem: MenuItem,
item: MusicDirectory.Child
): Boolean {
val songs = getClickedSong(item)
when (menuItem.itemId) {
R.id.song_menu_play_now -> {
playNow(false, songs)
}
R.id.song_menu_play_next -> {
downloadHandler.download(
fragment = this@TrackCollectionFragment,
append = true,
save = false,
autoPlay = false,
playNext = true,
shuffle = false,
songs = songs
)
}
R.id.song_menu_play_last -> {
playNow(true, songs)
}
R.id.song_menu_pin -> {
downloadBackground(true, songs)
}
R.id.song_menu_unpin -> {
unpin(songs)
}
R.id.song_menu_download -> {
downloadBackground(false, songs)
}
R.id.select_album_play_all -> {
// TODO: Why is this being handled here?!
playAll()
}
R.id.song_menu_share -> {
if (item is Track) {
shareHandler.createShare(
this, listOf(item), refreshListView,
cancellationToken!!
)
}
}
else -> {
return super.onContextItemSelected(menuItem)
}
}
return true
}
private fun getClickedSong(item: MusicDirectory.Child): List<Track> {
2022-03-06 00:19:25 +01:00
// This can probably be done better
return viewAdapter.getCurrentList().mapNotNull {
if (it is Track && (it.id == item.id))
it
else
null
}
}
override fun onItemClick(item: MusicDirectory.Child) {
when {
item.isDirectory -> {
val bundle = Bundle()
2021-11-30 21:21:50 +01:00
bundle.putString(Constants.INTENT_ID, item.id)
bundle.putBoolean(Constants.INTENT_IS_ALBUM, item.isDirectory)
bundle.putString(Constants.INTENT_NAME, item.title)
bundle.putString(Constants.INTENT_PARENT_ID, item.parent)
Navigation.findNavController(requireView()).navigate(
R.id.trackCollectionFragment,
bundle
)
}
item is Track && item.isVideo -> {
VideoPlayer.playVideo(requireContext(), item)
}
else -> {
enableButtons()
}
}
}
}