Introduce new Generic Fragments, ViewModels, and Adapters for the display of API data.

* Splits former SelectAlbumFragment into separate fragments for Albums and general collections of tracks
* Renames and refactors SelectArtist view to extend the new Generic classes
* Adds error handling (Fixes #484)
* Adds EndlessScrolling capabilities to all Album Lists
* Uses RecyclerViews and LiveData for performance and in-memory caching
* Refreshes the UI to be aligned with the ArtistList UI
* Add a new GenericEntry to the domain data classes, and make other types extend it
This commit is contained in:
tzugen 2021-05-12 13:28:33 +02:00
parent c6a744cc14
commit 72c03cc500
No known key found for this signature in database
GPG Key ID: 61E9C34BC10EC930
40 changed files with 1388 additions and 676 deletions

View File

@ -3,13 +3,13 @@ package org.moire.ultrasonic.domain
import java.io.Serializable
data class Artist(
var id: String? = null,
var name: String? = null,
override var id: String? = null,
override var name: String? = null,
var index: String? = null,
var coverArt: String? = null,
var albumCount: Long? = null,
var closeness: Int = 0
) : Serializable {
) : Serializable, GenericEntry() {
companion object {
private const val serialVersionUID = -5790532593784846982L
}

View File

@ -0,0 +1,7 @@
package org.moire.ultrasonic.domain
abstract class GenericEntry {
// TODO Should be non-null!
abstract val id: String?
open val name: String? = null
}

View File

@ -36,7 +36,7 @@ class MusicDirectory {
}
data class Entry(
var id: String? = null,
override var id: String? = null,
var parent: String? = null,
var isDirectory: Boolean = false,
var title: String? = null,
@ -66,7 +66,7 @@ class MusicDirectory {
var bookmarkPosition: Int = 0,
var userRating: Int? = null,
var averageRating: Float? = null
) : Serializable {
) : Serializable, GenericEntry() {
fun setDuration(duration: Long) {
this.duration = duration.toInt()
}

View File

@ -4,6 +4,6 @@ package org.moire.ultrasonic.domain
* Represents a top level directory in which music or other media is stored.
*/
data class MusicFolder(
val id: String,
val name: String
)
override val id: String,
override val name: String
) : GenericEntry()

View File

@ -3,14 +3,14 @@ package org.moire.ultrasonic.domain
import java.io.Serializable
data class Playlist @JvmOverloads constructor(
val id: String,
var name: String,
override val id: String,
override var name: String,
val owner: String = "",
val comment: String = "",
val songCount: String = "",
val created: String = "",
val public: Boolean? = null
) : Serializable {
) : Serializable, GenericEntry() {
companion object {
private const val serialVersionUID = -4160515427075433798L
}

View File

@ -3,12 +3,12 @@ package org.moire.ultrasonic.domain
import java.io.Serializable
data class PodcastsChannel(
val id: String,
override val id: String,
val title: String?,
val url: String?,
val description: String?,
val status: String?
) : Serializable {
) : Serializable, GenericEntry() {
companion object {
private const val serialVersionUID = -4160515427075433798L
}

View File

@ -4,7 +4,7 @@ import java.io.Serializable
import org.moire.ultrasonic.domain.MusicDirectory.Entry
data class Share(
var id: String? = null,
override var id: String? = null,
var url: String? = null,
var description: String? = null,
var username: String? = null,
@ -13,8 +13,8 @@ data class Share(
var expires: String? = null,
var visitCount: Long? = null,
private val entries: MutableList<Entry> = mutableListOf()
) : Serializable {
val name: String?
) : Serializable, GenericEntry() {
override val name: String?
get() = url?.let { urlPattern.matcher(url).replaceFirst("$1") }
fun getEntries(): List<Entry> {

View File

@ -4,7 +4,6 @@ import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.TextView;
@ -142,71 +141,66 @@ public class MainFragment extends Fragment {
}
list.setAdapter(adapter);
list.setOnItemClickListener(new AdapterView.OnItemClickListener()
{
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id)
list.setOnItemClickListener((parent, view, position, id) -> {
if (view == serverButton)
{
if (view == serverButton)
{
showServers();
}
else if (view == albumsNewestButton)
{
showAlbumList("newest", R.string.main_albums_newest);
}
else if (view == albumsRandomButton)
{
showAlbumList("random", R.string.main_albums_random);
}
else if (view == albumsHighestButton)
{
showAlbumList("highest", R.string.main_albums_highest);
}
else if (view == albumsRecentButton)
{
showAlbumList("recent", R.string.main_albums_recent);
}
else if (view == albumsFrequentButton)
{
showAlbumList("frequent", R.string.main_albums_frequent);
}
else if (view == albumsStarredButton)
{
showAlbumList(Constants.STARRED, R.string.main_albums_starred);
}
else if (view == albumsAlphaByNameButton)
{
showAlbumList(Constants.ALPHABETICAL_BY_NAME, R.string.main_albums_alphaByName);
}
else if (view == albumsAlphaByArtistButton)
{
showAlbumList("alphabeticalByArtist", R.string.main_albums_alphaByArtist);
}
else if (view == songsStarredButton)
{
showStarredSongs();
}
else if (view == artistsButton)
{
showArtists();
}
else if (view == albumsButton)
{
showAlbumList(Constants.ALPHABETICAL_BY_NAME, R.string.main_albums_title);
}
else if (view == randomSongsButton)
{
showRandomSongs();
}
else if (view == genresButton)
{
showGenres();
}
else if (view == videosButton)
{
showVideos();
}
showServers();
}
else if (view == albumsNewestButton)
{
showAlbumList("newest", R.string.main_albums_newest);
}
else if (view == albumsRandomButton)
{
showAlbumList("random", R.string.main_albums_random);
}
else if (view == albumsHighestButton)
{
showAlbumList("highest", R.string.main_albums_highest);
}
else if (view == albumsRecentButton)
{
showAlbumList("recent", R.string.main_albums_recent);
}
else if (view == albumsFrequentButton)
{
showAlbumList("frequent", R.string.main_albums_frequent);
}
else if (view == albumsStarredButton)
{
showAlbumList(Constants.STARRED, R.string.main_albums_starred);
}
else if (view == albumsAlphaByNameButton)
{
showAlbumList(Constants.ALPHABETICAL_BY_NAME, R.string.main_albums_alphaByName);
}
else if (view == albumsAlphaByArtistButton)
{
showAlbumList("alphabeticalByArtist", R.string.main_albums_alphaByArtist);
}
else if (view == songsStarredButton)
{
showStarredSongs();
}
else if (view == artistsButton)
{
showArtists();
}
else if (view == albumsButton)
{
showAlbumList(Constants.ALPHABETICAL_BY_NAME, R.string.main_albums_title);
}
else if (view == randomSongsButton)
{
showRandomSongs();
}
else if (view == genresButton)
{
showGenres();
}
else if (view == videosButton)
{
showVideos();
}
});
}
@ -219,21 +213,11 @@ public class MainFragment extends Fragment {
currentSetting.getLdapSupport(), currentSetting.getMinimumApiVersion());
}
private void showAlbumList(final String type, final int title)
{
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type);
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE, title);
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, Util.getMaxAlbums());
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0);
Navigation.findNavController(getView()).navigate(R.id.mainToSelectAlbum, bundle);
}
private void showStarredSongs()
{
Bundle bundle = new Bundle();
bundle.putInt(Constants.INTENT_EXTRA_NAME_STARRED, 1);
Navigation.findNavController(getView()).navigate(R.id.mainToSelectAlbum, bundle);
Navigation.findNavController(getView()).navigate(R.id.mainToTrackCollection, bundle);
}
private void showRandomSongs()
@ -241,14 +225,23 @@ public class MainFragment extends Fragment {
Bundle bundle = new Bundle();
bundle.putInt(Constants.INTENT_EXTRA_NAME_RANDOM, 1);
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, Util.getMaxSongs());
Navigation.findNavController(getView()).navigate(R.id.mainToSelectAlbum, bundle);
Navigation.findNavController(getView()).navigate(R.id.mainToTrackCollection, bundle);
}
private void showArtists()
{
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE, getContext().getResources().getString(R.string.main_artists_title));
Navigation.findNavController(getView()).navigate(R.id.selectArtistFragment, bundle);
Navigation.findNavController(getView()).navigate(R.id.mainToArtistList, bundle);
}
private void showAlbumList(final String type, final int title) {
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type);
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE, title);
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, Util.getMaxAlbums());
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0);
Navigation.findNavController(getView()).navigate(R.id.mainToAlbumList, bundle);
}
private void showGenres()
@ -260,7 +253,7 @@ public class MainFragment extends Fragment {
{
Bundle bundle = new Bundle();
bundle.putInt(Constants.INTENT_EXTRA_NAME_VIDEOS, 1);
Navigation.findNavController(getView()).navigate(R.id.mainToSelectAlbum, bundle);
Navigation.findNavController(getView()).navigate(R.id.mainToTrackCollection, bundle);
}
private void showServers()

View File

@ -125,7 +125,7 @@ public class NowPlayingFragment extends Fragment {
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.getAlbum());
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, song.getAlbum());
Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(R.id.selectAlbumFragment, bundle);
Navigation.findNavController(getActivity(), R.id.nav_host_fragment).navigate(R.id.trackCollectionFragment, bundle);
});
}

View File

@ -105,7 +105,7 @@ public class PlaylistsFragment extends Fragment {
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, playlist.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName());
Navigation.findNavController(getView()).navigate(R.id.selectAlbumFragment, bundle);
Navigation.findNavController(getView()).navigate(R.id.trackCollectionFragment, bundle);
}
});
registerForContextMenu(playlistsListView);
@ -154,7 +154,7 @@ public class PlaylistsFragment extends Fragment {
if (ActiveServerProvider.Companion.isOffline()) inflater.inflate(R.menu.select_playlist_context_offline, menu);
else inflater.inflate(R.menu.select_playlist_context, menu);
MenuItem downloadMenuItem = menu.findItem(R.id.album_menu_download);
MenuItem downloadMenuItem = menu.findItem(R.id.playlist_menu_download);
if (downloadMenuItem != null)
{
@ -190,14 +190,14 @@ public class PlaylistsFragment extends Fragment {
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName());
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true);
Navigation.findNavController(getView()).navigate(R.id.selectAlbumFragment, bundle);
Navigation.findNavController(getView()).navigate(R.id.trackCollectionFragment, bundle);
} else if (itemId == R.id.playlist_menu_play_shuffled) {
bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_ID, playlist.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_PLAYLIST_NAME, playlist.getName());
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_AUTOPLAY, true);
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_SHUFFLE, true);
Navigation.findNavController(getView()).navigate(R.id.selectAlbumFragment, bundle);
Navigation.findNavController(getView()).navigate(R.id.trackCollectionFragment, bundle);
} else if (itemId == R.id.playlist_menu_delete) {
deletePlaylist(playlist);
} else if (itemId == R.id.playlist_info) {

View File

@ -76,7 +76,7 @@ public class PodcastFragment extends Fragment {
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_PODCAST_CHANNEL_ID, pc.getId());
Navigation.findNavController(view).navigate(R.id.selectAlbumFragment, bundle);
Navigation.findNavController(view).navigate(R.id.trackCollectionFragment, bundle);
}
});

View File

@ -272,11 +272,11 @@ public class SearchFragment extends Fragment {
}
else
{
inflater.inflate(R.menu.select_album_context, menu);
inflater.inflate(R.menu.generic_context_menu, menu);
}
MenuItem shareButton = menu.findItem(R.id.menu_item_share);
MenuItem downloadMenuItem = menu.findItem(R.id.album_menu_download);
MenuItem downloadMenuItem = menu.findItem(R.id.menu_download);
if (downloadMenuItem != null)
{
@ -324,17 +324,17 @@ public class SearchFragment extends Fragment {
List<MusicDirectory.Entry> songs = new ArrayList<>(1);
int itemId = menuItem.getItemId();
if (itemId == R.id.album_menu_play_now) {
if (itemId == R.id.menu_play_now) {
downloadHandler.getValue().downloadRecursively(this, id, false, false, true, false, false, false, false, false);
} else if (itemId == R.id.album_menu_play_next) {
} else if (itemId == R.id.menu_play_next) {
downloadHandler.getValue().downloadRecursively(this, id, false, true, false, true, false, true, false, false);
} else if (itemId == R.id.album_menu_play_last) {
} else if (itemId == R.id.menu_play_last) {
downloadHandler.getValue().downloadRecursively(this, id, false, true, false, false, false, false, false, false);
} else if (itemId == R.id.album_menu_pin) {
} else if (itemId == R.id.menu_pin) {
downloadHandler.getValue().downloadRecursively(this, id, true, true, false, false, false, false, false, false);
} else if (itemId == R.id.album_menu_unpin) {
} else if (itemId == R.id.menu_unpin) {
downloadHandler.getValue().downloadRecursively(this, id, false, false, false, false, false, false, true, false);
} else if (itemId == R.id.album_menu_download) {
} else if (itemId == R.id.menu_download) {
downloadHandler.getValue().downloadRecursively(this, id, false, false, false, false, true, false, false, false);
} else if (itemId == R.id.song_menu_play_now) {
if (entry != null) {

View File

@ -77,7 +77,7 @@ public class SelectGenreFragment extends Fragment {
bundle.putString(Constants.INTENT_EXTRA_NAME_GENRE_NAME, genre.getName());
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, Util.getMaxSongs());
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0);
Navigation.findNavController(view).navigate(R.id.selectAlbumFragment, bundle);
Navigation.findNavController(view).navigate(R.id.trackCollectionFragment, bundle);
}
}
});

View File

@ -106,7 +106,7 @@ public class SharesFragment extends Fragment {
Bundle bundle = new Bundle();
bundle.putString(Constants.INTENT_EXTRA_NAME_SHARE_ID, share.getId());
bundle.putString(Constants.INTENT_EXTRA_NAME_SHARE_NAME, share.getName());
Navigation.findNavController(view).navigate(R.id.selectAlbumFragment, bundle);
Navigation.findNavController(view).navigate(R.id.trackCollectionFragment, bundle);
}
});
registerForContextMenu(sharesListView);

View File

@ -58,6 +58,7 @@ public final class Constants
public static final String INTENT_EXTRA_NAME_IS_ALBUM = "subsonic.isalbum";
public static final String INTENT_EXTRA_NAME_VIDEOS = "subsonic.videos";
public static final String INTENT_EXTRA_NAME_SHOW_PLAYER = "subsonic.showplayer";
public static final String INTENT_EXTRA_NAME_APPEND = "subsonic.append";
// Names for Intent Actions
public static final String CMD_PROCESS_KEYCODE = "org.moire.ultrasonic.CMD_PROCESS_KEYCODE";

View File

@ -71,7 +71,7 @@ public class AlbumView extends UpdateView
public void setLayout()
{
LayoutInflater.from(context).inflate(R.layout.album_list_item, this, true);
LayoutInflater.from(context).inflate(R.layout.album_list_item_legacy, this, true);
viewHolder = new EntryAdapter.AlbumViewHolder();
viewHolder.title = findViewById(R.id.album_title);
viewHolder.artist = findViewById(R.id.album_artist);

View File

@ -103,7 +103,7 @@ class NavigationActivity : AppCompatActivity() {
appBarConfiguration = AppBarConfiguration(
setOf(
R.id.mainFragment,
R.id.selectArtistFragment,
R.id.mediaLibraryFragment,
R.id.searchFragment,
R.id.playlistsFragment,
R.id.sharesFragment,

View File

@ -4,7 +4,6 @@ package org.moire.ultrasonic.di
import kotlin.math.abs
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext
import org.koin.android.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.moire.ultrasonic.BuildConfig
@ -13,7 +12,6 @@ import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration
import org.moire.ultrasonic.cache.PermanentFileStorage
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.fragment.ArtistListModel
import org.moire.ultrasonic.log.TimberOkHttpLogger
import org.moire.ultrasonic.service.ApiCallResponseChecker
import org.moire.ultrasonic.service.CachedMusicService
@ -81,8 +79,6 @@ val musicServiceModule = module {
single { SubsonicImageLoader(androidContext(), get()) }
viewModel { ArtistListModel(get()) }
single { DownloadHandler(get(), get()) }
single { NetworkAndStorageChecker(androidContext()) }
single { VideoPlayer() }

View File

@ -0,0 +1,110 @@
package org.moire.ultrasonic.fragment
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import org.koin.core.component.KoinApiExtension
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.util.Constants
/**
* Displays a list of Albums from the media library
* TODO: Check refresh is working
*/
@KoinApiExtension
class AlbumListFragment : GenericListFragment<MusicDirectory.Entry, AlbumRowAdapter>() {
/**
* The ViewModel to use to get the data
*/
override val listModel: AlbumListModel by viewModels()
/**
* The id of the main layout
*/
override val mainLayout: Int = R.layout.generic_list
/**
* The id of the refresh view
*/
override val refreshListId: Int = R.id.generic_list_refresh
/**
* The id of the RecyclerView
*/
override val recyclerViewId = R.id.generic_list_recycler
/**
* The id of the target in the navigation graph where we should go,
* after the user has clicked on an item
*/
override val itemClickTarget: Int = R.id.trackCollectionFragment
/**
* Whether to show the folder selector
*/
override var folderHeaderEnabled = false
/**
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(args: Bundle?): LiveData<List<MusicDirectory.Entry>> {
if (args == null) throw IllegalArgumentException("Required arguments are missing")
val refresh = args.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH)
return listModel.getAlbumList(refresh, refreshListView!!, args)
}
/**
* Provide the Adapter for the RecyclerView with a lazy delegate
*/
override val viewAdapter: AlbumRowAdapter by lazy {
AlbumRowAdapter(
liveDataItems.value ?: listOf(),
selectFolderHeader,
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider.getImageLoader()
)
}
val newBundleClone: Bundle
get() = arguments?.clone() as Bundle
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Attach our onScrollListener
listView = view.findViewById<RecyclerView>(recyclerViewId).apply {
val scrollListener = object : EndlessScrollListener(viewManager) {
override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?) {
// Triggered only when new data needs to be appended to the list
// Add whatever code is needed to append new items to the bottom of the list
val appendArgs = newBundleClone
appendArgs.putBoolean(Constants.INTENT_EXTRA_NAME_APPEND, true)
getLiveData(appendArgs)
}
}
addOnScrollListener(scrollListener)
}
}
override fun onItemClick(item: MusicDirectory.Entry) {
val bundle = Bundle()
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, item.id)
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_IS_ALBUM, item.isDirectory)
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, item.title)
bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, item.parent)
findNavController().navigate(itemClickTarget, bundle)
}
override val musicFolderObserver = { _: List<MusicFolder> ->
// Do nothing
}
}

