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

709 lines
24 KiB
Kotlin

/*
* TrackCollectionFragment.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* 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
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
import org.moire.ultrasonic.adapters.HeaderViewBinder
import org.moire.ultrasonic.adapters.TrackViewBinder
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()
/**
* 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)
// Hook up the view with the manager and the adapter
listView = view.findViewById<RecyclerView>(recyclerViewId).apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
}
viewAdapter.register(
HeaderViewBinder(
context = requireContext()
)
)
viewAdapter.register(
TrackViewBinder(
onItemClick = { file, _ -> onItemClick(file.track) },
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
checkable = true,
draggable = false,
context = requireContext(),
lifecycleOwner = viewLifecycleOwner
)
)
viewAdapter.register(
AlbumRowBinder(
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider.getImageLoader(),
context = requireContext()
)
)
enableButtons()
// Update the buttons when the selection has changed
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(
this, append, false, !append, playNext = false,
shuffle = false, songs = selectedSongs
)
} else {
playAll(false, append)
}
}
/**
* Get the size of the underlying list
*/
private val childCount: Int
get() {
val count = viewAdapter.getCurrentList().count()
return if (listModel.showHeader) {
count - 1
} else {
count
}
}
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
}
}
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
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()) {
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 = {
Timber.i("Received list")
val entryList: MutableList<MusicDirectory.Child> = it.toMutableList()
if (listModel.currentListIsSortable && Settings.shouldSortByDisc) {
Collections.sort(entryList, EntryByDiscAndTrackComparator())
}
var allVideos = true
var songCount = 0
for (entry in entryList) {
if (!entry.isVideo) {
allVideos = false
}
if (!entry.isDirectory) {
songCount++
}
}
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
if (songCount > 0) {
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()
}
}
}
// Show a text if we have no entries
emptyView.isVisible = entryList.isEmpty()
enableButtons()
val isAlbumList = arguments?.containsKey(
Constants.INTENT_ALBUM_LIST_TYPE
) ?: false
playAllButtonVisible = !(isAlbumList || entryList.isEmpty()) && !allVideos
shareButtonVisible = !isOffline() && songCount > 0
playAllButton?.isVisible = playAllButtonVisible
shareButton?.isVisible = shareButtonVisible
if (songCount > 0 && listModel.showHeader) {
val intentAlbumName = arguments?.getString(Constants.INTENT_NAME, "")
val albumHeader = AlbumHeader(it, intentAlbumName)
val mixedList: MutableList<Identifiable> = mutableListOf(albumHeader)
mixedList.addAll(entryList)
viewAdapter.submitList(mixedList)
} else {
viewAdapter.submitList(entryList)
}
val playAll = arguments?.getBoolean(Constants.INTENT_AUTOPLAY, false) ?: false
if (playAll && songCount > 0) {
playAll(
arguments?.getBoolean(Constants.INTENT_SHUFFLE, false) ?: false,
false
)
}
listModel.currentListIsSortable = true
Timber.i("Processed list")
}
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
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)
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> {
// 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()
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()
}
}
}
}