diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt index 9da865e5..466d4832 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Artist.kt @@ -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 } diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/GenericEntry.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/GenericEntry.kt new file mode 100644 index 00000000..194408e6 --- /dev/null +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/GenericEntry.kt @@ -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 +} diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt index 530bb49b..7523dd12 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicDirectory.kt @@ -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() } diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt index 9a62688b..1c23e86c 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/MusicFolder.kt @@ -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() diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Playlist.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Playlist.kt index aed96150..fa91d9b9 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Playlist.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Playlist.kt @@ -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 } diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/PodcastsChannel.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/PodcastsChannel.kt index 9f5ce4c2..a589877e 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/PodcastsChannel.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/PodcastsChannel.kt @@ -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 } diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt index 8912ea94..f6b8987a 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Share.kt @@ -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 = mutableListOf() -) : Serializable { - val name: String? +) : Serializable, GenericEntry() { + override val name: String? get() = url?.let { urlPattern.matcher(url).replaceFirst("$1") } fun getEntries(): List { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/MainFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/MainFragment.java index 4c814320..144d012f 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/MainFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/MainFragment.java @@ -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() diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/NowPlayingFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/NowPlayingFragment.java index 9608ae9b..05306643 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/NowPlayingFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/NowPlayingFragment.java @@ -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); }); } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PlaylistsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PlaylistsFragment.java index 8483f848..e98e5798 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PlaylistsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PlaylistsFragment.java @@ -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) { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PodcastFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PodcastFragment.java index d2913d87..eb068046 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PodcastFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/PodcastFragment.java @@ -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); } }); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SearchFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SearchFragment.java index ff8da2b0..a3cd9b4a 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SearchFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SearchFragment.java @@ -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 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) { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SelectGenreFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SelectGenreFragment.java index 171f039e..c2559df2 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SelectGenreFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SelectGenreFragment.java @@ -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); } } }); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SharesFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SharesFragment.java index 965db039..4da42549 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SharesFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SharesFragment.java @@ -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); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java index 92660026..14806a76 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java @@ -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"; diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java index 0b7fba7c..21ef70b5 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/AlbumView.java @@ -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); diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index 5aa200a7..6c8e5691 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -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, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt index 91ab71a6..285e91be 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt @@ -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() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt new file mode 100644 index 00000000..48d8a22c --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt @@ -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() { + + /** + * 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> { + 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(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 -> + // Do nothing + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt new file mode 100644 index 00000000..ebb9d157 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt @@ -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> = MutableLiveData() + private var loadedUntil: Int = 0 + + fun getAlbumList( + refresh: Boolean, + swipe: SwipeRefreshLayout, + args: Bundle + ): LiveData> { + + 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() + 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" + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt new file mode 100644 index 00000000..65c1d6d1 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumRowAdapter.kt @@ -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, + private var selectFolderHeader: SelectMusicFolderView?, + onItemClick: (MusicDirectory.Entry) -> Unit, + onContextMenuClick: (MenuItem, MusicDirectory.Entry) -> Boolean, + private val imageLoader: ImageLoader +) : GenericRowAdapter( + 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) { + 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 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt index 69a8d055..1836eb15 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt @@ -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() { - 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> { + 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(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 -> + viewAdapter.notifyDataSetChanged() + selectFolderHeader!!.setData( + activeServerProvider.getActiveServer().musicFolderId, + changedFolders + ) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt index ef6dcc5e..21ebc39f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt @@ -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> = MutableLiveData() +@KoinApiExtension +class ArtistListModel(application: Application) : GenericListModel(application) { private val artists: MutableLiveData> = MutableLiveData() /** - * Retrieves the available Artists in a LiveData + * Retrieves all available Artists in a LiveData */ - fun getArtists(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData> { + fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData> { backgroundLoadFromServer(refresh, swipe) return artists } - /** - * Retrieves the available Music Folders in a LiveData - */ - fun getMusicFolders(): LiveData> { - 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 = + 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 = - 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) - } - } - } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt index de7c2f8d..a021b606 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistRowAdapter.kt @@ -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 . - - 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, + artistList: List, 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(), SectionedAdapter { +) : GenericRowAdapter( + 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) { - 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) { + 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 - } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EndlessScrollListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EndlessScrollListener.kt new file mode 100644 index 00000000..4e12491a --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EndlessScrollListener.kt @@ -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 it’s 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 isn’t 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 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt new file mode 100644 index 00000000..e976dbc1 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListFragment.kt @@ -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> : Fragment() { + internal val activeServerProvider: ActiveServerProvider by inject() + internal val serverSettingsModel: ServerSettingsModel by viewModel() + internal val imageLoaderProvider: ImageLoaderProvider by inject() + protected val downloadHandler: DownloadHandler by inject() + protected var refreshListView: SwipeRefreshLayout? = null + internal var listView: RecyclerView? = null + internal lateinit var viewManager: LinearLayoutManager + internal var selectFolderHeader: SelectMusicFolderView? = null + + /** + * The Adapter for the RecyclerView + * Recommendation: Implement this as a lazy delegate + */ + internal abstract val viewAdapter: TA + + /** + * The ViewModel to use to get the data + */ + open val listModel: GenericListModel by viewModels() + + /** + * The LiveData containing the list provided by the model + * Implement this as a getter + */ + internal lateinit var liveDataItems: LiveData> + + /** + * The central function to pass a query to the model and return a LiveData object + */ + abstract fun getLiveData(args: Bundle? = null): LiveData> + + /** + * The id of the target in the navigation graph where we should go, + * after the user has clicked on an item + */ + protected abstract val itemClickTarget: Int + + /** + * The id of the RecyclerView + */ + protected abstract val recyclerViewId: Int + + /** + * The id of the main layout + */ + abstract val mainLayout: Int + + /** + * The id of the refresh view + */ + abstract val refreshListId: Int + + /** + * The observer to be called if the available music folders have changed + */ + abstract val musicFolderObserver: (List) -> 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(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) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt new file mode 100644 index 00000000..6087e309 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt @@ -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().applicationContext + + var currentListIsSortable = true + var showHeader = true + var showSelectFolderHeader = false + + internal val musicFolders: MutableLiveData> = 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> { + return musicFolders + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt new file mode 100644 index 00000000..32714999 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericRowAdapter.kt @@ -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 in a RecyclerView +*/ +abstract class GenericRowAdapter( + private var selectFolderHeader: SelectMusicFolderView?, + val onItemClick: (T) -> Unit, + val onContextMenuClick: (MenuItem, T) -> Boolean, + private val imageLoader: ImageLoader +) : RecyclerView.Adapter() { + + open var itemList: List = 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) { + 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 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSettingsModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSettingsModel.kt index 02386b5e..af33133c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSettingsModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSettingsModel.kt @@ -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. */ diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 1e0984a3..90e524f5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -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 -> - 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 -> // 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( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt index d8800ad6..f0d5e4e2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionModel.kt @@ -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().applicationContext - - private val activeServerProvider: ActiveServerProvider by inject() +class TrackCollectionModel(application: Application) : GenericListModel(application) { private val allSongsId = "-1" - val musicFolders: MutableLiveData> = MutableLiveData() - val albumList: MutableLiveData = MutableLiveData() val currentDirectory: MutableLiveData = MutableLiveData() val songsForGenre: MutableLiveData = 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 = 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 + } } diff --git a/ultrasonic/src/main/res/layout/album_list_item.xml b/ultrasonic/src/main/res/layout/album_list_item.xml index e4129ff1..7959aa3d 100644 --- a/ultrasonic/src/main/res/layout/album_list_item.xml +++ b/ultrasonic/src/main/res/layout/album_list_item.xml @@ -1,51 +1,95 @@ - + a:background="?android:attr/selectableItemBackground" + a:clickable="true" + a:focusable="true"> - + 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" /> + 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"> + a:textAppearance="?android:attr/textAppearanceMedium" + tools:text="TITLE" /> + tools:text="ARTIST" /> + 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" /> - + + + + + diff --git a/ultrasonic/src/main/res/layout/album_list_item_legacy.xml b/ultrasonic/src/main/res/layout/album_list_item_legacy.xml new file mode 100644 index 00000000..e4129ff1 --- /dev/null +++ b/ultrasonic/src/main/res/layout/album_list_item_legacy.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + diff --git a/ultrasonic/src/main/res/layout/select_artist.xml b/ultrasonic/src/main/res/layout/generic_list.xml similarity index 93% rename from ultrasonic/src/main/res/layout/select_artist.xml rename to ultrasonic/src/main/res/layout/generic_list.xml index a9c04a70..1cb9529d 100644 --- a/ultrasonic/src/main/res/layout/select_artist.xml +++ b/ultrasonic/src/main/res/layout/generic_list.xml @@ -6,13 +6,13 @@ a:orientation="vertical"> + android:gravity="center" + android:layout_marginStart="6dp" /> + android:paddingLeft="11.0dip" + android:paddingStart="11.0dip"> \ No newline at end of file diff --git a/ultrasonic/src/main/res/menu/select_album_context.xml b/ultrasonic/src/main/res/menu/generic_context_menu.xml similarity index 73% rename from ultrasonic/src/main/res/menu/select_album_context.xml rename to ultrasonic/src/main/res/menu/generic_context_menu.xml index 8e03b96d..553bf63e 100644 --- a/ultrasonic/src/main/res/menu/select_album_context.xml +++ b/ultrasonic/src/main/res/menu/generic_context_menu.xml @@ -2,22 +2,22 @@ diff --git a/ultrasonic/src/main/res/navigation/navigation_graph.xml b/ultrasonic/src/main/res/navigation/navigation_graph.xml index 1d89f6df..dc877465 100644 --- a/ultrasonic/src/main/res/navigation/navigation_graph.xml +++ b/ultrasonic/src/main/res/navigation/navigation_graph.xml @@ -8,8 +8,14 @@ android:name="org.moire.ultrasonic.fragment.MainFragment" android:label="@string/common.appname" > + android:id="@+id/mainToTrackCollection" + app:destination="@id/trackCollectionFragment" /> + + @@ -18,37 +24,48 @@ app:destination="@id/serverSelectorFragment" /> + app:destination="@id/trackCollectionFragment" /> + + + + + + app:destination="@id/trackCollectionFragment" /> + app:destination="@id/trackCollectionFragment" /> + app:destination="@id/trackCollectionFragment" /> + app:destination="@id/trackCollectionFragment" /> + app:destination="@id/trackCollectionFragment" /> diff --git a/ultrasonic/src/main/res/values/styles.xml b/ultrasonic/src/main/res/values/styles.xml index e3687b97..f4b776e8 100644 --- a/ultrasonic/src/main/res/values/styles.xml +++ b/ultrasonic/src/main/res/values/styles.xml @@ -25,6 +25,11 @@ 8dp + +