mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
add star/unstar and long-press item menus to the library lists
Star is a new write path: MusicSource.setStar posts star.view/unstar.view and LibraryRepository updates the local row on success (toast on failure). Long-press an album, artist, song or playlist in the library to open a bottom sheet with play/shuffle, star/unstar and view-album/view-artist.
This commit is contained in:
@@ -62,6 +62,27 @@ interface LibraryDao {
|
|||||||
@Upsert
|
@Upsert
|
||||||
suspend fun upsertPlaylistSongs(items: List<PlaylistSong>)
|
suspend fun upsertPlaylistSongs(items: List<PlaylistSong>)
|
||||||
|
|
||||||
|
@Query("UPDATE artists SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
|
||||||
|
suspend fun setArtistStar(
|
||||||
|
sourceId: Long,
|
||||||
|
id: String,
|
||||||
|
starred: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("UPDATE albums SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
|
||||||
|
suspend fun setAlbumStar(
|
||||||
|
sourceId: Long,
|
||||||
|
id: String,
|
||||||
|
starred: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("UPDATE songs SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
|
||||||
|
suspend fun setSongStar(
|
||||||
|
sourceId: Long,
|
||||||
|
id: String,
|
||||||
|
starred: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
@Query("SELECT id FROM artists WHERE sourceId = :sourceId")
|
@Query("SELECT id FROM artists WHERE sourceId = :sourceId")
|
||||||
suspend fun artistIds(sourceId: Long): List<String>
|
suspend fun artistIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import com.subtracks.data.prefs.ArtistSort
|
|||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.SongSort
|
import com.subtracks.data.prefs.SongSort
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
|
import com.subtracks.data.source.StarType
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
class LibraryRepository(
|
class LibraryRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
|
private val showMessage: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
|
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
|
||||||
|
|
||||||
@@ -234,6 +236,33 @@ class LibraryRepository(
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
): Flow<PagingData<SongListItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) }
|
): Flow<PagingData<SongListItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) }
|
||||||
|
|
||||||
|
suspend fun setStar(
|
||||||
|
type: StarType,
|
||||||
|
id: String,
|
||||||
|
starred: Boolean,
|
||||||
|
): Result<Unit> {
|
||||||
|
val sourceId = sourceRepository.activeSourceIdOnce()
|
||||||
|
val source = sourceRepository.activeMusicSource()
|
||||||
|
if (sourceId == null || source == null) return Result.failure(IllegalStateException("No active server"))
|
||||||
|
return runCatching { source.setStar(type, id, starred) }
|
||||||
|
.onSuccess { updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null) }
|
||||||
|
.onFailure { showMessage("Could not update star") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateStarred(
|
||||||
|
sourceId: Long,
|
||||||
|
type: StarType,
|
||||||
|
id: String,
|
||||||
|
starred: Long?,
|
||||||
|
) {
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
when (type) {
|
||||||
|
StarType.Song -> dao.setSongStar(sourceId, id, starred)
|
||||||
|
StarType.Album -> dao.setAlbumStar(sourceId, id, starred)
|
||||||
|
StarType.Artist -> dao.setArtistStar(sourceId, id, starred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T : Any> pager(
|
private fun <T : Any> pager(
|
||||||
pageSize: Int,
|
pageSize: Int,
|
||||||
source: () -> PagingSource<Int, T>,
|
source: () -> PagingSource<Int, T>,
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ interface MusicSource {
|
|||||||
|
|
||||||
suspend fun ping()
|
suspend fun ping()
|
||||||
|
|
||||||
|
suspend fun setStar(
|
||||||
|
type: StarType,
|
||||||
|
id: String,
|
||||||
|
starred: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
fun artists(): Flow<List<Artist>>
|
fun artists(): Flow<List<Artist>>
|
||||||
|
|
||||||
fun albums(): Flow<List<Album>>
|
fun albums(): Flow<List<Album>>
|
||||||
@@ -22,3 +28,9 @@ interface MusicSource {
|
|||||||
|
|
||||||
fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>>
|
fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class StarType {
|
||||||
|
Song,
|
||||||
|
Album,
|
||||||
|
Artist,
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
|
import com.subtracks.data.source.StarType
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -40,6 +41,22 @@ class SubsonicSource(
|
|||||||
withContext(Dispatchers.IO) { client.check("ping") }
|
withContext(Dispatchers.IO) { client.check("ping") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun setStar(
|
||||||
|
type: StarType,
|
||||||
|
id: String,
|
||||||
|
starred: Boolean,
|
||||||
|
) {
|
||||||
|
val param =
|
||||||
|
when (type) {
|
||||||
|
StarType.Song -> "id"
|
||||||
|
StarType.Album -> "albumId"
|
||||||
|
StarType.Artist -> "artistId"
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
client.check(if (starred) "star" else "unstar", mapOf(param to id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun artists(): Flow<List<Artist>> =
|
override fun artists(): Flow<List<Artist>> =
|
||||||
entityBatches(
|
entityBatches(
|
||||||
method = "getArtists",
|
method = "getArtists",
|
||||||
|
|||||||
@@ -32,20 +32,14 @@ fun appModule(
|
|||||||
http: OkHttpClient,
|
http: OkHttpClient,
|
||||||
) = module {
|
) = module {
|
||||||
val mainHandler = Handler(Looper.getMainLooper())
|
val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
val toast: (String) -> Unit = { message ->
|
||||||
|
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
|
||||||
|
}
|
||||||
single { createAndroidDatabase(context) }
|
single { createAndroidDatabase(context) }
|
||||||
single { http }
|
single { http }
|
||||||
single { createUserPreferences(context) }
|
single { createUserPreferences(context) }
|
||||||
single {
|
single { SourceRepository(get(), get(), get(), showMessage = toast) }
|
||||||
SourceRepository(
|
single { LibraryRepository(get(), get(), toast) }
|
||||||
get(),
|
|
||||||
get(),
|
|
||||||
get(),
|
|
||||||
showMessage = { message ->
|
|
||||||
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
single { LibraryRepository(get(), get()) }
|
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get()) }
|
single { SyncManager(get(), get(), get()) }
|
||||||
single { QueueRepository(get()) }
|
single { QueueRepository(get()) }
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ private fun MainNavigation() {
|
|||||||
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) },
|
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) },
|
||||||
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
|
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
|
||||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
onViewAlbum = { albumId -> navController.navigate(Routes.album(albumId)) },
|
||||||
|
onViewArtist = { artistId -> navController.navigate(Routes.artist(artistId)) },
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Album
|
||||||
|
import androidx.compose.material.icons.rounded.Person
|
||||||
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
|
import androidx.compose.material.icons.rounded.Star
|
||||||
|
import androidx.compose.material.icons.rounded.StarBorder
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.data.model.Album as AlbumModel
|
||||||
|
import com.subtracks.data.model.Artist as ArtistModel
|
||||||
|
import com.subtracks.data.model.Playlist as PlaylistModel
|
||||||
|
import com.subtracks.data.model.Song as SongModel
|
||||||
|
|
||||||
|
sealed interface MenuTarget {
|
||||||
|
val title: String
|
||||||
|
val subtitle: String?
|
||||||
|
|
||||||
|
data class Song(
|
||||||
|
val song: SongModel,
|
||||||
|
) : MenuTarget {
|
||||||
|
override val title: String get() = song.title
|
||||||
|
|
||||||
|
override val subtitle: String? get() = song.artist
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Album(
|
||||||
|
val album: AlbumModel,
|
||||||
|
) : MenuTarget {
|
||||||
|
override val title: String get() = album.name
|
||||||
|
|
||||||
|
override val subtitle: String? get() = album.albumArtist
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Artist(
|
||||||
|
val artist: ArtistModel,
|
||||||
|
) : MenuTarget {
|
||||||
|
override val title: String get() = artist.name
|
||||||
|
|
||||||
|
override val subtitle: String? get() = null
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Playlist(
|
||||||
|
val playlist: PlaylistModel,
|
||||||
|
) : MenuTarget {
|
||||||
|
override val title: String get() = playlist.name
|
||||||
|
|
||||||
|
override val subtitle: String? get() = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ItemActions(
|
||||||
|
val playSong: (SongModel) -> Unit = {},
|
||||||
|
val playAlbum: (AlbumModel) -> Unit = {},
|
||||||
|
val shuffleAlbum: (AlbumModel) -> Unit = {},
|
||||||
|
val playPlaylist: (PlaylistModel) -> Unit = {},
|
||||||
|
val shufflePlaylist: (PlaylistModel) -> Unit = {},
|
||||||
|
val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> },
|
||||||
|
val viewAlbum: (String) -> Unit = {},
|
||||||
|
val viewArtist: (String) -> Unit = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ItemContextMenu(
|
||||||
|
target: MenuTarget,
|
||||||
|
actions: ItemActions,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
val dismissThen: (() -> Unit) -> Unit = { action ->
|
||||||
|
onDismiss()
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
MenuHeader(target)
|
||||||
|
when (target) {
|
||||||
|
is MenuTarget.Song -> {
|
||||||
|
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playSong(target.song) } }
|
||||||
|
MenuItem(starIcon(target.song.starred), starLabel(target.song.starred)) {
|
||||||
|
dismissThen { actions.setStar(StarType.Song, target.song.id, target.song.starred == null) }
|
||||||
|
}
|
||||||
|
target.song.albumId?.let { albumId ->
|
||||||
|
MenuItem(Icons.Rounded.Album, "View album") { dismissThen { actions.viewAlbum(albumId) } }
|
||||||
|
}
|
||||||
|
target.song.artistId?.let { artistId ->
|
||||||
|
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Album -> {
|
||||||
|
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playAlbum(target.album) } }
|
||||||
|
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shuffleAlbum(target.album) } }
|
||||||
|
MenuItem(starIcon(target.album.starred), starLabel(target.album.starred)) {
|
||||||
|
dismissThen { actions.setStar(StarType.Album, target.album.id, target.album.starred == null) }
|
||||||
|
}
|
||||||
|
target.album.artistId?.let { artistId ->
|
||||||
|
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Artist -> {
|
||||||
|
MenuItem(starIcon(target.artist.starred), starLabel(target.artist.starred)) {
|
||||||
|
dismissThen { actions.setStar(StarType.Artist, target.artist.id, target.artist.starred == null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Playlist -> {
|
||||||
|
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playPlaylist(target.playlist) } }
|
||||||
|
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shufflePlaylist(target.playlist) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MenuHeader(target: MenuTarget) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = target.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent =
|
||||||
|
target.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MenuItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(label) },
|
||||||
|
leadingContent = { Icon(icon, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun starIcon(starred: Long?): ImageVector = if (starred == null) Icons.Rounded.StarBorder else Icons.Rounded.Star
|
||||||
|
|
||||||
|
private fun starLabel(starred: Long?): String = if (starred == null) "Star" else "Unstar"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
|
|||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.FilteredEmptyState
|
import com.subtracks.ui.components.FilteredEmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
@@ -40,6 +41,7 @@ fun AlbumsContent(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
bottomInset: Dp,
|
bottomInset: Dp,
|
||||||
onAlbumClick: (Album) -> Unit,
|
onAlbumClick: (Album) -> Unit,
|
||||||
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
filtered: Boolean = false,
|
filtered: Boolean = false,
|
||||||
onClearFilters: () -> Unit = {},
|
onClearFilters: () -> Unit = {},
|
||||||
resetKey: Any? = null,
|
resetKey: Any? = null,
|
||||||
@@ -90,8 +92,10 @@ fun AlbumsContent(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(2.dp))
|
.clip(RoundedCornerShape(2.dp))
|
||||||
.clickable { onAlbumClick(album) }
|
.combinedClickable(
|
||||||
.testTag(ALBUM_COVER_TAG),
|
onClick = { onAlbumClick(album) },
|
||||||
|
onLongClick = { onLongClick(MenuTarget.Album(album)) },
|
||||||
|
).testTag(ALBUM_COVER_TAG),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
|
|||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.FilteredEmptyState
|
import com.subtracks.ui.components.FilteredEmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
@@ -38,6 +39,7 @@ fun ArtistsContent(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
bottomInset: Dp,
|
bottomInset: Dp,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
filtered: Boolean = false,
|
filtered: Boolean = false,
|
||||||
onClearFilters: () -> Unit = {},
|
onClearFilters: () -> Unit = {},
|
||||||
resetKey: Any? = null,
|
resetKey: Any? = null,
|
||||||
@@ -93,7 +95,11 @@ fun ArtistsContent(
|
|||||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.clickable { onArtistClick(artist) },
|
modifier =
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClick = { onArtistClick(artist) },
|
||||||
|
onLongClick = { onLongClick(MenuTarget.Artist(artist)) },
|
||||||
|
),
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.prefs.SongSort
|
import com.subtracks.data.prefs.SongSort
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ItemActions
|
||||||
|
import com.subtracks.ui.components.ItemContextMenu
|
||||||
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.statusBarScrim
|
import com.subtracks.ui.components.statusBarScrim
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.librarySurfaceColor
|
import com.subtracks.ui.theme.librarySurfaceColor
|
||||||
@@ -189,12 +192,15 @@ fun LibraryRoute(
|
|||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onPlaylistClick: (Playlist) -> Unit,
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onViewAlbum: (String) -> Unit,
|
||||||
|
onViewArtist: (String) -> Unit,
|
||||||
bottomInset: Dp,
|
bottomInset: Dp,
|
||||||
viewModel: LibraryViewModel = koinViewModel(),
|
viewModel: LibraryViewModel = koinViewModel(),
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
||||||
var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
||||||
|
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
|
||||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
|
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
|
||||||
val listTab = selectedTab.listTab()
|
val listTab = selectedTab.listTab()
|
||||||
@@ -231,6 +237,7 @@ fun LibraryRoute(
|
|||||||
onSongClick = viewModel::playSong,
|
onSongClick = viewModel::playSong,
|
||||||
onSync = viewModel::sync,
|
onSync = viewModel::sync,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onItemLongClick = { menuTarget = it },
|
||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
listQuery = listQuery,
|
listQuery = listQuery,
|
||||||
resetKeys = resetKeys,
|
resetKeys = resetKeys,
|
||||||
@@ -254,6 +261,23 @@ fun LibraryRoute(
|
|||||||
search = search,
|
search = search,
|
||||||
onSearchChange = { viewModel.setSearch(listTab, it) },
|
onSearchChange = { viewModel.setSearch(listTab, it) },
|
||||||
)
|
)
|
||||||
|
menuTarget?.let { target ->
|
||||||
|
ItemContextMenu(
|
||||||
|
target = target,
|
||||||
|
actions =
|
||||||
|
ItemActions(
|
||||||
|
playSong = { viewModel.playSong(it.id) },
|
||||||
|
playAlbum = { viewModel.playAlbum(it.id) },
|
||||||
|
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
|
||||||
|
playPlaylist = { viewModel.playPlaylist(it.id) },
|
||||||
|
shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
|
||||||
|
setStar = viewModel::setStar,
|
||||||
|
viewAlbum = onViewAlbum,
|
||||||
|
viewArtist = onViewArtist,
|
||||||
|
),
|
||||||
|
onDismiss = { menuTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -272,6 +296,7 @@ fun LibraryScreen(
|
|||||||
onSongClick: (String) -> Unit,
|
onSongClick: (String) -> Unit,
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||||
syncing: Boolean = false,
|
syncing: Boolean = false,
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
@@ -364,6 +389,7 @@ fun LibraryScreen(
|
|||||||
dismissSearch()
|
dismissSearch()
|
||||||
onAlbumClick(it)
|
onAlbumClick(it)
|
||||||
},
|
},
|
||||||
|
onLongClick = onItemLongClick,
|
||||||
filtered = filtersActive,
|
filtered = filtersActive,
|
||||||
onClearFilters = onClearFilters,
|
onClearFilters = onClearFilters,
|
||||||
resetKey = resetKey,
|
resetKey = resetKey,
|
||||||
@@ -381,6 +407,7 @@ fun LibraryScreen(
|
|||||||
dismissSearch()
|
dismissSearch()
|
||||||
onArtistClick(it)
|
onArtistClick(it)
|
||||||
},
|
},
|
||||||
|
onLongClick = onItemLongClick,
|
||||||
filtered = filtersActive,
|
filtered = filtersActive,
|
||||||
onClearFilters = onClearFilters,
|
onClearFilters = onClearFilters,
|
||||||
resetKey = resetKey,
|
resetKey = resetKey,
|
||||||
@@ -398,6 +425,7 @@ fun LibraryScreen(
|
|||||||
dismissSearch()
|
dismissSearch()
|
||||||
onSongClick(it)
|
onSongClick(it)
|
||||||
},
|
},
|
||||||
|
onLongClick = onItemLongClick,
|
||||||
playingSongId,
|
playingSongId,
|
||||||
filtered = filtersActive,
|
filtered = filtersActive,
|
||||||
onClearFilters = onClearFilters,
|
onClearFilters = onClearFilters,
|
||||||
@@ -416,6 +444,7 @@ fun LibraryScreen(
|
|||||||
dismissSearch()
|
dismissSearch()
|
||||||
onPlaylistClick(it)
|
onPlaylistClick(it)
|
||||||
},
|
},
|
||||||
|
onLongClick = onItemLongClick,
|
||||||
filtered = filtersActive,
|
filtered = filtersActive,
|
||||||
onClearFilters = onClearFilters,
|
onClearFilters = onClearFilters,
|
||||||
resetKey = resetKey,
|
resetKey = resetKey,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.subtracks.data.prefs.UserPreferences
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.QueueSongQuery
|
import com.subtracks.data.repo.QueueSongQuery
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
@@ -138,6 +139,38 @@ class LibraryViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun playAlbum(albumId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sourceRepository.activeSourceIdOnce()?.let { playbackController.playAlbum(it, albumId, 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shuffleAlbum(albumId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sourceRepository.activeSourceIdOnce()?.let { playbackController.shuffleAlbum(it, albumId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playPlaylist(playlistId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sourceRepository.activeSourceIdOnce()?.let { playbackController.playPlaylist(it, playlistId, 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shufflePlaylist(playlistId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sourceRepository.activeSourceIdOnce()?.let { playbackController.shufflePlaylist(it, playlistId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStar(
|
||||||
|
type: StarType,
|
||||||
|
id: String,
|
||||||
|
starred: Boolean,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch { libraryRepository.setStar(type, id, starred) }
|
||||||
|
}
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun sync() = syncManager.requestSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
|
|||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.FilteredEmptyState
|
import com.subtracks.ui.components.FilteredEmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ fun PlaylistsContent(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
bottomInset: Dp,
|
bottomInset: Dp,
|
||||||
onPlaylistClick: (Playlist) -> Unit,
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
filtered: Boolean = false,
|
filtered: Boolean = false,
|
||||||
onClearFilters: () -> Unit = {},
|
onClearFilters: () -> Unit = {},
|
||||||
resetKey: Any? = null,
|
resetKey: Any? = null,
|
||||||
@@ -91,7 +93,11 @@ fun PlaylistsContent(
|
|||||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
|
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.clickable { onPlaylistClick(playlist) },
|
modifier =
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClick = { onPlaylistClick(playlist) },
|
||||||
|
onLongClick = { onLongClick(MenuTarget.Playlist(playlist)) },
|
||||||
|
),
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -39,6 +39,7 @@ import com.subtracks.ui.components.CoverArt
|
|||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.FilteredEmptyState
|
import com.subtracks.ui.components.FilteredEmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ fun SongsContent(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
bottomInset: Dp,
|
bottomInset: Dp,
|
||||||
onSongClick: (String) -> Unit,
|
onSongClick: (String) -> Unit,
|
||||||
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
filtered: Boolean = false,
|
filtered: Boolean = false,
|
||||||
onClearFilters: () -> Unit = {},
|
onClearFilters: () -> Unit = {},
|
||||||
@@ -86,7 +88,11 @@ fun SongsContent(
|
|||||||
coverArtId = item.coverArt,
|
coverArtId = item.coverArt,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
isPlaying = item.song.id == playingSongId,
|
isPlaying = item.song.id == playingSongId,
|
||||||
modifier = Modifier.clickable { onSongClick(item.song.id) },
|
modifier =
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClick = { onSongClick(item.song.id) },
|
||||||
|
onLongClick = { onLongClick(MenuTarget.Song(item.song)) },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room3.Room
|
||||||
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
|
import com.subtracks.data.source.StarType
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class LibraryRepositoryTest {
|
||||||
|
private lateinit var db: SubtracksDatabase
|
||||||
|
private lateinit var sourceRepository: SourceRepository
|
||||||
|
private lateinit var repository: LibraryRepository
|
||||||
|
private val messages = ArrayList<String>()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
db =
|
||||||
|
Room
|
||||||
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
|
.setDriver(BundledSQLiteDriver())
|
||||||
|
.build()
|
||||||
|
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
|
||||||
|
repository = LibraryRepository(db, sourceRepository, showMessage = { messages += it })
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
sourceRepository.close()
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun starringASongPostsToTheServerAndUpdatesTheRow() =
|
||||||
|
runBlocking {
|
||||||
|
withSource { server ->
|
||||||
|
server.enqueue(ok())
|
||||||
|
|
||||||
|
assertTrue(repository.setStar(StarType.Song, "s1", true).isSuccess)
|
||||||
|
|
||||||
|
val request = server.takeRequest()
|
||||||
|
assertEquals("/rest/star.view", request.requestUrl!!.encodedPath)
|
||||||
|
assertEquals("s1", request.requestUrl!!.queryParameter("id"))
|
||||||
|
assertNotNull(starred())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unstarringPostsToTheServerAndClearsTheRow() =
|
||||||
|
runBlocking {
|
||||||
|
withSource { server ->
|
||||||
|
server.enqueue(ok())
|
||||||
|
repository.setStar(StarType.Song, "s1", true)
|
||||||
|
server.enqueue(ok())
|
||||||
|
|
||||||
|
repository.setStar(StarType.Song, "s1", false)
|
||||||
|
|
||||||
|
server.takeRequest()
|
||||||
|
assertEquals("/rest/unstar.view", server.takeRequest().requestUrl!!.encodedPath)
|
||||||
|
assertNull(starred())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRejectedStarLeavesTheRowUntouchedAndReports() =
|
||||||
|
runBlocking {
|
||||||
|
withSource { server ->
|
||||||
|
server.enqueue(failed(40))
|
||||||
|
|
||||||
|
assertTrue(repository.setStar(StarType.Song, "s1", true).isFailure)
|
||||||
|
|
||||||
|
assertNull(starred())
|
||||||
|
assertEquals(listOf("Could not update star"), messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun withSource(block: suspend (MockWebServer) -> Unit) {
|
||||||
|
val server = MockWebServer()
|
||||||
|
server.start()
|
||||||
|
try {
|
||||||
|
val sourceId = sourceRepository.addSource("nav", server.url("/").toString(), "u", "p", false)
|
||||||
|
db.libraryDao().upsertSongs(listOf(seedSong(sourceId)))
|
||||||
|
block(server)
|
||||||
|
} finally {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun starred(): Long? =
|
||||||
|
repository
|
||||||
|
.albumSongs(sourceRepository.activeSourceIdOnce()!!, "al1")
|
||||||
|
.first()
|
||||||
|
.first { it.id == "s1" }
|
||||||
|
.starred
|
||||||
|
|
||||||
|
private fun seedSong(sourceId: Long) =
|
||||||
|
Song(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = "s1",
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
title = "Track",
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 1_000,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
genre = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
|
||||||
|
|
||||||
|
private fun failed(code: Int) =
|
||||||
|
MockResponse().setBody("<subsonic-response status=\"failed\"><error code=\"$code\" message=\"nope\"/></subsonic-response>")
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import com.subtracks.data.model.PlaylistSong
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
|
import com.subtracks.data.source.StarType
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
@@ -679,6 +680,12 @@ private class FakeMusicSource(
|
|||||||
|
|
||||||
override suspend fun ping() = Unit
|
override suspend fun ping() = Unit
|
||||||
|
|
||||||
|
override suspend fun setStar(
|
||||||
|
type: StarType,
|
||||||
|
id: String,
|
||||||
|
starred: Boolean,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
override fun artists(): Flow<List<Artist>> = batches(artists)
|
override fun artists(): Flow<List<Artist>> = batches(artists)
|
||||||
|
|
||||||
override fun albums(): Flow<List<Album>> = batches(albums)
|
override fun albums(): Flow<List<Album>> = batches(albums)
|
||||||
|
|||||||
Reference in New Issue
Block a user