View File

@ -0,0 +1,94 @@
package org.moire.ultrasonic.fragment
import android.app.Application
import android.os.Bundle
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.koin.core.component.KoinApiExtension
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Util
@KoinApiExtension
class AlbumListModel(application: Application) : GenericListModel(application) {
val albumList: MutableLiveData<List<MusicDirectory.Entry>> = MutableLiveData()
private var loadedUntil: Int = 0
fun getAlbumList(
refresh: Boolean,
swipe: SwipeRefreshLayout,
args: Bundle
): LiveData<List<MusicDirectory.Entry>> {
backgroundLoadFromServer(refresh, swipe, args)
return albumList
}
override fun load(
isOffline: Boolean,
useId3Tags: Boolean,
musicService: MusicService,
refresh: Boolean,
args: Bundle
) {
val musicDirectory: MusicDirectory
val musicFolderId = if (showSelectFolderHeader) {
activeServerProvider.getActiveServer().musicFolderId
} else {
null
}
val albumListType = args.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE)!!
val size = args.getInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0)
var offset = args.getInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0)
val append = args.getBoolean(Constants.INTENT_EXTRA_NAME_APPEND, false)
showHeader = showHeader(albumListType)
// Handle the logic for endless scrolling:
// If appending the existing list, set the offset from where to load
if (append) offset += (size + loadedUntil)
if (useId3Tags) {
musicDirectory = musicService.getAlbumList2(
albumListType, size,
offset, musicFolderId
)
} else {
musicDirectory = musicService.getAlbumList(
albumListType, size,
offset, musicFolderId
)
}
currentListIsSortable = sortableCollection(albumListType)
if (append && albumList.value != null) {
val list = ArrayList<MusicDirectory.Entry>()
list.addAll(albumList.value!!)
list.addAll(musicDirectory.getAllChild())
albumList.postValue(list)
} else {
albumList.postValue(musicDirectory.getAllChild())
}
loadedUntil = offset
}
private fun showHeader(albumListType: String): Boolean {
val isAlphabetical = (albumListType == AlbumListType.SORTED_BY_NAME.toString()) ||
(albumListType == AlbumListType.SORTED_BY_ARTIST.toString())
return !isOffline() && !Util.getShouldUseId3Tags() && isAlphabetical
}
private fun sortableCollection(albumListType: String): Boolean {
return albumListType != "newest" && albumListType != "random" &&
albumListType != "highest" && albumListType != "recent" &&
albumListType != "frequent"
}
}

View File

@ -0,0 +1,100 @@
/*
* ArtistRowAdapter.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.util.ImageLoader
import org.moire.ultrasonic.view.SelectMusicFolderView
/**
* Creates a Row in a RecyclerView which contains the details of an Artist
*/
class AlbumRowAdapter(
albumList: List<MusicDirectory.Entry>,
private var selectFolderHeader: SelectMusicFolderView?,
onItemClick: (MusicDirectory.Entry) -> Unit,
onContextMenuClick: (MenuItem, MusicDirectory.Entry) -> Boolean,
private val imageLoader: ImageLoader
) : GenericRowAdapter<MusicDirectory.Entry>(
selectFolderHeader,
onItemClick,
onContextMenuClick,
imageLoader
) {
override var itemList = albumList
// Set our layout files
override val layout = R.layout.album_list_item
override val contextMenuLayout = R.menu.artist_context_menu
// Sets the data to be displayed in the RecyclerView
override fun setData(data: List<MusicDirectory.Entry>) {
itemList = data
super.notifyDataSetChanged()
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
if (viewType == TYPE_ITEM) {
val row = LayoutInflater.from(parent.context)
.inflate(layout, parent, false)
return AlbumViewHolder(row)
}
return selectFolderHeader!!
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is AlbumViewHolder) {
val listPosition = if (selectFolderHeader != null) position - 1 else position
val entry = itemList[listPosition]
holder.album.text = entry.title
holder.artist.text = entry.artist
holder.details.setOnClickListener { onItemClick(entry) }
holder.details.setOnLongClickListener { view -> createPopupMenu(view, listPosition) }
holder.coverArtId = entry.coverArt
imageLoader.loadImage(
holder.coverArt,
MusicDirectory.Entry().apply { coverArt = holder.coverArtId },
false, 0, false, true, R.drawable.ic_contact_picture
)
}
}
override fun getItemCount(): Int {
if (selectFolderHeader != null)
return itemList.size + 1
else
return itemList.size
}
/**
* Holds the view properties of an Item row
*/
class AlbumViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
var album: TextView = itemView.findViewById(R.id.album_title)
var artist: TextView = itemView.findViewById(R.id.album_artist)
var details: LinearLayout = itemView.findViewById(R.id.row_album_details)
var coverArt: ImageView = itemView.findViewById(R.id.album_coverart)
var coverArtId: String? = null
}
}

View File

@ -1,222 +1,72 @@
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.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.koin.android.ext.android.inject
import org.koin.android.viewmodel.ext.android.viewModel
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import org.koin.core.component.KoinApiExtension
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.view.SelectMusicFolderView
/**
* Displays the list of Artists from the media library
*/
class ArtistListFragment : Fragment() {
private val activeServerProvider: ActiveServerProvider by inject()
private val serverSettingsModel: ServerSettingsModel by viewModel()
private val artistListModel: ArtistListModel by viewModel()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val downloadHandler: DownloadHandler by inject()
@KoinApiExtension
class ArtistListFragment : GenericListFragment<Artist, ArtistRowAdapter>() {
private var refreshArtistListView: SwipeRefreshLayout? = null
private var artistListView: RecyclerView? = null
private lateinit var viewManager: RecyclerView.LayoutManager
private lateinit var viewAdapter: ArtistRowAdapter
private var selectFolderHeader: SelectMusicFolderView? = null
/**
* The ViewModel to use to get the data
*/
override val listModel: ArtistListModel by viewModels()
@Override
override fun onCreate(savedInstanceState: Bundle?) {
Util.applyTheme(this.context)
super.onCreate(savedInstanceState)
/**
* The id of the main layout
*/
override val mainLayout = R.layout.generic_list
/**
* The id of the refresh view
*/
override val refreshListId = 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
*/
override val itemClickTarget = R.id.selectArtistToSelectAlbum
/**
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(args: Bundle?): LiveData<List<Artist>> {
val refresh = args?.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH) ?: false
return listModel.getItems(refresh, refreshListView!!)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.select_artist, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
refreshArtistListView = view.findViewById(R.id.select_artist_refresh)
refreshArtistListView!!.setOnRefreshListener {
artistListModel.refresh(refreshArtistListView!!)
}
if (!ActiveServerProvider.isOffline() &&
!Util.getShouldUseId3Tags()
) {
selectFolderHeader = SelectMusicFolderView(
requireContext(), view as ViewGroup,
{ selectedFolderId ->
if (!ActiveServerProvider.isOffline()) {
val currentSetting = activeServerProvider.getActiveServer()
currentSetting.musicFolderId = selectedFolderId
serverSettingsModel.updateItem(currentSetting)
}
viewAdapter.notifyDataSetChanged()
artistListModel.refresh(refreshArtistListView!!)
}
)
}
val title = arguments?.getString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE)
if (title == null) {
setTitle(
this,
if (ActiveServerProvider.isOffline())
R.string.music_library_label_offline
else R.string.music_library_label
)
} else {
setTitle(this, title)
}
val refresh = arguments?.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH) ?: false
artistListModel.getMusicFolders()
.observe(
viewLifecycleOwner,
Observer { changedFolders ->
if (changedFolders != null) {
viewAdapter.notifyDataSetChanged()
selectFolderHeader!!.setData(
activeServerProvider.getActiveServer().musicFolderId,
changedFolders
)
}
}
)
val artists = artistListModel.getArtists(refresh, refreshArtistListView!!)
artists.observe(
viewLifecycleOwner, Observer { changedArtists -> viewAdapter.setData(changedArtists) }
)
viewManager = LinearLayoutManager(this.context)
viewAdapter = ArtistRowAdapter(
artists.value ?: listOf(),
/**
* Provide the Adapter for the RecyclerView with a lazy delegate
*/
override val viewAdapter: ArtistRowAdapter by lazy {
ArtistRowAdapter(
liveDataItems.value ?: listOf(),
selectFolderHeader,
{ artist -> onItemClick(artist) },
{ menuItem, artist -> onArtistMenuItemSelected(menuItem, artist) },
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider.getImageLoader()
)
artistListView = view.findViewById<RecyclerView>(R.id.select_artist_list).apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
}
super.onViewCreated(view, savedInstanceState)
}
private fun onItemClick(artist: Artist) {
val bundle = Bundle()
bundle.putString(Constants.INTENT_EXTRA_NAME_ID, artist.id)
bundle.putString(Constants.INTENT_EXTRA_NAME_NAME, artist.name)
bundle.putString(Constants.INTENT_EXTRA_NAME_PARENT_ID, artist.id)
bundle.putBoolean(Constants.INTENT_EXTRA_NAME_ARTIST, true)
findNavController().navigate(R.id.selectArtistToSelectAlbum, bundle)
}
private fun onArtistMenuItemSelected(menuItem: MenuItem, artist: Artist): Boolean {
when (menuItem.itemId) {
R.id.artist_menu_play_now ->
downloadHandler.downloadRecursively(
this,
artist.id,
save = false,
append = false,
autoPlay = true,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = true
)
R.id.artist_menu_play_next ->
downloadHandler.downloadRecursively(
this,
artist.id,
save = false,
append = false,
autoPlay = true,
shuffle = true,
background = false,
playNext = true,
unpin = false,
isArtist = true
)
R.id.artist_menu_play_last ->
downloadHandler.downloadRecursively(
this,
artist.id,
save = false,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = true
)
R.id.artist_menu_pin ->
downloadHandler.downloadRecursively(
this,
artist.id,
save = true,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = true
)
R.id.artist_menu_unpin ->
downloadHandler.downloadRecursively(
this,
artist.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = true,
isArtist = true
)
R.id.artist_menu_download ->
downloadHandler.downloadRecursively(
this,
artist.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false,
isArtist = true
)
}
return true
override val musicFolderObserver = { changedFolders: List<MusicFolder> ->
viewAdapter.notifyDataSetChanged()
selectFolderHeader!!.setData(
activeServerProvider.getActiveServer().musicFolderId,
changedFolders
)
}
}

View File

@ -18,90 +18,53 @@
*/
package org.moire.ultrasonic.fragment
import android.os.Handler
import android.os.Looper
import android.app.Application
import android.os.Bundle
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.data.ActiveServerProvider
import org.koin.core.component.KoinApiExtension
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.service.CommunicationErrorHandler
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.service.MusicService
/**
* Provides ViewModel which contains the list of available Artists
*/
class ArtistListModel(
private val activeServerProvider: ActiveServerProvider
) : ViewModel() {
private val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData()
@KoinApiExtension
class ArtistListModel(application: Application) : GenericListModel(application) {
private val artists: MutableLiveData<List<Artist>> = MutableLiveData()
/**
* Retrieves the available Artists in a LiveData
* Retrieves all available Artists in a LiveData
*/
fun getArtists(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<Artist>> {
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData<List<Artist>> {
backgroundLoadFromServer(refresh, swipe)
return artists
}
/**
* Retrieves the available Music Folders in a LiveData
*/
fun getMusicFolders(): LiveData<List<MusicFolder>> {
return musicFolders
}
/**
* Refreshes the cached Artists from the server
*/
fun refresh(swipe: SwipeRefreshLayout) {
backgroundLoadFromServer(true, swipe)
}
private fun backgroundLoadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout) {
viewModelScope.launch {
swipe.isRefreshing = true
loadFromServer(refresh, swipe)
swipe.isRefreshing = false
override fun load(
isOffline: Boolean,
useId3Tags: Boolean,
musicService: MusicService,
refresh: Boolean,
args: Bundle
) {
if (!isOffline && !useId3Tags) {
musicFolders.postValue(
musicService.getMusicFolders(refresh)
)
}
val musicFolderId = activeServer.musicFolderId
val result = if (!isOffline && useId3Tags)
musicService.getArtists(refresh)
else musicService.getIndexes(musicFolderId, refresh)
val retrievedArtists: MutableList<Artist> =
ArrayList(result.shortcuts.size + result.artists.size)
retrievedArtists.addAll(result.shortcuts)
retrievedArtists.addAll(result.artists)
artists.postValue(retrievedArtists)
}
private suspend fun loadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout) =
withContext(Dispatchers.IO) {
val musicService = MusicServiceFactory.getMusicService()
val isOffline = ActiveServerProvider.isOffline()
val useId3Tags = Util.getShouldUseId3Tags()
try {
if (!isOffline && !useId3Tags) {
musicFolders.postValue(
musicService.getMusicFolders(refresh)
)
}
val musicFolderId = activeServerProvider.getActiveServer().musicFolderId
val result = if (!isOffline && useId3Tags)
musicService.getArtists(refresh)
else musicService.getIndexes(musicFolderId, refresh)
val retrievedArtists: MutableList<Artist> =
ArrayList(result.shortcuts.size + result.artists.size)
retrievedArtists.addAll(result.shortcuts)
retrievedArtists.addAll(result.artists)
artists.postValue(retrievedArtists)
} catch (exception: Exception) {
Handler(Looper.getMainLooper()).post {
CommunicationErrorHandler.handleError(exception, swipe.context)
}
}
}
}

View File

@ -1,37 +1,18 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2020 (C) Jozsef Varga
* ArtistRowAdapter.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.view.LayoutInflater
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupMenu
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter
import java.text.Collator
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.util.ImageLoader
@ -42,61 +23,41 @@ import org.moire.ultrasonic.view.SelectMusicFolderView
* Creates a Row in a RecyclerView which contains the details of an Artist
*/
class ArtistRowAdapter(
private var artistList: List<Artist>,
artistList: List<Artist>,
private var selectFolderHeader: SelectMusicFolderView?,
val onArtistClick: (Artist) -> Unit,
val onContextMenuClick: (MenuItem, Artist) -> Boolean,
onItemClick: (Artist) -> Unit,
onContextMenuClick: (MenuItem, Artist) -> Boolean,
private val imageLoader: ImageLoader
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), SectionedAdapter {
) : GenericRowAdapter<Artist>(
selectFolderHeader,
onItemClick,
onContextMenuClick,
imageLoader
),
SectionedAdapter {
override var itemList = artistList
// Set our layout files
override val layout = R.layout.artist_list_item
override val contextMenuLayout = R.menu.artist_context_menu
/**
* Sets the data to be displayed in the RecyclerView
*/
fun setData(data: List<Artist>) {
artistList = data.sortedWith(compareBy(Collator.getInstance()) { t -> t.name })
notifyDataSetChanged()
}
/**
* Holds the view properties of an Artist row
*/
class ArtistViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
var section: TextView = itemView.findViewById(R.id.row_section)
var textView: TextView = itemView.findViewById(R.id.row_artist_name)
var layout: RelativeLayout = itemView.findViewById(R.id.row_artist_layout)
var coverArt: ImageView = itemView.findViewById(R.id.artist_coverart)
var coverArtId: String? = null
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
if (viewType == TYPE_ITEM) {
val row = LayoutInflater.from(parent.context)
.inflate(R.layout.artist_list_item, parent, false)
return ArtistViewHolder(row)
}
return selectFolderHeader!!
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if ((holder is ArtistViewHolder) && (holder.coverArtId != null)) {
imageLoader.cancel(holder.coverArtId)
}
super.onViewRecycled(holder)
override fun setData(data: List<Artist>) {
itemList = data.sortedWith(compareBy(Collator.getInstance()) { t -> t.name })
super.notifyDataSetChanged()
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ArtistViewHolder) {
if (holder is ItemViewHolder) {
val listPosition = if (selectFolderHeader != null) position - 1 else position
holder.textView.text = artistList[listPosition].name
holder.textView.text = itemList[listPosition].name
holder.section.text = getSectionForArtist(listPosition)
holder.layout.setOnClickListener { onArtistClick(artistList[listPosition]) }
holder.layout.setOnClickListener { onItemClick(itemList[listPosition]) }
holder.layout.setOnLongClickListener { view -> createPopupMenu(view, listPosition) }
holder.coverArtId = artistList[listPosition].coverArt
holder.coverArtId = itemList[listPosition].coverArt
if (Util.getShouldShowArtistPicture()) {
holder.coverArt.visibility = View.VISIBLE
@ -111,15 +72,6 @@ class ArtistRowAdapter(
}
}
override fun getItemCount() = if (selectFolderHeader != null)
artistList.size + 1
else
artistList.size
override fun getItemViewType(position: Int): Int {
return if (position == 0 && selectFolderHeader != null) TYPE_HEADER else TYPE_ITEM
}
override fun getSectionName(position: Int): String {
var listPosition = if (selectFolderHeader != null) position - 1 else position
@ -127,18 +79,18 @@ class ArtistRowAdapter(
// scrolled up to the "Select Folder" row
if (listPosition < 0) listPosition = 0
return getSectionFromName(artistList[listPosition].name ?: " ")
return getSectionFromName(itemList[listPosition].name ?: " ")
}
private fun getSectionForArtist(artistPosition: Int): String {
if (artistPosition == 0)
return getSectionFromName(artistList[artistPosition].name ?: " ")
return getSectionFromName(itemList[artistPosition].name ?: " ")
val previousArtistSection = getSectionFromName(
artistList[artistPosition - 1].name ?: " "
itemList[artistPosition - 1].name ?: " "
)
val currentArtistSection = getSectionFromName(
artistList[artistPosition].name ?: " "
itemList[artistPosition].name ?: " "
)
return if (previousArtistSection == currentArtistSection) "" else currentArtistSection
@ -149,24 +101,4 @@ class ArtistRowAdapter(
if (!section.isLetter()) section = '#'
return section.toString()
}
private fun createPopupMenu(view: View, position: Int): Boolean {
val popup = PopupMenu(view.context, view)
val inflater: MenuInflater = popup.menuInflater
inflater.inflate(R.menu.select_artist_context, popup.menu)
val downloadMenuItem = popup.menu.findItem(R.id.artist_menu_download)
downloadMenuItem?.isVisible = !isOffline()
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick(menuItem, artistList[position])
}
popup.show()
return true
}
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_ITEM = 1
}
}

View File

@ -0,0 +1,126 @@
package org.moire.ultrasonic.fragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
/*
* An abstract ScrollListener, which can be extended to provide endless scrolling capabilities
*/
abstract class EndlessScrollListener : RecyclerView.OnScrollListener {
// The minimum amount of items to have below your current scroll position
// before loading more.
private var treshold = VISIBLE_TRESHOLD
// The current offset index of data you have loaded
private var currentPage = 0
// The total number of items in the dataset after the last load
private var previousTotalItemCount = 0
// True if we are still waiting for the last set of data to load.
private var loading = true
// Sets the starting page index
private val startingPageIndex = 0
var thisManager: RecyclerView.LayoutManager
constructor(layoutManager: LinearLayoutManager) {
thisManager = layoutManager
}
@Suppress("Unused")
constructor(layoutManager: GridLayoutManager) {
thisManager = layoutManager
treshold *= layoutManager.spanCount
}
@Suppress("Unused")
constructor(layoutManager: StaggeredGridLayoutManager) {
thisManager = layoutManager
treshold *= layoutManager.spanCount
}
private fun getLastVisibleItem(lastVisibleItemPositions: IntArray): Int {
var maxSize = 0
for (i in lastVisibleItemPositions.indices) {
if (i == 0) {
maxSize = lastVisibleItemPositions[i]
} else if (lastVisibleItemPositions[i] > maxSize) {
maxSize = lastVisibleItemPositions[i]
}
}
return maxSize
}
// This happens many times a second during a scroll, so be wary of the code you place here.
// We are given a few useful parameters to help us work out if we need to load some more data,
// but first we check if we are waiting for the previous load to finish.
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
var lastVisibleItemPosition = 0
val thisManager: RecyclerView.LayoutManager = thisManager
val totalItemCount = thisManager.itemCount
when (thisManager) {
is StaggeredGridLayoutManager -> {
val lastVisibleItemPositions =
thisManager.findLastVisibleItemPositions(null)
// get maximum element within the list
lastVisibleItemPosition = getLastVisibleItem(lastVisibleItemPositions)
}
is GridLayoutManager -> {
lastVisibleItemPosition =
thisManager.findLastVisibleItemPosition()
}
is LinearLayoutManager -> {
lastVisibleItemPosition =
thisManager.findLastVisibleItemPosition()
}
}
// If the total item count is zero and the previous isn't, assume the
// list is invalidated and should be reset back to initial state
if (totalItemCount < previousTotalItemCount) {
currentPage = startingPageIndex
previousTotalItemCount = totalItemCount
if (totalItemCount == 0) {
loading = true
}
}
// If its still loading, we check to see if the dataset count has
// changed, if so we conclude it has finished loading and update the current page
// number and total item count.
if (loading && totalItemCount > previousTotalItemCount) {
loading = false
previousTotalItemCount = totalItemCount
}
// If it isnt currently loading, we check to see if we have breached
// the visibleThreshold and need to reload more data.
// If we do need to reload some more data, we execute onLoadMore to fetch the data.
// threshold should reflect how many total columns there are too
if (!loading && lastVisibleItemPosition + treshold > totalItemCount) {
currentPage++
onLoadMore(currentPage, totalItemCount, view)
loading = true
}
}
// Call this method whenever performing new searches
fun resetState() {
currentPage = startingPageIndex
previousTotalItemCount = 0
loading = true
}
// Defines the process for actually loading more data based on page
abstract fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?)
companion object {
// The minimum amount of items to have below your current scroll position
// before loading more.
const val VISIBLE_TRESHOLD = 7
}
}

View File

@ -0,0 +1,271 @@
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 org.koin.android.ext.android.inject
import org.koin.android.viewmodel.ext.android.viewModel
import org.koin.core.component.KoinApiExtension
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.MusicFolder
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Constants
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)
*/
@KoinApiExtension
abstract class GenericListFragment<T : GenericEntry, TA : GenericRowAdapter<T>> : 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<List<T>>
/**
* The central function to pass a query to the model and return a LiveData object
*/
abstract fun getLiveData(args: Bundle? = null): LiveData<List<T>>
/**
* 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
*/
abstract val musicFolderObserver: (List<MusicFolder>) -> Unit
/**
* Whether to show the folder selector
*/
internal open var folderHeaderEnabled: Boolean = true
fun showFolderHeader(): Boolean {
return folderHeaderEnabled && listModel.isOffline() && !Util.getShouldUseId3Tags()
}
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.setData(newItems) })
// Setup the Music folder handling
listModel.getMusicFolders().observe(viewLifecycleOwner, musicFolderObserver)
// Create a View Manager
viewManager = LinearLayoutManager(this.context)
// Show folder selector UI if enabled
if (showFolderHeader()) {
selectFolderHeader = SelectMusicFolderView(
requireContext(), view as ViewGroup
) { selectedFolderId ->
if (!listModel.isOffline()) {
val currentSetting = listModel.activeServer
currentSetting.musicFolderId = selectedFolderId
serverSettingsModel.updateItem(currentSetting)
}
viewAdapter.notifyDataSetChanged()
listModel.refresh(refreshListView!!, arguments)
}
}
// Hook up the view with the manager and the adapter
listView = view.findViewById<RecyclerView>(recyclerViewId).apply {
setHasFixedSize(true)
layoutManager = viewManager
adapter = viewAdapter
}
}
@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)
}
@Suppress("LongMethod")
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
}
open 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)
}
}

