diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt index ddae1ac14..8166aa326 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt @@ -1,9 +1,12 @@ package com.theveloper.pixelplay.presentation.components import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.runtime.* import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.ui.Modifier @@ -21,8 +24,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.first import com.theveloper.pixelplay.data.preferences.AlbumArtQuality -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MotionScheme // ====== TIPOS/STATE DEL CARRUSEL (wrapper para mantener compatibilidad) ====== @@ -35,7 +36,7 @@ fun rememberRoundedParallaxCarouselState( // ====== TU SECCIÓN: ACOPLADA AL NUEVO API ====== -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AlbumCarouselSection( currentSong: Song?, @@ -66,8 +67,17 @@ fun AlbumCarouselSection( pageCount = { queue.size } ) - val motionScheme = remember { MotionScheme.expressive() } - val carouselAnimationSpec = remember { motionScheme.defaultSpatialSpec() } + // Snappy, critically-damped (no-overshoot) spring for programmatic skip scrolls. + // The previous MotionScheme.expressive().defaultSpatialSpec was a slow, bouncy spring whose + // long settle tail produced many frames of per-item measure/clip work, reading as "laggy". + // A no-bounce spring snaps in and settles fast, matching the native feel of the open/close + // animation while cutting the number of expensive carousel frames. + val carouselAnimationSpec = remember { + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ) + } // Calculate target size based on quality val targetSize = remember(albumArtQuality) { @@ -99,12 +109,35 @@ fun AlbumCarouselSection( targetSize = targetSize, anchorIndex = effectiveTargetIndex ) - var ignoreNextSettledSelectionForPage by remember { mutableStateOf(null) } var programmaticScrollInProgress by remember { mutableStateOf(false) } var lastSettledSongId by remember { mutableStateOf(currentSong?.id) } + + // Track whether the *user* is actively dragging the carousel (via the pager's interaction + // source) so we can tell a real swipe apart from our own programmatic skip/seek scrolls. + var isUserDragging by remember { mutableStateOf(false) } + var userDragSettlePending by remember { mutableStateOf(false) } + LaunchedEffect(carouselState) { + carouselState.pagerState.interactionSource.interactions.collect { interaction -> + when (interaction) { + is DragInteraction.Start -> { + isUserDragging = true + userDragSettlePending = true + } + is DragInteraction.Stop, is DragInteraction.Cancel -> isUserDragging = false + } + } + } + + // Player -> Carousel. Retargets continuously: each press cancels the in-flight animation and + // animates from the current position to the new target, so a rapid skip burst scrolls smoothly + // through the albums instead of freezing. We only ever defer to an active *user* drag — never + // to our own (cancelled) programmatic scroll, which is what previously caused the freeze: the + // effect restarted on every press and then blocked waiting for the just-cancelled scroll to go + // idle, so the carousel never advanced until the user stopped tapping. LaunchedEffect(effectiveTargetIndex, requestedTargetIndex, queue) { - snapshotFlow { carouselState.pagerState.isScrollInProgress } - .first { !it } + if (isUserDragging) { + snapshotFlow { isUserDragging }.first { !it } + } val currentPage = carouselState.pagerState.currentPage if (currentPage != effectiveTargetIndex) { @@ -117,9 +150,6 @@ fun AlbumCarouselSection( // and avoid showing the wrong item for the duration of an animation. carouselState.pagerState.scrollToPage(effectiveTargetIndex) } else { - if (requestedTargetIndex != null) { - ignoreNextSettledSelectionForPage = effectiveTargetIndex - } programmaticScrollInProgress = true try { carouselState.animateScrollToItem(effectiveTargetIndex, animationSpec = carouselAnimationSpec) @@ -138,11 +168,13 @@ fun AlbumCarouselSection( .distinctUntilChanged() .filter { !it } .collect { + // Only a settle that followed a real user drag changes the song. Programmatic + // skip/seek scrolls (and their mid-burst cancellations) must not feed back into + // the player — it already drove them — otherwise a rapid skip burst would fire + // spurious selections and fight the optimistic carousel index. + if (!userDragSettlePending) return@collect + userDragSettlePending = false val settled = carouselState.pagerState.currentPage - if (ignoreNextSettledSelectionForPage == settled) { - ignoreNextSettledSelectionForPage = null - return@collect - } if (settled != currentSongIndex) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) queue.getOrNull(settled)?.let { onSongSelected(it, settled) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt index 6fce01567..fd2df196b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt @@ -91,6 +91,7 @@ import com.theveloper.pixelplay.ui.theme.GoogleSansRounded import com.theveloper.pixelplay.ui.theme.LocalShowScrollbar import androidx.compose.foundation.combinedClickable import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -239,7 +240,11 @@ fun PlaylistItems( onPlaylistLongPress: (Playlist) -> Unit = {}, onPlaylistSelectionToggle: (Playlist) -> Unit = {} ) { - val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() + val hasCurrentSong by remember(playerViewModel) { + playerViewModel.stablePlayerState + .map { it.currentSong != null && it.currentSong != Song.emptySong() } + .distinctUntilChanged() + }.collectAsStateWithLifecycle(initialValue = false) val listState = rememberLazyListState() val playlistFastScrollLabelProvider = remember(filteredPlaylists, currentSortOption) { { index: Int -> @@ -318,7 +323,7 @@ fun PlaylistItems( } } - val bottomPadding = if (stablePlayerState.currentSong != null && stablePlayerState.currentSong != Song.emptySong()) + val bottomPadding = if (hasCurrentSong) bottomBarHeight + MiniPlayerHeight + 16.dp else bottomBarHeight + 16.dp diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetThemeState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetThemeState.kt index 6c7edbeac..e70a2cc84 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetThemeState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetThemeState.kt @@ -192,7 +192,9 @@ private fun rememberBatchAnimatedColorScheme(target: ColorScheme): ColorScheme { progress.snapTo(0f) progress.animateTo( targetValue = 1f, - animationSpec = spring(stiffness = Spring.StiffnessLow) + // Snappier than the old StiffnessLow so the palette resolves in step with the + // (now faster) carousel skip animation instead of trailing behind it. + animationSpec = spring(stiffness = Spring.StiffnessMediumLow) ) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt index 07b463383..a223b04c1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt @@ -72,6 +72,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import androidx.compose.ui.text.style.TextOverflow @androidx.annotation.OptIn(UnstableApi::class) @@ -93,6 +94,12 @@ fun LibraryAlbumsTab( getSelectionIndex: (Long) -> Int? = { null }, storageFilter: StorageFilter = StorageFilter.ALL ) { + val hasCurrentSong by remember(playerViewModel) { + playerViewModel.stablePlayerState + .map { it.currentSong != null && it.currentSong != Song.emptySong() } + .distinctUntilChanged() + }.collectAsStateWithLifecycle(initialValue = false) + val gridState = rememberLazyGridState() val listState = rememberLazyListState() val dummyListState = rememberLazyListState() @@ -372,8 +379,7 @@ fun LibraryAlbumsTab( } } } - val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() - val bottomPadding = if (stablePlayerState.currentSong != null && stablePlayerState.currentSong != Song.emptySong()) + val bottomPadding = if (hasCurrentSong) bottomBarHeight + MiniPlayerHeight + 16.dp else bottomBarHeight + 16.dp @@ -444,8 +450,7 @@ fun LibraryAlbumsTab( } } - val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() - val bottomPadding = if (stablePlayerState.currentSong != null && stablePlayerState.currentSong != Song.emptySong()) + val bottomPadding = if (hasCurrentSong) bottomBarHeight + MiniPlayerHeight + 16.dp else bottomBarHeight + 16.dp @@ -478,6 +483,12 @@ fun LibraryArtistsTab( onRefresh: () -> Unit, storageFilter: StorageFilter = StorageFilter.ALL ) { + val hasCurrentSong by remember(playerViewModel) { + playerViewModel.stablePlayerState + .map { it.currentSong != null && it.currentSong != Song.emptySong() } + .distinctUntilChanged() + }.collectAsStateWithLifecycle(initialValue = false) + val listState = rememberLazyListState() val dummyListState = rememberLazyListState() val artistFastScrollLabelProvider = remember(artists, currentArtistSortOption) { @@ -633,8 +644,7 @@ fun LibraryArtistsTab( } } - val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() - val bottomPadding = if (stablePlayerState.currentSong != null && stablePlayerState.currentSong != Song.emptySong()) + val bottomPadding = if (hasCurrentSong) bottomBarHeight + MiniPlayerHeight + 16.dp else bottomBarHeight + 16.dp diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 8f0ff124c..8d19a9907 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -1108,10 +1108,6 @@ fun LibraryScreen( } } } - val allSongsLazyPagingItems = libraryViewModel.songsPagingFlow.collectAsLazyPagingItems() - val albumsLazyPagingItems = libraryViewModel.albumsPagingFlow.collectAsLazyPagingItems() - val artistsLazyPagingItems = libraryViewModel.artistsPagingFlow.collectAsLazyPagingItems() - val favoritePagingItems = libraryViewModel.favoritesPagingFlow.collectAsLazyPagingItems() val isLibraryLoading by libraryViewModel.isLoadingLibrary.collectAsStateWithLifecycle() val hasCurrentSong by remember(playerViewModel) { playerViewModel.stablePlayerState @@ -1230,7 +1226,11 @@ fun LibraryScreen( onSelectAll = { when (tabTitles.getOrNull(currentTabIndex)?.toLibraryTabIdOrNull()) { LibraryTabId.LIKED -> { - multiSelectionState.selectAll(favoritePagingItems.itemSnapshotList.items) + scope.launch { + val songsToSelect = + playerViewModel.getSongsForCurrentFavoriteSelection() + multiSelectionState.selectAll(songsToSelect) + } } LibraryTabId.FOLDERS -> { val songsToSelect = @@ -1514,6 +1514,7 @@ fun LibraryScreen( ) when (tabTitles.getOrNull(tabIndex)?.toLibraryTabIdOrNull()) { LibraryTabId.SONGS -> { + val allSongsLazyPagingItems = libraryViewModel.songsPagingFlow.collectAsLazyPagingItems() LibrarySongsTab( songs = allSongsLazyPagingItems, isLoading = isLibraryLoading, @@ -1538,6 +1539,7 @@ fun LibraryScreen( ) } LibraryTabId.ALBUMS -> { + val albumsLazyPagingItems = libraryViewModel.albumsPagingFlow.collectAsLazyPagingItems() val isLoading = playerUiState.isLoadingLibraryCategories val stableOnAlbumClick: (Long) -> Unit = remember(navController) { @@ -1568,6 +1570,7 @@ fun LibraryScreen( } LibraryTabId.ARTISTS -> { + val artistsLazyPagingItems = libraryViewModel.artistsPagingFlow.collectAsLazyPagingItems() val isLoading = playerUiState.isLoadingLibraryCategories LibraryArtistsTab( @@ -1607,6 +1610,7 @@ fun LibraryScreen( } LibraryTabId.LIKED -> { + val favoritePagingItems = libraryViewModel.favoritesPagingFlow.collectAsLazyPagingItems() LibraryFavoritesTab( favoriteSongs = favoritePagingItems, playerViewModel = playerViewModel, @@ -1634,7 +1638,6 @@ fun LibraryScreen( val folders = playerUiState.musicFolders val currentFolder = playerUiState.currentFolder val isLoading = playerUiState.isLoadingLibraryCategories - val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() val defaultFolderName = stringResource(R.string.presentation_batch_d_folder_name_fallback) LibraryFoldersTab( @@ -1642,7 +1645,7 @@ fun LibraryScreen( currentFolder = currentFolder, isLoading = isLoading, bottomBarHeight = bottomBarHeightDp, - stablePlayerState = stablePlayerState, + playerViewModel = playerViewModel, onNavigateBack = { playerViewModel.navigateBackFolder() }, onFolderClick = { folderPath -> playerViewModel.navigateToFolder(folderPath) }, onFolderAsPlaylistClick = { folder -> @@ -2851,7 +2854,7 @@ fun LibraryFoldersTab( onFolderClick: (String) -> Unit, onFolderAsPlaylistClick: (MusicFolder) -> Unit, onPlaySong: (Song, List) -> Unit, - stablePlayerState: StablePlayerState, + playerViewModel: PlayerViewModel, bottomBarHeight: Dp, onMoreOptionsClick: (Song) -> Unit, isPlaylistView: Boolean = false, @@ -2917,7 +2920,12 @@ fun LibraryFoldersTab( val songsToShow = remember(activeFolder, currentSortOption) { sortSongsForFolderView(activeFolder?.songs ?: emptyList(), currentSortOption) }.toImmutableList() - val currentSong = stablePlayerState.currentSong + val currentSong by remember(playerViewModel) { + playerViewModel.stablePlayerState + .map { it.currentSong } + .distinctUntilChanged() + }.collectAsStateWithLifecycle(initialValue = null) + val currentSongId = currentSong?.id val currentSongIndexInSongs = remember(songsToShow, currentSongId) { currentSongId?.let { songId -> songsToShow.indexOfFirst { it.id == songId } } ?: -1 @@ -2925,6 +2933,11 @@ fun LibraryFoldersTab( val currentSongListIndex = remember(itemsToShow.size, currentSongIndexInSongs) { if (currentSongIndexInSongs < 0) -1 else itemsToShow.size + currentSongIndexInSongs } + val hasCurrentSong by remember(playerViewModel) { + playerViewModel.stablePlayerState + .map { it.currentSong != null && it.currentSong != Song.emptySong() } + .distinctUntilChanged() + }.collectAsStateWithLifecycle(initialValue = false) val songInCurrentFolder = currentSongIndexInSongs >= 0 val currentSongParentPath: String? = remember(currentSong?.path) { currentSong?.path @@ -3097,15 +3110,14 @@ fun LibraryFoldersTab( } items(songsToShow, key = { it.id }, contentType = { "song" }) { song -> - EnhancedSongListItem( + LibraryPlaybackAwareSongItem( song = song, - isPlaying = stablePlayerState.currentSong?.id == song.id && stablePlayerState.isPlaying, - isCurrentSong = stablePlayerState.currentSong?.id == song.id, - onMoreOptionsClick = { onMoreOptionsClick(song) }, + playerViewModel = playerViewModel, isSelected = selectedSongIds.contains(song.id), selectionIndex = if (isSelectionMode) getSelectionIndex(song.id) else null, isSelectionMode = isSelectionMode, onLongPress = { onSongLongPress(song) }, + onMoreOptionsClick = { onMoreOptionsClick(song) }, onClick = { if (isSelectionMode) { onSongSelectionToggle(song) @@ -3118,7 +3130,7 @@ fun LibraryFoldersTab( } // ScrollBar Overlay - val bottomPadding = if (stablePlayerState.currentSong != null && stablePlayerState.currentSong != Song.emptySong()) + val bottomPadding = if (hasCurrentSong) bottomBarHeight + MiniPlayerHeight + 16.dp else bottomBarHeight + 16.dp diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 54e89c128..d278d596b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -111,6 +111,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn @@ -793,6 +794,13 @@ class PlayerViewModel @Inject constructor( return resolvePlaybackQueueFromSortedIds(sortedIds) } + suspend fun getSongsForCurrentFavoriteSelection(): List { + val sortOption = playerUiState.value.currentFavoriteSortOption + val storageFilter = playerUiState.value.currentStorageFilter + val sortedIds = musicRepository.getFavoriteSongIdsSorted(sortOption, storageFilter) + return resolvePlaybackQueueFromSortedIds(sortedIds) + } + private fun launchLatestFullQueuePlayback( song: Song, queueName: String, @@ -1007,7 +1015,11 @@ class PlayerViewModel @Inject constructor( stablePlayerState .map { it.currentSong?.albumArtUriString?.takeIf { uri -> uri.isNotBlank() } } .distinctUntilChanged() - .onEach { artworkUri -> + // mapLatest cancels in-flight extraction for songs that are skipped over during a + // rapid next/previous burst, so only the latest song's palette is computed. Combined + // with the neighbor preloading below, the latest song is usually already a cache hit, + // so the color resolves immediately instead of after a backlog of intermediate songs. + .mapLatest { artworkUri -> themeStateHolder.extractAndGenerateColorScheme( albumArtUriAsUri = artworkUri?.toUri(), currentSongUriString = artworkUri, @@ -1016,6 +1028,35 @@ class PlayerViewModel @Inject constructor( } .launchIn(viewModelScope) + // Preload neighbor album-art palettes so a skip lands on an already-cached color scheme + // (instant memory-cache hit) and the color animation starts in step with the carousel + // instead of trailing it. ensureAlbumColorScheme runs off-thread (IO -> Default) and + // dedups in-flight work, so this adds no main-thread cost. Bounded to ±radius neighbors. + combine( + stablePlayerState.map { it.currentMediaItemIndex }.distinctUntilChanged(), + queueFlow + ) { index, queue -> index to queue } + // Collapse rapid skip bursts: mapLatest cancels the pending delay whenever the index + // changes again within the window, so we only quantize neighbor palettes once the user + // settles on a song — never for every intermediate song flicked past. Keeps the heavy + // Celebi work off the critical path during a burst. + .mapLatest { pair -> + kotlinx.coroutines.delay(220) + pair + } + .onEach { (index, queue) -> + if (index !in queue.indices) return@onEach + val radius = 1 + for (offset in -radius..radius) { + if (offset == 0) continue + queue.getOrNull(index + offset) + ?.albumArtUriString + ?.takeIf { it.isNotBlank() } + ?.let { themeStateHolder.ensureAlbumColorScheme(it) } + } + } + .launchIn(viewModelScope) + viewModelScope.launch { lyricsStateHolder.songUpdates.collect { update: Pair -> val song = update.first