From 187c795912f82e78fb1436734672a3c0710f2392 Mon Sep 17 00:00:00 2001 From: Taras Pylypiv Date: Mon, 14 Sep 2026 21:39:34 +0300 Subject: [PATCH 1/4] Fix navigation back-stack, dead Library taps, and playlist UX gaps The app felt unfinished due to a handful of concrete bugs rather than vague roughness: - navigate() applied popUpTo(Home) to every push, including drill-downs into Player/Queue/Search/PlaylistDetail/Favorites, so back from those screens always landed on Home instead of the screen the user came from. Split into navigateToTab() (dock/rail only) and a plain-push navigate() for everything else. - Library's Artist/Genre/Folder rows had no onClick at all, unlike Albums. Wired playArtist/playGenre/playFolder (new GetFolderSongsUseCase + getSongsOfFolder query, mirroring the existing album/artist/genre pattern) so tapping any of them plays and opens the queue. - Playlists list deleted with no confirmation while the detail screen did confirm; unified both on the same ResonanceDialog flow. - Playlist detail had no way to add songs while viewing it; added a SongPickerSheet (searchable multi-select) wired to a new addSongs() on PlaylistDetailViewModel. - "Add to playlist" only existed on Library's Songs tab; extended the same SongOverflowSheet/PlaylistPickerSheet wiring to Home, Search, and Favorites song rows. - Playing an empty playlist silently no-opped with zero feedback (the app had no Toast/snackbar anywhere despite a ready-made ResonanceSnackbar component). Wired a shared SnackbarHost into the app shell and surfaced it there. Also switched every collectAsState() to collectAsStateWithLifecycle() across the app so StateFlow collection pauses while the app is backgrounded instead of running continuously. Co-Authored-By: Claude Sonnet 5 --- app/build.gradle.kts | 1 + .../java/com/resonance/player/MainActivity.kt | 6 +- .../com/resonance/player/app/AppContainer.kt | 2 + .../player/core/database/dao/SongDao.kt | 7 + .../core/ui/components/PermissionGate.kt | 4 +- .../core/ui/components/SongPickerSheet.kt | 131 ++++++++++++++++++ .../player/data/local/RoomMusicRepository.kt | 14 ++ .../player/domain/library/LibraryBrowse.kt | 6 + .../player/domain/library/MusicRepository.kt | 1 + .../feature/favorites/FavoritesScreen.kt | 56 +++++++- .../feature/favorites/FavoritesViewModel.kt | 62 ++++++++- .../player/feature/home/HomeScreen.kt | 81 +++++++++-- .../player/feature/home/HomeViewModel.kt | 61 +++++++- .../player/feature/library/LibraryScreen.kt | 54 +++++--- .../feature/library/LibraryViewModel.kt | 28 ++++ .../player/feature/player/PlayerScreen.kt | 10 +- .../feature/playlists/PlaylistDetailScreen.kt | 26 +++- .../feature/playlists/PlaylistsScreen.kt | 33 ++++- .../feature/playlists/PlaylistsViewModels.kt | 22 ++- .../player/feature/queue/QueueScreen.kt | 4 +- .../player/feature/search/SearchScreen.kt | 57 +++++++- .../player/feature/search/SearchViewModel.kt | 60 +++++++- .../player/feature/settings/SettingsScreen.kt | 14 +- .../player/navigation/AppNavGraph.kt | 127 ++++++++++++----- app/src/main/res/values/strings.xml | 4 + .../java/com/resonance/player/fakes/Fakes.kt | 3 + gradle/libs.versions.toml | 1 + 27 files changed, 770 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/com/resonance/player/core/ui/components/SongPickerSheet.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3887841..d74d0de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) diff --git a/app/src/main/java/com/resonance/player/MainActivity.kt b/app/src/main/java/com/resonance/player/MainActivity.kt index a8d5191..7f56207 100644 --- a/app/src/main/java/com/resonance/player/MainActivity.kt +++ b/app/src/main/java/com/resonance/player/MainActivity.kt @@ -9,8 +9,8 @@ import androidx.core.app.ActivityCompat import androidx.lifecycle.lifecycleScope import com.resonance.player.core.permissions.MusicPermissions import kotlinx.coroutines.launch -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.resonance.player.app.ResonanceApp import com.resonance.player.core.ui.theme.ResonanceTheme import com.resonance.player.domain.settings.ThemeMode @@ -40,8 +40,8 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() val container = (application as ResonanceApp).container setContent { - val themeMode by container.settingsRepository.themeMode.collectAsState( - initial = ThemeMode.SYSTEM + val themeMode by container.settingsRepository.themeMode.collectAsStateWithLifecycle( + initialValue = ThemeMode.SYSTEM ) ResonanceTheme(themeMode = themeMode) { ResonanceAppShell(container) diff --git a/app/src/main/java/com/resonance/player/app/AppContainer.kt b/app/src/main/java/com/resonance/player/app/AppContainer.kt index e0def49..d274d93 100644 --- a/app/src/main/java/com/resonance/player/app/AppContainer.kt +++ b/app/src/main/java/com/resonance/player/app/AppContainer.kt @@ -19,6 +19,7 @@ import com.resonance.player.data.media.MediaStoreAudioDataSource import com.resonance.player.data.media.MediaStoreLibraryScanner import com.resonance.player.data.media.RetrieverArtworkExtractor import com.resonance.player.domain.library.GetAlbumSongsUseCase +import com.resonance.player.domain.library.GetFolderSongsUseCase import com.resonance.player.domain.library.GetArtistSongsUseCase import com.resonance.player.domain.library.GetGenreSongsUseCase import com.resonance.player.domain.library.GetLibraryStatsUseCase @@ -194,6 +195,7 @@ class AppContainer(context: Context) { val observeGenres = ObserveGenresUseCase(musicRepository) val observeFolders = ObserveFoldersUseCase(musicRepository) val getAlbumSongs = GetAlbumSongsUseCase(musicRepository) + val getFolderSongs = GetFolderSongsUseCase(musicRepository) val observeScanState = ObserveScanStateUseCase(musicRepository) val rescanLibrary = RescanLibraryUseCase(musicRepository) val getLibraryStats = GetLibraryStatsUseCase(musicRepository) diff --git a/app/src/main/java/com/resonance/player/core/database/dao/SongDao.kt b/app/src/main/java/com/resonance/player/core/database/dao/SongDao.kt index 0d62e0b..4be18a3 100644 --- a/app/src/main/java/com/resonance/player/core/database/dao/SongDao.kt +++ b/app/src/main/java/com/resonance/player/core/database/dao/SongDao.kt @@ -196,6 +196,13 @@ interface SongDao { ) suspend fun getSongsOfAlbum(albumName: String, albumArtist: String?): List + @Query( + "SELECT * FROM songs WHERE " + + "((relativePath IS NULL AND :relativePath IS NULL) OR relativePath = :relativePath) " + + "ORDER BY title COLLATE NOCASE ASC" + ) + suspend fun getSongsOfFolder(relativePath: String?): List + @Query("SELECT DISTINCT artworkKey FROM songs WHERE artworkKey IS NOT NULL") suspend fun getReferencedArtworkKeys(): List diff --git a/app/src/main/java/com/resonance/player/core/ui/components/PermissionGate.kt b/app/src/main/java/com/resonance/player/core/ui/components/PermissionGate.kt index cb259b0..a645c6d 100644 --- a/app/src/main/java/com/resonance/player/core/ui/components/PermissionGate.kt +++ b/app/src/main/java/com/resonance/player/core/ui/components/PermissionGate.kt @@ -18,9 +18,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -60,7 +60,7 @@ fun AudioPermissionGate( onPermissionGranted: () -> Unit, content: @Composable () -> Unit ) { - val status by manager.status.collectAsState() + val status by manager.status.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val context = LocalContext.current val activity = context as? Activity diff --git a/app/src/main/java/com/resonance/player/core/ui/components/SongPickerSheet.kt b/app/src/main/java/com/resonance/player/core/ui/components/SongPickerSheet.kt new file mode 100644 index 0000000..6c65679 --- /dev/null +++ b/app/src/main/java/com/resonance/player/core/ui/components/SongPickerSheet.kt @@ -0,0 +1,131 @@ +package com.resonance.player.core.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.resonance.player.R +import com.resonance.player.core.model.Song +import com.resonance.player.core.ui.theme.ResonanceTheme + +/** + * Shared multi-select song browser (add-songs-to-playlist flow). Filters the + * full library by title/artist locally; selection is local until confirmed. + */ +@Composable +fun SongPickerSheet( + title: String, + songs: List, + onConfirm: (List) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val colors = ResonanceTheme.colors + val typography = ResonanceTheme.typography + val spacing = ResonanceTheme.spacing + var query by remember { mutableStateOf("") } + var selected by remember { mutableStateOf(setOf()) } + val filtered = remember(songs, query) { + if (query.isBlank()) { + songs + } else { + songs.filter { + it.title.contains(query, ignoreCase = true) || + it.artistName.contains(query, ignoreCase = true) + } + } + } + ResonanceBottomSheet(onDismiss = onDismiss, modifier = modifier) { + Text( + title, + style = typography.titleMd, + color = colors.textPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(spacing.sm)) + ResonanceSearchField( + value = query, + onValueChange = { query = it }, + label = stringResource(R.string.search_hint) + ) + Spacer(Modifier.height(spacing.sm)) + LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) { + items(filtered, key = { it.id }) { song -> + val isSelected = song.id in selected + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + selected = if (isSelected) selected - song.id else selected + song.id + } + .padding(vertical = spacing.sm) + ) { + ArtworkImage( + artworkUri = song.artworkUri, + contentDescription = song.albumName, + modifier = Modifier.size(ResonanceTheme.dimensions.songArtwork) + ) + Spacer(Modifier.width(spacing.md)) + Column(Modifier.weight(1f)) { + Text( + song.title, + style = typography.bodyLg, + color = colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + song.artistName, + style = typography.bodySm, + color = colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + if (isSelected) Icons.Filled.CheckCircle else Icons.Outlined.Circle, + contentDescription = null, + tint = if (isSelected) colors.accent else colors.textMuted + ) + } + } + } + Spacer(Modifier.height(spacing.sm)) + val label = if (selected.isEmpty()) { + stringResource(R.string.action_add) + } else { + stringResource(R.string.action_add) + " (${selected.size})" + } + ResonancePrimaryButton( + label = label, + onClick = { onConfirm(selected.toList()) }, + enabled = selected.isNotEmpty(), + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/app/src/main/java/com/resonance/player/data/local/RoomMusicRepository.kt b/app/src/main/java/com/resonance/player/data/local/RoomMusicRepository.kt index 262f723..6eaddbc 100644 --- a/app/src/main/java/com/resonance/player/data/local/RoomMusicRepository.kt +++ b/app/src/main/java/com/resonance/player/data/local/RoomMusicRepository.kt @@ -164,6 +164,20 @@ class RoomMusicRepository( } } + override suspend fun getFolderSongs(relativePath: String?): Result> = + withContext(dispatchers.io) { + try { + val entities = database.songDao().getSongsOfFolder(relativePath) + if (entities.isEmpty()) { + return@withContext Result.Failure(AppError.EmptyLibrary) + } + val favorites = database.favoriteDao().getFavoriteIds().toSet() + Result.Success(entities.map { it.toDomain(isFavorite = favorites.contains(it.id)) }) + } catch (t: Exception) { + Result.Failure(AppError.DatabaseError(t.message)) + } + } + override suspend fun getLibraryStats(): LibraryStats = withContext(dispatchers.io) { val dao = database.songDao() val lastScan: Long? = try { diff --git a/app/src/main/java/com/resonance/player/domain/library/LibraryBrowse.kt b/app/src/main/java/com/resonance/player/domain/library/LibraryBrowse.kt index 3b28f45..8a42ed1 100644 --- a/app/src/main/java/com/resonance/player/domain/library/LibraryBrowse.kt +++ b/app/src/main/java/com/resonance/player/domain/library/LibraryBrowse.kt @@ -44,6 +44,12 @@ class GetGenreSongsUseCase(private val repository: MusicRepository) { repository.getGenreSongs(genreName) } +/** Songs directly inside one folder (relative path), for tap-to-play-folder. */ +class GetFolderSongsUseCase(private val repository: MusicRepository) { + suspend operator fun invoke(relativePath: String?): Result> = + repository.getFolderSongs(relativePath) +} + /** Scanner state for progress banners and Settings. */ class ObserveScanStateUseCase(private val repository: MusicRepository) { operator fun invoke(): Flow = repository.observeScanState() diff --git a/app/src/main/java/com/resonance/player/domain/library/MusicRepository.kt b/app/src/main/java/com/resonance/player/domain/library/MusicRepository.kt index 7b65f0e..df8e3b3 100644 --- a/app/src/main/java/com/resonance/player/domain/library/MusicRepository.kt +++ b/app/src/main/java/com/resonance/player/domain/library/MusicRepository.kt @@ -34,6 +34,7 @@ interface MusicRepository { fun observeGenres(): Flow> fun observeFolders(): Flow> suspend fun getAlbumSongs(albumName: String, albumArtist: String?): Result> + suspend fun getFolderSongs(relativePath: String?): Result> fun observeRecentlyPlayed(limit: Int): Flow> fun observeMostPlayed(limit: Int): Flow> fun observeRecentlyAdded(limit: Int): Flow> diff --git a/app/src/main/java/com/resonance/player/feature/favorites/FavoritesScreen.kt b/app/src/main/java/com/resonance/player/feature/favorites/FavoritesScreen.kt index d5be338..0de1de5 100644 --- a/app/src/main/java/com/resonance/player/feature/favorites/FavoritesScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/favorites/FavoritesScreen.kt @@ -5,17 +5,23 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.resonance.player.R import com.resonance.player.core.common.formatDurationMs +import com.resonance.player.core.model.Song import com.resonance.player.core.ui.components.ArtworkImage +import com.resonance.player.core.ui.components.PlaylistPickerSheet import com.resonance.player.core.ui.components.ResonanceEmptyState import com.resonance.player.core.ui.components.ResonanceSongRow import com.resonance.player.core.ui.components.ResonanceTopBar import com.resonance.player.core.ui.components.SongFormatBadge +import com.resonance.player.core.ui.components.SongOverflowSheet import com.resonance.player.core.ui.components.songRowState @Composable @@ -25,7 +31,8 @@ fun FavoritesScreen( onBack: () -> Unit, onSongClick: (Long) -> Unit ) { - val songs by viewModel.songs.collectAsState() + val songs by viewModel.songs.collectAsStateWithLifecycle() + var overflowSong by remember { mutableStateOf(null) } Column(Modifier.fillMaxSize()) { ResonanceTopBar( title = stringResource(R.string.favorites_title), @@ -61,10 +68,53 @@ fun FavoritesScreen( isLoading = false ), isPlayingAnimation = song.id == currentSongId, - badge = { SongFormatBadge(song) } + badge = { SongFormatBadge(song) }, + onOverflowClick = { overflowSong = song } ) } } } } + overflowSong?.let { song -> + var showPicker by remember { mutableStateOf(false) } + val playlists by viewModel.playlists.collectAsStateWithLifecycle() + val playlistError by viewModel.playlistError.collectAsStateWithLifecycle() + if (showPicker) { + PlaylistPickerSheet( + songTitle = song.title, + playlists = playlists, + error = playlistError, + onPick = { playlistId -> + viewModel.addToPlaylist(playlistId, song.id) { + showPicker = false + overflowSong = null + } + }, + onNewPlaylist = { name -> + viewModel.createPlaylistAndAdd(name, song.id) { + showPicker = false + overflowSong = null + } + }, + onDismiss = { + showPicker = false + viewModel.clearPlaylistError() + } + ) + } else { + SongOverflowSheet( + song = song, + onDismiss = { overflowSong = null }, + onPlayNext = { + viewModel.playNext(song) + overflowSong = null + }, + onAddToQueue = { + viewModel.addToQueue(song) + overflowSong = null + }, + onAddToPlaylist = { showPicker = true } + ) + } + } } diff --git a/app/src/main/java/com/resonance/player/feature/favorites/FavoritesViewModel.kt b/app/src/main/java/com/resonance/player/feature/favorites/FavoritesViewModel.kt index 62bf208..82e10c5 100644 --- a/app/src/main/java/com/resonance/player/feature/favorites/FavoritesViewModel.kt +++ b/app/src/main/java/com/resonance/player/feature/favorites/FavoritesViewModel.kt @@ -2,23 +2,83 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.resonance.player.core.common.Result +import com.resonance.player.core.common.userMessage +import com.resonance.player.core.model.Playlist import com.resonance.player.core.model.Song import com.resonance.player.domain.favorites.ObserveFavoriteSongsUseCase +import com.resonance.player.domain.playback.AppendToQueueUseCase +import com.resonance.player.domain.playback.PlayNextUseCase import com.resonance.player.domain.playback.PlaySongsUseCase +import com.resonance.player.domain.playlists.AddSongToPlaylistUseCase +import com.resonance.player.domain.playlists.CreatePlaylistUseCase +import com.resonance.player.domain.playlists.ObservePlaylistsUseCase +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** Favorites list: real favorite songs, tap to play from the list. */ class FavoritesViewModel( observeFavorites: ObserveFavoriteSongsUseCase, - private val playSongs: PlaySongsUseCase + private val playSongs: PlaySongsUseCase, + private val playNextUseCase: PlayNextUseCase, + private val appendToQueueUseCase: AppendToQueueUseCase, + observePlaylists: ObservePlaylistsUseCase, + private val addSongToPlaylist: AddSongToPlaylistUseCase, + private val createPlaylist: CreatePlaylistUseCase ) : ViewModel() { val songs: StateFlow> = observeFavorites() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val playlists: StateFlow> = observePlaylists() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val playlistErrorMutable = MutableStateFlow(null) + val playlistError: StateFlow = playlistErrorMutable.asStateFlow() + fun playFrom(songs: List, index: Int) { viewModelScope.launch { playSongs(songs, index) } } + + fun playNext(song: Song) { + viewModelScope.launch { playNextUseCase(song) } + } + + fun addToQueue(song: Song) { + viewModelScope.launch { appendToQueueUseCase(listOf(song)) } + } + + fun addToPlaylist(playlistId: Long, songId: Long, onDone: () -> Unit = {}) { + viewModelScope.launch { + when (val result = addSongToPlaylist(playlistId, songId)) { + is Result.Success -> { + playlistErrorMutable.value = null + onDone() + } + is Result.Failure -> playlistErrorMutable.value = result.error.userMessage() + is Result.Loading -> Unit + } + } + } + + fun createPlaylistAndAdd(name: String, songId: Long, onDone: () -> Unit = {}) { + viewModelScope.launch { + when (val result = createPlaylist(name)) { + is Result.Success -> { + playlistErrorMutable.value = null + addSongToPlaylist(result.value.id, songId) + onDone() + } + is Result.Failure -> playlistErrorMutable.value = result.error.userMessage() + is Result.Loading -> Unit + } + } + } + + fun clearPlaylistError() { + playlistErrorMutable.value = null + } } diff --git a/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt b/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt index 914552a..7dee13c 100644 --- a/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt @@ -30,14 +30,17 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.resonance.player.R import com.resonance.player.core.common.formatBytes import com.resonance.player.core.common.formatDurationMs @@ -47,11 +50,13 @@ import com.resonance.player.core.permissions.AudioPermissionManager import com.resonance.player.core.ui.components.ArtworkImage import com.resonance.player.core.ui.components.AudioPermissionGate import com.resonance.player.core.ui.components.EmptyLibraryView +import com.resonance.player.core.ui.components.PlaylistPickerSheet import com.resonance.player.core.ui.components.ResonanceAlbumCard import com.resonance.player.core.ui.components.ResonanceCardPlayButton import com.resonance.player.core.ui.components.ResonanceMetric import com.resonance.player.core.ui.components.ResonanceSectionHeader import com.resonance.player.core.ui.components.SongFormatBadge +import com.resonance.player.core.ui.components.SongOverflowSheet import com.resonance.player.core.ui.components.ResonanceSongRow import com.resonance.player.core.ui.components.ResonanceTopBar import com.resonance.player.core.ui.components.ScanProgressBanner @@ -95,8 +100,8 @@ private fun HomeContent( val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing - val scanState by viewModel.scanState.collectAsState() - val storage by viewModel.storage.collectAsState() + val scanState by viewModel.scanState.collectAsStateWithLifecycle() + val storage by viewModel.storage.collectAsStateWithLifecycle() val trackCount = storage?.trackCount ?: 0 val isEmpty = trackCount == 0 && scanState !is ScanState.Scanning @@ -142,7 +147,7 @@ private fun StorageCard( val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing - val overview by viewModel.storage.collectAsState() + val overview by viewModel.storage.collectAsStateWithLifecycle() Surface( shape = ResonanceTheme.radii.card, color = colors.surfaceContainer, @@ -222,7 +227,7 @@ private fun RecentlyPlayedSection( onOpenLibrary: (Int) -> Unit, onOpenQueue: () -> Unit ) { - val albums by viewModel.recentAlbums.collectAsState() + val albums by viewModel.recentAlbums.collectAsStateWithLifecycle() if (albums.isEmpty()) return val spacing = ResonanceTheme.spacing ResonanceSectionHeader( @@ -276,9 +281,9 @@ private fun JumpBackInSection( val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing - val favCount by viewModel.favoriteCount.collectAsState() - val mostPlayed by viewModel.mostPlayed.collectAsState() - val recentSongs by viewModel.recentSongs.collectAsState() + val favCount by viewModel.favoriteCount.collectAsStateWithLifecycle() + val mostPlayed by viewModel.mostPlayed.collectAsStateWithLifecycle() + val recentSongs by viewModel.recentSongs.collectAsStateWithLifecycle() ResonanceSectionHeader(title = stringResource(R.string.home_jump_back_in)) Spacer(Modifier.height(spacing.md)) Column( @@ -389,14 +394,64 @@ private fun RecentlyAddedSection( currentSongId: Long?, onSongClick: (Long) -> Unit ) { - val songs by viewModel.recentSongs.collectAsState() + val songs by viewModel.recentSongs.collectAsStateWithLifecycle() if (songs.isEmpty()) return val spacing = ResonanceTheme.spacing + var overflowSong by remember { mutableStateOf(null) } ResonanceSectionHeader(title = stringResource(R.string.home_recently_added)) Spacer(Modifier.height(spacing.sm)) Column { songs.forEach { song -> - SongHomeRow(song, song.id == currentSongId, viewModel, songs, onSongClick) + SongHomeRow( + song, + song.id == currentSongId, + viewModel, + songs, + onSongClick, + onOverflowClick = { overflowSong = song } + ) + } + } + overflowSong?.let { song -> + var showPicker by remember { mutableStateOf(false) } + val playlists by viewModel.playlists.collectAsStateWithLifecycle() + val playlistError by viewModel.playlistError.collectAsStateWithLifecycle() + if (showPicker) { + PlaylistPickerSheet( + songTitle = song.title, + playlists = playlists, + error = playlistError, + onPick = { playlistId -> + viewModel.addToPlaylist(playlistId, song.id) { + showPicker = false + overflowSong = null + } + }, + onNewPlaylist = { name -> + viewModel.createPlaylistAndAdd(name, song.id) { + showPicker = false + overflowSong = null + } + }, + onDismiss = { + showPicker = false + viewModel.clearPlaylistError() + } + ) + } else { + SongOverflowSheet( + song = song, + onDismiss = { overflowSong = null }, + onPlayNext = { + viewModel.playNext(song) + overflowSong = null + }, + onAddToQueue = { + viewModel.addToQueue(song) + overflowSong = null + }, + onAddToPlaylist = { showPicker = true } + ) } } } @@ -407,7 +462,8 @@ private fun SongHomeRow( isCurrent: Boolean, viewModel: HomeViewModel, songs: List, - onSongClick: (Long) -> Unit + onSongClick: (Long) -> Unit, + onOverflowClick: () -> Unit ) { ResonanceSongRow( title = song.title, @@ -433,7 +489,8 @@ private fun SongHomeRow( isPlayingAnimation = isCurrent, badge = { SongFormatBadge(song) - } + }, + onOverflowClick = onOverflowClick ) } diff --git a/app/src/main/java/com/resonance/player/feature/home/HomeViewModel.kt b/app/src/main/java/com/resonance/player/feature/home/HomeViewModel.kt index 910ac6e..930b479 100644 --- a/app/src/main/java/com/resonance/player/feature/home/HomeViewModel.kt +++ b/app/src/main/java/com/resonance/player/feature/home/HomeViewModel.kt @@ -3,6 +3,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.resonance.player.core.model.Album +import com.resonance.player.core.model.Playlist import com.resonance.player.core.model.ShuffleMode import com.resonance.player.core.model.Song import com.resonance.player.core.model.StorageOverview @@ -16,11 +17,19 @@ import com.resonance.player.domain.library.ObserveScanStateUseCase import com.resonance.player.domain.library.GetAlbumSongsUseCase import com.resonance.player.domain.library.RescanLibraryUseCase import com.resonance.player.domain.library.recentAlbumsFromSongs +import com.resonance.player.domain.playback.AppendToQueueUseCase +import com.resonance.player.domain.playback.PlayNextUseCase import com.resonance.player.domain.playback.PlaySongsUseCase import com.resonance.player.domain.playback.SetShuffleModeUseCase +import com.resonance.player.domain.playlists.AddSongToPlaylistUseCase +import com.resonance.player.domain.playlists.CreatePlaylistUseCase +import com.resonance.player.domain.playlists.ObservePlaylistsUseCase import com.resonance.player.core.common.Result +import com.resonance.player.core.common.userMessage +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -40,9 +49,20 @@ class HomeViewModel( private val playSongs: PlaySongsUseCase, private val getAlbumSongs: GetAlbumSongsUseCase, private val setShuffleMode: SetShuffleModeUseCase, - private val rescanLibrary: RescanLibraryUseCase + private val rescanLibrary: RescanLibraryUseCase, + private val playNextUseCase: PlayNextUseCase, + private val appendToQueueUseCase: AppendToQueueUseCase, + observePlaylists: ObservePlaylistsUseCase, + private val addSongToPlaylist: AddSongToPlaylistUseCase, + private val createPlaylist: CreatePlaylistUseCase ) : ViewModel() { + val playlists: StateFlow> = observePlaylists() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val playlistErrorMutable = MutableStateFlow(null) + val playlistError: StateFlow = playlistErrorMutable.asStateFlow() + val recentAlbums: StateFlow> = observeRecentlyPlayed(25) .map { recentAlbumsFromSongs(it, 10) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) @@ -86,4 +106,43 @@ class HomeViewModel( fun rescan() { viewModelScope.launch { rescanLibrary() } } + + fun playNext(song: Song) { + viewModelScope.launch { playNextUseCase(song) } + } + + fun addToQueue(song: Song) { + viewModelScope.launch { appendToQueueUseCase(listOf(song)) } + } + + fun addToPlaylist(playlistId: Long, songId: Long, onDone: () -> Unit = {}) { + viewModelScope.launch { + when (val result = addSongToPlaylist(playlistId, songId)) { + is Result.Success -> { + playlistErrorMutable.value = null + onDone() + } + is Result.Failure -> playlistErrorMutable.value = result.error.userMessage() + is Result.Loading -> Unit + } + } + } + + fun createPlaylistAndAdd(name: String, songId: Long, onDone: () -> Unit = {}) { + viewModelScope.launch { + when (val result = createPlaylist(name)) { + is Result.Success -> { + playlistErrorMutable.value = null + addSongToPlaylist(result.value.id, songId) + onDone() + } + is Result.Failure -> playlistErrorMutable.value = result.error.userMessage() + is Result.Loading -> Unit + } + } + } + + fun clearPlaylistError() { + playlistErrorMutable.value = null + } } diff --git a/app/src/main/java/com/resonance/player/feature/library/LibraryScreen.kt b/app/src/main/java/com/resonance/player/feature/library/LibraryScreen.kt index c2b456b..d5f9e45 100644 --- a/app/src/main/java/com/resonance/player/feature/library/LibraryScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/library/LibraryScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -43,6 +42,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.resonance.player.R import com.resonance.player.core.common.formatDurationMs import com.resonance.player.core.model.Song @@ -99,7 +99,7 @@ private fun LibraryTabs( onOpenSearch: () -> Unit ) { var tab by remember(initialTab) { mutableIntStateOf(initialTab.coerceIn(0, 4)) } - val scanState by viewModel.scanState.collectAsState() + val scanState by viewModel.scanState.collectAsStateWithLifecycle() Column(Modifier.fillMaxSize()) { ResonanceTopBar( title = stringResource(R.string.nav_library), @@ -112,9 +112,9 @@ private fun LibraryTabs( when (tab) { 0 -> SongsTab(viewModel, currentSongId, onSongClick) 1 -> AlbumsTab(viewModel, onOpenQueue) - 2 -> ArtistsTab(viewModel) - 3 -> GenresTab(viewModel) - 4 -> FoldersTab(viewModel) + 2 -> ArtistsTab(viewModel, onOpenQueue) + 3 -> GenresTab(viewModel, onOpenQueue) + 4 -> FoldersTab(viewModel, onOpenQueue) } } } @@ -126,12 +126,12 @@ private fun CategoryChips( onSelect: (Int) -> Unit ) { val spacing = ResonanceTheme.spacing - val songs by viewModel.uiState.collectAsState() + val songs by viewModel.uiState.collectAsStateWithLifecycle() val songCount = (songs as? LibraryUiState.Content)?.songs?.size ?: 0 - val albums by viewModel.albums.collectAsState() - val artists by viewModel.artists.collectAsState() - val genres by viewModel.genres.collectAsState() - val folders by viewModel.folders.collectAsState() + val albums by viewModel.albums.collectAsStateWithLifecycle() + val artists by viewModel.artists.collectAsStateWithLifecycle() + val genres by viewModel.genres.collectAsStateWithLifecycle() + val folders by viewModel.folders.collectAsStateWithLifecycle() LazyRow( modifier = Modifier .fillMaxWidth() @@ -198,7 +198,7 @@ private fun SortToolbar(viewModel: LibraryViewModel) { val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing - val sort by viewModel.sort.collectAsState() + val sort by viewModel.sort.collectAsStateWithLifecycle() var expanded by remember { mutableStateOf(false) } Row( verticalAlignment = Alignment.CenterVertically, @@ -268,7 +268,7 @@ private fun SongsTab( currentSongId: Long?, onSongClick: (Long) -> Unit ) { - val state by viewModel.uiState.collectAsState() + val state by viewModel.uiState.collectAsStateWithLifecycle() when (val s = state) { LibraryUiState.Loading -> LoadingView() LibraryUiState.Empty -> EmptyLibraryView() @@ -327,8 +327,8 @@ private fun SongsTab( } overflowSong?.let { song -> var showPicker by remember { mutableStateOf(false) } - val playlists by viewModel.playlists.collectAsState() - val playlistError by viewModel.playlistError.collectAsState() + val playlists by viewModel.playlists.collectAsStateWithLifecycle() + val playlistError by viewModel.playlistError.collectAsStateWithLifecycle() if (showPicker) { PlaylistPickerSheet( songTitle = song.title, @@ -451,7 +451,7 @@ private fun pickLetter( @Composable private fun AlbumsTab(viewModel: LibraryViewModel, onOpenQueue: () -> Unit) { - val albums by viewModel.albums.collectAsState() + val albums by viewModel.albums.collectAsStateWithLifecycle() val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing @@ -502,8 +502,8 @@ private fun AlbumsTab(viewModel: LibraryViewModel, onOpenQueue: () -> Unit) { } @Composable -private fun ArtistsTab(viewModel: LibraryViewModel) { - val artists by viewModel.artists.collectAsState() +private fun ArtistsTab(viewModel: LibraryViewModel, onOpenQueue: () -> Unit) { + val artists by viewModel.artists.collectAsStateWithLifecycle() val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing @@ -516,6 +516,10 @@ private fun ArtistsTab(viewModel: LibraryViewModel) { Column( modifier = Modifier .fillMaxWidth() + .clickable { + viewModel.playArtist(artist.name) + onOpenQueue() + } .padding(horizontal = spacing.lg, vertical = spacing.md) ) { Text( @@ -539,8 +543,8 @@ private fun ArtistsTab(viewModel: LibraryViewModel) { } @Composable -private fun GenresTab(viewModel: LibraryViewModel) { - val genres by viewModel.genres.collectAsState() +private fun GenresTab(viewModel: LibraryViewModel, onOpenQueue: () -> Unit) { + val genres by viewModel.genres.collectAsStateWithLifecycle() val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing @@ -554,6 +558,10 @@ private fun GenresTab(viewModel: LibraryViewModel) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() + .clickable { + viewModel.playGenre(genre.name) + onOpenQueue() + } .padding(horizontal = spacing.lg, vertical = spacing.md) ) { Text( @@ -576,8 +584,8 @@ private fun GenresTab(viewModel: LibraryViewModel) { } @Composable -private fun FoldersTab(viewModel: LibraryViewModel) { - val folders by viewModel.folders.collectAsState() +private fun FoldersTab(viewModel: LibraryViewModel, onOpenQueue: () -> Unit) { + val folders by viewModel.folders.collectAsStateWithLifecycle() val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing @@ -591,6 +599,10 @@ private fun FoldersTab(viewModel: LibraryViewModel) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() + .clickable { + viewModel.playFolder(folder.path) + onOpenQueue() + } .padding(horizontal = spacing.lg, vertical = spacing.md) ) { Column(Modifier.weight(1f)) { diff --git a/app/src/main/java/com/resonance/player/feature/library/LibraryViewModel.kt b/app/src/main/java/com/resonance/player/feature/library/LibraryViewModel.kt index 87df012..4e3cda0 100644 --- a/app/src/main/java/com/resonance/player/feature/library/LibraryViewModel.kt +++ b/app/src/main/java/com/resonance/player/feature/library/LibraryViewModel.kt @@ -13,6 +13,9 @@ import com.resonance.player.core.model.Playlist import com.resonance.player.core.model.ShuffleMode import com.resonance.player.core.model.Song import com.resonance.player.domain.library.GetAlbumSongsUseCase +import com.resonance.player.domain.library.GetArtistSongsUseCase +import com.resonance.player.domain.library.GetFolderSongsUseCase +import com.resonance.player.domain.library.GetGenreSongsUseCase import com.resonance.player.domain.library.ObserveAlbumsUseCase import com.resonance.player.domain.library.ObserveArtistsUseCase import com.resonance.player.domain.library.ObserveFoldersUseCase @@ -60,6 +63,9 @@ class LibraryViewModel( observeGenres: ObserveGenresUseCase, observeFolders: ObserveFoldersUseCase, private val getAlbumSongs: GetAlbumSongsUseCase, + private val getArtistSongs: GetArtistSongsUseCase, + private val getGenreSongs: GetGenreSongsUseCase, + private val getFolderSongs: GetFolderSongsUseCase, private val setShuffleMode: SetShuffleModeUseCase, private val playNextUseCase: PlayNextUseCase, private val appendToQueueUseCase: AppendToQueueUseCase, @@ -116,6 +122,28 @@ class LibraryViewModel( } } + fun playArtist(artistName: String) { + viewModelScope.launch { + val songs = (getArtistSongs(artistName) as? Result.Success)?.value + if (!songs.isNullOrEmpty()) playSongs(songs, 0) + } + } + + fun playGenre(genreName: String) { + viewModelScope.launch { + val songs = (getGenreSongs(genreName) as? Result.Success)?.value + if (!songs.isNullOrEmpty()) playSongs(songs, 0) + } + } + + /** [relativePath] mirrors [com.resonance.player.core.model.MusicFolder.path]: "" means the root folder. */ + fun playFolder(relativePath: String) { + viewModelScope.launch { + val songs = (getFolderSongs(relativePath.ifBlank { null }) as? Result.Success)?.value + if (!songs.isNullOrEmpty()) playSongs(songs, 0) + } + } + /** Shuffle-all: play the visible list from the head, engine shuffles. */ fun shuffleAll() { val songs = (uiState.value as? LibraryUiState.Content)?.songs ?: return diff --git a/app/src/main/java/com/resonance/player/feature/player/PlayerScreen.kt b/app/src/main/java/com/resonance/player/feature/player/PlayerScreen.kt index 7ec2ae1..e829d26 100644 --- a/app/src/main/java/com/resonance/player/feature/player/PlayerScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/player/PlayerScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -42,6 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.resonance.player.R import com.resonance.player.core.common.formatBytes import com.resonance.player.core.common.formatDurationMs @@ -74,10 +74,10 @@ fun PlayerScreen( val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing - val snapshot by viewModel.snapshot.collectAsState() - val details by viewModel.details.collectAsState() - val commandError by viewModel.commandError.collectAsState() - val isFavorite by viewModel.isFavorite.collectAsState() + val snapshot by viewModel.snapshot.collectAsStateWithLifecycle() + val details by viewModel.details.collectAsStateWithLifecycle() + val commandError by viewModel.commandError.collectAsStateWithLifecycle() + val isFavorite by viewModel.isFavorite.collectAsStateWithLifecycle() val song = snapshot.song ?: details val duration = snapshot.durationMs diff --git a/app/src/main/java/com/resonance/player/feature/playlists/PlaylistDetailScreen.kt b/app/src/main/java/com/resonance/player/feature/playlists/PlaylistDetailScreen.kt index cafee03..6707f4a 100644 --- a/app/src/main/java/com/resonance/player/feature/playlists/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/playlists/PlaylistDetailScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DragHandle @@ -21,11 +22,11 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -39,6 +40,7 @@ import com.resonance.player.core.ui.components.ResonanceDialog import com.resonance.player.core.ui.components.ResonanceEmptyState import com.resonance.player.core.ui.components.ResonanceIconButton import com.resonance.player.core.ui.components.ResonanceTopBar +import com.resonance.player.core.ui.components.SongPickerSheet import com.resonance.player.core.ui.theme.ResonanceTheme /** @@ -57,10 +59,11 @@ fun PlaylistDetailScreen( val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing - val songs by viewModel.songs.collectAsState() - val error by viewModel.error.collectAsState() + val songs by viewModel.songs.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() var showRename by remember { mutableStateOf(false) } var showDelete by remember { mutableStateOf(false) } + var showAddSongs by remember { mutableStateOf(false) } Column(Modifier.fillMaxSize()) { ResonanceTopBar( @@ -92,6 +95,12 @@ fun PlaylistDetailScreen( contentDescription = stringResource(R.string.cd_shuffle), enabled = songs.isNotEmpty() ) + ResonanceIconButton( + onClick = { showAddSongs = true }, + icon = Icons.Filled.Add, + contentDescription = stringResource(R.string.playlist_add_songs), + enabled = playlist != null + ) ResonanceIconButton( onClick = { showRename = true }, icon = Icons.Filled.Edit, @@ -189,4 +198,15 @@ fun PlaylistDetailScreen( dismissLabel = stringResource(R.string.action_dismiss) ) } + if (showAddSongs && playlist != null) { + val allSongs by viewModel.allSongs.collectAsStateWithLifecycle() + SongPickerSheet( + title = stringResource(R.string.playlist_add_songs_title, playlist.name), + songs = allSongs, + onConfirm = { ids -> + viewModel.addSongs(ids) { showAddSongs = false } + }, + onDismiss = { showAddSongs = false } + ) + } } diff --git a/app/src/main/java/com/resonance/player/feature/playlists/PlaylistsScreen.kt b/app/src/main/java/com/resonance/player/feature/playlists/PlaylistsScreen.kt index 5d18ee6..a1bad11 100644 --- a/app/src/main/java/com/resonance/player/feature/playlists/PlaylistsScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/playlists/PlaylistsScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -40,9 +39,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.resonance.player.R import com.resonance.player.core.model.Playlist import com.resonance.player.core.ui.components.ResonanceBottomSheet +import com.resonance.player.core.ui.components.ResonanceDialog import com.resonance.player.core.ui.components.ResonanceEmptyState import com.resonance.player.core.ui.components.ResonanceTopBar import com.resonance.player.core.ui.theme.ResonanceTheme @@ -55,16 +56,18 @@ import com.resonance.player.core.ui.theme.ResonanceTheme fun PlaylistsScreen( viewModel: PlaylistsViewModel, onOpenDetail: (Long) -> Unit, - onOpenQueue: () -> Unit + onOpenQueue: () -> Unit, + onShowMessage: (String) -> Unit = {} ) { val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing - val playlists by viewModel.playlists.collectAsState() - val error by viewModel.error.collectAsState() + val playlists by viewModel.playlists.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() var showCreate by remember { mutableStateOf(false) } var overflowPlaylist by remember { mutableStateOf(null) } var renameTarget by remember { mutableStateOf(null) } + var deleteTarget by remember { mutableStateOf(null) } Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { @@ -118,12 +121,17 @@ fun PlaylistsScreen( onDismiss = { showCreate = false } ) } + val emptyPlaylistMessage = stringResource(R.string.playlist_empty_action) overflowPlaylist?.let { playlist -> PlaylistOverflowSheet( playlist = playlist, onDismiss = { overflowPlaylist = null }, onPlay = { - viewModel.play(playlist.id) { onOpenQueue() } + viewModel.play( + playlist.id, + onPlaying = { onOpenQueue() }, + onEmpty = { onShowMessage(emptyPlaylistMessage) } + ) overflowPlaylist = null }, onRename = { @@ -131,7 +139,7 @@ fun PlaylistsScreen( overflowPlaylist = null }, onDelete = { - viewModel.delete(playlist.id) + deleteTarget = playlist overflowPlaylist = null } ) @@ -148,6 +156,19 @@ fun PlaylistsScreen( onDismiss = { renameTarget = null } ) } + deleteTarget?.let { playlist -> + ResonanceDialog( + title = stringResource(R.string.playlist_delete_title), + text = stringResource(R.string.playlist_delete_body), + confirmLabel = stringResource(R.string.playlist_delete), + onConfirm = { + viewModel.delete(playlist.id) + deleteTarget = null + }, + onDismiss = { deleteTarget = null }, + dismissLabel = stringResource(R.string.action_dismiss) + ) + } } @Composable diff --git a/app/src/main/java/com/resonance/player/feature/playlists/PlaylistsViewModels.kt b/app/src/main/java/com/resonance/player/feature/playlists/PlaylistsViewModels.kt index 3f2dcc1..1316834 100644 --- a/app/src/main/java/com/resonance/player/feature/playlists/PlaylistsViewModels.kt +++ b/app/src/main/java/com/resonance/player/feature/playlists/PlaylistsViewModels.kt @@ -17,6 +17,7 @@ import com.resonance.player.domain.playlists.ObservePlaylistSongsUseCase import com.resonance.player.domain.playlists.ObservePlaylistsUseCase import com.resonance.player.domain.playlists.RemoveSongFromPlaylistUseCase import com.resonance.player.domain.playlists.RenamePlaylistUseCase +import com.resonance.player.domain.library.ObserveSongsUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -66,12 +67,14 @@ class PlaylistsViewModel( } } - fun play(playlistId: Long, onPlaying: () -> Unit = {}) { + fun play(playlistId: Long, onPlaying: () -> Unit = {}, onEmpty: () -> Unit = {}) { viewModelScope.launch { val songs = (songsOf(playlistId) as? Result.Success)?.value if (!songs.isNullOrEmpty()) { playSongs(songs, 0) onPlaying() + } else { + onEmpty() } } } @@ -90,11 +93,17 @@ class PlaylistDetailViewModel( private val renameOp: RenamePlaylistUseCase, private val deleteOp: DeletePlaylistUseCase, private val removeSongOp: RemoveSongFromPlaylistUseCase, - private val moveItemOp: MovePlaylistItemUseCase + private val moveItemOp: MovePlaylistItemUseCase, + observeAllSongs: ObserveSongsUseCase, + private val addSongOp: AddSongToPlaylistUseCase ) : ViewModel() { private val songsMutable = MutableStateFlow>(emptyList()) val songs: StateFlow> = songsMutable.asStateFlow() + /** Full library, for the "add songs" picker. */ + val allSongs: StateFlow> = observeAllSongs() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + private val errorMutable = MutableStateFlow(null) val error: StateFlow = errorMutable.asStateFlow() @@ -137,6 +146,15 @@ class PlaylistDetailViewModel( } } + fun addSongs(songIds: List, onDone: () -> Unit = {}) { + if (songIds.isEmpty()) return + viewModelScope.launch { + songIds.forEach { addSongOp(playlistId, it) } + refresh() + onDone() + } + } + fun move(fromPosition: Int, toPosition: Int) { viewModelScope.launch { moveItemOp(playlistId, fromPosition, toPosition) diff --git a/app/src/main/java/com/resonance/player/feature/queue/QueueScreen.kt b/app/src/main/java/com/resonance/player/feature/queue/QueueScreen.kt index b2e47b5..99cbb5b 100644 --- a/app/src/main/java/com/resonance/player/feature/queue/QueueScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/queue/QueueScreen.kt @@ -17,11 +17,11 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -49,7 +49,7 @@ fun QueueScreen(viewModel: QueueViewModel, onBack: () -> Unit) { val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing - val snapshot by viewModel.snapshot.collectAsState() + val snapshot by viewModel.snapshot.collectAsStateWithLifecycle() var confirmClear by remember { mutableStateOf(false) } Column(Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/resonance/player/feature/search/SearchScreen.kt b/app/src/main/java/com/resonance/player/feature/search/SearchScreen.kt index c38a3df..3775f08 100644 --- a/app/src/main/java/com/resonance/player/feature/search/SearchScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/search/SearchScreen.kt @@ -14,8 +14,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -24,12 +27,14 @@ import com.resonance.player.R import com.resonance.player.core.common.formatDurationMs import com.resonance.player.core.model.Song import com.resonance.player.core.ui.components.ArtworkImage +import com.resonance.player.core.ui.components.PlaylistPickerSheet import com.resonance.player.core.ui.components.ResonanceEmptyState import com.resonance.player.core.ui.components.ResonanceSearchField import com.resonance.player.core.ui.components.ResonanceSectionHeader import com.resonance.player.core.ui.components.ResonanceSongRow import com.resonance.player.core.ui.components.ResonanceTopBar import com.resonance.player.core.ui.components.SongFormatBadge +import com.resonance.player.core.ui.components.SongOverflowSheet import com.resonance.player.core.ui.components.songRowState import com.resonance.player.core.ui.theme.ResonanceTheme import androidx.compose.material.icons.Icons @@ -49,9 +54,10 @@ fun SearchScreen( onOpenQueue: () -> Unit, onOpenPlaylist: (Long) -> Unit ) { - val query by viewModel.currentQuery.collectAsState() - val results by viewModel.grouped.collectAsState() + val query by viewModel.currentQuery.collectAsStateWithLifecycle() + val results by viewModel.grouped.collectAsStateWithLifecycle() val spacing = ResonanceTheme.spacing + var overflowSong by remember { mutableStateOf(null) } Column(Modifier.fillMaxSize()) { ResonanceTopBar( title = stringResource(R.string.nav_search), @@ -110,7 +116,8 @@ fun SearchScreen( isLoading = false ), isPlayingAnimation = isCurrent, - badge = { SongFormatBadge(song) } + badge = { SongFormatBadge(song) }, + onOverflowClick = { overflowSong = song } ) } } @@ -177,6 +184,48 @@ fun SearchScreen( } } } + overflowSong?.let { song -> + var showPicker by remember { mutableStateOf(false) } + val playlists by viewModel.playlists.collectAsStateWithLifecycle() + val playlistError by viewModel.playlistError.collectAsStateWithLifecycle() + if (showPicker) { + PlaylistPickerSheet( + songTitle = song.title, + playlists = playlists, + error = playlistError, + onPick = { playlistId -> + viewModel.addToPlaylist(playlistId, song.id) { + showPicker = false + overflowSong = null + } + }, + onNewPlaylist = { name -> + viewModel.createPlaylistAndAdd(name, song.id) { + showPicker = false + overflowSong = null + } + }, + onDismiss = { + showPicker = false + viewModel.clearPlaylistError() + } + ) + } else { + SongOverflowSheet( + song = song, + onDismiss = { overflowSong = null }, + onPlayNext = { + viewModel.playNext(song) + overflowSong = null + }, + onAddToQueue = { + viewModel.addToQueue(song) + overflowSong = null + }, + onAddToPlaylist = { showPicker = true } + ) + } + } } @Composable diff --git a/app/src/main/java/com/resonance/player/feature/search/SearchViewModel.kt b/app/src/main/java/com/resonance/player/feature/search/SearchViewModel.kt index 7136a58..fc96436 100644 --- a/app/src/main/java/com/resonance/player/feature/search/SearchViewModel.kt +++ b/app/src/main/java/com/resonance/player/feature/search/SearchViewModel.kt @@ -3,11 +3,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.resonance.player.core.common.Result +import com.resonance.player.core.common.userMessage +import com.resonance.player.core.model.Playlist import com.resonance.player.core.model.Song import com.resonance.player.domain.library.GetAlbumSongsUseCase import com.resonance.player.domain.library.GetArtistSongsUseCase import com.resonance.player.domain.library.GetGenreSongsUseCase +import com.resonance.player.domain.playback.AppendToQueueUseCase +import com.resonance.player.domain.playback.PlayNextUseCase import com.resonance.player.domain.playback.PlaySongsUseCase +import com.resonance.player.domain.playlists.AddSongToPlaylistUseCase +import com.resonance.player.domain.playlists.CreatePlaylistUseCase +import com.resonance.player.domain.playlists.ObservePlaylistsUseCase import com.resonance.player.domain.search.SearchAllUseCase import com.resonance.player.domain.search.SearchLibraryUseCase import com.resonance.player.domain.search.SearchResults @@ -16,6 +23,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn @@ -32,10 +40,21 @@ class SearchViewModel( private val playSongs: PlaySongsUseCase, private val getAlbumSongs: GetAlbumSongsUseCase, private val getArtistSongs: GetArtistSongsUseCase, - private val getGenreSongs: GetGenreSongsUseCase + private val getGenreSongs: GetGenreSongsUseCase, + private val playNextUseCase: PlayNextUseCase, + private val appendToQueueUseCase: AppendToQueueUseCase, + observePlaylists: ObservePlaylistsUseCase, + private val addSongToPlaylist: AddSongToPlaylistUseCase, + private val createPlaylist: CreatePlaylistUseCase ) : ViewModel() { private val query = MutableStateFlow("") + val playlists: StateFlow> = observePlaylists() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val playlistErrorMutable = MutableStateFlow(null) + val playlistError: StateFlow = playlistErrorMutable.asStateFlow() + @OptIn(ExperimentalCoroutinesApi::class) val results: StateFlow> = query .flatMapLatest { searchLibrary(it) } @@ -77,4 +96,43 @@ class SearchViewModel( if (!songs.isNullOrEmpty()) playSongs(songs, 0) } } + + fun playNext(song: Song) { + viewModelScope.launch { playNextUseCase(song) } + } + + fun addToQueue(song: Song) { + viewModelScope.launch { appendToQueueUseCase(listOf(song)) } + } + + fun addToPlaylist(playlistId: Long, songId: Long, onDone: () -> Unit = {}) { + viewModelScope.launch { + when (val result = addSongToPlaylist(playlistId, songId)) { + is Result.Success -> { + playlistErrorMutable.value = null + onDone() + } + is Result.Failure -> playlistErrorMutable.value = result.error.userMessage() + is Result.Loading -> Unit + } + } + } + + fun createPlaylistAndAdd(name: String, songId: Long, onDone: () -> Unit = {}) { + viewModelScope.launch { + when (val result = createPlaylist(name)) { + is Result.Success -> { + playlistErrorMutable.value = null + addSongToPlaylist(result.value.id, songId) + onDone() + } + is Result.Failure -> playlistErrorMutable.value = result.error.userMessage() + is Result.Loading -> Unit + } + } + } + + fun clearPlaylistError() { + playlistErrorMutable.value = null + } } diff --git a/app/src/main/java/com/resonance/player/feature/settings/SettingsScreen.kt b/app/src/main/java/com/resonance/player/feature/settings/SettingsScreen.kt index 3f8891e..67dcfe2 100644 --- a/app/src/main/java/com/resonance/player/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/settings/SettingsScreen.kt @@ -20,8 +20,8 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -49,12 +49,12 @@ import java.time.format.FormatStyle @Composable fun SettingsScreen(viewModel: SettingsViewModel) { - val theme by viewModel.themeMode.collectAsState() - val scanState by viewModel.scanState.collectAsState() - val lastScan by viewModel.lastScan.collectAsState() - val stats by viewModel.stats.collectAsState() - val permission by viewModel.permissionStatus.collectAsState() - val ignoreShort by viewModel.ignoreShortFiles.collectAsState() + val theme by viewModel.themeMode.collectAsStateWithLifecycle() + val scanState by viewModel.scanState.collectAsStateWithLifecycle() + val lastScan by viewModel.lastScan.collectAsStateWithLifecycle() + val stats by viewModel.stats.collectAsStateWithLifecycle() + val permission by viewModel.permissionStatus.collectAsStateWithLifecycle() + val ignoreShort by viewModel.ignoreShortFiles.collectAsStateWithLifecycle() val context = LocalContext.current val activity = context as? Activity val requestGrant = rememberPermissionGrant(viewModel.permissionManager) { diff --git a/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt b/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt index 089f05c..9422adc 100644 --- a/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt +++ b/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt @@ -15,8 +15,8 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -35,7 +35,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.ui.Alignment import com.resonance.player.R import com.resonance.player.core.ui.theme.ResonanceTheme import com.resonance.player.app.AppContainer @@ -45,6 +48,8 @@ import com.resonance.player.core.ui.components.ArtworkImage import com.resonance.player.core.ui.components.NavDockDestination import com.resonance.player.core.ui.components.ResonanceMiniPlayer import com.resonance.player.core.ui.components.ResonanceNavDock +import com.resonance.player.core.ui.components.ResonanceSnackbar +import com.resonance.player.core.ui.components.ResonanceSnackbarVisuals import com.resonance.player.core.ui.components.shouldShowMiniPlayer import com.resonance.player.feature.favorites.FavoritesScreen import com.resonance.player.feature.favorites.FavoritesViewModel @@ -104,11 +109,18 @@ fun ResonanceAppShell(container: AppContainer) { val currentRoute = backStack?.destination?.route val selectedTab = AppDestination.tabForRoute(currentRoute)?.route val scope = rememberCoroutineScope() - val snapshot by container.playbackController.snapshot.collectAsState() + val snapshot by container.playbackController.snapshot.collectAsStateWithLifecycle() val showMiniPlayer = shouldShowMiniPlayer(snapshot) val dockDestinations = dockDestinations() + val snackbarHostState = remember { SnackbarHostState() } - fun navigate(route: String) { + /** Shared feedback channel for actions that otherwise silently no-op (e.g. playing an empty playlist). */ + fun showMessage(message: String) { + scope.launch { snackbarHostState.showSnackbar(ResonanceSnackbarVisuals(message)) } + } + + /** Bottom-nav/rail tab switches only: single-top with saved/restored tab state. */ + fun navigateToTab(route: String) { navController.navigate(route) { popUpTo(AppDestination.Home.route) { saveState = true } launchSingleTop = true @@ -116,6 +128,15 @@ fun ResonanceAppShell(container: AppContainer) { } } + /** + * Drill-down pushes (Player/Queue/Search/PlaylistDetail/Favorites). Plain + * push so back returns to the screen the user actually came from, not to + * Home — popUpTo(Home) is only correct for the 4 tab destinations above. + */ + fun navigate(route: String) { + navController.navigate(route) { launchSingleTop = true } + } + fun openPlayerForCurrentTrack() { val song = snapshot.song ?: return navigate(AppDestination.Player.routeFor(song.id)) @@ -171,7 +192,12 @@ fun ResonanceAppShell(container: AppContainer) { container.playSongs, container.getAlbumSongs, container.setShuffleMode, - container.rescanLibrary + container.rescanLibrary, + container.playNext, + container.appendToQueue, + container.observePlaylists, + container.addSongToPlaylist, + container.createPlaylist ) } ) @@ -205,6 +231,9 @@ fun ResonanceAppShell(container: AppContainer) { container.observeGenres, container.observeFolders, container.getAlbumSongs, + container.getArtistSongs, + container.getGenreSongs, + container.getFolderSongs, container.setShuffleMode, container.playNext, container.appendToQueue, @@ -234,7 +263,12 @@ fun ResonanceAppShell(container: AppContainer) { container.playSongs, container.getAlbumSongs, container.getArtistSongs, - container.getGenreSongs + container.getGenreSongs, + container.playNext, + container.appendToQueue, + container.observePlaylists, + container.addSongToPlaylist, + container.createPlaylist ) } ) @@ -324,7 +358,8 @@ fun ResonanceAppShell(container: AppContainer) { PlaylistsScreen( vm, onOpenDetail = { navigate(AppDestination.PlaylistDetail.routeFor(it)) }, - onOpenQueue = { navigate(AppDestination.Queue.route) } + onOpenQueue = { navigate(AppDestination.Queue.route) }, + onShowMessage = ::showMessage ) } composable( @@ -346,12 +381,14 @@ fun ResonanceAppShell(container: AppContainer) { container.renamePlaylist, container.deletePlaylist, container.removeSongFromPlaylist, - container.movePlaylistItem + container.movePlaylistItem, + container.observeSongs, + container.addSongToPlaylist ) } ) val playlistsFlow = remember { container.observePlaylists() } - val playlists by playlistsFlow.collectAsState(initial = emptyList()) + val playlists by playlistsFlow.collectAsStateWithLifecycle(initialValue = emptyList()) PlaylistDetailScreen( vm, playlists.firstOrNull { it.id == playlistId }, @@ -363,7 +400,15 @@ fun ResonanceAppShell(container: AppContainer) { composable(AppDestination.Favorites.route) { val vm: FavoritesViewModel = viewModel( factory = factory { - FavoritesViewModel(container.observeFavoriteSongs, container.playSongs) + FavoritesViewModel( + container.observeFavoriteSongs, + container.playSongs, + container.playNext, + container.appendToQueue, + container.observePlaylists, + container.addSongToPlaylist, + container.createPlaylist + ) } ) FavoritesScreen( @@ -376,34 +421,52 @@ fun ResonanceAppShell(container: AppContainer) { } } - Row( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.statusBars) - ) { - if (widthSize == WindowWidthSize.EXPANDED) { - ResonanceRail( - destinations = dockDestinations, - selectedRoute = selectedTab, - onSelect = ::navigate - ) - } - Column(modifier = Modifier.weight(1f)) { - AppGraph(modifier = Modifier.weight(1f)) - if (showMiniPlayer) { - Box(modifier = Modifier.padding(horizontal = 8.dp)) { - MiniPlayerSlot() - } - } - if (widthSize != WindowWidthSize.EXPANDED) { - ResonanceNavDock( + Box(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + ) { + if (widthSize == WindowWidthSize.EXPANDED) { + ResonanceRail( destinations = dockDestinations, selectedRoute = selectedTab, - onSelect = ::navigate, - modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars) + onSelect = ::navigateToTab ) } + Column(modifier = Modifier.weight(1f)) { + AppGraph(modifier = Modifier.weight(1f)) + if (showMiniPlayer) { + Box(modifier = Modifier.padding(horizontal = 8.dp)) { + MiniPlayerSlot() + } + } + if (widthSize != WindowWidthSize.EXPANDED) { + ResonanceNavDock( + destinations = dockDestinations, + selectedRoute = selectedTab, + onSelect = ::navigateToTab, + modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars) + ) + } + } } + val snackbarBottomInset = if (widthSize != WindowWidthSize.EXPANDED) { + ResonanceTheme.dimensions.navigationDockHeight + if (showMiniPlayer) { + ResonanceTheme.dimensions.miniPlayerHeight + } else { + 0.dp + } + } else { + 0.dp + } + SnackbarHost( + hostState = snackbarHostState, + snackbar = { data -> ResonanceSnackbar(data) }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = snackbarBottomInset) + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43342ab..a7df0c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,6 +91,10 @@ Empty playlist Add songs from any song menu to fill it up. Add to playlist: %1$s + Add songs + Add songs to %1$s + Playlist is empty + Add Favorite Tech specs %1$d / %2$d diff --git a/app/src/test/java/com/resonance/player/fakes/Fakes.kt b/app/src/test/java/com/resonance/player/fakes/Fakes.kt index 78589c4..84175a0 100644 --- a/app/src/test/java/com/resonance/player/fakes/Fakes.kt +++ b/app/src/test/java/com/resonance/player/fakes/Fakes.kt @@ -97,6 +97,9 @@ class FakeMusicRepository(songs: List = listOf(testSong(1L), testSong(2L)) albumArtist: String? ): Result> = Result.Success(backing) + override suspend fun getFolderSongs(relativePath: String?): Result> = + Result.Success(backing) + override suspend fun getLibraryStats(): com.resonance.player.core.model.LibraryStats = com.resonance.player.core.model.LibraryStats( songCount = backing.size, albumCount = 1, artistCount = 1, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 43782f9..fcb6532 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } From cce4ee5c05e799a699e142d66cec8cd814c3f2bd Mon Sep 17 00:00:00 2001 From: Taras Pylypiv Date: Wed, 16 Sep 2026 10:37:00 +0300 Subject: [PATCH 2/4] Phase 1: rebrand to Crate (name + icon) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing rename only — app_name, permission rationale string, window theme name, manifest label, root project name, ARCHITECTURE.md title. Kotlin package, applicationId, and internal Resonance* symbol names are unchanged (deferred, no user-facing benefit for the churn). Replaced the launcher icon foreground: it was still the unmodified stock Android Studio template (purple circle, default glyph), never actually designed. New mark is a simple rounded-square outline with a play triangle, flat accent color, no gradients — matches the "simpler/mainstream" direction from the plan. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 2 +- app/src/main/AndroidManifest.xml | 4 ++-- app/src/main/res/drawable/ic_launcher_foreground.xml | 9 +++++---- app/src/main/res/values/strings.xml | 4 ++-- app/src/main/res/values/themes.xml | 4 ++-- settings.gradle.kts | 2 +- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 395a199..6d8d1ce 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Resonance — Architecture (Phase 3: library ingestion pipeline) +# Crate — Architecture (Phase 3: library ingestion pipeline) Local-first, offline-first music player. No INTERNET permission by design (verified in the merged manifest). diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78b2f49..d5af32b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ -