add a home tab with local recommendation rows

This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent 111078353d
commit 378d6a3b47
18 changed files with 3611 additions and 24 deletions
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"))
}
}