View File

@ -0,0 +1,113 @@
package org.moire.ultrasonic.fragment
import android.app.Application
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.net.ConnectException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinApiExtension
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.service.CommunicationErrorHandler
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Util
/**
* An abstract Model, which can be extended to retrieve a list of items from the API
*/
@KoinApiExtension
abstract class GenericListModel(application: Application) :
AndroidViewModel(application), KoinComponent {
val activeServerProvider: ActiveServerProvider by inject()
val activeServer: ServerSetting
get() = activeServerProvider.getActiveServer()
val context: Context
get() = getApplication<Application>().applicationContext
var currentListIsSortable = true
var showHeader = true
var showSelectFolderHeader = false
internal val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData()
/**
* Helper function to check online status
*/
fun isOffline(): Boolean {
return ActiveServerProvider.isOffline()
}
/**
* Refreshes the cached items from the server
*/
fun refresh(swipe: SwipeRefreshLayout, bundle: Bundle?) {
backgroundLoadFromServer(true, swipe, bundle ?: Bundle())
}
/**
* Trigger a load() and notify the UI that we are loading
*/
fun backgroundLoadFromServer(
refresh: Boolean,
swipe: SwipeRefreshLayout,
bundle: Bundle = Bundle()
) {
viewModelScope.launch {
swipe.isRefreshing = true
loadFromServer(refresh, swipe, bundle)
swipe.isRefreshing = false
}
}
/**
* Calls the load() function with error handling
*/
suspend fun loadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout, bundle: Bundle) =
withContext(Dispatchers.IO) {
val musicService = MusicServiceFactory.getMusicService()
val isOffline = ActiveServerProvider.isOffline()
val useId3Tags = Util.getShouldUseId3Tags()
try {
load(isOffline, useId3Tags, musicService, refresh, bundle)
} catch (exception: ConnectException) {
Handler(Looper.getMainLooper()).post {
CommunicationErrorHandler.handleError(exception, swipe.context)
}
}
}
/**
* This is the central function you need to implement if you want to extend this class
*/
abstract fun load(
isOffline: Boolean,
useId3Tags: Boolean,
musicService: MusicService,
refresh: Boolean,
args: Bundle
)
/**
* Retrieves the available Music Folders in a LiveData
*/
fun getMusicFolders(): LiveData<List<MusicFolder>> {
return musicFolders
}
}

View File

@ -0,0 +1,111 @@
/*
* GenericRowAdapter.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.view.LayoutInflater
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupMenu
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.util.ImageLoader
import org.moire.ultrasonic.view.SelectMusicFolderView
/*
* An abstract Adapter, which can be extended to display a List of <T> in a RecyclerView
*/
abstract class GenericRowAdapter<T>(
private var selectFolderHeader: SelectMusicFolderView?,
val onItemClick: (T) -> Unit,
val onContextMenuClick: (MenuItem, T) -> Boolean,
private val imageLoader: ImageLoader
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
open var itemList: List<T> = listOf()
protected abstract val layout: Int
protected abstract val contextMenuLayout: Int
/**
* Sets the data to be displayed in the RecyclerView
*/
open fun setData(data: List<T>) {
itemList = data
notifyDataSetChanged()
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
if (viewType == TYPE_ITEM) {
val row = LayoutInflater.from(parent.context)
.inflate(layout, parent, false)
return ItemViewHolder(row)
}
return selectFolderHeader!!
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if ((holder is ItemViewHolder) && (holder.coverArtId != null)) {
imageLoader.cancel(holder.coverArtId)
}
super.onViewRecycled(holder)
}
abstract override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
override fun getItemCount(): Int {
if (selectFolderHeader != null)
return itemList.size + 1
else
return itemList.size
}
override fun getItemViewType(position: Int): Int {
return if (position == 0 && selectFolderHeader != null) TYPE_HEADER else TYPE_ITEM
}
internal fun createPopupMenu(view: View, position: Int): Boolean {
val popup = PopupMenu(view.context, view)
val inflater: MenuInflater = popup.menuInflater
inflater.inflate(contextMenuLayout, popup.menu)
val downloadMenuItem = popup.menu.findItem(R.id.menu_download)
downloadMenuItem?.isVisible = !ActiveServerProvider.isOffline()
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick(menuItem, itemList[position])
}
popup.show()
return true
}
/**
* Holds the view properties of an Item row
*/
class ItemViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
var section: TextView = itemView.findViewById(R.id.row_section)
var textView: TextView = itemView.findViewById(R.id.row_artist_name)
var layout: RelativeLayout = itemView.findViewById(R.id.row_artist_layout)
var coverArt: ImageView = itemView.findViewById(R.id.artist_coverart)
var coverArtId: String? = null
}
companion object {
internal const val TYPE_HEADER = 0
internal const val TYPE_ITEM = 1
}
}

View File

@ -223,7 +223,7 @@ class ServerSettingsModel(
/**
* Checks if there are any missing indexes in the ServerSetting list
* For displaying the Server Settings in a ListView, it is mandatory that their indexes
* are'nt missing. Ideally the indexes are continuous, but some circumstances (e.g.
* aren't missing. Ideally the indexes are continuous, but some circumstances (e.g.
* concurrency or migration errors) may get them out of order.
* This would make the List Adapter crash, so it is best to prepare and check the list.
*/

View File

@ -1,5 +1,5 @@
/*
* SelectAlbumFragment.kt
* TrackCollectionFragment.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
@ -8,6 +8,8 @@
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
@ -29,6 +31,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.security.SecureRandom
import java.util.Collections
import java.util.Random
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.android.viewmodel.ext.android.viewModel
@ -40,6 +43,7 @@ import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.getTitle
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.service.CommunicationErrorHandler
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
@ -58,8 +62,8 @@ import org.moire.ultrasonic.view.SongView
import timber.log.Timber
/**
* Displays a group of playable media from the library, which can be an Album, a Playlist, etc.
* TODO: Break up this class into smaller more specific classes, extending a base class if necessary
* 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
*/
@KoinApiExtension
class TrackCollectionFragment : Fragment() {
@ -94,7 +98,6 @@ class TrackCollectionFragment : Fragment() {
private val activeServerProvider: ActiveServerProvider by inject()
private val model: TrackCollectionModel by viewModels()
private val random: Random = SecureRandom()
override fun onCreate(savedInstanceState: Bundle?) {
@ -143,7 +146,6 @@ class TrackCollectionFragment : Fragment() {
model.musicFolders.observe(viewLifecycleOwner, musicFolderObserver)
model.currentDirectory.observe(viewLifecycleOwner, defaultObserver)
model.songsForGenre.observe(viewLifecycleOwner, songsForGenreObserver)
model.albumList.observe(viewLifecycleOwner, albumListObserver)
albumListView!!.choiceMode = ListView.CHOICE_MODE_MULTIPLE
albumListView!!.setOnItemClickListener { parent, theView, position, _ ->
@ -156,7 +158,7 @@ class TrackCollectionFragment : Fragment() {
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.selectAlbumFragment,
R.id.trackCollectionFragment,
bundle
)
} else if (entry != null && entry.isVideo) {
@ -229,6 +231,14 @@ class TrackCollectionFragment : Fragment() {
updateDisplay(false)
}
val handler = CoroutineExceptionHandler { _, exception ->
println("CoroutineExceptionHandler got $exception")
Handler(Looper.getMainLooper()).post {
context?.let { CommunicationErrorHandler.handleError(exception, it) }
}
refreshAlbumListView!!.isRefreshing = false
}
private fun updateDisplay(refresh: Boolean) {
val args = requireArguments()
val id = args.getString(Constants.INTENT_EXTRA_NAME_ID)
@ -242,13 +252,8 @@ class TrackCollectionFragment : Fragment() {
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 albumListType = args.getString(
Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE
)
val genreName = args.getString(Constants.INTENT_EXTRA_NAME_GENRE_NAME)
val albumListTitle = args.getInt(
Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE, 0
)
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)
@ -267,7 +272,7 @@ class TrackCollectionFragment : Fragment() {
setTitle(this@TrackCollectionFragment, name)
}
model.viewModelScope.launch {
model.viewModelScope.launch(handler) {
refreshAlbumListView!!.isRefreshing = true
model.getMusicFolders(refresh)
@ -281,9 +286,6 @@ class TrackCollectionFragment : Fragment() {
} else if (shareId != null) {
setTitle(shareName)
model.getShare(shareId)
} else if (albumListType != null) {
setTitle(albumListTitle)
model.getAlbumList(albumListType, albumListSize, albumListOffset)
} else if (genreName != null) {
setTitle(genreName)
model.getSongsForGenre(genreName, albumListSize, albumListOffset)
@ -321,7 +323,7 @@ class TrackCollectionFragment : Fragment() {
if (entry != null && entry.isDirectory) {
val inflater = requireActivity().menuInflater
inflater.inflate(R.menu.select_album_context, menu)
inflater.inflate(R.menu.generic_context_menu, menu)
}
shareButton = menu.findItem(R.id.menu_item_share)
@ -330,7 +332,7 @@ class TrackCollectionFragment : Fragment() {
shareButton!!.isVisible = !isOffline()
}
val downloadMenuItem = menu.findItem(R.id.album_menu_download)
val downloadMenuItem = menu.findItem(R.id.menu_download)
if (downloadMenuItem != null) {
downloadMenuItem.isVisible = !isOffline()
}
@ -346,42 +348,42 @@ class TrackCollectionFragment : Fragment() {
val entryId = entry.id
when (menuItem.itemId) {
R.id.album_menu_play_now -> {
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.album_menu_play_next -> {
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.album_menu_play_last -> {
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.album_menu_pin -> {
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.album_menu_unpin -> {
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.album_menu_download -> {
R.id.menu_download -> {
downloadHandler.downloadRecursively(
this, entryId, save = false, append = false,
autoPlay = false, shuffle = false, background = true,
@ -389,6 +391,7 @@ class TrackCollectionFragment : Fragment() {
)
}
R.id.select_album_play_all -> {
// TODO: Why is this being handled here?!
playAll()
}
R.id.menu_item_share -> {
@ -629,54 +632,6 @@ class TrackCollectionFragment : Fragment() {
}
}
private val albumListObserver = Observer<MusicDirectory> { musicDirectory ->
if (musicDirectory.getChildren().isNotEmpty()) {
pinButton!!.visibility = View.GONE
unpinButton!!.visibility = View.GONE
downloadButton!!.visibility = View.GONE
deleteButton!!.visibility = View.GONE
// 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
)
) {
moreButton!!.visibility = View.GONE
} else {
moreButton!!.visibility = View.VISIBLE
moreButton!!.setOnClickListener {
val theAlbumListTitle = requireArguments().getInt(
Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE, 0
)
val type = requireArguments().getString(
Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE
)
val theSize = requireArguments().getInt(
Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, 0
)
val theOffset = requireArguments().getInt(
Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, 0
) + theSize
val bundle = Bundle()
bundle.putInt(
Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TITLE, theAlbumListTitle
)
bundle.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, theSize)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, theOffset)
Navigation.findNavController(requireView()).navigate(
R.id.selectAlbumFragment, bundle
)
}
}
} else {
moreButton!!.visibility = View.GONE
}
updateInterfaceWithEntries(musicDirectory)
}
private val songsForGenreObserver = Observer<MusicDirectory> { musicDirectory ->
// Hide more button when results are less than album list size
@ -699,7 +654,9 @@ class TrackCollectionFragment : Fragment() {
bundle.putString(Constants.INTENT_EXTRA_NAME_GENRE_NAME, theGenre)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, size)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, theOffset)
Navigation.findNavController(requireView()).navigate(R.id.selectAlbumFragment, bundle)
Navigation.findNavController(requireView())
.navigate(R.id.trackCollectionFragment, bundle)
}
updateInterfaceWithEntries(musicDirectory)
@ -710,7 +667,7 @@ class TrackCollectionFragment : Fragment() {
private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory) {
val entries = musicDirectory.getChildren()
if (model.currentDirectoryIsSortable && Util.getShouldSortByDisc()) {
if (model.currentListIsSortable && Util.getShouldSortByDisc()) {
Collections.sort(entries, EntryByDiscAndTrackComparator())
}
@ -764,7 +721,7 @@ class TrackCollectionFragment : Fragment() {
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_SIZE, listSize)
bundle.putInt(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET, offset)
Navigation.findNavController(requireView()).navigate(
R.id.selectAlbumFragment, bundle
R.id.trackCollectionFragment, bundle
)
}
}
@ -829,7 +786,7 @@ class TrackCollectionFragment : Fragment() {
)
}
model.currentDirectoryIsSortable = true
model.currentListIsSortable = true
}
private fun createHeader(

View File

@ -1,46 +1,40 @@
/*
* TrackCollectionModel.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.fragment
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import android.os.Bundle
import androidx.lifecycle.MutableLiveData
import java.util.LinkedList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinApiExtension
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Util
// TODO: Break up this class into smaller more specific classes, extending a base class if necessary
/*
* Model for retrieving different collections of tracks from the API
* TODO: Refactor this model to extend the GenericListModel
*/
@KoinApiExtension
class TrackCollectionModel(application: Application) : AndroidViewModel(application), KoinComponent {
private val context: Context
get() = getApplication<Application>().applicationContext
private val activeServerProvider: ActiveServerProvider by inject()
class TrackCollectionModel(application: Application) : GenericListModel(application) {
private val allSongsId = "-1"
val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData()
val albumList: MutableLiveData<MusicDirectory> = MutableLiveData()
val currentDirectory: MutableLiveData<MusicDirectory> = MutableLiveData()
val songsForGenre: MutableLiveData<MusicDirectory> = MutableLiveData()
var currentDirectoryIsSortable = true
var showHeader = true
var showSelectFolderHeader = false
suspend fun getMusicFolders(refresh: Boolean) {
withContext(Dispatchers.IO) {
if (!ActiveServerProvider.isOffline()) {
if (!isOffline()) {
val musicService = MusicServiceFactory.getMusicService()
musicFolders.postValue(musicService.getMusicFolders(refresh))
}
@ -124,6 +118,10 @@ class TrackCollectionModel(application: Application) : AndroidViewModel(applicat
}
}
/*
* TODO: This method should be moved to AlbumListModel,
* since it displays a list of albums by a specified artist.
*/
suspend fun getArtist(refresh: Boolean, id: String?, name: String?) {
withContext(Dispatchers.IO) {
@ -164,7 +162,7 @@ class TrackCollectionModel(application: Application) : AndroidViewModel(applicat
val musicDirectory: MusicDirectory
musicDirectory = if (allSongsId == id) {
if (allSongsId == id) {
val root = MusicDirectory()
val songs: MutableCollection<MusicDirectory.Entry> = LinkedList()
@ -189,10 +187,11 @@ class TrackCollectionModel(application: Application) : AndroidViewModel(applicat
root.addChild(song)
}
}
root
musicDirectory = root
} else {
service.getAlbum(id, name, refresh)
musicDirectory = service.getAlbum(id, name, refresh)
}
currentDirectory.postValue(musicDirectory)
}
}
@ -237,7 +236,7 @@ class TrackCollectionModel(application: Application) : AndroidViewModel(applicat
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getRandomSongs(size)
currentDirectoryIsSortable = false
currentListIsSortable = false
currentDirectory.postValue(musicDirectory)
}
}
@ -281,49 +280,18 @@ class TrackCollectionModel(application: Application) : AndroidViewModel(applicat
}
}
suspend fun getAlbumList(albumListType: String, size: Int, offset: Int) {
showHeader = false
showSelectFolderHeader = !ActiveServerProvider.isOffline() &&
!Util.getShouldUseId3Tags() && (
(albumListType == AlbumListType.SORTED_BY_NAME.toString()) ||
(albumListType == AlbumListType.SORTED_BY_ARTIST.toString())
)
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory: MusicDirectory
val musicFolderId = if (showSelectFolderHeader) {
activeServerProvider.getActiveServer().musicFolderId
} else {
null
}
if (Util.getShouldUseId3Tags()) {
musicDirectory = service.getAlbumList2(
albumListType, size,
offset, musicFolderId
)
} else {
musicDirectory = service.getAlbumList(
albumListType, size,
offset, musicFolderId
)
}
currentDirectoryIsSortable = sortableCollection(albumListType)
albumList.postValue(musicDirectory)
}
}
private fun sortableCollection(albumListType: String): Boolean {
return albumListType != "newest" && albumListType != "random" &&
albumListType != "highest" && albumListType != "recent" &&
albumListType != "frequent"
}
// Returns true if the directory contains only folders
private fun hasOnlyFolders(musicDirectory: MusicDirectory) =
musicDirectory.getChildren(includeDirs = true, includeFiles = false).size ==
musicDirectory.getChildren(includeDirs = true, includeFiles = true).size
override fun load(
isOffline: Boolean,
useId3Tags: Boolean,
musicService: MusicService,
refresh: Boolean,
args: Bundle
) {
// See To_Do at the top
}
}

View File

@ -1,51 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
a:orientation="horizontal"
a:layout_width="fill_parent"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:a="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
a:id="@+id/row_artist_layout"
a:layout_width="match_parent"
a:layout_height="wrap_content"
a:minHeight="?android:attr/listPreferredItemHeight">
a:background="?android:attr/selectableItemBackground"
a:clickable="true"
a:focusable="true">
<ImageView
<com.google.android.material.imageview.ShapeableImageView
a:id="@+id/album_coverart"
a:layout_width="64dp"
a:layout_height="64dp"
a:layout_gravity="left|center_vertical"
a:paddingLeft="3dip" />
a:layout_gravity="center_horizontal|center_vertical"
a:layout_marginStart="6dp"
a:layout_marginLeft="6dp"
a:layout_marginTop="6dp"
a:scaleType="fitCenter"
a:src="@drawable/unknown_album"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/largeRoundedImageView" />
<LinearLayout
a:id="@+id/row_album_details"
a:layout_width="0dp"
a:layout_height="74dp"
a:layout_marginStart="10dp"
a:layout_marginLeft="10dp"
a:drawablePadding="6dip"
a:gravity="center_vertical"
a:minHeight="56dip"
a:orientation="vertical"
a:layout_width="0dip"
a:layout_height="wrap_content"
a:layout_weight="1"
a:layout_gravity="left|center_vertical"
a:paddingLeft="6dip"
a:paddingRight="3dip">
a:paddingLeft="3dip"
a:paddingRight="3dip"
a:textAppearance="?android:attr/textAppearanceMedium"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintLeft_toRightOf="@+id/album_coverart"
app:layout_constraintStart_toEndOf="@+id/album_coverart"
app:layout_constraintTop_toTopOf="parent">
<TextView
a:id="@+id/album_title"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:textAppearance="?android:attr/textAppearanceMedium"
a:ellipsize="marquee"
a:singleLine="true"
a:ellipsize="marquee" />
a:textAppearance="?android:attr/textAppearanceMedium"
tools:text="TITLE" />
<TextView
a:id="@+id/album_artist"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:singleLine="true"
a:textAppearance="?android:attr/textAppearanceSmall"
a:singleLine="true" />
tools:text="ARTIST" />
</LinearLayout>
<ImageView
a:id="@+id/album_star"
a:layout_width="38dp"
a:layout_height="fill_parent"
a:gravity="center_vertical"
a:layout_height="38dp"
a:layout_marginStart="16dp"
a:layout_marginLeft="16dp"
a:layout_marginTop="16dp"
a:background="@android:color/transparent"
a:src="?attr/star_hollow"
a:focusable="false"
a:paddingRight="3dip" />
a:gravity="center_horizontal"
a:paddingRight="3dip"
a:src="?attr/star_hollow"
app:layout_constraintLeft_toRightOf="@+id/row_album_details"
app:layout_constraintStart_toEndOf="@+id/row_album_details"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_star_hollow_dark"
a:paddingEnd="3dip" />
</LinearLayout>
<androidx.constraintlayout.widget.Guideline
a:id="@+id/guideline"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:orientation="vertical"
app:layout_constraintGuide_begin="76dp" />
<androidx.constraintlayout.widget.Guideline
a:id="@+id/guideline2"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:orientation="vertical"
app:layout_constraintGuide_begin="346dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
a:orientation="horizontal"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:minHeight="?android:attr/listPreferredItemHeight">
<ImageView
a:id="@+id/album_coverart"
a:layout_width="64dp"
a:layout_height="64dp"
a:layout_gravity="left|center_vertical"
a:paddingLeft="3dip" />
<LinearLayout
a:orientation="vertical"
a:layout_width="0dip"
a:layout_height="wrap_content"
a:layout_weight="1"
a:layout_gravity="left|center_vertical"
a:paddingLeft="6dip"
a:paddingRight="3dip">
<TextView
a:id="@+id/album_title"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:textAppearance="?android:attr/textAppearanceMedium"
a:singleLine="true"
a:ellipsize="marquee" />
<TextView
a:id="@+id/album_artist"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:textAppearance="?android:attr/textAppearanceSmall"
a:singleLine="true" />
</LinearLayout>
<ImageView
a:id="@+id/album_star"
a:layout_width="38dp"
a:layout_height="fill_parent"
a:gravity="center_vertical"
a:background="@android:color/transparent"
a:src="?attr/star_hollow"
a:focusable="false"
a:paddingRight="3dip" />
</LinearLayout>

View File

@ -6,13 +6,13 @@
a:orientation="vertical">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
a:id="@+id/select_artist_refresh"
a:id="@+id/generic_list_refresh"
a:layout_width="fill_parent"
a:layout_height="0dip"
a:layout_weight="1.0">
<com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
a:id="@+id/select_artist_list"
a:id="@+id/generic_list_recycler"
a:layout_width="match_parent"
a:layout_height="match_parent"
a:paddingTop="8dp"

View File

@ -19,8 +19,10 @@
android:id="@+id/now_playing_image"
android:layout_width="64.0dip"
android:layout_height="64.0dip"
android:layout_marginLeft="6dp"
android:focusable="true"
android:gravity="center" />
android:gravity="center"
android:layout_marginStart="6dp" />
<LinearLayout
android:layout_width="0.0dp"
@ -28,7 +30,8 @@
android:layout_gravity="center_vertical"
android:layout_weight="1.0"
android:orientation="vertical"
android:paddingLeft="11.0dip">
android:paddingLeft="11.0dip"
android:paddingStart="11.0dip">
<TextView
android:id="@+id/now_playing_trackname"

View File

@ -2,22 +2,22 @@
<menu xmlns:a="http://schemas.android.com/apk/res/android" >
<item
a:id="@+id/artist_menu_play_now"
a:id="@+id/menu_play_now"
a:title="@string/common.play_now"/>
<item
a:id="@+id/artist_menu_play_next"
a:id="@+id/menu_play_next"
a:title="@string/common.play_next"/>
<item
a:id="@+id/artist_menu_play_last"
a:id="@+id/menu_play_last"
a:title="@string/common.play_last"/>
<item
a:id="@+id/artist_menu_pin"
a:id="@+id/menu_pin"
a:title="@string/common.pin"/>
<item
a:id="@+id/artist_menu_unpin"
a:id="@+id/menu_unpin"
a:title="@string/common.unpin"/>
<item
a:id="@+id/artist_menu_download"
a:id="@+id/menu_download"
a:title="@string/common.download"/>
</menu>

View File

@ -2,22 +2,22 @@
<menu xmlns:a="http://schemas.android.com/apk/res/android" >
<item
a:id="@+id/album_menu_play_now"
a:id="@+id/menu_play_now"
a:title="@string/common.play_now"/>
<item
a:id="@+id/album_menu_play_next"
a:title="@string/common.play_next"/>
<item
a:id="@+id/album_menu_play_last"
a:id="@+id/menu_play_last"
a:title="@string/common.play_last"/>
<item
a:id="@+id/album_menu_pin"
a:id="@+id/menu_pin"
a:title="@string/common.pin"/>
<item
a:id="@+id/album_menu_unpin"
a:id="@+id/menu_unpin"
a:title="@string/common.unpin"/>
<item
a:id="@+id/album_menu_download"
a:id="@+id/menu_download"
a:title="@string/common.download"/>
<item
a:id="@+id/menu_item_share"

View File

@ -11,7 +11,7 @@
a:icon="?attr/home"
a:title="@string/button_bar.home" />
<item
a:id="@+id/selectArtistFragment"
a:id="@+id/mediaLibraryFragment"
a:checkable="true"
a:icon="?attr/browse"
a:title="@string/button_bar.browse" />

View File

@ -8,8 +8,14 @@
android:name="org.moire.ultrasonic.fragment.MainFragment"
android:label="@string/common.appname" >
<action
android:id="@+id/mainToSelectAlbum"
app:destination="@id/selectAlbumFragment" />
android:id="@+id/mainToTrackCollection"
app:destination="@id/trackCollectionFragment" />
<action
android:id="@+id/mainToAlbumList"
app:destination="@id/albumListFragment" />
<action
android:id="@+id/mainToArtistList"
app:destination="@id/artistListFragment" />
<action
android:id="@+id/mainToSelectGenre"
app:destination="@id/selectGenreFragment" />
@ -18,37 +24,48 @@
app:destination="@id/serverSelectorFragment" />
</fragment>
<fragment
android:id="@+id/selectArtistFragment"
android:id="@+id/mediaLibraryFragment"
android:name="org.moire.ultrasonic.fragment.ArtistListFragment"
android:label="@string/music_library.label" >
<action
android:id="@+id/selectArtistToSelectAlbum"
app:destination="@id/selectAlbumFragment" />
app:destination="@id/trackCollectionFragment" />
</fragment>
<fragment
android:id="@+id/selectAlbumFragment"
android:id="@+id/artistListFragment"
android:name="org.moire.ultrasonic.fragment.ArtistListFragment" >
<action
android:id="@+id/selectArtistToSelectAlbum"
app:destination="@id/trackCollectionFragment" />
</fragment>
<fragment
android:id="@+id/trackCollectionFragment"
android:name="org.moire.ultrasonic.fragment.TrackCollectionFragment" >
</fragment>
<fragment
android:id="@+id/albumListFragment"
android:name="org.moire.ultrasonic.fragment.AlbumListFragment" >
</fragment>
<fragment
android:id="@+id/searchFragment"
android:name="org.moire.ultrasonic.fragment.SearchFragment" >
<action
android:id="@+id/searchToSelectAlbum"
app:destination="@id/selectAlbumFragment" />
app:destination="@id/trackCollectionFragment" />
</fragment>
<fragment
android:id="@+id/playlistsFragment"
android:name="org.moire.ultrasonic.fragment.PlaylistsFragment" >
<action
android:id="@+id/playlistsToSelectAlbum"
app:destination="@id/selectAlbumFragment" />
app:destination="@id/trackCollectionFragment" />
</fragment>
<fragment
android:id="@+id/sharesFragment"
android:name="org.moire.ultrasonic.fragment.SharesFragment" >
<action
android:id="@+id/sharesToSelectAlbum"
app:destination="@id/selectAlbumFragment" />
app:destination="@id/trackCollectionFragment" />
</fragment>
<fragment
android:id="@+id/bookmarksFragment"
@ -61,7 +78,7 @@
android:name="org.moire.ultrasonic.fragment.PodcastFragment" >
<action
android:id="@+id/podcastToSelectAlbum"
app:destination="@id/selectAlbumFragment" />
app:destination="@id/trackCollectionFragment" />
</fragment>
<fragment
android:id="@+id/settingsFragment"
@ -81,7 +98,7 @@
android:name="org.moire.ultrasonic.fragment.PlayerFragment" >
<action
android:id="@+id/playerToSelectAlbum"
app:destination="@id/selectAlbumFragment" />
app:destination="@id/trackCollectionFragment" />
<action
android:id="@+id/playerToLyrics"
app:destination="@id/lyricsFragment" />

View File

@ -25,6 +25,11 @@
<item name="cornerSize">8dp</item>
</style>
<style name="largeRoundedImageView" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">2dp</item>
</style>
<style name="ThemeOverlay.AppCompat.navTheme">
<item name="colorPrimary">?attr/color_menu_selected</item>
<item name="colorControlHighlight">?attr/color_selected</item>