mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
add a home tab with local recommendation rows
This commit is contained in:
+1577
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.Source
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LibraryHomeDaoTest {
|
||||
private lateinit var db: SubtracksDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentlyPlayedAlbumsOrderByMostRecentFirstAndLimit() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a", "Alpha", played = 100),
|
||||
album(sourceId, "b", "Beta", played = 300),
|
||||
album(sourceId, "c", "Gamma", played = 200),
|
||||
album(sourceId, "d", "Never", played = null),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Beta", "Gamma"),
|
||||
dao.recentlyPlayedAlbums(sourceId, limit = 2).first().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Gamma", "Alpha"),
|
||||
dao.recentlyPlayedAlbums(sourceId, limit = 10).first().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mostPlayedAlbumsIgnoreUnplayedAndOrderByCount() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a", "Alpha", playCount = 3),
|
||||
album(sourceId, "b", "Beta", playCount = 9),
|
||||
album(sourceId, "c", "Gamma", playCount = 0),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha"),
|
||||
dao.mostPlayedAlbums(sourceId, limit = 10).first().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistsUseAggregatedPlayData() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "r1", "Alpha", played = 50, playCount = 1),
|
||||
artist(sourceId, "r2", "Beta", played = 200, playCount = 5),
|
||||
artist(sourceId, "r3", "Gamma", played = null, playCount = 0),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha"),
|
||||
dao.recentlyPlayedArtists(sourceId, limit = 10).first().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha"),
|
||||
dao.mostPlayedArtists(sourceId, limit = 10).first().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentlyStarredSongsAreNewestFirstJoinAlbumCoverArtAndLimit() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1")))
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "First", starred = 100),
|
||||
song(sourceId, "s2", "Second", starred = 300),
|
||||
song(sourceId, "s3", "Third", starred = null),
|
||||
),
|
||||
)
|
||||
|
||||
val starred = dao.recentlyStarredSongs(sourceId, limit = 1).first()
|
||||
assertEquals(listOf("s2"), starred.map { it.song.id })
|
||||
assertEquals("cover-1", starred.single().coverArt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genresOrderByMostRecentPlayWithinTheGenre() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", genre = "Rock", played = 100),
|
||||
song(sourceId, "s2", "Two", genre = "Jazz", played = 400),
|
||||
song(sourceId, "s3", "Three", genre = "Rock", played = 300),
|
||||
song(sourceId, "s4", "Four", genre = "Classical", played = null),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Jazz", "Rock", "Classical"),
|
||||
dao.genresByRecentPlay(sourceId).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genresComeFromTheGenreRelationsAndFallBackPerSong() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", genre = "Legacy", played = 100),
|
||||
song(sourceId, "s2", "Two", genre = "Legacy", played = 300),
|
||||
),
|
||||
)
|
||||
insertGenre(sourceId, "s2", 0, "Rock")
|
||||
insertGenre(sourceId, "s2", 1, "Electronic")
|
||||
|
||||
assertEquals(
|
||||
listOf("Electronic", "Rock", "Legacy"),
|
||||
dao.genresByRecentPlay(sourceId).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songsByGenreMatchesTheGenreRelationsWithFallback() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1")))
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "Fallback", genre = "Rock"),
|
||||
song(sourceId, "s2", "Relation", genre = "Jazz"),
|
||||
song(sourceId, "s3", "NotRock", genre = "Rock"),
|
||||
),
|
||||
)
|
||||
insertGenre(sourceId, "s2", 0, "Rock")
|
||||
insertGenre(sourceId, "s3", 0, "Jazz")
|
||||
|
||||
val page = dao.songsByGenre(sourceId, "Rock").page()
|
||||
assertEquals(listOf("s1", "s2"), page.map { it.song.id })
|
||||
assertEquals("cover-1", page.first().coverArt)
|
||||
}
|
||||
|
||||
private suspend fun insertGenre(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
position: Long,
|
||||
genre: String,
|
||||
) {
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared(
|
||||
"INSERT INTO song_genres (sourceId, songId, position, genre) " +
|
||||
"VALUES ($sourceId, '$songId', $position, '$genre')",
|
||||
) { it.step() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decadesAreDistinctAndOldestFirstExcludingMissingYears() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a", "A", year = 1975),
|
||||
album(sourceId, "b", "B", year = 1979),
|
||||
album(sourceId, "c", "C", year = 2001),
|
||||
album(sourceId, "d", "D", year = null),
|
||||
album(sourceId, "e", "E", year = 0),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf(1970L, 2000L), dao.decades(sourceId).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rediscoverOnlyIncludesStalePlaysOldestFirst() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a", "Recent", played = 900),
|
||||
album(sourceId, "b", "Old", played = 100),
|
||||
album(sourceId, "c", "Older", played = 50),
|
||||
album(sourceId, "d", "Never", played = null),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Older", "Old"),
|
||||
dao.rediscoverAlbums(sourceId, cutoff = 500, limit = 10).first().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeMorePagingSourcesMatchTheirRowsWithoutLimits() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a", "Alpha", created = 100, playCount = 3, played = 100),
|
||||
album(sourceId, "b", "Beta", created = 300, playCount = 9, played = 300),
|
||||
album(sourceId, "c", "Gamma", created = 200, playCount = 0, played = null),
|
||||
),
|
||||
)
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "r1", "Alpha", playCount = 3, played = 100),
|
||||
artist(sourceId, "r2", "Beta", playCount = 9, played = 300),
|
||||
artist(sourceId, "r3", "Gamma", playCount = 0, played = null),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha"),
|
||||
dao.homeRecentlyPlayedAlbums(sourceId).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha"),
|
||||
dao.homeMostPlayedAlbums(sourceId).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Gamma", "Alpha"),
|
||||
dao.homeRecentlyAddedAlbums(sourceId).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha"),
|
||||
dao.homeRecentlyPlayedArtists(sourceId).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha"),
|
||||
dao.homeMostPlayedArtists(sourceId).page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rediscoverPagingSourceOrdersOldestFirst() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a", "Old", played = 100),
|
||||
album(sourceId, "b", "Older", played = 50),
|
||||
album(sourceId, "c", "Recent", played = 900),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Older", "Old"),
|
||||
dao.albumsByRediscover(sourceId, starredFilter = 0, search = "", cutoff = 500).page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starredSongsPagingJoinsAlbumCoverArt() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1")))
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "First", starred = 100),
|
||||
song(sourceId, "s2", "Second", starred = 300),
|
||||
song(sourceId, "s3", "Third", starred = null),
|
||||
),
|
||||
)
|
||||
|
||||
val page = dao.starredSongs(sourceId).page()
|
||||
assertEquals(listOf("s2", "s1"), page.map { it.song.id })
|
||||
assertEquals("cover-1", page.first().coverArt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songsByGenrePagingFiltersAndJoinsAlbumCoverArt() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1")))
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "Rock One", genre = "Rock"),
|
||||
song(sourceId, "s2", "Jazz One", genre = "Jazz"),
|
||||
song(sourceId, "s3", "Rock Two", genre = "Rock"),
|
||||
),
|
||||
)
|
||||
|
||||
val page = dao.songsByGenre(sourceId, "Rock").page()
|
||||
assertEquals(listOf("s1", "s3"), page.map { it.song.id })
|
||||
assertEquals("cover-1", page.first().coverArt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumsByDecadePagingFiltersTheYearRange() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a", "A", year = 1969),
|
||||
album(sourceId, "b", "B", year = 1970),
|
||||
album(sourceId, "c", "C", year = 1979),
|
||||
album(sourceId, "d", "D", year = 1980),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("C", "B"),
|
||||
dao.albumsByDecade(sourceId, start = 1970, end = 1980).page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun source(): Long =
|
||||
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
|
||||
|
||||
private suspend fun <T : Any> PagingSource<Int, T>.page(): List<T> =
|
||||
(
|
||||
load(
|
||||
PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false),
|
||||
) as PagingSource.LoadResult.Page
|
||||
).data
|
||||
|
||||
private fun album(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
name: String,
|
||||
year: Long? = null,
|
||||
coverArt: String? = null,
|
||||
created: Long = 0,
|
||||
playCount: Long = 0,
|
||||
played: Long? = null,
|
||||
) = Album(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
artistId = "ar-1",
|
||||
name = name,
|
||||
albumArtist = "Artist",
|
||||
created = created,
|
||||
coverArt = coverArt,
|
||||
genre = null,
|
||||
year = year,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
playCount = playCount,
|
||||
played = played,
|
||||
)
|
||||
|
||||
private fun artist(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
name: String,
|
||||
playCount: Long = 0,
|
||||
played: Long? = null,
|
||||
) = Artist(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
name = name,
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
playCount = playCount,
|
||||
played = played,
|
||||
)
|
||||
|
||||
private fun song(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
title: String,
|
||||
starred: Long? = null,
|
||||
genre: String? = null,
|
||||
playCount: Long = 0,
|
||||
played: Long? = null,
|
||||
) = Song(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
albumId = "al1",
|
||||
artistId = "ar-1",
|
||||
title = title,
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 200,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = starred,
|
||||
genre = genre,
|
||||
created = 0,
|
||||
playCount = playCount,
|
||||
played = played,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.subtracks.ui.home
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Render smoke test: asserts the awaited node exists and writes a PNG for local review; it does not verify pixels. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
||||
class HomeScreenshotTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun home() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
HomeScreen(
|
||||
feed = feed(),
|
||||
coverArt = { _, _ -> null },
|
||||
playingSongId = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithText("Recently played").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/home.png")
|
||||
}
|
||||
|
||||
private fun feed() =
|
||||
HomeFeed(
|
||||
recentlyPlayedAlbums = albums("Recent"),
|
||||
recentlyPlayedArtists = artists("Recent"),
|
||||
mostPlayedAlbums = albums("Frequent"),
|
||||
mostPlayedArtists = artists("Frequent"),
|
||||
genres = listOf("Rock", "Jazz", "Classical", "Electronic", "Pop", "Folk", "Blues"),
|
||||
decades = listOf(1960L, 1970L, 1980L, 1990L, 2000L, 2010L),
|
||||
recentlyStarredSongs = songs(),
|
||||
recentlyAddedAlbums = albums("Added"),
|
||||
rediscoverAlbums = albums("Repeat"),
|
||||
)
|
||||
|
||||
private fun albums(prefix: String) =
|
||||
(1..6).map { index ->
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "$prefix-$index",
|
||||
artistId = "ar-$index",
|
||||
name = "$prefix Album $index",
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = 2000,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
)
|
||||
}
|
||||
|
||||
private fun artists(prefix: String) =
|
||||
(1..6).map { index ->
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "$prefix-ar-$index",
|
||||
name = "$prefix Artist $index",
|
||||
albumCount = 2,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun songs() =
|
||||
(1..5).map { index ->
|
||||
AlbumSongItem(
|
||||
song =
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "song-$index",
|
||||
albumId = "Recent-1",
|
||||
artistId = "ar-1",
|
||||
title = "Starred Song $index",
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 185,
|
||||
track = index.toLong(),
|
||||
disc = 1,
|
||||
starred = 1000L - index,
|
||||
genre = null,
|
||||
),
|
||||
coverArt = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.subtracks.ui.home
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Song
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HomeViewModelTest {
|
||||
@Test
|
||||
fun playRowsAreKeptWhenTheServerHasPlayData() {
|
||||
val feed =
|
||||
buildHomeFeed(
|
||||
recentAlbums = listOf(album("a")),
|
||||
recentArtists = listOf(artist("r")),
|
||||
frequentAlbums = listOf(album("b")),
|
||||
frequentArtists = listOf(artist("s")),
|
||||
genres = listOf("Rock"),
|
||||
decades = listOf(1970L),
|
||||
starredSongs = listOf(starred("song")),
|
||||
addedAlbums = listOf(album("c")),
|
||||
rediscover = listOf(album("d")),
|
||||
hasPlayData = true,
|
||||
)
|
||||
|
||||
assertEquals(listOf("a"), feed.recentlyPlayedAlbums.map { it.id })
|
||||
assertEquals(listOf("r"), feed.recentlyPlayedArtists.map { it.id })
|
||||
assertEquals(listOf("b"), feed.mostPlayedAlbums.map { it.id })
|
||||
assertEquals(listOf("s"), feed.mostPlayedArtists.map { it.id })
|
||||
assertEquals(listOf("Rock"), feed.genres)
|
||||
assertEquals(listOf(1970L), feed.decades)
|
||||
assertEquals(listOf("song"), feed.recentlyStarredSongs.map { it.song.id })
|
||||
assertEquals(listOf("c"), feed.recentlyAddedAlbums.map { it.id })
|
||||
assertEquals(listOf("d"), feed.rediscoverAlbums.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aServerWithNoPlayDataHidesThePlayRowsButKeepsTheRest() {
|
||||
val feed =
|
||||
buildHomeFeed(
|
||||
recentAlbums = listOf(album("a")),
|
||||
recentArtists = listOf(artist("r")),
|
||||
frequentAlbums = listOf(album("b")),
|
||||
frequentArtists = listOf(artist("s")),
|
||||
genres = listOf("Rock"),
|
||||
decades = listOf(1970L),
|
||||
starredSongs = listOf(starred("song")),
|
||||
addedAlbums = listOf(album("c")),
|
||||
rediscover = listOf(album("d")),
|
||||
hasPlayData = false,
|
||||
)
|
||||
|
||||
assertTrue(feed.recentlyPlayedAlbums.isEmpty())
|
||||
assertTrue(feed.recentlyPlayedArtists.isEmpty())
|
||||
assertTrue(feed.mostPlayedAlbums.isEmpty())
|
||||
assertTrue(feed.mostPlayedArtists.isEmpty())
|
||||
assertEquals(listOf("c"), feed.recentlyAddedAlbums.map { it.id })
|
||||
assertEquals(listOf("Rock"), feed.genres)
|
||||
assertEquals(listOf("song"), feed.recentlyStarredSongs.map { it.song.id })
|
||||
}
|
||||
|
||||
private fun album(id: String) =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = id,
|
||||
artistId = "ar",
|
||||
name = id,
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
)
|
||||
|
||||
private fun artist(id: String) =
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = id,
|
||||
name = id,
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
)
|
||||
|
||||
private fun starred(id: String) =
|
||||
AlbumSongItem(
|
||||
song =
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = id,
|
||||
albumId = "al",
|
||||
artistId = "ar",
|
||||
title = id,
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = 1,
|
||||
genre = null,
|
||||
),
|
||||
coverArt = null,
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import org.junit.runner.RunWith
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LibraryTabTest {
|
||||
@Test
|
||||
fun theStoredNameMapsToItsTabAndAnythingElseToAlbums() {
|
||||
fun theStoredNameMapsToItsTabAndAnythingElseToHome() {
|
||||
assertEquals(LibraryTab.Playlists, libraryTabFor("Playlists"))
|
||||
assertEquals(LibraryTab.Albums, libraryTabFor(null))
|
||||
assertEquals(LibraryTab.Albums, libraryTabFor("Books"))
|
||||
assertEquals(LibraryTab.Home, libraryTabFor(null))
|
||||
assertEquals(LibraryTab.Home, libraryTabFor("Books"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user