mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
add a home tab with local recommendation rows
This commit is contained in:
@@ -8,6 +8,7 @@ import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DiscKey
|
||||
@@ -237,6 +238,92 @@ interface LibraryDao {
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL)")
|
||||
fun hasAlbumPlayed(sourceId: Long): Flow<Boolean>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
||||
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun recentlyPlayedAlbums(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
||||
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun recentlyPlayedArtists(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Artist>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
|
||||
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun mostPlayedAlbums(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " +
|
||||
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun mostPlayedArtists(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Artist>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"ORDER BY created DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun recentlyAddedAlbums(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
|
||||
"ORDER BY played ASC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun rediscoverAlbums(
|
||||
sourceId: Long,
|
||||
cutoff: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
)
|
||||
fun recentlyStarredSongs(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<AlbumSongItem>>
|
||||
|
||||
@Query(
|
||||
"SELECT genre FROM (" +
|
||||
"SELECT song_genres.genre AS genre, songs.played AS played FROM song_genres " +
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"WHERE song_genres.sourceId = :sourceId " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.genre AS genre, songs.played AS played FROM songs " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.genre IS NOT NULL AND songs.genre != '' " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM song_genres " +
|
||||
"WHERE song_genres.sourceId = songs.sourceId AND song_genres.songId = songs.id)" +
|
||||
") GROUP BY genre ORDER BY MAX(played) DESC NULLS LAST, genre COLLATE NOCASE",
|
||||
)
|
||||
fun genresByRecentPlay(sourceId: Long): Flow<List<String>>
|
||||
|
||||
@Query(
|
||||
"SELECT DISTINCT (year / 10) * 10 AS decade FROM albums " +
|
||||
"WHERE sourceId = :sourceId AND year IS NOT NULL AND year > 0 ORDER BY decade ASC",
|
||||
)
|
||||
fun decades(sourceId: Long): Flow<List<Long>>
|
||||
|
||||
@Query("UPDATE artists SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
|
||||
suspend fun setArtistStar(
|
||||
sourceId: Long,
|
||||
@@ -585,6 +672,64 @@ interface LibraryDao {
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff ORDER BY played ASC, name COLLATE NOCASE, id")
|
||||
fun albumsByRediscover(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
cutoff: Long,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
|
||||
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
|
||||
fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE, id")
|
||||
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
|
||||
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||
|
||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
|
||||
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (" +
|
||||
"EXISTS (SELECT 1 FROM song_genres " +
|
||||
"WHERE song_genres.sourceId = songs.sourceId AND song_genres.songId = songs.id " +
|
||||
"AND song_genres.genre = :genre) " +
|
||||
"OR (songs.genre = :genre AND NOT EXISTS (SELECT 1 FROM song_genres " +
|
||||
"WHERE song_genres.sourceId = songs.sourceId AND song_genres.songId = songs.id))) " +
|
||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun songsByGenre(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
): PagingSource<Int, AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
|
||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
||||
)
|
||||
fun albumsByDecade(
|
||||
sourceId: Long,
|
||||
start: Long,
|
||||
end: Long,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(PLAYLIST_SONGS_SQL)
|
||||
fun playlistSongs(
|
||||
sourceId: Long,
|
||||
|
||||
@@ -324,6 +324,21 @@ val MIGRATION_21_22 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_22_23 =
|
||||
object : Migration(22, 23) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `songs` " +
|
||||
"(`sourceId` ASC, `starred` DESC, `id` ASC)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_songs_genre` ON `songs` " +
|
||||
"(`sourceId` ASC, `genre` ASC, `played` DESC)",
|
||||
)
|
||||
createSearchIndex(connection, "song_search", "songs", listOf("title", "artist"))
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||
private suspend fun createSearchIndex(
|
||||
connection: SQLiteConnection,
|
||||
@@ -379,4 +394,5 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_19_20,
|
||||
MIGRATION_20_21,
|
||||
MIGRATION_21_22,
|
||||
MIGRATION_22_23,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.subtracks.data.model.QueueKindConverter
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.SongGenre
|
||||
import com.subtracks.data.model.SongSearch
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.UpNextEntry
|
||||
@@ -37,13 +38,14 @@ import com.subtracks.data.model.UpNextEntry
|
||||
PlaylistSong::class,
|
||||
Song::class,
|
||||
SongGenre::class,
|
||||
SongSearch::class,
|
||||
QueueEntry::class,
|
||||
UpNextEntry::class,
|
||||
PlaybackCursor::class,
|
||||
ArtworkSeed::class,
|
||||
SongDownload::class,
|
||||
],
|
||||
version = 22,
|
||||
version = 23,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
|
||||
|
||||
@@ -241,6 +241,16 @@ data class PlaylistSong(
|
||||
name = "index_songs_sourceId_albumId_order",
|
||||
value = ["sourceId", "albumId", "disc", "track", "id"],
|
||||
),
|
||||
Index(
|
||||
name = "index_songs_starred",
|
||||
value = ["sourceId", "starred", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_songs_genre",
|
||||
value = ["sourceId", "genre", "played"],
|
||||
orders = [Index.Order.ASC, Index.Order.ASC, Index.Order.DESC],
|
||||
),
|
||||
],
|
||||
)
|
||||
data class Song(
|
||||
@@ -422,6 +432,14 @@ data class PlaylistSearch(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "song_search")
|
||||
@Fts5(contentEntity = Song::class, tokenizer = FtsOptions.TOKENIZER_TRIGRAM)
|
||||
data class SongSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val title: String,
|
||||
val artist: String?,
|
||||
)
|
||||
|
||||
class QueueKindConverter {
|
||||
@ColumnTypeConverter
|
||||
fun fromQueueKind(kind: QueueKind): String = kind.name
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.paging.PagingData
|
||||
import androidx.paging.PagingSource
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.Playlist
|
||||
@@ -42,6 +43,72 @@ class LibraryRepository(
|
||||
|
||||
fun hasAlbumPlayed(sourceId: Long): Flow<Boolean> = db.libraryDao().hasAlbumPlayed(sourceId)
|
||||
|
||||
fun recentlyPlayedAlbums(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Album>> = db.libraryDao().recentlyPlayedAlbums(sourceId, limit)
|
||||
|
||||
fun recentlyPlayedArtists(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Artist>> = db.libraryDao().recentlyPlayedArtists(sourceId, limit)
|
||||
|
||||
fun mostPlayedAlbums(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Album>> = db.libraryDao().mostPlayedAlbums(sourceId, limit)
|
||||
|
||||
fun mostPlayedArtists(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Artist>> = db.libraryDao().mostPlayedArtists(sourceId, limit)
|
||||
|
||||
fun recentlyAddedAlbums(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Album>> = db.libraryDao().recentlyAddedAlbums(sourceId, limit)
|
||||
|
||||
fun rediscoverAlbums(
|
||||
sourceId: Long,
|
||||
cutoff: Long,
|
||||
limit: Int,
|
||||
): Flow<List<Album>> = db.libraryDao().rediscoverAlbums(sourceId, cutoff, limit)
|
||||
|
||||
fun recentlyStarredSongs(
|
||||
sourceId: Long,
|
||||
limit: Int,
|
||||
): Flow<List<AlbumSongItem>> = db.libraryDao().recentlyStarredSongs(sourceId, limit)
|
||||
|
||||
fun genresByRecentPlay(sourceId: Long): Flow<List<String>> = db.libraryDao().genresByRecentPlay(sourceId)
|
||||
|
||||
fun decades(sourceId: Long): Flow<List<Long>> = db.libraryDao().decades(sourceId)
|
||||
|
||||
fun rediscoverAlbumsPage(sourceId: Long): Flow<PagingData<Album>> =
|
||||
pager(40) { db.libraryDao().albumsByRediscover(sourceId, starredFilter = 0, search = "", cutoff = rediscoverCutoff()) }
|
||||
|
||||
fun starredSongs(sourceId: Long): Flow<PagingData<AlbumSongItem>> = pager(60) { db.libraryDao().starredSongs(sourceId) }
|
||||
|
||||
fun songsByGenre(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
): Flow<PagingData<AlbumSongItem>> = pager(60) { db.libraryDao().songsByGenre(sourceId, genre) }
|
||||
|
||||
fun albumsByDecade(
|
||||
sourceId: Long,
|
||||
decade: Long,
|
||||
): Flow<PagingData<Album>> = pager(40) { db.libraryDao().albumsByDecade(sourceId, decade, decade + 10) }
|
||||
|
||||
fun homeRecentlyPlayedAlbums(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().homeRecentlyPlayedAlbums(sourceId) }
|
||||
|
||||
fun homeMostPlayedAlbums(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().homeMostPlayedAlbums(sourceId) }
|
||||
|
||||
fun homeRecentlyAddedAlbums(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().homeRecentlyAddedAlbums(sourceId) }
|
||||
|
||||
fun homeRecentlyPlayedArtists(sourceId: Long): Flow<PagingData<Artist>> =
|
||||
pager(60) { db.libraryDao().homeRecentlyPlayedArtists(sourceId) }
|
||||
|
||||
fun homeMostPlayedArtists(sourceId: Long): Flow<PagingData<Artist>> = pager(60) { db.libraryDao().homeMostPlayedArtists(sourceId) }
|
||||
|
||||
fun albums(
|
||||
sourceId: Long,
|
||||
sort: AlbumSort,
|
||||
@@ -365,3 +432,7 @@ class LibraryRepository(
|
||||
pagingSourceFactory = source,
|
||||
).flow
|
||||
}
|
||||
|
||||
const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
||||
|
||||
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
||||
|
||||
@@ -30,6 +30,8 @@ import com.subtracks.playback.PlayerConnection
|
||||
import com.subtracks.playback.Scrobbler
|
||||
import com.subtracks.ui.RootViewModel
|
||||
import com.subtracks.ui.downloads.DownloadsViewModel
|
||||
import com.subtracks.ui.home.HomeListViewModel
|
||||
import com.subtracks.ui.home.HomeViewModel
|
||||
import com.subtracks.ui.library.AlbumDetailViewModel
|
||||
import com.subtracks.ui.library.ArtistDetailViewModel
|
||||
import com.subtracks.ui.library.LibraryViewModel
|
||||
@@ -108,6 +110,8 @@ fun appModule(
|
||||
}
|
||||
viewModel { RootViewModel(get()) }
|
||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
|
||||
viewModel { HomeViewModel(get(), get(), get(), get()) }
|
||||
viewModel { params -> HomeListViewModel(get(), get(), get(), params.get()) }
|
||||
viewModel { SettingsViewModel(get(), get()) }
|
||||
viewModel { DownloadsViewModel(get(), get(), get()) }
|
||||
viewModel { params -> AddSourceViewModel(get(), get(), get(), params.getOrNull()) }
|
||||
|
||||
@@ -205,6 +205,11 @@ class PlaybackController(
|
||||
startOrdinal: Long,
|
||||
) = playContext(queueRepository.albumEntry(sourceId, albumId), startOrdinal)
|
||||
|
||||
fun playSong(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
) = playContext(queueRepository.songEntry(sourceId, songId), 0)
|
||||
|
||||
fun playPlaylist(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
|
||||
@@ -69,6 +69,9 @@ import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.SongInfoDialog
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.downloads.DownloadsRoute
|
||||
import com.subtracks.ui.home.HomeListRequest
|
||||
import com.subtracks.ui.home.HomeListRoute
|
||||
import com.subtracks.ui.home.HomeSection
|
||||
import com.subtracks.ui.library.AlbumDetailRoute
|
||||
import com.subtracks.ui.library.ArtistDetailRoute
|
||||
import com.subtracks.ui.library.LibraryRoute
|
||||
@@ -117,6 +120,9 @@ private object Routes {
|
||||
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
|
||||
const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}"
|
||||
const val PLAYLIST_DETAIL = "playlist/{playlistId}"
|
||||
const val HOME_SECTION = "home/more/{section}"
|
||||
const val HOME_GENRE = "home/genre/{genre}"
|
||||
const val HOME_DECADE = "home/decade/{decade}"
|
||||
|
||||
fun album(
|
||||
id: String,
|
||||
@@ -131,6 +137,12 @@ private object Routes {
|
||||
fun playlist(id: String) = "playlist/${Uri.encode(id)}"
|
||||
|
||||
fun editServer(id: Long) = "edit-server/$id"
|
||||
|
||||
fun homeSection(section: HomeSection) = "home/more/${section.name}"
|
||||
|
||||
fun homeGenre(genre: String) = "home/genre/${Uri.encode(genre)}"
|
||||
|
||||
fun homeDecade(decade: Long) = "home/decade/$decade"
|
||||
}
|
||||
|
||||
private val DETAIL_ROUTES =
|
||||
@@ -351,6 +363,9 @@ private fun MainNavigation() {
|
||||
onViewArtist = { artistId ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
|
||||
},
|
||||
onHomeMore = { section -> navController.navigate(Routes.homeSection(section)) },
|
||||
onGenreClick = { genre -> navController.navigate(Routes.homeGenre(genre)) },
|
||||
onDecadeClick = { decade -> navController.navigate(Routes.homeDecade(decade)) },
|
||||
contextMenuHost = contextMenuHost,
|
||||
setStar = libraryRepository::star,
|
||||
bottomInset = bottomInset,
|
||||
@@ -439,6 +454,78 @@ private fun MainNavigation() {
|
||||
setStar = libraryRepository::star,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.HOME_SECTION,
|
||||
arguments = listOf(navArgument("section") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val section = HomeSection.fromRoute(entry.arguments?.getString("section")) ?: return@composable
|
||||
HomeListRoute(
|
||||
request = HomeListRequest(title = section.title, section = section),
|
||||
onBack = { navController.popBackStack() },
|
||||
onAlbumClick = { album ->
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
|
||||
},
|
||||
onGenreClick = { genre -> navController.navigate(Routes.homeGenre(genre)) },
|
||||
onDecadeClick = { decade -> navController.navigate(Routes.homeDecade(decade)) },
|
||||
contextMenuHost = contextMenuHost,
|
||||
setStar = libraryRepository::star,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.HOME_GENRE,
|
||||
arguments = listOf(navArgument("genre") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val genre = entry.arguments?.getString("genre").orEmpty()
|
||||
HomeListRoute(
|
||||
request = HomeListRequest(title = genre, genre = genre),
|
||||
onBack = { navController.popBackStack() },
|
||||
onAlbumClick = { album ->
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
|
||||
},
|
||||
onGenreClick = { value -> navController.navigate(Routes.homeGenre(value)) },
|
||||
onDecadeClick = { decade -> navController.navigate(Routes.homeDecade(decade)) },
|
||||
contextMenuHost = contextMenuHost,
|
||||
setStar = libraryRepository::star,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.HOME_DECADE,
|
||||
arguments = listOf(navArgument("decade") { type = NavType.LongType }),
|
||||
) { entry ->
|
||||
val decade = entry.arguments?.getLong("decade") ?: return@composable
|
||||
HomeListRoute(
|
||||
request = HomeListRequest(title = "${decade}s", decade = decade),
|
||||
onBack = { navController.popBackStack() },
|
||||
onAlbumClick = { album ->
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
|
||||
},
|
||||
onGenreClick = { genre -> navController.navigate(Routes.homeGenre(genre)) },
|
||||
onDecadeClick = { value -> navController.navigate(Routes.homeDecade(value)) },
|
||||
contextMenuHost = contextMenuHost,
|
||||
setStar = libraryRepository::star,
|
||||
)
|
||||
}
|
||||
composable(Routes.ADD_SERVER) {
|
||||
AddSourceRoute(
|
||||
onSaved = { navController.popBackStack() },
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.subtracks.ui.home
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.library.AlbumsContent
|
||||
import com.subtracks.ui.library.ArtistsContent
|
||||
import com.subtracks.ui.library.SongRow
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun HomeListRoute(
|
||||
request: HomeListRequest,
|
||||
onBack: () -> Unit,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onViewAlbum: (String) -> Unit,
|
||||
onViewArtist: (String) -> Unit,
|
||||
onGenreClick: (String) -> Unit,
|
||||
onDecadeClick: (Long) -> Unit,
|
||||
contextMenuHost: ContextMenuHost? = null,
|
||||
setStar: (StarType, String, Boolean) -> Unit,
|
||||
viewModel: HomeListViewModel = koinViewModel { parametersOf(request) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val genresState = if (request.section == HomeSection.Genres) viewModel.genres.collectAsStateWithLifecycle() else null
|
||||
val genres = genresState?.value.orEmpty()
|
||||
val decadesState = if (request.section == HomeSection.Decades) viewModel.decades.collectAsStateWithLifecycle() else null
|
||||
val decades = decadesState?.value.orEmpty()
|
||||
val actions =
|
||||
ItemActions(
|
||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||
setStar = setStar,
|
||||
viewAlbum = onViewAlbum,
|
||||
viewArtist = onViewArtist,
|
||||
)
|
||||
HomeListScreen(
|
||||
request = request,
|
||||
albums = viewModel.albums.collectAsLazyPagingItems(),
|
||||
artists = viewModel.artists.collectAsLazyPagingItems(),
|
||||
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||
genres = genres,
|
||||
decades = decades,
|
||||
coverArt = viewModel::coverArt,
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onSongClick = viewModel::play,
|
||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||
onGenreClick = onGenreClick,
|
||||
onDecadeClick = onDecadeClick,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeListScreen(
|
||||
request: HomeListRequest,
|
||||
albums: LazyPagingItems<Album>,
|
||||
artists: LazyPagingItems<Artist>,
|
||||
songs: LazyPagingItems<AlbumSongItem>,
|
||||
genres: List<String> = emptyList(),
|
||||
decades: List<Long> = emptyList(),
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
onArtistClick: (Artist) -> Unit = {},
|
||||
onSongClick: (Song) -> Unit = {},
|
||||
onLongClick: (MenuTarget) -> Unit = {},
|
||||
onGenreClick: (String) -> Unit = {},
|
||||
onDecadeClick: (Long) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(request.title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
when (request.section) {
|
||||
HomeSection.Genres -> {
|
||||
PlainList(
|
||||
items = genres,
|
||||
label = { it },
|
||||
onClick = onGenreClick,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
|
||||
HomeSection.Decades -> {
|
||||
PlainList(
|
||||
items = decades,
|
||||
label = { "${it}s" },
|
||||
onClick = onDecadeClick,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
|
||||
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
||||
ArtistsContent(
|
||||
items = artists,
|
||||
coverArt = coverArt,
|
||||
bottomInset = 0.dp,
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
|
||||
HomeSection.RecentlyStarredSongs -> {
|
||||
SongsList(
|
||||
songs = songs,
|
||||
coverArt = coverArt,
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
|
||||
HomeSection.RecentlyPlayedAlbums,
|
||||
HomeSection.MostPlayedAlbums,
|
||||
HomeSection.RecentlyAddedAlbums,
|
||||
HomeSection.Rediscover,
|
||||
null,
|
||||
-> {
|
||||
if (request.genre != null) {
|
||||
SongsList(
|
||||
songs = songs,
|
||||
coverArt = coverArt,
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
} else {
|
||||
AlbumsContent(
|
||||
items = albums,
|
||||
coverArt = coverArt,
|
||||
bottomInset = 0.dp,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> PlainList(
|
||||
items: List<T>,
|
||||
label: (T) -> String,
|
||||
onClick: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
items(items) { item ->
|
||||
ListItem(
|
||||
headlineContent = { Text(label(item)) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable { onClick(item) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SongsList(
|
||||
songs: LazyPagingItems<AlbumSongItem>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
onSongClick: (Song) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
||||
LoadingState(modifier)
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||
val item = songs[index]
|
||||
if (item != null) {
|
||||
SongRow(
|
||||
song = item.song,
|
||||
coverArtId = item.coverArt,
|
||||
coverArt = coverArt,
|
||||
isPlaying = item.song.id == playingSongId,
|
||||
durationSeconds = item.song.duration,
|
||||
modifier =
|
||||
Modifier.combinedClickable(
|
||||
onClick = { onSongClick(item.song) },
|
||||
onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.subtracks.ui.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
data class HomeListRequest(
|
||||
val title: String,
|
||||
val section: HomeSection? = null,
|
||||
val genre: String? = null,
|
||||
val decade: Long? = null,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class HomeListViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
val request: HomeListRequest,
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val albums: Flow<PagingData<Album>> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
when {
|
||||
request.decade != null -> {
|
||||
libraryRepository.albumsByDecade(id, request.decade)
|
||||
}
|
||||
|
||||
request.section == HomeSection.RecentlyPlayedAlbums -> {
|
||||
libraryRepository.homeRecentlyPlayedAlbums(id)
|
||||
}
|
||||
|
||||
request.section == HomeSection.MostPlayedAlbums -> {
|
||||
libraryRepository.homeMostPlayedAlbums(id)
|
||||
}
|
||||
|
||||
request.section == HomeSection.RecentlyAddedAlbums -> {
|
||||
libraryRepository.homeRecentlyAddedAlbums(id)
|
||||
}
|
||||
|
||||
request.section == HomeSection.Rediscover -> {
|
||||
libraryRepository.rediscoverAlbumsPage(id)
|
||||
}
|
||||
|
||||
else -> {
|
||||
flowOf(PagingData.empty<Album>())
|
||||
}
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
val artists: Flow<PagingData<Artist>> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
when (request.section) {
|
||||
HomeSection.RecentlyPlayedArtists -> {
|
||||
libraryRepository.homeRecentlyPlayedArtists(id)
|
||||
}
|
||||
|
||||
HomeSection.MostPlayedArtists -> {
|
||||
libraryRepository.homeMostPlayedArtists(id)
|
||||
}
|
||||
|
||||
else -> {
|
||||
flowOf(PagingData.empty<Artist>())
|
||||
}
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
val songs: Flow<PagingData<AlbumSongItem>> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
when {
|
||||
request.genre != null -> libraryRepository.songsByGenre(id, request.genre)
|
||||
request.section == HomeSection.RecentlyStarredSongs -> libraryRepository.starredSongs(id)
|
||||
else -> flowOf(PagingData.empty<AlbumSongItem>())
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
val genres: StateFlow<List<String>> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.genresByRecentPlay(it) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val decades: StateFlow<List<Long>> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.decades(it) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun play(song: Song) {
|
||||
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package com.subtracks.ui.home
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.library.SongRow
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
enum class HomeSection(
|
||||
val title: String,
|
||||
) {
|
||||
RecentlyPlayedAlbums("Recently played"),
|
||||
RecentlyPlayedArtists("Recently played artists"),
|
||||
MostPlayedAlbums("Most played"),
|
||||
MostPlayedArtists("Most played artists"),
|
||||
Genres("Genres"),
|
||||
Decades("Decades"),
|
||||
RecentlyStarredSongs("Recently starred"),
|
||||
RecentlyAddedAlbums("Recently added"),
|
||||
Rediscover("On repeat"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromRoute(value: String?): HomeSection? = entries.firstOrNull { it.name == value }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeRoute(
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onViewAlbum: (String) -> Unit,
|
||||
onViewArtist: (String) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
onGenreClick: (String) -> Unit,
|
||||
onDecadeClick: (Long) -> Unit,
|
||||
contextMenuHost: ContextMenuHost? = null,
|
||||
setStar: (StarType, String, Boolean) -> Unit,
|
||||
topInset: Dp,
|
||||
bottomInset: Dp,
|
||||
viewModel: HomeViewModel = koinViewModel(),
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val feed by viewModel.feed.collectAsStateWithLifecycle()
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val actions =
|
||||
ItemActions(
|
||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||
setStar = setStar,
|
||||
viewAlbum = onViewAlbum,
|
||||
viewArtist = onViewArtist,
|
||||
)
|
||||
HomeScreen(
|
||||
feed = feed,
|
||||
coverArt = viewModel::coverArt,
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onSongClick = viewModel::play,
|
||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||
onMore = onMore,
|
||||
onGenreClick = onGenreClick,
|
||||
onDecadeClick = onDecadeClick,
|
||||
onSync = viewModel::sync,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
feed: HomeFeed,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
onArtistClick: (Artist) -> Unit = {},
|
||||
onSongClick: (Song) -> Unit = {},
|
||||
onLongClick: (MenuTarget) -> Unit = {},
|
||||
onMore: (HomeSection) -> Unit = {},
|
||||
onGenreClick: (String) -> Unit = {},
|
||||
onDecadeClick: (Long) -> Unit = {},
|
||||
onSync: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (feed.loading) {
|
||||
LoadingState(modifier)
|
||||
return
|
||||
}
|
||||
if (feed.isEmpty()) {
|
||||
EmptyState(
|
||||
text = "Nothing to show yet.\nSync with your server to fill your library.",
|
||||
modifier = modifier,
|
||||
actionLabel = "Sync",
|
||||
onAction = onSync,
|
||||
)
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = topInset + 8.dp, bottom = bottomInset + 24.dp),
|
||||
) {
|
||||
albumRow(
|
||||
HomeSection.RecentlyPlayedAlbums,
|
||||
feed.recentlyPlayedAlbums,
|
||||
coverArt,
|
||||
onAlbumClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
)
|
||||
artistRow(
|
||||
HomeSection.RecentlyPlayedArtists,
|
||||
feed.recentlyPlayedArtists,
|
||||
coverArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
)
|
||||
albumRow(
|
||||
HomeSection.MostPlayedAlbums,
|
||||
feed.mostPlayedAlbums,
|
||||
coverArt,
|
||||
onAlbumClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
)
|
||||
artistRow(
|
||||
HomeSection.MostPlayedArtists,
|
||||
feed.mostPlayedArtists,
|
||||
coverArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
)
|
||||
genreBlock(HomeSection.Genres, feed.genres, onGenreClick, onMore)
|
||||
decadeRow(HomeSection.Decades, feed.decades, onDecadeClick, onMore)
|
||||
starredSongs(HomeSection.RecentlyStarredSongs, feed.recentlyStarredSongs, coverArt, playingSongId, onSongClick, onLongClick, onMore)
|
||||
albumRow(
|
||||
HomeSection.RecentlyAddedAlbums,
|
||||
feed.recentlyAddedAlbums,
|
||||
coverArt,
|
||||
onAlbumClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
)
|
||||
albumRow(
|
||||
HomeSection.Rediscover,
|
||||
feed.rediscoverAlbums,
|
||||
coverArt,
|
||||
onAlbumClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun HomeFeed.isEmpty(): Boolean =
|
||||
recentlyPlayedAlbums.isEmpty() &&
|
||||
recentlyPlayedArtists.isEmpty() &&
|
||||
mostPlayedAlbums.isEmpty() &&
|
||||
mostPlayedArtists.isEmpty() &&
|
||||
genres.isEmpty() &&
|
||||
decades.isEmpty() &&
|
||||
recentlyStarredSongs.isEmpty() &&
|
||||
recentlyAddedAlbums.isEmpty() &&
|
||||
rediscoverAlbums.isEmpty()
|
||||
|
||||
@Composable
|
||||
private fun HomeSectionHeader(
|
||||
title: String,
|
||||
onMore: (() -> Unit)?,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 20.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (onMore != null) {
|
||||
TextButton(onClick = onMore) {
|
||||
Text("More")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val TILE_SIZE = 132.dp
|
||||
private val ARTIST_TILE_SIZE = 104.dp
|
||||
private val TILE_SPACING = 8.dp
|
||||
|
||||
private fun LazyListScope.albumRow(
|
||||
section: HomeSection,
|
||||
albums: List<Album>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
) {
|
||||
if (albums.isEmpty()) return
|
||||
item(key = "${section.name}-header") {
|
||||
HomeSectionHeader(section.title) { onMore(section) }
|
||||
}
|
||||
item(key = "${section.name}-row") {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
|
||||
) {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
val ref = coverArt(album.coverArt, true)
|
||||
AlbumTile(
|
||||
album = album,
|
||||
ref = ref,
|
||||
onClick = { onAlbumClick(album) },
|
||||
onLongClick = { onLongClick(MenuTarget.Album(album, ref)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.artistRow(
|
||||
section: HomeSection,
|
||||
artists: List<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
) {
|
||||
if (artists.isEmpty()) return
|
||||
item(key = "${section.name}-header") {
|
||||
HomeSectionHeader(section.title) { onMore(section) }
|
||||
}
|
||||
item(key = "${section.name}-row") {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
|
||||
) {
|
||||
items(artists, key = { it.id }) { artist ->
|
||||
val ref = coverArt(artist.coverArt, true)
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.genreBlock(
|
||||
section: HomeSection,
|
||||
genres: List<String>,
|
||||
onGenreClick: (String) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
) {
|
||||
if (genres.isEmpty()) return
|
||||
item(key = "${section.name}-header") {
|
||||
HomeSectionHeader(section.title) { onMore(section) }
|
||||
}
|
||||
item(key = "${section.name}-row") {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
|
||||
) {
|
||||
items(genres.chunked(GENRE_COLUMN_HEIGHT), key = { it.first() }) { column ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(TILE_SPACING)) {
|
||||
column.forEach { genre ->
|
||||
AssistChip(onClick = { onGenreClick(genre) }, label = { Text(genre) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.decadeRow(
|
||||
section: HomeSection,
|
||||
decades: List<Long>,
|
||||
onDecadeClick: (Long) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
) {
|
||||
if (decades.isEmpty()) return
|
||||
item(key = "${section.name}-header") {
|
||||
HomeSectionHeader(section.title) { onMore(section) }
|
||||
}
|
||||
item(key = "${section.name}-row") {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
|
||||
) {
|
||||
items(decades, key = { it }) { decade ->
|
||||
AssistChip(onClick = { onDecadeClick(decade) }, label = { Text("${decade}s") })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.starredSongs(
|
||||
section: HomeSection,
|
||||
songs: List<AlbumSongItem>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
onSongClick: (Song) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
) {
|
||||
if (songs.isEmpty()) return
|
||||
item(key = "${section.name}-header") {
|
||||
HomeSectionHeader(section.title) { onMore(section) }
|
||||
}
|
||||
items(songs, key = { it.song.id }) { item ->
|
||||
SongRow(
|
||||
song = item.song,
|
||||
coverArtId = item.coverArt,
|
||||
coverArt = coverArt,
|
||||
isPlaying = item.song.id == playingSongId,
|
||||
durationSeconds = item.song.duration,
|
||||
modifier =
|
||||
Modifier.combinedClickable(
|
||||
onClick = { onSongClick(item.song) },
|
||||
onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val GENRE_COLUMN_HEIGHT = 3
|
||||
|
||||
@Composable
|
||||
private fun AlbumTile(
|
||||
album: Album,
|
||||
ref: CoverArtRef?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.width(TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = album.name,
|
||||
modifier = Modifier.size(TILE_SIZE).clip(RoundedCornerShape(4.dp)),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = album.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = album.albumArtist.orEmpty(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistTile(
|
||||
artist: Artist,
|
||||
ref: CoverArtRef?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = artist.name,
|
||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = artist.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.subtracks.ui.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.repo.rediscoverCutoff
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
const val HOME_ROW_LIMIT = 10
|
||||
const val HOME_STARRED_LIMIT = 5
|
||||
|
||||
data class HomeFeed(
|
||||
val recentlyPlayedAlbums: List<Album> = emptyList(),
|
||||
val recentlyPlayedArtists: List<Artist> = emptyList(),
|
||||
val mostPlayedAlbums: List<Album> = emptyList(),
|
||||
val mostPlayedArtists: List<Artist> = emptyList(),
|
||||
val genres: List<String> = emptyList(),
|
||||
val decades: List<Long> = emptyList(),
|
||||
val recentlyStarredSongs: List<AlbumSongItem> = emptyList(),
|
||||
val recentlyAddedAlbums: List<Album> = emptyList(),
|
||||
val rediscoverAlbums: List<Album> = emptyList(),
|
||||
val loading: Boolean = false,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class HomeViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val syncManager: SyncManager,
|
||||
private val playbackController: PlaybackController,
|
||||
) : ViewModel() {
|
||||
val syncing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it == SyncStatus.Running }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
val feed: StateFlow<HomeFeed> =
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
val plays =
|
||||
combine(
|
||||
libraryRepository.recentlyPlayedAlbums(sourceId, HOME_ROW_LIMIT),
|
||||
libraryRepository.recentlyPlayedArtists(sourceId, HOME_ROW_LIMIT),
|
||||
libraryRepository.mostPlayedAlbums(sourceId, HOME_ROW_LIMIT),
|
||||
libraryRepository.mostPlayedArtists(sourceId, HOME_ROW_LIMIT),
|
||||
) { albums, artists, frequentAlbums, frequentArtists ->
|
||||
PlayRows(albums, artists, frequentAlbums, frequentArtists)
|
||||
}
|
||||
val discovery =
|
||||
combine(
|
||||
libraryRepository.genresByRecentPlay(sourceId),
|
||||
libraryRepository.decades(sourceId),
|
||||
libraryRepository.recentlyStarredSongs(sourceId, HOME_STARRED_LIMIT),
|
||||
libraryRepository.recentlyAddedAlbums(sourceId, HOME_ROW_LIMIT),
|
||||
libraryRepository.rediscoverAlbums(sourceId, rediscoverCutoff(), HOME_ROW_LIMIT),
|
||||
) { genres, decades, starred, added, rediscover ->
|
||||
Discovery(genres, decades, starred, added, rediscover)
|
||||
}
|
||||
val hasPlayData =
|
||||
combine(
|
||||
libraryRepository.hasAlbumPlayCount(sourceId),
|
||||
libraryRepository.hasAlbumPlayed(sourceId),
|
||||
) { frequent, recent -> frequent || recent }
|
||||
combine(plays, discovery, hasPlayData) { rows, found, played ->
|
||||
buildHomeFeed(
|
||||
recentAlbums = rows.recentAlbums,
|
||||
recentArtists = rows.recentArtists,
|
||||
frequentAlbums = rows.frequentAlbums,
|
||||
frequentArtists = rows.frequentArtists,
|
||||
genres = found.genres,
|
||||
decades = found.decades,
|
||||
starredSongs = found.starredSongs,
|
||||
addedAlbums = found.addedAlbums,
|
||||
rediscover = found.rediscover,
|
||||
hasPlayData = played,
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeFeed(loading = true))
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun play(song: Song) {
|
||||
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
||||
}
|
||||
|
||||
fun sync() = syncManager.requestSync()
|
||||
|
||||
private data class PlayRows(
|
||||
val recentAlbums: List<Album>,
|
||||
val recentArtists: List<Artist>,
|
||||
val frequentAlbums: List<Album>,
|
||||
val frequentArtists: List<Artist>,
|
||||
)
|
||||
|
||||
private data class Discovery(
|
||||
val genres: List<String>,
|
||||
val decades: List<Long>,
|
||||
val starredSongs: List<AlbumSongItem>,
|
||||
val addedAlbums: List<Album>,
|
||||
val rediscover: List<Album>,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun buildHomeFeed(
|
||||
recentAlbums: List<Album>,
|
||||
recentArtists: List<Artist>,
|
||||
frequentAlbums: List<Album>,
|
||||
frequentArtists: List<Artist>,
|
||||
genres: List<String>,
|
||||
decades: List<Long>,
|
||||
starredSongs: List<AlbumSongItem>,
|
||||
addedAlbums: List<Album>,
|
||||
rediscover: List<Album>,
|
||||
hasPlayData: Boolean,
|
||||
): HomeFeed =
|
||||
HomeFeed(
|
||||
recentlyPlayedAlbums = recentAlbums.takeIf { hasPlayData }.orEmpty(),
|
||||
recentlyPlayedArtists = recentArtists.takeIf { hasPlayData }.orEmpty(),
|
||||
mostPlayedAlbums = frequentAlbums.takeIf { hasPlayData }.orEmpty(),
|
||||
mostPlayedArtists = frequentArtists.takeIf { hasPlayData }.orEmpty(),
|
||||
genres = genres,
|
||||
decades = decades,
|
||||
recentlyStarredSongs = starredSongs,
|
||||
recentlyAddedAlbums = addedAlbums,
|
||||
rediscoverAlbums = rediscover,
|
||||
)
|
||||
|
||||
internal fun CoroutineScope.playSongInContext(
|
||||
libraryRepository: LibraryRepository,
|
||||
playbackController: PlaybackController,
|
||||
song: Song,
|
||||
) {
|
||||
launch {
|
||||
val albumId = song.albumId
|
||||
if (albumId == null) {
|
||||
playbackController.playSong(song.sourceId, song.id)
|
||||
} else {
|
||||
val ordinal = libraryRepository.albumSongOrdinal(song.sourceId, albumId, song.id)
|
||||
playbackController.playAlbum(song.sourceId, albumId, ordinal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material.icons.rounded.DownloadDone
|
||||
import androidx.compose.material.icons.rounded.FilterAltOff
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Remove
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
@@ -124,6 +125,8 @@ import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDelete
|
||||
import com.subtracks.ui.components.bulkRef
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.home.HomeRoute
|
||||
import com.subtracks.ui.home.HomeSection
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.librarySurfaceColor
|
||||
import com.subtracks.ui.theme.playerSurfaceColor
|
||||
@@ -139,6 +142,7 @@ enum class LibraryTab(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Home("Home", Icons.Rounded.Home),
|
||||
Albums("Albums", Icons.Rounded.Album),
|
||||
Artists("Artists", Icons.Rounded.Person),
|
||||
Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay),
|
||||
@@ -150,9 +154,9 @@ data class SortOption(
|
||||
val descendingByDefault: Boolean = false,
|
||||
)
|
||||
|
||||
fun LibraryTab.listTab(): LibraryListTab = LibraryListTab.valueOf(name)
|
||||
fun LibraryTab.listTab(): LibraryListTab? = LibraryListTab.entries.firstOrNull { it.name == name }
|
||||
|
||||
internal fun libraryTabFor(name: String?): LibraryTab = LibraryTab.entries.firstOrNull { it.name == name } ?: LibraryTab.Albums
|
||||
internal fun libraryTabFor(name: String?): LibraryTab = LibraryTab.entries.firstOrNull { it.name == name } ?: LibraryTab.Home
|
||||
|
||||
fun sortOptionsFor(
|
||||
tab: LibraryTab,
|
||||
@@ -188,6 +192,10 @@ fun sortOptionsFor(
|
||||
SortOption(PlaylistSort.Updated.name, "Updated", descendingByDefault = true),
|
||||
)
|
||||
}
|
||||
|
||||
LibraryTab.Home -> {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -199,6 +207,9 @@ fun LibraryRoute(
|
||||
onOpenSettings: () -> Unit,
|
||||
onViewAlbum: (String) -> Unit,
|
||||
onViewArtist: (String) -> Unit,
|
||||
onHomeMore: (HomeSection) -> Unit = {},
|
||||
onGenreClick: (String) -> Unit = {},
|
||||
onDecadeClick: (Long) -> Unit = {},
|
||||
contextMenuHost: ContextMenuHost? = null,
|
||||
setStar: (StarType, String, Boolean) -> Unit,
|
||||
bottomInset: Dp,
|
||||
@@ -214,24 +225,29 @@ fun LibraryRoute(
|
||||
val listTab = selectedTab.listTab()
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (previousTab != selectedTab) {
|
||||
viewModel.setSearch(previousTab.listTab(), "")
|
||||
previousTab.listTab()?.let { viewModel.setSearch(it, "") }
|
||||
previousTabName = selectedTab.name
|
||||
}
|
||||
}
|
||||
val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle()
|
||||
val listQueryState = listTab?.let { viewModel.listQuery(it).collectAsStateWithLifecycle() }
|
||||
val listQuery = listQueryState?.value ?: ListQuery("")
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val playSort by viewModel.playSortAvailability.collectAsStateWithLifecycle()
|
||||
val displayQuery = if (offline) listQuery.copy(downloaded = true) else listQuery
|
||||
val search by viewModel.search(listTab).collectAsStateWithLifecycle()
|
||||
val searchState = listTab?.let { viewModel.search(it).collectAsStateWithLifecycle() }
|
||||
val search = searchState?.value.orEmpty()
|
||||
val resetKeys =
|
||||
LibraryTab.entries.associateWith { tab ->
|
||||
val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle()
|
||||
val term by viewModel.search(tab.listTab()).collectAsStateWithLifecycle()
|
||||
"${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term"
|
||||
}
|
||||
LibraryTab.entries
|
||||
.mapNotNull { tab ->
|
||||
val tabList = tab.listTab() ?: return@mapNotNull null
|
||||
val query by viewModel.listQuery(tabList).collectAsStateWithLifecycle()
|
||||
val term by viewModel.search(tabList).collectAsStateWithLifecycle()
|
||||
tab to "${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term"
|
||||
}.toMap()
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
||||
val downloads by viewModel.downloads(listTab).collectAsStateWithLifecycle()
|
||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||
val downloads = downloadsState?.value ?: emptyMap()
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||
val itemActions =
|
||||
@@ -275,9 +291,11 @@ fun LibraryRoute(
|
||||
offline = offline,
|
||||
onExitOffline = { viewModel.setOffline(false) },
|
||||
sortOptions = sortOptionsFor(selectedTab, playSort),
|
||||
starredSupported = listTab.supportsStarred,
|
||||
onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) },
|
||||
onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) },
|
||||
starredSupported = listTab?.supportsStarred == true,
|
||||
onSortChange = { sort -> listTab?.let { viewModel.setListQuery(it, listQuery.copy(sort = sort)) } },
|
||||
onToggleSortDirection = {
|
||||
listTab?.let { viewModel.setListQuery(it, listQuery.copy(descending = !listQuery.descending)) }
|
||||
},
|
||||
onCycleStarred = {
|
||||
val next =
|
||||
when (listQuery.starred) {
|
||||
@@ -285,18 +303,37 @@ fun LibraryRoute(
|
||||
StarredFilter.Starred -> StarredFilter.NotStarred
|
||||
StarredFilter.NotStarred -> StarredFilter.Any
|
||||
}
|
||||
viewModel.setListQuery(listTab, listQuery.copy(starred = next))
|
||||
listTab?.let { viewModel.setListQuery(it, listQuery.copy(starred = next)) }
|
||||
},
|
||||
onClearFilters = {
|
||||
viewModel.setListQuery(listTab, listQuery.copy(starred = StarredFilter.Any, downloaded = false))
|
||||
viewModel.setSearch(listTab, "")
|
||||
listTab?.let {
|
||||
viewModel.setListQuery(it, listQuery.copy(starred = StarredFilter.Any, downloaded = false))
|
||||
viewModel.setSearch(it, "")
|
||||
}
|
||||
},
|
||||
search = search,
|
||||
onSearchChange = { viewModel.setSearch(listTab, it) },
|
||||
onToggleDownloaded = { viewModel.setListQuery(listTab, listQuery.copy(downloaded = !listQuery.downloaded)) },
|
||||
onSearchChange = { value -> listTab?.let { viewModel.setSearch(it, value) } },
|
||||
onToggleDownloaded = {
|
||||
listTab?.let { viewModel.setListQuery(it, listQuery.copy(downloaded = !listQuery.downloaded)) }
|
||||
},
|
||||
albumDownloads = downloads.takeIf { listTab == LibraryListTab.Albums }.orEmpty(),
|
||||
artistDownloads = downloads.takeIf { listTab == LibraryListTab.Artists }.orEmpty(),
|
||||
playlistDownloads = downloads.takeIf { listTab == LibraryListTab.Playlists }.orEmpty(),
|
||||
homeContent = { topInset, bottomInset ->
|
||||
HomeRoute(
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onViewAlbum = onViewAlbum,
|
||||
onViewArtist = onViewArtist,
|
||||
onMore = onHomeMore,
|
||||
onGenreClick = onGenreClick,
|
||||
onDecadeClick = onDecadeClick,
|
||||
contextMenuHost = contextMenuHost,
|
||||
setStar = setStar,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
pendingDelete?.let { pending ->
|
||||
@@ -343,6 +380,7 @@ fun LibraryScreen(
|
||||
albumDownloads: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
artistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
playlistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
homeContent: @Composable (topInset: Dp, bottomInset: Dp) -> Unit = { _, _ -> },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pagerState =
|
||||
@@ -413,6 +451,10 @@ fun LibraryScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when (pageTab) {
|
||||
LibraryTab.Home -> {
|
||||
homeContent(listTopInset, listBottomInset)
|
||||
}
|
||||
|
||||
LibraryTab.Albums -> {
|
||||
AlbumsContent(
|
||||
albums.collectAsLazyPagingItems(),
|
||||
@@ -518,7 +560,7 @@ fun LibraryScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (!searchActive) {
|
||||
if (!searchActive && selectedTab != LibraryTab.Home) {
|
||||
FloatingActionButton(
|
||||
onClick = { showOptions = true },
|
||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
|
||||
Reference in New Issue
Block a user