From d57a233f8720370fb28de9e49549995588de5640 Mon Sep 17 00:00:00 2001 From: zack Date: Fri, 19 Jun 2026 23:50:17 +0800 Subject: [PATCH 1/3] feat: Added a frosted glass effect; added an option to hide the lyrics page control buttons --- app/build.gradle.kts | 2 + .../com/theveloper/pixelplay/MainActivity.kt | 29 +- .../preferences/UserPreferencesRepository.kt | 8 + .../presentation/components/GradientTopBar.kt | 10 +- .../presentation/components/LyricsSheet.kt | 331 ++++++++++-------- .../components/UnifiedPlayerSheetV2.kt | 21 +- .../components/player/FullPlayerContent.kt | 35 +- .../subcomps/LyricsMoreBottomSheet.kt | 3 +- .../presentation/screens/HomeScreen.kt | 5 +- .../presentation/screens/LibraryScreen.kt | 5 +- .../presentation/screens/SearchScreen.kt | 4 +- .../screens/SettingsCategoryScreen.kt | 21 +- .../presentation/screens/SettingsScreen.kt | 4 +- .../presentation/viewmodel/PlayerViewModel.kt | 27 +- .../viewmodel/SettingsViewModel.kt | 19 +- .../res/values-zh-rCN/strings_settings.xml | 2 + app/src/main/res/values/strings_settings.xml | 2 + gradle/libs.versions.toml | 3 + 18 files changed, 337 insertions(+), 194 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 04d49b171..cfcd9b4fe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -298,6 +298,8 @@ dependencies { exclude(group = "androidx.compose.runtime") exclude(group = "androidx.compose.ui") } + implementation(libs.haze) + implementation(libs.haze.materials) // Projects implementation(project(":shared")) diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index 503ac7a21..34509b1c5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -87,6 +87,8 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState import androidx.compose.ui.unit.Dp @@ -152,6 +154,14 @@ import com.theveloper.pixelplay.presentation.utils.AppHapticsConfig import com.theveloper.pixelplay.presentation.utils.LocalAppHapticsConfig import com.theveloper.pixelplay.presentation.utils.NoOpHapticFeedback import com.theveloper.pixelplay.utils.CrashLogData +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.haze +import dev.chrisbanes.haze.hazeChild +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.HazeMaterials import javax.annotation.concurrent.Immutable import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -193,6 +203,12 @@ class MainActivity : ComponentActivity() { // Handle the result in onResume } + companion object { + val LocalHazeState = staticCompositionLocalOf { + error("No HazeState provided") + } + } + @CallSuper override fun attachBaseContext(newBase: Context) { super.attachBaseContext(AppLocaleManager.wrapContext(newBase)) @@ -681,7 +697,7 @@ class MainActivity : ComponentActivity() { val scopedHapticFeedback = remember(platformHapticFeedback, appHapticsConfig.enabled) { if (appHapticsConfig.enabled) platformHapticFeedback else NoOpHapticFeedback } - + val hazeState = remember { HazeState() } val systemNavBarInset = sanitizeNavigationBarBottomInset( WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() ) @@ -774,7 +790,8 @@ class MainActivity : ComponentActivity() { CompositionLocalProvider( LocalAppHapticsConfig provides appHapticsConfig, - LocalHapticFeedback provides scopedHapticFeedback + LocalHapticFeedback provides scopedHapticFeedback, + LocalHazeState provides hazeState ) { AppSidebarDrawer( drawerState = drawerState, @@ -903,6 +920,7 @@ class MainActivity : ComponentActivity() { bottomBarPadding = bottomBarPadding, onSearchIconDoubleTap = onSearchIconDoubleTap, modifier = Modifier.fillMaxSize() + .hazeEffect(state = LocalHazeState.current, style = HazeMaterials.ultraThin()) ) } } @@ -948,6 +966,7 @@ class MainActivity : ComponentActivity() { Box( modifier = Modifier .fillMaxSize() +// .hazeSource(hazeState) .graphicsLayer { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (disableBlurAllOver) { @@ -1008,7 +1027,8 @@ class MainActivity : ComponentActivity() { hideMiniPlayer = shouldHideMiniPlayer, containerHeight = containerHeight, navController = navController, - isNavBarHidden = isNavBarEffectivelyHidden + isNavBarHidden = isNavBarEffectivelyHidden, + hazeState = LocalHazeState.current ) val dismissUndoBarSlice by remember { @@ -1041,7 +1061,8 @@ class MainActivity : ComponentActivity() { modifier = Modifier .fillMaxWidth() .height(MiniPlayerHeight) - .padding(horizontal = 14.dp), + .padding(horizontal = 14.dp) + .hazeEffect(state = LocalHazeState.current, style = HazeMaterials.regular()), onUndo = onUndoDismissPlaylist, onClose = onCloseDismissUndoBar, durationMillis = dismissUndoBarSlice.durationMillis diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 9efca552a..301425848 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -221,6 +221,7 @@ class UserPreferencesRepository @Inject constructor( longPreferencesKey("advanced_performance_diagnostics_expires_at_epoch_ms") val IMMERSIVE_LYRICS_ENABLED = booleanPreferencesKey("immersive_lyrics_enabled") val IMMERSIVE_LYRICS_TIMEOUT = longPreferencesKey("immersive_lyrics_timeout") + val CONTROLS_BUTTONS_ENABLED = booleanPreferencesKey("controls_button_enabled") val USE_ANIMATED_LYRICS = booleanPreferencesKey("use_animated_lyrics") val ANIMATED_LYRICS_BLUR_ENABLED = booleanPreferencesKey("animated_lyrics_blur_enabled") val ANIMATED_LYRICS_BLUR_STRENGTH = androidx.datastore.preferences.core.floatPreferencesKey("animated_lyrics_blur_strength") @@ -1095,6 +1096,13 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) { dataStore.edit { it[PreferencesKeys.IMMERSIVE_LYRICS_TIMEOUT] = timeout } } + val controlsButtonEnabledFlow: Flow = + pref { it[PreferencesKeys.CONTROLS_BUTTONS_ENABLED] ?: true } + + suspend fun setControlsButtonEnabled(enabled: Boolean) { + dataStore.edit { it[PreferencesKeys.CONTROLS_BUTTONS_ENABLED] = enabled } + } + val useAnimatedLyricsFlow: Flow = pref { it[PreferencesKeys.USE_ANIMATED_LYRICS] ?: false } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/GradientTopBar.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/GradientTopBar.kt index 8d8ef3597..992fbaf99 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/GradientTopBar.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/GradientTopBar.kt @@ -41,6 +41,9 @@ import com.theveloper.pixelplay.R import com.theveloper.pixelplay.ui.theme.GoogleSansRounded import com.theveloper.pixelplay.ui.theme.PixelPlayStatusBarStyle import androidx.compose.ui.res.stringResource +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.HazeMaterials @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -116,7 +119,12 @@ fun HomeGradientTopBar( ) TopAppBar( - modifier = Modifier.background(surfaceContainerHigh.copy(alpha = animatedAlpha)), + modifier = Modifier + .background(surfaceContainerHigh.copy(alpha = animatedAlpha * 0.4f)) + .hazeEffect( + state = LocalHazeState.current, + style = HazeMaterials.regular() // 可以根据喜好换成 thin() 或 ultraThin() + ), title = { /* nada, usamos solo acciones */ }, navigationIcon = { Row( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt index 9787826e8..8cab21751 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt @@ -136,8 +136,12 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.ui.text.style.TextGeometricTransform import androidx.compose.ui.text.style.TextOverflow +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon import com.theveloper.pixelplay.utils.MultiLangRomanizer +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.HazeMaterials internal data class LyricsSheetColors( val container: Color, @@ -244,6 +248,7 @@ fun LyricsSheet( immersiveLyricsTimeout: Long, isImmersiveTemporarilyDisabled: Boolean, onSetImmersiveTemporarilyDisabled: (Boolean) -> Unit, + controlsButtonEnabled: Boolean, onSaveLyricsToFile: (Song, Lyrics, Boolean) -> Unit, onTranslateViaAi: () -> Unit, // BottomToggleRow Params @@ -453,6 +458,7 @@ fun LyricsSheet( // Immersive Mode State var immersiveMode by remember { mutableStateOf(false) } var lastInteractionTime by remember { mutableLongStateOf(System.currentTimeMillis()) } + var controlsButtonVisible by remember { mutableStateOf(true) } var showMoreSheet by remember { mutableStateOf(false) } val moreSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true @@ -487,12 +493,22 @@ fun LyricsSheet( } // Auto-hide controls logic - LaunchedEffect(immersiveLyricsEnabled, lastInteractionTime, showSyncedLyrics, isImmersiveTemporarilyDisabled) { - if (immersiveLyricsEnabled && showSyncedLyrics == true && !isImmersiveTemporarilyDisabled) { - delay(immersiveLyricsTimeout) - immersiveMode = true + LaunchedEffect( + immersiveLyricsEnabled, + lastInteractionTime, + controlsButtonEnabled, + showSyncedLyrics, + isImmersiveTemporarilyDisabled + ) { + if (controlsButtonEnabled) { + if (immersiveLyricsEnabled && showSyncedLyrics == true && !isImmersiveTemporarilyDisabled) { + delay(immersiveLyricsTimeout) + immersiveMode = true + } else { + immersiveMode = false + } } else { - immersiveMode = false + immersiveMode = true } } @@ -636,16 +652,16 @@ fun LyricsSheet( }, onDragEnd = { isSwipeActive = false - val committed = abs(dragOffset) > swipeThresholdPx && !hasTriggeredAction - + val committed = abs(dragOffset) > swipeThresholdPx && !hasTriggeredAction + if (committed) { if (dragOffset > 0) onPrev() else onNext() hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) } coroutineScope.launch { - swipeProgress.animateTo(0f, tween(200)) - dragOffset = 0f + swipeProgress.animateTo(0f, tween(200)) + dragOffset = 0f } }, onDragCancel = { @@ -658,11 +674,11 @@ fun LyricsSheet( onDrag = { change, dragAmount -> change.consume() resetImmersiveTimer() - + if (!hasTriggeredAction) { dragOffset += dragAmount.x val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f) - + coroutineScope.launch { swipeProgress.snapTo(progress) } @@ -679,12 +695,12 @@ fun LyricsSheet( modifier = Modifier .fillMaxSize() .padding(top = paddingValues.calculateTopPadding()) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - resetImmersiveTimer() - } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + resetImmersiveTimer() + } ) { val initialSyncedLineIndex = remember(lyrics, playbackPositionFlow, lyricsSyncOffset) { resolveCurrentLineIndex( @@ -708,7 +724,7 @@ fun LyricsSheet( AnimatedContent( targetState = currentSong, transitionSpec = { - (fadeIn(animationSpec = tween(300)) + + (fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.9f, animationSpec = tween(300))) .togetherWith(fadeOut(animationSpec = tween(300))) }, @@ -725,132 +741,138 @@ fun LyricsSheet( .padding( top = 4.dp, bottom = 24.dp, start = 18.dp, end = 18.dp ) + .clip(CircleShape) .background( - color = backgroundColor, + color = backgroundColor.copy(0.4f), shape = CircleShape ) .wrapContentWidth() .animateContentSize(), // Animate width changes - backgroundColor = backgroundColor, // Distinct solid background + backgroundColor = backgroundColor.copy(0.4f), // Distinct solid background contentColor = onBackgroundColor, isPlaying = isPlaying ) } - when (showSyncedLyrics) { - null -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 110.dp, bottom = 24.dp, start = 24.dp, end = 24.dp) - ) { - item(key = "loader_or_empty") { - Box( - modifier = Modifier - .fillParentMaxSize(), - contentAlignment = Alignment.Center - ) { - if (isLoadingLyrics) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.lyrics_loading), - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.height(8.dp)) - LinearWavyProgressIndicator( - trackColor = accentColor.copy(alpha = 0.4f), - color = accentColor, - modifier = Modifier.width(100.dp) - ) + Box(modifier = Modifier + .fillMaxSize() + ) { + when (showSyncedLyrics) { + null -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 110.dp, bottom = 24.dp, start = 24.dp, end = 24.dp) + ) { + item(key = "loader_or_empty") { + Box( + modifier = Modifier + .fillParentMaxSize(), + contentAlignment = Alignment.Center + ) { + if (isLoadingLyrics) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.lyrics_loading), + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + LinearWavyProgressIndicator( + trackColor = accentColor.copy(alpha = 0.4f), + color = accentColor, + modifier = Modifier.width(100.dp) + ) + } } } } } } - } - true -> { - lyrics?.synced?.let { synced -> - SyncedLyricsList( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), - contentPadding = PaddingValues(top = 130.dp, bottom = 100.dp), - lines = synced, - listState = syncedListState, - playbackPositionFlow = playbackPositionFlow, - lyricsSyncOffset = lyricsSyncOffset, - positionOverrideMs = previewSeekPositionMs, - accentColor = lyricHighlightColor, - textStyle = scaledTextStyle, - onLineClick = { syncedLine -> - onSeekTo( - resolveSeekPositionMs( - lineTimeMs = syncedLine.time.toLong(), - lyricsSyncOffsetMs = lyricsSyncOffset - ) - ) - resetImmersiveTimer() - }, - highlightZoneFraction = highlightZoneFraction, - highlightOffsetDp = highlightOffsetDp, - autoscrollAnimationSpec = resolvedAutoscrollSpec, - useAnimatedLyrics = useAnimatedLyrics, - animatedLyricsBlurEnabled = animatedLyricsBlurEnabled && !disableBlurAllOver, - animatedLyricsBlurStrength = animatedLyricsBlurStrength, - immersiveMode = immersiveMode, - lyricsAlignment = lyricsAlignment, - showTranslation = showLyricsTranslation, - showRomanization = showLyricsRomanization, - footer = { - if (lyrics?.areFromRemote == true) { - item(key = "provider_text") { - ProviderText( - providerText = stringResource(R.string.lyrics_provided_by), - uri = stringResource(R.string.lyrics_lrclib_uri), - textAlign = TextAlign.Center, - accentColor = lyricHighlightColor, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) + true -> { + lyrics?.synced?.let { synced -> + SyncedLyricsList( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + contentPadding = PaddingValues(top = 130.dp, bottom = 100.dp), + lines = synced, + listState = syncedListState, + playbackPositionFlow = playbackPositionFlow, + lyricsSyncOffset = lyricsSyncOffset, + positionOverrideMs = previewSeekPositionMs, + accentColor = lyricHighlightColor, + textStyle = scaledTextStyle, + onLineClick = { syncedLine -> + onSeekTo( + resolveSeekPositionMs( + lineTimeMs = syncedLine.time.toLong(), + lyricsSyncOffsetMs = lyricsSyncOffset ) + ) + resetImmersiveTimer() + }, + highlightZoneFraction = highlightZoneFraction, + highlightOffsetDp = highlightOffsetDp, + autoscrollAnimationSpec = resolvedAutoscrollSpec, + useAnimatedLyrics = useAnimatedLyrics, + animatedLyricsBlurEnabled = animatedLyricsBlurEnabled && !disableBlurAllOver, + animatedLyricsBlurStrength = animatedLyricsBlurStrength, + immersiveMode = immersiveMode, + lyricsAlignment = lyricsAlignment, + showTranslation = showLyricsTranslation, + showRomanization = showLyricsRomanization, + footer = { + if (lyrics?.areFromRemote == true) { + item(key = "provider_text") { + ProviderText( + providerText = stringResource(R.string.lyrics_provided_by), + uri = stringResource(R.string.lyrics_lrclib_uri), + textAlign = TextAlign.Center, + accentColor = lyricHighlightColor, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } } } - } - ) + ) + } } - } - false -> { - lyrics?.plain?.let { plain -> - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = staticListState, - contentPadding = PaddingValues( - start = 24.dp, - end = 24.dp, - top = 130.dp, - bottom = 24.dp - ) - ) { - itemsIndexed( - items = plain, - key = { index, line -> "$index-$line" } - ) { _, line -> - PlainLyricsLine( - line = line, - style = lyricsTextStyle, - lyricsAlignment = lyricsAlignment, - showTranslation = if (hasTranslatedLyrics) showLyricsTranslation else true, - showRomanization = if (hasRomanizedLyrics) showLyricsRomanization else true, - modifier = Modifier.fillMaxWidth() + false -> { + lyrics?.plain?.let { plain -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = staticListState, + contentPadding = PaddingValues( + start = 24.dp, + end = 24.dp, + top = 130.dp, + bottom = 24.dp ) - Spacer(modifier = Modifier.height(16.dp)) + ) { + itemsIndexed( + items = plain, + key = { index, line -> "$index-$line" } + ) { _, line -> + PlainLyricsLine( + line = line, + style = lyricsTextStyle, + lyricsAlignment = lyricsAlignment, + showTranslation = if (hasTranslatedLyrics) showLyricsTranslation else true, + showRomanization = if (hasRomanizedLyrics) showLyricsRomanization else true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + } } } } } } - + + // Top Gradient for fade Box( modifier = Modifier @@ -880,7 +902,7 @@ fun LyricsSheet( // Controls Section (Auto-hide in immersive mode) AnimatedVisibility( - visible = !immersiveMode, + visible = if (controlsButtonEnabled) { !immersiveMode } else false, enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut() ) { @@ -888,36 +910,40 @@ fun LyricsSheet( modifier = Modifier .fillMaxWidth() .background(containerColor) - .padding(bottom = paddingValues.calculateBottomPadding() + 10.dp, end = 16.dp, start = 16.dp) + .padding( + bottom = paddingValues.calculateBottomPadding() + 10.dp, + end = 16.dp, + start = 16.dp + ) .pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() // Reset timer on any touch down or move in this area if (event.changes.any { it.pressed }) { - resetImmersiveTimer() + resetImmersiveTimer() } } } } ) { - AnimatedVisibility( - visible = showSyncedLyrics == true && lyrics?.synced != null && showSyncControls, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - LyricsSyncControls( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - offsetMillis = lyricsSyncOffset, - onOffsetChange = onLyricsSyncOffsetChange, - backgroundColor = backgroundColor, - accentColor = sheetColors.syncButtonContainer, - onAccentColor = sheetColors.syncButtonContent, - onBackgroundColor = onBackgroundColor - ) - } + AnimatedVisibility( + visible = showSyncedLyrics == true && lyrics?.synced != null && showSyncControls, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + LyricsSyncControls( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + offsetMillis = lyricsSyncOffset, + onOffsetChange = onLyricsSyncOffsetChange, + backgroundColor = backgroundColor, + accentColor = sheetColors.syncButtonContainer, + onAccentColor = sheetColors.syncButtonContent, + onBackgroundColor = onBackgroundColor + ) + } // Playback Controls Row Row( @@ -1071,6 +1097,7 @@ fun LyricsSheet( } }, immersiveLyricsEnabled = immersiveLyricsEnabled, + controlsButtonEnabled = controlsButtonVisible, isShuffleEnabled = isShuffleEnabled, repeatMode = repeatMode, isFavoriteProvider = isFavoriteProvider, @@ -1092,7 +1119,7 @@ fun LyricsSheet( // Show Controls Button (Overlay) AnimatedVisibility( - visible = immersiveMode, + visible = if (controlsButtonEnabled) { immersiveMode } else false, enter = fadeIn() + slideInVertically { it / 2 }, exit = fadeOut() + slideOutVertically { it / 2 }, modifier = Modifier @@ -1125,25 +1152,25 @@ fun LyricsSheet( .align(overlayAlignment) .size(100.dp) // Base size .padding( - start = if(isNext) 0.dp else 6.dp, - end = if(isNext) 6.dp else 0.dp + start = if (isNext) 0.dp else 6.dp, + end = if (isNext) 6.dp else 0.dp ) .graphicsLayer { - val widthPx = size.width - val initialOffset = if(isNext) widthPx else -widthPx - translationX = initialOffset * (1f - swipeProgress.value) + val widthPx = size.width + val initialOffset = if (isNext) widthPx else -widthPx + translationX = initialOffset * (1f - swipeProgress.value) - scaleX = 0.8f + (swipeProgress.value * 0.2f) - scaleY = 0.8f + (swipeProgress.value * 0.2f) + scaleX = 0.8f + (swipeProgress.value * 0.2f) + scaleY = 0.8f + (swipeProgress.value * 0.2f) } .background( - color = accentColor, // No alpha modulation - shape = RoundedCornerShape( - topStart = if(isNext) 360.dp else 8.dp, - bottomStart = if(isNext) 360.dp else 8.dp, - topEnd = if(isNext) 8.dp else 360.dp, - bottomEnd = if(isNext) 8.dp else 360.dp - ) + color = accentColor, // No alpha modulation + shape = RoundedCornerShape( + topStart = if (isNext) 360.dp else 8.dp, + bottomStart = if (isNext) 360.dp else 8.dp, + topEnd = if (isNext) 8.dp else 360.dp, + bottomEnd = if (isNext) 8.dp else 360.dp + ) ), contentAlignment = Alignment.Center ) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt index d692ed9fd..6d523b4eb 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -88,6 +89,9 @@ import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState import com.theveloper.pixelplay.ui.theme.LocalPixelPlayDarkTheme +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.HazeMaterials import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -115,13 +119,14 @@ fun UnifiedPlayerSheetV2( collapsedStateHorizontalPadding: Dp = 12.dp, navController: NavHostController, hideMiniPlayer: Boolean = false, - isNavBarHidden: Boolean = false + isNavBarHidden: Boolean = false, + hazeState: HazeState ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val latestContext by rememberUpdatedState(context) var showNoInternetDialog by remember { mutableStateOf(false) } - +// val hazeState = LocalHazeState.current // MediaStore write-permission launcher (for metadata editing without MANAGE_EXTERNAL_STORAGE) val writePermissionLauncher = androidx.activity.compose.rememberLauncherForActivityResult( androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult() @@ -694,11 +699,19 @@ fun UnifiedPlayerSheetV2( shape = sheetInteractionState.playerShadowShape, clip = false ) + .clip(sheetInteractionState.playerShadowShape) + // 1. 在背景之上应用 Haze 模糊特效 + .hazeEffect( + state = hazeState, + style = HazeMaterials.regular() + ) + // 2. 将原本的背景颜色设为透明或半透明 .background( - color = playerAreaBackground, + // 如果想要完全通透的模糊效果,使用 Color.Transparent + // 如果想保留原本的专辑主题色/深色模式色调,可以保留 playerAreaBackground 但降低透明度 + color = playerAreaBackground.copy(alpha = 0.2f), shape = sheetInteractionState.playerShadowShape ) - .clip(sheetInteractionState.playerShadowShape) // innerLayout: // Measures the actual player content with full screen height targetContentHeightPx // so that it can render correctly, while reporting targetHeightPx to the outer diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt index cb6e3d400..c80667b74 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt @@ -256,6 +256,7 @@ fun FullPlayerContent( val immersiveLyricsEnabled = fullPlayerSlice.immersiveLyricsEnabled val immersiveLyricsTimeout = fullPlayerSlice.immersiveLyricsTimeout val isImmersiveTemporarilyDisabled = fullPlayerSlice.isImmersiveTemporarilyDisabled + val controlsButtonEnabled = fullPlayerSlice.controlsButtonEnabled val isRemotePlaybackActive = fullPlayerSlice.isRemotePlaybackActive val selectedRouteName = fullPlayerSlice.selectedRouteName val isBluetoothEnabled = fullPlayerSlice.isBluetoothEnabled @@ -970,6 +971,7 @@ fun FullPlayerContent( immersiveLyricsEnabled = immersiveLyricsEnabled, immersiveLyricsTimeout = immersiveLyricsTimeout, isImmersiveTemporarilyDisabled = isImmersiveTemporarilyDisabled, + controlsButtonEnabled = controlsButtonEnabled, onSetImmersiveTemporarilyDisabled = { playerViewModel.setImmersiveTemporarilyDisabled(it) }, isShuffleEnabled = isShuffleEnabled, repeatMode = repeatMode, @@ -1152,6 +1154,23 @@ private fun FullPlayerControlsSection( Column( horizontalAlignment = Alignment.CenterHorizontally ) { + + BottomToggleRow( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp, max = 48.dp) + .padding(horizontal = 26.dp, vertical = 0.dp), + isShuffleEnabled = isShuffleEnabledProvider(), + isShuffleTransitionInProgress = shuffleTransitionInProgress, + repeatMode = repeatModeProvider(), + isFavoriteProvider = isFavoriteProvider, + onShuffleToggle = onShuffleToggle, + onRepeatToggle = onRepeatToggle, + onFavoriteToggle = onFavoriteToggle + ) + + Spacer(modifier = Modifier.height(14.dp)) + AnimatedPlaybackControls( modifier = Modifier .padding(horizontal = 12.dp, vertical = 8.dp), @@ -1172,22 +1191,6 @@ private fun FullPlayerControlsSection( tintNextIcon = transportSkipColors.content ) - Spacer(modifier = Modifier.height(14.dp)) - - BottomToggleRow( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 66.dp, max = 86.dp) - .padding(horizontal = 26.dp, vertical = 0.dp) - .padding(bottom = 6.dp), - isShuffleEnabled = isShuffleEnabledProvider(), - isShuffleTransitionInProgress = shuffleTransitionInProgress, - repeatMode = repeatModeProvider(), - isFavoriteProvider = isFavoriteProvider, - onShuffleToggle = onShuffleToggle, - onRepeatToggle = onRepeatToggle, - onFavoriteToggle = onFavoriteToggle - ) } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LyricsMoreBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LyricsMoreBottomSheet.kt index 61a717c8b..5926e24be 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LyricsMoreBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LyricsMoreBottomSheet.kt @@ -85,6 +85,7 @@ fun LyricsMoreBottomSheet( onShowTranslationChange: (Boolean) -> Unit, onShowRomanizationChange: (Boolean) -> Unit, immersiveLyricsEnabled: Boolean, + controlsButtonEnabled: Boolean, // BottomToggleRow params isShuffleEnabled: Boolean, repeatMode: Int, @@ -331,7 +332,7 @@ fun LyricsMoreBottomSheet( val isSyncVisible = showSyncedLyrics val isRomanizationVisible = hasRomanizedLyrics val isTranslationVisible = hasTranslatedLyrics - val isImmersiveVisible = showSyncedLyrics && immersiveLyricsEnabled + val isImmersiveVisible = showSyncedLyrics && (immersiveLyricsEnabled || controlsButtonEnabled) val isKeepScreenOnVisible = true if (isSyncVisible || isRomanizationVisible || isTranslationVisible || isKeepScreenOnVisible) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt index ba622628f..7ae186d52 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt @@ -119,6 +119,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.compose.ui.res.stringResource +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState +import dev.chrisbanes.haze.hazeSource private const val HomeLoadingPlaceholderMinDurationMillis = 1200L @@ -336,7 +338,8 @@ fun HomeScreen( state = listState, modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.background) + .hazeSource(LocalHazeState.current), contentPadding = PaddingValues( top = innerPadding.calculateTopPadding(), bottom = paddingValuesParent.calculateBottomPadding() 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 2260b4880..1dfc72f78 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 @@ -241,12 +241,14 @@ import kotlinx.coroutines.flow.first import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.LoadState +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import com.theveloper.pixelplay.presentation.components.ExpressiveScrollBar import com.theveloper.pixelplay.ui.theme.LocalShowScrollbar import com.theveloper.pixelplay.presentation.components.LibrarySortBottomSheet import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem import com.theveloper.pixelplay.data.service.wear.PhoneWatchTransferState import com.theveloper.pixelplay.shared.WearTransferProgress +import dev.chrisbanes.haze.hazeSource import java.io.File import kotlin.math.abs @@ -841,7 +843,8 @@ fun LibraryScreen( val headerContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) Scaffold( - modifier = Modifier.background(brush = gradientBrush), + modifier = Modifier.background(brush = gradientBrush) + .hazeSource(LocalHazeState.current), topBar = { Column( modifier = Modifier.background(headerContainerColor) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt index bfe566be2..c8f45eff1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt @@ -148,6 +148,8 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import timber.log.Timber import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem import androidx.compose.ui.res.stringResource +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState +import dev.chrisbanes.haze.hazeSource private const val MAX_ALBUM_MULTI_SELECTION = 6 @@ -309,7 +311,7 @@ fun SearchScreen( ) { Column( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().hazeSource(LocalHazeState.current) ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index d1ff2628b..daeb0b652 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -395,7 +395,9 @@ fun SettingsCategoryScreen( Box( modifier = - Modifier.nestedScroll(nestedScrollConnection).fillMaxSize() + Modifier + .nestedScroll(nestedScrollConnection) + .fillMaxSize() ) { val currentTopBarHeightDp = with(density) { topBarHeight.value.toDp() } @@ -703,6 +705,21 @@ fun SettingsCategoryScreen( leadingIcon = { Icon(Icons.Rounded.Timer, null, tint = MaterialTheme.colorScheme.secondary) } ) } + + SwitchSettingItem( + title = stringResource(R.string.settings_hide_controls_button_title), + subtitle = stringResource(R.string.settings_hide_controls_button_subtitle), + checked = !uiState.controlsButtonEnabled, + onCheckedChange = { settingsViewModel.setControlsButtonEnabled(!it) }, + leadingIcon = { + Icon( + painterResource(R.drawable.rounded_lyrics_24), + null, + tint = MaterialTheme.colorScheme.secondary + ) + } + ) + } SettingsSubsection( @@ -1276,7 +1293,7 @@ fun SettingsCategoryScreen( Box(modifier = Modifier .fillMaxSize() .pointerInput(Unit) { - awaitPointerEventScope { + awaitPointerEventScope { while (true) { awaitPointerEvent() } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt index fa40ecb26..c71fbec9a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt @@ -85,7 +85,9 @@ import kotlinx.coroutines.launch import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import com.theveloper.pixelplay.data.preferences.LaunchTab +import dev.chrisbanes.haze.hazeSource // SettingsTopBar removed, replaced by CollapsibleCommonTopBar @@ -208,7 +210,7 @@ fun SettingsScreen( bottom = MiniPlayerHeight + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 8.dp ), verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().hazeSource(LocalHazeState.current) ) { item { val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f 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 d6829b847..eee3dc93a 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 @@ -604,6 +604,13 @@ class PlayerViewModel @Inject constructor( _isImmersiveTemporarilyDisabled.value = disabled } + val controlsButtonEnabled: StateFlow = userPreferencesRepository.controlsButtonEnabledFlow + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = true + ) + val albumArtQuality: StateFlow = userPreferencesRepository.albumArtQualityFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AlbumArtQuality.MEDIUM) @@ -1230,6 +1237,7 @@ class PlayerViewModel @Inject constructor( val immersiveLyricsEnabled: Boolean = false, val immersiveLyricsTimeout: Long = 4000L, val isImmersiveTemporarilyDisabled: Boolean = false, + val controlsButtonEnabled: Boolean = true, val isRemotePlaybackActive: Boolean = false, val selectedRouteName: String? = null, val isBluetoothEnabled: Boolean = false, @@ -1256,15 +1264,20 @@ class PlayerViewModel @Inject constructor( // Intermediate combine #2: remaining flows (≤5 for Kotlin type inference) private val fullPlayerSlicePart2 = combine( - immersiveLyricsEnabled, - immersiveLyricsTimeout, - isImmersiveTemporarilyDisabled, + combine( + immersiveLyricsEnabled, + immersiveLyricsTimeout, + isImmersiveTemporarilyDisabled, + + ) { immersive, immersiveTimeout, immersiveDisabled -> + Triple(immersive, immersiveTimeout, immersiveDisabled) }, + controlsButtonEnabled, isRemotePlaybackActive, combine(selectedRouteName, bluetoothSlice) { route, bt -> route to bt } - ) { immersive: Boolean, immersiveTimeout: Long, immersiveDisabled: Boolean, - remotePb: Boolean, routeAndBt: Pair -> + ) { immersive: Triple, controlsEnable: Boolean, remotePb: Boolean, routeAndBt: Pair -> + val (immersiveEnabled, immersiveTimeout, isImmersiveDisabled) = immersive val (routeName, bt) = routeAndBt - FullPlayerSlicePart2(immersive, immersiveTimeout, immersiveDisabled, remotePb, routeName, bt.enabled, bt.name) + FullPlayerSlicePart2(immersiveEnabled, immersiveTimeout, isImmersiveDisabled, controlsEnable, remotePb, routeName, bt.enabled, bt.name) } private data class FullPlayerSlicePart1( @@ -1279,6 +1292,7 @@ class PlayerViewModel @Inject constructor( val immersiveLyricsEnabled: Boolean, val immersiveLyricsTimeout: Long, val isImmersiveTemporarilyDisabled: Boolean, + val controlsButtonEnabled: Boolean, val isRemotePlaybackActive: Boolean, val selectedRouteName: String?, val isBluetoothEnabled: Boolean, @@ -1298,6 +1312,7 @@ class PlayerViewModel @Inject constructor( immersiveLyricsEnabled = p2.immersiveLyricsEnabled, immersiveLyricsTimeout = p2.immersiveLyricsTimeout, isImmersiveTemporarilyDisabled = p2.isImmersiveTemporarilyDisabled, + controlsButtonEnabled = p2.controlsButtonEnabled, isRemotePlaybackActive = p2.isRemotePlaybackActive, selectedRouteName = p2.selectedRouteName, isBluetoothEnabled = p2.isBluetoothEnabled, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt index d07d8b3df..d7015d141 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt @@ -93,6 +93,7 @@ data class SettingsUiState( val hapticsEnabled: Boolean = true, val immersiveLyricsEnabled: Boolean = false, val immersiveLyricsTimeout: Long = 4000L, + val controlsButtonEnabled: Boolean = true, val useAnimatedLyrics: Boolean = false, val animatedLyricsBlurEnabled: Boolean = true, val animatedLyricsBlurStrength: Float = 2.5f, @@ -167,6 +168,7 @@ private sealed interface SettingsUiUpdate { val hapticsEnabled: Boolean, val immersiveLyricsEnabled: Boolean, val immersiveLyricsTimeout: Long, + val controlsButtonEnabled: Boolean, val animatedLyricsBlurEnabled: Boolean, val animatedLyricsBlurStrength: Float, val disableBlurAllOver: Boolean, @@ -560,6 +562,7 @@ class SettingsViewModel @Inject constructor( userPreferencesRepository.hapticsEnabledFlow, userPreferencesRepository.immersiveLyricsEnabledFlow, userPreferencesRepository.immersiveLyricsTimeoutFlow, + userPreferencesRepository.controlsButtonEnabledFlow, userPreferencesRepository.animatedLyricsBlurEnabledFlow, userPreferencesRepository.animatedLyricsBlurStrengthFlow, userPreferencesRepository.disableBlurAllOverFlow, @@ -581,10 +584,11 @@ class SettingsViewModel @Inject constructor( hapticsEnabled = values[12] as Boolean, immersiveLyricsEnabled = values[13] as Boolean, immersiveLyricsTimeout = values[14] as Long, - animatedLyricsBlurEnabled = values[15] as Boolean, - animatedLyricsBlurStrength = values[16] as Float, - disableBlurAllOver = values[17] as Boolean, - showScrollbar = values[18] as Boolean + controlsButtonEnabled = values[15] as Boolean, + animatedLyricsBlurEnabled = values[16] as Boolean, + animatedLyricsBlurStrength = values[17] as Float, + disableBlurAllOver = values[18] as Boolean, + showScrollbar = values[19] as Boolean ) }.collect { update -> _uiState.update { state -> @@ -604,6 +608,7 @@ class SettingsViewModel @Inject constructor( hapticsEnabled = update.hapticsEnabled, immersiveLyricsEnabled = update.immersiveLyricsEnabled, immersiveLyricsTimeout = update.immersiveLyricsTimeout, + controlsButtonEnabled = update.controlsButtonEnabled, animatedLyricsBlurEnabled = update.animatedLyricsBlurEnabled, animatedLyricsBlurStrength = update.animatedLyricsBlurStrength, disableBlurAllOver = update.disableBlurAllOver, @@ -1078,6 +1083,12 @@ class SettingsViewModel @Inject constructor( } } + fun setControlsButtonEnabled(enabled: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setControlsButtonEnabled(enabled) + } + } + /** * Completely rebuilds the database from scratch. * Clears all data including user edits (lyrics, favorites) and rescans. diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml index 0822b25e0..5a8b02c57 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -215,6 +215,8 @@ 歌词界面 沉浸式歌词 自动隐藏控件并放大文本。 + 隐藏控制按钮 + 隐藏多余控制按钮 自动隐藏延迟 控件隐藏前的时间。 3秒 diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 298046dec..6d178f369 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -215,6 +215,8 @@ Lyrics Screen Immersive Lyrics Auto-hide controls and enlarge text. + 隐藏控制按钮 + 隐藏多余控制按钮 Auto-hide Delay Time before controls hide. 3s diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab045a2d3..607e9b4be 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ foundation = "1.11.2" glance = "1.3.0-alpha01" graphicsShapes = "1.1.0" gson = "2.14.0" +haze = "1.7.2" hiltAndroid = "2.59.2" hiltNavigationCompose = "1.3.0" ktor = "3.5.0" @@ -112,6 +113,8 @@ playServicesWearable = "20.0.1" [libraries] accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistDrawablepainter" } +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } lifecycleprocess = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleRuntimeKtx" } junitplatformlauncher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitJupiter" } worktesting = { group = "androidx.work", name = "work-testing", version.ref = "workRuntimeKtx" } From f45533d6eaa8a8cf56170f67bb2137189abd8ae8 Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 21 Jun 2026 07:42:21 +0800 Subject: [PATCH 2/3] feat: Improve Gaussian blur and introduce music-synced EQ animation - Refine the visual effect and usage scope of the Gaussian blur while - optimizing its overall rendering smoothness. Additionally, add the new - `PlayingEqIconV2` component, featuring a spectrum animation that dynamically synchronizes with the music rhythm. --- app/build.gradle.kts | 2 + .../com/theveloper/pixelplay/MainActivity.kt | 64 ++++++++++++--- .../pixelplay/data/media/AudioRmsSink.kt | 49 ++++++++++++ .../pixelplay/data/service/MusicService.kt | 7 ++ .../data/service/player/DualPlayerEngine.kt | 15 ++++ .../components/DailyMixSection.kt | 2 + .../presentation/components/LyricsSheet.kt | 32 +++++--- .../components/QueueBottomSheet.kt | 73 ++++++++++++----- .../components/UnifiedPlayerOverlaysLayer.kt | 13 ++- .../components/UnifiedPlayerSheetLayers.kt | 7 ++ .../components/UnifiedPlayerSheetV2.kt | 35 ++++---- .../subcomps/EnhancedSongListItem.kt | 8 +- .../components/subcomps/PlayingEqIconV2.kt | 80 +++++++++++++++++++ .../presentation/screens/AboutScreen.kt | 4 +- .../presentation/screens/AccountsScreen.kt | 4 +- .../presentation/screens/AlbumDetailScreen.kt | 5 +- .../screens/ArtistDetailScreen.kt | 5 ++ .../presentation/screens/DailyMixScreen.kt | 4 + .../screens/DeviceCapabilitiesScreen.kt | 4 +- .../presentation/screens/EqualizerScreen.kt | 4 +- .../presentation/screens/GenreDetailScreen.kt | 14 +++- .../presentation/screens/HomeScreen.kt | 11 ++- .../screens/LibraryPlaybackAwareSongItem.kt | 1 + .../presentation/screens/LibraryScreen.kt | 1 - .../screens/LibrarySongsAndFavoritesTabs.kt | 4 + .../presentation/screens/LibrarySongsTab.kt | 2 + .../screens/PlaylistDetailScreen.kt | 9 ++- .../screens/RecentlyPlayedScreen.kt | 4 + .../presentation/screens/SearchScreen.kt | 10 ++- .../screens/SettingsCategoryScreen.kt | 4 +- .../presentation/screens/StatsScreen.kt | 5 +- .../viewmodel/PlaybackStateHolder.kt | 38 ++++++++- .../viewmodel/StablePlayerState.kt | 3 +- gradle/libs.versions.toml | 2 + 34 files changed, 434 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/media/AudioRmsSink.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIconV2.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cfcd9b4fe..604e3beea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -247,6 +247,8 @@ dependencies { implementation(libs.androidx.media3.exoplayer.ffmpeg) implementation(libs.androidx.media3.exoplayer.midi) implementation(libs.androidx.media3.transformer) + implementation(libs.androidx.media3.common) + implementation(libs.androidx.media3.common.ktx) implementation(libs.androidx.mediarouter) implementation(libs.androidx.media) implementation(libs.coil.compose) diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index 34509b1c5..aae82af72 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -322,7 +322,9 @@ class MainActivity : ComponentActivity() { } Surface( - modifier = Modifier.fillMaxSize().graphicsLayer { alpha = contentAlpha }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = contentAlpha }, color = MaterialTheme.colorScheme.background ) { if (showSetupScreen == null) { @@ -874,13 +876,21 @@ class MainActivity : ComponentActivity() { // hide and the route-based hide as a pure translation, // so child items never resize or get clipped/squished. val expansionHide = if (showPlayerContentArea) { - playerViewModel.playerContentExpansionFraction.value.coerceIn(0f, 1f) + playerViewModel.playerContentExpansionFraction.value.coerceIn( + 0f, + 1f + ) } else { 0f } - val routeHide = (1f - navBarVisibilityProgressState.value).coerceIn(0f, 1f) + val routeHide = + (1f - navBarVisibilityProgressState.value).coerceIn( + 0f, + 1f + ) val hideFraction = maxOf(expansionHide, routeHide) - translationY = (componentHeightPx + shadowOverflowPx + bottomBarPaddingPx) * hideFraction + translationY = + (componentHeightPx + shadowOverflowPx + bottomBarPaddingPx) * hideFraction alpha = 1f } .height(navBarHeight) @@ -889,23 +899,39 @@ class MainActivity : ComponentActivity() { // Animated corner shape resolved in the draw phase: // animating the radius re-clips this layer only — no // recomposition and no layout pass for the bar. - val fraction = playerViewModel.playerContentExpansionFraction.value + val fraction = + playerViewModel.playerContentExpansionFraction.value val safeFraction = fraction.coerceIn(0f, 1f) val topDp = when { navBarStyle == NavBarStyle.DEFAULT -> animatedDefaultTopCornerRadius.value - navBarStyle == NavBarStyle.FULL_WIDTH -> lerp(navBarCornerRadius.dp, 26.dp, safeFraction) + navBarStyle == NavBarStyle.FULL_WIDTH -> lerp( + navBarCornerRadius.dp, + 26.dp, + safeFraction + ) + showPlayerContentArea -> if (fraction < 0.2f) { - lerp(navBarCornerRadius.dp, 26.dp, (fraction / 0.2f).coerceIn(0f, 1f)) + lerp( + navBarCornerRadius.dp, + 26.dp, + (fraction / 0.2f).coerceIn(0f, 1f) + ) } else { 26.dp } + else -> navBarCornerRadius.dp } val bottomDp = when (navBarStyle) { NavBarStyle.FULL_WIDTH -> 0.dp else -> animatedNavBarCornerRadius.value } - shape = navBarShapeCache.get(this, topDp.toPx(), bottomDp.toPx(), useSmoothCorners) + shape = navBarShapeCache.get( + this, + topDp.toPx(), + bottomDp.toPx(), + useSmoothCorners + ) clip = true shadowElevation = navBarElevationPx }, @@ -919,8 +945,12 @@ class MainActivity : ComponentActivity() { compactMode = navBarCompactMode, bottomBarPadding = bottomBarPadding, onSearchIconDoubleTap = onSearchIconDoubleTap, - modifier = Modifier.fillMaxSize() - .hazeEffect(state = LocalHazeState.current, style = HazeMaterials.ultraThin()) + modifier = Modifier + .fillMaxSize() + .hazeEffect( + state = LocalHazeState.current, + style = HazeMaterials.ultraThin() + ) ) } } @@ -973,11 +1003,16 @@ class MainActivity : ComponentActivity() { renderEffect = null } else { val expansion = expansionFractionProvider() - val fraction = (expansion * (1f - predictiveBackCollapseFraction)).coerceIn(0f, 1f) + val fraction = + (expansion * (1f - predictiveBackCollapseFraction)).coerceIn( + 0f, + 1f + ) // Quantize to 2px steps: rebuild the RenderEffect only // when the blur crosses a step, reuse the cached object // every other frame. - val quantizedBlurPx = (fraction * 120f / 2f).roundToInt() * 2f + val quantizedBlurPx = + (fraction * 120f / 2f).roundToInt() * 2f renderEffect = blurEffectCache.get(quantizedBlurPx) } } @@ -1062,7 +1097,10 @@ class MainActivity : ComponentActivity() { .fillMaxWidth() .height(MiniPlayerHeight) .padding(horizontal = 14.dp) - .hazeEffect(state = LocalHazeState.current, style = HazeMaterials.regular()), + .hazeEffect( + state = LocalHazeState.current, + style = HazeMaterials.regular() + ), onUndo = onUndoDismissPlaylist, onClose = onCloseDismissUndoBar, durationMillis = dismissUndoBarSlice.durationMillis diff --git a/app/src/main/java/com/theveloper/pixelplay/data/media/AudioRmsSink.kt b/app/src/main/java/com/theveloper/pixelplay/data/media/AudioRmsSink.kt new file mode 100644 index 000000000..a3640399c --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/media/AudioRmsSink.kt @@ -0,0 +1,49 @@ +package com.theveloper.pixelplay.data.media + +import androidx.media3.common.audio.AudioProcessor.AudioFormat +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.audio.TeeAudioProcessor +import java.nio.ByteBuffer +import kotlin.math.sqrt + +@UnstableApi +class AudioRmsSink( + private val onRmsChanged: (Float) -> Unit +) : TeeAudioProcessor.AudioBufferSink { + + private var maxRms = 0f // 用于归一化 + + override fun flush(sampleRateHz: Int, channelCount: Int, encoding: Int) { + // 重置状态 + maxRms = 0f + onRmsChanged(0f) + } + + override fun handleBuffer(buffer: ByteBuffer) { + if (!buffer.hasRemaining()) return + + // 假设标准的 16-bit PCM 编码 + val shortBuffer = buffer.asShortBuffer() + var sumSquares = 0.0 + val sampleCount = shortBuffer.remaining() + + if (sampleCount == 0) return + + while (shortBuffer.hasRemaining()) { + val sample = shortBuffer.get().toDouble() + sumSquares += sample * sample + } + + // 计算均方根 (RMS) + val rms = sqrt(sumSquares / sampleCount).toFloat() + + // 动态调整最大基准值以适应不同音量的歌曲 + if (rms > maxRms) maxRms = rms + + // 归一化到 0.0f ~ 1.0f 之间 + val normalizedRms = if (maxRms > 0) (rms / maxRms).coerceIn(0f, 1f) else 0f + + // 将数据回调出去 + onRmsChanged(normalizedRms) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 48dde7b34..2f1b36390 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -93,6 +93,7 @@ import coil.size.Precision import javax.inject.Inject import androidx.core.net.toUri +import com.theveloper.pixelplay.presentation.viewmodel.PlaybackStateHolder // Acciones personalizadas para compatibilidad con el widget existente @@ -140,6 +141,8 @@ class MusicService : MediaLibraryService() { @Inject lateinit var engine: DualPlayerEngine @Inject + lateinit var playbackStateHolder: PlaybackStateHolder + @Inject lateinit var controller: TransitionController @Inject lateinit var musicRepository: MusicRepository @@ -413,6 +416,10 @@ class MusicService : MediaLibraryService() { engine.masterPlayer.addListener(playerListener) // Handle player swaps (crossfade) to keep MediaSession in sync + engine.setOnAmplitudeUpdateListener { amplitude -> + playbackStateHolder.updateAudioAmplitude(amplitude) + } + engine.setOnPlayerAboutToBeReleasedListener { oldPlayer -> oldPlayer.removeListener(playerListener) } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt index c8f05dc0a..e2e9d3ec7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt @@ -62,7 +62,9 @@ import com.theveloper.pixelplay.data.netease.NeteaseStreamProxy import com.theveloper.pixelplay.data.navidrome.NavidromeStreamProxy import com.theveloper.pixelplay.data.qqmusic.QqMusicStreamProxy import androidx.core.net.toUri +import androidx.media3.exoplayer.audio.TeeAudioProcessor import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics +import com.theveloper.pixelplay.data.media.AudioRmsSink data class ActiveDecoderInfo( val name: String, @@ -267,6 +269,12 @@ class DualPlayerEngine @Inject constructor( private val onTransitionDisplayPlayerListeners = mutableListOf<(Player) -> Unit>() private val onTransitionFinishedListeners = mutableListOf<() -> Unit>() + private var onAmplitudeUpdateListener: ((Float) -> Unit)? = null + + fun setOnAmplitudeUpdateListener(listener: ((Float) -> Unit)?) { + onAmplitudeUpdateListener = listener + } + private var onPlayerAboutToBeReleasedListener: ((Player) -> Unit)? = null fun setOnPlayerAboutToBeReleasedListener(listener: (Player) -> Unit) { @@ -348,6 +356,11 @@ class DualPlayerEngine @Inject constructor( */ var incomingTrackReplayGainVolume: Float? = null + val rmsSink = AudioRmsSink { amplitude -> + // amplitude 是 0.0f 到 1.0f 的值 + onAmplitudeUpdateListener?.invoke(amplitude) + } + private val focusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> { @@ -1029,6 +1042,7 @@ class DualPlayerEngine @Inject constructor( } private fun buildPlayer(): ExoPlayer { + val teeAudioProcessor = TeeAudioProcessor(rmsSink) val mediaCodecSelector = MediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder -> val decoderInfos = MediaCodecSelector.DEFAULT.getDecoderInfos( mimeType, @@ -1049,6 +1063,7 @@ class DualPlayerEngine @Inject constructor( .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams) .setAudioProcessorChain( DefaultAudioSink.DefaultAudioProcessorChain( + teeAudioProcessor, HiResSampleRateCapAudioProcessor(), SurroundDownmixProcessor() ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt index 235d7ab0b..e3aa16b7d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -353,6 +354,7 @@ private fun DailyMixSongList( onMoreOptionsClick = onMoreOptionsClick, customShape = RoundedCornerShape(10.dp), showAlbumArt = false, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onClick = { playerViewModel.showAndPlaySong( song = song, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt index 8cab21751..afccb057e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt @@ -137,7 +137,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.ui.text.style.TextGeometricTransform import androidx.compose.ui.text.style.TextOverflow import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState -import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon +import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIconV2 import com.theveloper.pixelplay.utils.MultiLangRomanizer import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource @@ -686,7 +686,7 @@ fun LyricsSheet( } ) }, - containerColor = containerColor, +// containerColor = containerColor, contentColor = contentColor, // Removed TopBar and FAB ) { paddingValues -> @@ -694,7 +694,7 @@ fun LyricsSheet( Column( modifier = Modifier .fillMaxSize() - .padding(top = paddingValues.calculateTopPadding()) +// .padding(top = paddingValues.calculateTopPadding()) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null @@ -731,7 +731,8 @@ fun LyricsSheet( modifier = Modifier .align(Alignment.TopStart) .zIndex(2f) - .wrapContentWidth(), + .wrapContentWidth() + .padding(top = paddingValues.calculateTopPadding()), label = "headerAnimation" ) { song -> LyricsTrackInfo( @@ -743,19 +744,21 @@ fun LyricsSheet( ) .clip(CircleShape) .background( - color = backgroundColor.copy(0.4f), - shape = CircleShape + color = backgroundColor.copy(0.6f), ) .wrapContentWidth() .animateContentSize(), // Animate width changes - backgroundColor = backgroundColor.copy(0.4f), // Distinct solid background - contentColor = onBackgroundColor, - isPlaying = isPlaying + backgroundColor = backgroundColor.copy(0.7f), // Distinct solid background + containerColor = containerColor, + contentColor = contentColor, + isPlaying = isPlaying, + stablePlayerStateFlow = stablePlayerStateFlow ) } Box(modifier = Modifier .fillMaxSize() + .background(containerColor) ) { when (showSyncedLyrics) { null -> { @@ -2014,8 +2017,10 @@ private fun LyricsTrackInfo( song: Song?, modifier: Modifier = Modifier, backgroundColor: Color, + containerColor: Color, contentColor: Color, - isPlaying: Boolean + isPlaying: Boolean, + stablePlayerStateFlow: StateFlow ) { if (song == null) return @@ -2100,12 +2105,13 @@ private fun LyricsTrackInfo( ) } - PlayingEqIcon( + PlayingEqIconV2( modifier = Modifier .padding(start = 8.dp, end = 18.dp) .size(width = 18.dp, height = 16.dp), - color = contentColor, - isPlaying = isPlaying + color = containerColor, + isPlaying = isPlaying, + stablePlayerStateFlow = stablePlayerStateFlow ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt index a3f564732..514a0ca93 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt @@ -143,7 +143,6 @@ import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.presentation.components.AutoScrollingText import com.theveloper.pixelplay.presentation.components.SmartImage -import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon import com.theveloper.pixelplay.presentation.components.player.AnimatedPlaybackControls import com.theveloper.pixelplay.presentation.viewmodel.PlayerUiState import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel @@ -192,6 +191,12 @@ import kotlinx.coroutines.flow.map import java.util.RandomAccess import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIconV2 +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.HazeMaterials import kotlin.math.abs private data class QueueUndoBarProjection( @@ -254,6 +259,7 @@ fun QueueBottomSheet( modifier: Modifier = Modifier, tonalElevation: Dp = 10.dp, shape: RoundedCornerShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + hazeState: HazeState ) { val colors = MaterialTheme.colorScheme var showTimerOptions by rememberSaveable { mutableStateOf(false) } @@ -758,7 +764,7 @@ fun QueueBottomSheet( Box( modifier = Modifier.fillMaxSize() ) { - Column { + Column(modifier = Modifier.hazeSource(hazeState)) { val headerTopPadding = WindowInsets.statusBars .asPaddingValues() .calculateTopPadding() + 10.dp @@ -892,6 +898,7 @@ fun QueueBottomSheet( swipeStateIdentity = itemStableKey, onDismissSong = { onRemoveSong(song.id) }, isFromPlaylist = true, + playerViewModel = viewModel, onMoreOptionsClick = { onSongInfoClick(song) }, dragHandle = { IconButton( @@ -937,7 +944,8 @@ fun QueueBottomSheet( .padding( top = 24.dp, end = 14.dp, - bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 14.dp + bottom = WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + 14.dp ) ) } @@ -954,6 +962,19 @@ fun QueueBottomSheet( label = "fabRotation" ) + val fabShape = remember { + AbsoluteSmoothCornerShape( + cornerRadiusTR = 50.dp, + smoothnessAsPercentTR = 60, + cornerRadiusTL = 8.dp, + smoothnessAsPercentTL = 60, + cornerRadiusBR = 50.dp, + smoothnessAsPercentBR = 60, + cornerRadiusBL = 8.dp, + smoothnessAsPercentBL = 60 + ) + } + val navigationBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() Row( @@ -974,6 +995,7 @@ fun QueueBottomSheet( isTimerActive = isTimerActiveDerived, onToggleShuffle = onToggleShuffle, onToggleRepeat = onToggleRepeat, + hazeState = hazeState, onTimerClick = { showTimerOptions = true } ) @@ -1409,30 +1431,36 @@ private fun QueueControlsToolbar( onToggleShuffle: () -> Unit, onToggleRepeat: () -> Unit, onTimerClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + hazeState: HazeState ) { val activeColors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, + containerColor = MaterialTheme.colorScheme.primary.copy(0.4f), contentColor = MaterialTheme.colorScheme.onPrimary ) val inactiveColors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(0.4f), contentColor = MaterialTheme.colorScheme.onSurfaceVariant ) + val shape = AbsoluteSmoothCornerShape( + cornerRadiusTR = 8.dp, + smoothnessAsPercentTR = 60, + cornerRadiusTL = 50.dp, + smoothnessAsPercentTL = 60, + cornerRadiusBR = 8.dp, + smoothnessAsPercentBR = 60, + cornerRadiusBL = 50.dp, + smoothnessAsPercentBL = 60 + ) + Surface( - modifier = modifier.fillMaxHeight(), - shape = AbsoluteSmoothCornerShape( - cornerRadiusTR = 8.dp, - smoothnessAsPercentTR = 60, - cornerRadiusTL = 50.dp, - smoothnessAsPercentTL = 60, - cornerRadiusBR = 8.dp, - smoothnessAsPercentBR = 60, - cornerRadiusBL = 50.dp, - smoothnessAsPercentBL = 60 - ), - color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = modifier + .fillMaxHeight() + .clip(shape) + .hazeEffect(state = hazeState, HazeMaterials.ultraThin()), + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(0.4f), shadowElevation = 0.dp ) { Row( @@ -1876,6 +1904,7 @@ fun SaveQueueAsPlaylistSheet( } } +@androidx.annotation.OptIn(UnstableApi::class) @Composable fun QueuePlaylistSongItem( modifier: Modifier = Modifier, @@ -1893,7 +1922,8 @@ fun QueuePlaylistSongItem( enableSwipeToDismiss: Boolean = false, swipeStateIdentity: Long = 0L, onDismissSong: () -> Unit = {}, - isFromPlaylist: Boolean + isFromPlaylist: Boolean, + playerViewModel: PlayerViewModel ) { val colors = MaterialTheme.colorScheme @@ -2085,12 +2115,13 @@ fun QueuePlaylistSongItem( if (isCurrentSong) { if (isPlaying != null) { - PlayingEqIcon( + PlayingEqIconV2( modifier = Modifier .padding(start = 8.dp) .size(width = 18.dp, height = 16.dp), color = colors.secondary, - isPlaying = isPlaying + isPlaying = isPlaying, + stablePlayerStateFlow = playerViewModel.stablePlayerState ) Spacer(Modifier.width(4.dp)) if (!isRemoveButtonVisible) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt index 5a7fba4fd..963d8ad33 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt @@ -40,6 +40,7 @@ import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.viewmodel.PlaylistViewModel import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState +import dev.chrisbanes.haze.HazeState import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.map import kotlin.math.roundToInt @@ -90,7 +91,8 @@ internal fun UnifiedPlayerQueueLayer( onQueueDrag: (Float) -> Unit, onQueueRelease: (Float, Float) -> Unit, queuePredictiveBackProgress: Animatable, - queuePredictiveBackSwipeEdge: State + queuePredictiveBackSwipeEdge: State, + hazeState: HazeState ) { if (!shouldRenderLayer) return @@ -167,7 +169,8 @@ internal fun UnifiedPlayerQueueLayer( onQueueRelease = onQueueRelease, predictiveBackProgress = queuePredictiveBackProgress, predictiveBackSwipeEdge = queuePredictiveBackSwipeEdge, - queueSheetOffset = queueSheetOffset + queueSheetOffset = queueSheetOffset, + hazeState = hazeState ) } } @@ -307,7 +310,8 @@ internal fun UnifiedPlayerQueueAndSongInfoHost( onNavigateToArtist: (Song) -> Unit, onNavigateToGenre: (Song) -> Unit, queuePredictiveBackProgress: Animatable, - queuePredictiveBackSwipeEdge: State + queuePredictiveBackSwipeEdge: State, + hazeState: HazeState ) { if (!shouldRenderHost) return @@ -440,7 +444,8 @@ internal fun UnifiedPlayerQueueAndSongInfoHost( onQueueDrag = onQueueDrag, onQueueRelease = onQueueRelease, queuePredictiveBackProgress = queuePredictiveBackProgress, - queuePredictiveBackSwipeEdge = queuePredictiveBackSwipeEdge + queuePredictiveBackSwipeEdge = queuePredictiveBackSwipeEdge, + hazeState = hazeState ) UnifiedPlayerSongInfoLayer( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt index 2aed35222..351169f9a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Dp @@ -39,6 +40,9 @@ import com.theveloper.pixelplay.presentation.components.scoped.rememberFullPlaye import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.HazeMaterials @OptIn(UnstableApi::class) @Composable @@ -54,6 +58,7 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( bottomSheetOpenFraction: Float, fullPlayerVisualState: FullPlayerVisualState, containerHeight: Dp, + containerColor: Color, currentQueueSourceName: String, currentSheetContentState: PlayerSheetState, carouselStyle: String, @@ -63,6 +68,7 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( currentPositionProvider: () -> Long, isFavorite: Boolean, shouldRenderFullPlayer: Boolean = true, + hazeState: HazeState, currentHorizontalPaddingStartPxProvider: () -> Float, currentHorizontalPaddingEndPxProvider: () -> Float, onShowQueueClicked: () -> Unit, @@ -128,6 +134,7 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( onNext = { playerViewModel.nextSong() }, canScroll = isMiniPlayerVisible && infrequentPlayerState.isPlaying, modifier = Modifier.fillMaxSize() + .hazeEffect(state = hazeState, HazeMaterials.ultraThin(containerColor = containerColor)) ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt index 6d523b4eb..ed1d975ee 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt @@ -90,6 +90,7 @@ import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState import com.theveloper.pixelplay.ui.theme.LocalPixelPlayDarkTheme import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.LocalHazeStyle import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.HazeMaterials import kotlinx.collections.immutable.persistentListOf @@ -126,7 +127,6 @@ fun UnifiedPlayerSheetV2( val lifecycleOwner = LocalLifecycleOwner.current val latestContext by rememberUpdatedState(context) var showNoInternetDialog by remember { mutableStateOf(false) } -// val hazeState = LocalHazeState.current // MediaStore write-permission launcher (for metadata editing without MANAGE_EXTERNAL_STORAGE) val writePermissionLauncher = androidx.activity.compose.rememberLauncherForActivityResult( androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult() @@ -627,11 +627,12 @@ fun UnifiedPlayerSheetV2( .fillMaxWidth() .layout { measurable, constraints -> val translationY = visualSheetTranslationYProvider().roundToInt() - val overshoot = if (currentSheetContentState == PlayerSheetState.EXPANDED && !isDragging) { - -translationY - } else { - if (translationY < 0) -translationY else 0 - } + val overshoot = + if (currentSheetContentState == PlayerSheetState.EXPANDED && !isDragging) { + -translationY + } else { + if (translationY < 0) -translationY else 0 + } val targetHeight = constraints.maxHeight + overshoot val placeable = measurable.measure( constraints.copy( @@ -674,9 +675,10 @@ fun UnifiedPlayerSheetV2( .toInt().coerceAtLeast(0) val endPaddingPx = currentHorizontalPaddingEndPxProvider() .toInt().coerceAtLeast(0) - val innerWidth = (constraints.maxWidth - startPaddingPx - endPaddingPx) - .coerceAtLeast(0) - + val innerWidth = + (constraints.maxWidth - startPaddingPx - endPaddingPx) + .coerceAtLeast(0) + val placeable = measurable.measure( constraints.copy( minWidth = innerWidth, @@ -701,15 +703,15 @@ fun UnifiedPlayerSheetV2( ) .clip(sheetInteractionState.playerShadowShape) // 1. 在背景之上应用 Haze 模糊特效 - .hazeEffect( - state = hazeState, - style = HazeMaterials.regular() - ) +// .hazeEffect( +// state = hazeState, +// style = HazeMaterials.ultraThin() +// ) // 2. 将原本的背景颜色设为透明或半透明 .background( // 如果想要完全通透的模糊效果,使用 Color.Transparent // 如果想保留原本的专辑主题色/深色模式色调,可以保留 playerAreaBackground 但降低透明度 - color = playerAreaBackground.copy(alpha = 0.2f), + color = playerAreaBackground, shape = sheetInteractionState.playerShadowShape ) // innerLayout: @@ -770,6 +772,7 @@ fun UnifiedPlayerSheetV2( bottomSheetOpenFraction = bottomSheetOpenFraction, fullPlayerVisualState = fullPlayerVisualState, containerHeight = containerHeight, + containerColor = playerAreaBackground, currentQueueSourceName = currentQueueSourceName, currentSheetContentState = currentSheetContentState, carouselStyle = carouselStyle, @@ -779,6 +782,7 @@ fun UnifiedPlayerSheetV2( currentPositionProvider = positionToDisplayProvider, isFavorite = isFavorite, shouldRenderFullPlayer = shouldRenderFullPlayer, + hazeState = hazeState, currentHorizontalPaddingStartPxProvider = currentHorizontalPaddingStartPxProvider, currentHorizontalPaddingEndPxProvider = currentHorizontalPaddingEndPxProvider, onShowQueueClicked = sheetActionHandlers.openQueueSheet, @@ -875,7 +879,8 @@ fun UnifiedPlayerSheetV2( onNavigateToArtist = sheetActionHandlers.onNavigateToArtist, onNavigateToGenre = sheetActionHandlers.onNavigateToGenre, queuePredictiveBackProgress = queuePredictiveBackProgress, - queuePredictiveBackSwipeEdge = queuePredictiveBackSwipeEdgeState + queuePredictiveBackSwipeEdge = queuePredictiveBackSwipeEdgeState, + hazeState = hazeState ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt index 30abec587..b2e53dda5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt @@ -54,6 +54,8 @@ import com.theveloper.pixelplay.presentation.components.ShimmerBox import androidx.compose.ui.res.stringResource import com.theveloper.pixelplay.R import com.theveloper.pixelplay.presentation.components.SmartImage +import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState +import kotlinx.coroutines.flow.StateFlow @Immutable private data class EnhancedSongAnimationTarget( @@ -97,6 +99,7 @@ fun EnhancedSongListItem( selectionIndex: Int? = null, isSelectionMode: Boolean = false, showMoreOptionsButton: Boolean = true, + stablePlayerStateFlow: StateFlow, onLongPress: () -> Unit = {}, onMoreOptionsClick: (Song) -> Unit, onClick: () -> Unit @@ -362,12 +365,13 @@ fun EnhancedSongListItem( val showTrailingAction = showMoreOptionsButton && !isSelectionMode if (showPlayingIndicator) { - PlayingEqIcon( + PlayingEqIconV2( modifier = Modifier .padding(start = 8.dp) .size(width = 18.dp, height = 16.dp), color = contentColor, - isPlaying = isPlaying + isPlaying = isPlaying, + stablePlayerStateFlow = stablePlayerStateFlow ) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIconV2.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIconV2.kt new file mode 100644 index 000000000..821b25527 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIconV2.kt @@ -0,0 +1,80 @@ +package com.theveloper.pixelplay.presentation.components.subcomps + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi +import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState +import kotlinx.coroutines.flow.StateFlow + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun PlayingEqIconV2( + modifier: Modifier = Modifier, + color: Color, + isPlaying: Boolean, + stablePlayerStateFlow: StateFlow +) { + + val stablePlayerState by stablePlayerStateFlow.collectAsStateWithLifecycle() + + val audioAmplitude by remember(stablePlayerState) { + derivedStateOf { stablePlayerState.audioAmplitude } + } + + // 使用 tween 加快反应速度,使动画紧跟音乐节奏,同时保持一定的平滑过渡 + val animatedAmp by animateFloatAsState( + targetValue = if (isPlaying) audioAmplitude else 0.1f, + animationSpec = tween(durationMillis = 60), + label = "eq_amplitude" + ) + + // 通过简单的数学偏移,让一根基础振幅数据变成三根看起来独立的跳动柱子 + // 保证它们最小有 0.1f 的高度(不会完全消失),最大不超过 1f + val bar1Height = (animatedAmp * 0.7f + 0.1f).coerceIn(0.1f, 1f) + val bar2Height = (animatedAmp * 1.0f).coerceIn(0.2f, 1f) // 中间这根最高 + val bar3Height = (animatedAmp * 0.5f + 0.3f).coerceIn(0.1f, 1f) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight(bar1Height) + .clip(RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp)) + .background(color) + ) + Spacer(modifier = Modifier.width(2.dp)) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight(bar2Height) + .clip(RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp)) + .background(color) + ) + Spacer(modifier = Modifier.width(2.dp)) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight(bar3Height) + .clip(RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp)) + .background(color) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt index fdb1b7b19..298aeb83b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt @@ -96,6 +96,7 @@ import androidx.navigation.NavController import coil.compose.AsyncImagePainter import coil.request.ImageRequest import coil.size.Size +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.github.GitHubContributorService import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar @@ -104,6 +105,7 @@ import com.theveloper.pixelplay.presentation.components.SmartImage import com.theveloper.pixelplay.presentation.navigation.Screen import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import dev.chrisbanes.haze.hazeSource import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import timber.log.Timber @@ -358,7 +360,7 @@ fun AboutScreen( .asPaddingValues() .calculateBottomPadding() + 12.dp, ), - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().hazeSource(LocalHazeState.current), horizontalAlignment = Alignment.CenterHorizontally, ) { item(key = "hero_card") { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AccountsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AccountsScreen.kt index b5aeee48a..8a739bed7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AccountsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AccountsScreen.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import com.theveloper.pixelplay.R import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight @@ -87,6 +88,7 @@ import com.theveloper.pixelplay.presentation.telegram.auth.TelegramLoginActivity import com.theveloper.pixelplay.presentation.viewmodel.AccountsViewModel import com.theveloper.pixelplay.presentation.viewmodel.ExternalAccountUiModel import com.theveloper.pixelplay.presentation.viewmodel.ExternalServiceAccount +import dev.chrisbanes.haze.hazeSource import kotlin.math.roundToInt import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @@ -172,7 +174,7 @@ fun AccountsScreen( LazyColumn( state = lazyListState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().hazeSource(LocalHazeState.current), contentPadding = PaddingValues( top = currentTopBarHeightDp + 8.dp, start = 16.dp, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt index a8f3f31f1..5a04282e1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt @@ -104,6 +104,8 @@ import com.theveloper.pixelplay.utils.shapes.RoundedStarShape import kotlinx.coroutines.launch import kotlin.math.roundToInt import androidx.compose.ui.res.stringResource +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState +import dev.chrisbanes.haze.hazeSource private const val UseSharedCollapsibleTopBarProbe = true @@ -302,7 +304,7 @@ fun AlbumDetailScreen( val extraHeight = (topBarHeight.value - minTopBarHeightPx).roundToInt() IntOffset(0, extraHeight) - }, + }.hazeSource(LocalHazeState.current), contentPadding = PaddingValues( top = minTopBarHeight + 8.dp, start = 16.dp, @@ -334,6 +336,7 @@ fun AlbumDetailScreen( isCurrentSong = stablePlayerState.currentSong?.id == song.id, isPlaying = stablePlayerState.isPlaying, showAlbumArt = false, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onMoreOptionsClick = { playerViewModel.selectSongForInfo(song) showSongInfoBottomSheet = true diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt index 6f9199237..fa5cbd8c4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt @@ -100,6 +100,8 @@ import coil.size.Size import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.compose.ui.res.stringResource import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState +import kotlinx.coroutines.flow.StateFlow private const val UseSharedCollapsibleTopBarProbe = true @@ -367,6 +369,7 @@ fun ArtistDetailScreen( songCount = section.songs.size, isCurrentSong = stablePlayerState.currentSong?.id == song.id, isPlaying = stablePlayerState.isPlaying, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onSongClick = { playerViewModel.showAndPlaySong(song, section.songs) }, @@ -666,6 +669,7 @@ private fun ArtistAlbumSectionSongItem( songCount: Int, isCurrentSong: Boolean, isPlaying: Boolean, + stablePlayerStateFlow: StateFlow, onSongClick: () -> Unit, onMoreOptionsClick: () -> Unit ) { @@ -717,6 +721,7 @@ private fun ArtistAlbumSectionSongItem( isPlaying = isPlaying, showAlbumArt = false, customShape = songItemShape, + stablePlayerStateFlow = stablePlayerStateFlow, onMoreOptionsClick = { onMoreOptionsClick() }, onClick = onSongClick ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt index 24785f39d..9fbe16133 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt @@ -94,6 +94,8 @@ import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongLis import com.theveloper.pixelplay.presentation.components.subcomps.TightWrapText import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState +import dev.chrisbanes.haze.hazeSource @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -261,6 +263,7 @@ fun DailyMixScreen( modifier = Modifier .fillMaxSize() .background(backgroundBrush) + .hazeSource(LocalHazeState.current) ) { if (dailyMixSongs.isEmpty()) { Box( @@ -365,6 +368,7 @@ fun DailyMixScreen( song = song, isCurrentSong = stablePlayerState.currentSong?.id == song.id, isPlaying = currentSongId == song.id && isPlaying, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onClick = { playerViewModel.showAndPlaySong(song, dailyMixSongs, dailyMixTitle, isVoluntaryPlay = false) }, onMoreOptionsClick = { playerViewModel.selectSongForInfo(song) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt index a9d376f9c..b5b6b641b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt @@ -91,6 +91,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import com.theveloper.pixelplay.R import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight @@ -104,6 +105,7 @@ import com.theveloper.pixelplay.presentation.viewmodel.LocalMusicStorageSummary import com.theveloper.pixelplay.presentation.viewmodel.MemorySummary import com.theveloper.pixelplay.presentation.viewmodel.PlaybackCompatibilitySummary import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import dev.chrisbanes.haze.hazeSource import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -232,7 +234,7 @@ private fun DeviceCapabilitiesContent( bottom = MiniPlayerHeight + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp ), verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier + modifier = modifier.hazeSource(LocalHazeState.current) ) { item { PlaybackReadinessCard( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/EqualizerScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/EqualizerScreen.kt index a223cea51..31361e21f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/EqualizerScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/EqualizerScreen.kt @@ -151,6 +151,8 @@ import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState +import dev.chrisbanes.haze.hazeSource @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -297,7 +299,7 @@ fun EqualizerScreen( top = currentTopBarHeightDp + 8.dp, bottom = MiniPlayerHeight + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 20.dp ), - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().hazeSource(LocalHazeState.current), verticalArrangement = Arrangement.spacedBy(6.dp) ) { // Preset Tabs diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt index f401829b6..1ae18ddce 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt @@ -90,7 +90,10 @@ import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import kotlin.math.roundToInt import androidx.compose.ui.res.stringResource +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import com.theveloper.pixelplay.presentation.components.subcomps.TightWrapText +import dev.chrisbanes.haze.hazeSource +import kotlinx.coroutines.flow.StateFlow // --- Data Models & Helpers --- @@ -323,7 +326,7 @@ fun GenreDetailScreen( // Offset the entire list down by the current "expansion" of the top bar val extraHeight = (topBarHeight.value - minTopBarHeightPx).roundToInt() IntOffset(0, extraHeight) - } + }.hazeSource(LocalHazeState.current) ) { // Optimization: Limit rendered items during the navigation transition // to ensure the slide-in animation remains smooth. @@ -356,7 +359,7 @@ fun GenreDetailScreen( val selectionIndex = multiSelectionState.getSelectionIndex(item.song.id) GenreSongItemWrapper( item = item, - stablePlayerState = stablePlayerState, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onSongClick = { song -> playerViewModel.showAndPlaySong(song, uiState.sortedSongs, genreDisplayName) }, @@ -1001,7 +1004,7 @@ fun GenreAlbumHeader( @Composable fun GenreSongItemWrapper( item: com.theveloper.pixelplay.presentation.viewmodel.GenreDetailListItem.SongItem, - stablePlayerState: StablePlayerState, + stablePlayerStateFlow: StateFlow, onSongClick: (Song) -> Unit, onMoreOptionsClick: (Song) -> Unit, isSelectionMode: Boolean = false, @@ -1015,6 +1018,8 @@ fun GenreSongItemWrapper( val isLastAlbumInSection = item.isLastAlbumInSection val useArtistStyle = item.useArtistStyle + val stablePlayerState by stablePlayerStateFlow.collectAsStateWithLifecycle() + // Optimization: Cache shapes to avoid reallocation during scroll val songItemShape = remember(isFirstInAlbum, isLastInAlbum) { when { @@ -1063,7 +1068,8 @@ fun GenreSongItemWrapper( isSelected = isSelected, selectionIndex = selectionIndex, isSelectionMode = isSelectionMode, - onLongPress = onLongPress + onLongPress = onLongPress, + stablePlayerStateFlow = stablePlayerStateFlow, ) if (isLastInAlbum) Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt index 7ae186d52..189641a8c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt @@ -103,7 +103,6 @@ import com.theveloper.pixelplay.presentation.components.StatsOverviewCard import com.theveloper.pixelplay.presentation.components.resolveMainScreenBottomGradientHeight import com.theveloper.pixelplay.presentation.model.collectRecentlyPlayedSongIds import com.theveloper.pixelplay.presentation.model.mapRecentlyPlayedSongs -import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon import com.theveloper.pixelplay.presentation.navigation.Screen import com.theveloper.pixelplay.presentation.components.StreamingProviderSheet import com.theveloper.pixelplay.presentation.telegram.auth.TelegramLoginActivity @@ -120,7 +119,10 @@ import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.compose.ui.res.stringResource import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState +import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIconV2 +import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState import dev.chrisbanes.haze.hazeSource +import kotlinx.coroutines.flow.StateFlow private const val HomeLoadingPlaceholderMinDurationMillis = 1200L @@ -769,6 +771,7 @@ fun SongListItemFavs( albumArtUrl: String?, isPlaying: Boolean, isCurrentSong: Boolean, + stablePlayerStateFlow: StateFlow, onClick: () -> Unit ) { val colors = MaterialTheme.colorScheme @@ -820,13 +823,14 @@ fun SongListItemFavs( } Spacer(Modifier.width(16.dp)) if (isCurrentSong) { - PlayingEqIcon( + PlayingEqIconV2( modifier = Modifier .weight(0.1f) .padding(start = 8.dp) .size(width = 18.dp, height = 16.dp), // similar al tamaño del ícono color = colors.primary, - isPlaying = isPlaying // o conectalo a tu estado real de reproducción + isPlaying = isPlaying, // o conectalo a tu estado real de reproducción + stablePlayerStateFlow = stablePlayerStateFlow ) } } @@ -859,6 +863,7 @@ fun SongListItemFavsWrapper( albumArtUrl = song.albumArtUriString, isPlaying = stablePlayerState.isPlaying, isCurrentSong = song.id == stablePlayerState.currentSong?.id, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onClick = onClick ) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryPlaybackAwareSongItem.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryPlaybackAwareSongItem.kt index 5c7da0dff..096fe0e93 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryPlaybackAwareSongItem.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryPlaybackAwareSongItem.kt @@ -55,6 +55,7 @@ internal fun LibraryPlaybackAwareSongItem( isSelected = isSelected, selectionIndex = selectionIndex, isSelectionMode = isSelectionMode, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onLongPress = onLongPress, onMoreOptionsClick = onMoreOptionsClick, onClick = onClick 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 1dfc72f78..6db29f3c0 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 @@ -229,7 +229,6 @@ import com.theveloper.pixelplay.presentation.components.AutoScrollingTextOnDeman import com.theveloper.pixelplay.presentation.screens.CreatePlaylistDialog import com.theveloper.pixelplay.presentation.components.PlaylistBottomSheet import com.theveloper.pixelplay.presentation.components.PlaylistContainer -import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon import com.theveloper.pixelplay.ui.theme.GoogleSansRounded import java.util.Locale import androidx.compose.ui.platform.LocalContext diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsAndFavoritesTabs.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsAndFavoritesTabs.kt index c245e6506..02a49b9f1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsAndFavoritesTabs.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsAndFavoritesTabs.kt @@ -268,6 +268,7 @@ fun LibraryFavoritesTab( isPlaying = false, isLoading = true, isCurrentSong = false, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onMoreOptionsClick = {}, onClick = {} ) @@ -331,6 +332,7 @@ fun LibrarySongsTabPaginated( isPlaying = false, isLoading = true, isCurrentSong = false, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onMoreOptionsClick = {}, onClick = {} ) @@ -448,6 +450,7 @@ fun LibrarySongsTabPaginated( isPlaying = isPlayingThisSong, isCurrentSong = stablePlayerState.currentSong?.id == song.id, isLoading = false, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onMoreOptionsClick = rememberedOnMoreOptionsClick, onClick = rememberedOnClick ) @@ -457,6 +460,7 @@ fun LibrarySongsTabPaginated( isPlaying = false, isLoading = true, isCurrentSong = false, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onMoreOptionsClick = {}, onClick = {} ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt index 5fdab7ddb..7e8d3953f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt @@ -266,6 +266,7 @@ fun LibrarySongsTab( isPlaying = false, isLoading = true, isCurrentSong = false, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onMoreOptionsClick = {}, onClick = {} ) @@ -359,6 +360,7 @@ fun LibrarySongsTab( isPlaying = false, isLoading = true, isCurrentSong = false, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onMoreOptionsClick = {}, onClick = {} ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index 646f002f0..374a99911 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -112,6 +112,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController import coil.size.Size +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight @@ -140,6 +141,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState import com.theveloper.pixelplay.presentation.components.LibrarySortBottomSheet import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.model.PlaylistShapeType +import dev.chrisbanes.haze.hazeSource import kotlinx.coroutines.launch @androidx.annotation.OptIn(UnstableApi::class) @@ -661,7 +663,8 @@ fun PlaylistDetailScreen( if (localReorderableSongs.isEmpty()) { Box(Modifier .fillMaxSize() - .weight(1f), Alignment.Center) { + .weight(1f) + .hazeSource(LocalHazeState.current), Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(Icons.Filled.MusicOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(8.dp)) @@ -693,7 +696,8 @@ fun PlaylistDetailScreen( smoothnessAsPercentTL = 60, ) ) - .background(color = MaterialTheme.colorScheme.surfaceContainerHigh), + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh) + .hazeSource(LocalHazeState.current), verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues( top = 12.dp, @@ -750,6 +754,7 @@ fun PlaylistDetailScreen( } }, isFromPlaylist = true, + playerViewModel = playerViewModel, isReorderModeEnabled = isReorderModeEnabled, isDragHandleVisible = isReorderModeEnabled, isRemoveButtonVisible = isRemoveModeEnabled, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt index a90154869..dad95ceb5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt @@ -96,6 +96,8 @@ import java.util.Locale import android.text.format.DateFormat as AndroidDateFormat import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.compose.ui.res.stringResource +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState +import dev.chrisbanes.haze.hazeSource @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -179,6 +181,7 @@ fun RecentlyPlayedScreen( modifier = Modifier .fillMaxSize() .background(backgroundBrush) + .hazeSource(LocalHazeState.current) ) { if (recentlyPlayedSourceSongs == null) { Box( @@ -255,6 +258,7 @@ fun RecentlyPlayedScreen( song = item.song, isCurrentSong = currentSongId == item.song.id, isPlaying = currentSongId == item.song.id && isPlaying, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onClick = { playerViewModel.playSongs( songsToPlay = queueSongs, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt index c8f45eff1..2b1eb7759 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt @@ -149,7 +149,10 @@ import timber.log.Timber import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem import androidx.compose.ui.res.stringResource import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.HazeMaterials private const val MAX_ALBUM_MULTI_SELECTION = 6 @@ -311,7 +314,7 @@ fun SearchScreen( ) { Column( - modifier = Modifier.fillMaxSize().hazeSource(LocalHazeState.current) + modifier = Modifier.fillMaxSize() ) { Row( modifier = Modifier @@ -436,7 +439,7 @@ fun SearchScreen( label = "search_mode_transition" ) { isGenreMode -> if (isGenreMode) { - Column(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize().hazeSource(LocalHazeState.current)) { if (isGenreSelectionMode) { SelectionActionRow( selectedCount = selectedGenres.size, @@ -497,7 +500,7 @@ fun SearchScreen( Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp) + .padding(horizontal = 16.dp).hazeSource(LocalHazeState.current) ) { if (anySelectionMode) { val count = when { @@ -1225,6 +1228,7 @@ fun SearchResultsList( isSelected = isSelected, selectionIndex = selectionIndex, isSelectionMode = isSelectionMode, + stablePlayerStateFlow = playerViewModel.stablePlayerState, onLongPress = { onSongLongPress(item.song) } ) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index daeb0b652..219b197b1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -146,6 +146,7 @@ import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import kotlin.math.roundToInt import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -177,6 +178,7 @@ import com.theveloper.pixelplay.presentation.viewmodel.LyricsRefreshProgress import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.viewmodel.SettingsViewModel import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import dev.chrisbanes.haze.hazeSource @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -403,7 +405,7 @@ fun SettingsCategoryScreen( LazyColumn( state = lazyListState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().hazeSource(LocalHazeState.current), contentPadding = PaddingValues( top = currentTopBarHeightDp + 8.dp, start = 16.dp, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt index 0cbace0d4..15cb211db 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt @@ -132,7 +132,9 @@ import androidx.compose.material.icons.outlined.Album import com.theveloper.pixelplay.utils.shapes.RoundedStarShape import androidx.compose.material.icons.outlined.MusicNote import androidx.compose.material.icons.outlined.PlayCircleOutline +import com.theveloper.pixelplay.MainActivity.Companion.LocalHazeState import com.theveloper.pixelplay.ui.theme.ExpTitleTypography +import dev.chrisbanes.haze.hazeSource private const val PULL_TO_REFRESH_MIN_DURATION_MS = 3500L @@ -275,7 +277,8 @@ fun StatsScreen( LazyColumn( state = lazyListState, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .hazeSource(LocalHazeState.current), contentPadding = PaddingValues( top = currentTopBarHeightDp + tabsHeight + tabIndicatorExtraSpacing + tabContentSpacing + 0.dp, bottom = MiniPlayerHeight + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt index 1ef56cb28..d8b34e532 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt @@ -94,6 +94,37 @@ class PlaybackStateHolder @Inject constructor( _sliderUiMounted.value = mounted } + // 用于节流的时间戳 + private var lastAmplitudeUpdateTimeMs = 0L + + /* -------------------------------------------------------------------------- */ + /* Audio EQ Visualizer */ + /* -------------------------------------------------------------------------- */ + + /** + * 高频调用的音频振幅更新通道。 + * 注意:此方法会被 ExoPlayer 的后台音频线程高频调用,绝对不能包含任何 IPC + * 或复杂的耗时逻辑! + */ + fun updateAudioAmplitude(amplitude: Float) { + val nowMs = SystemClock.elapsedRealtime() + + // 节流:限制更新频率约为 50ms 一次 (20 FPS),避免 StateFlow 频繁发射导致 UI 掉帧 + if (nowMs - lastAmplitudeUpdateTimeMs >= 50L) { + lastAmplitudeUpdateTimeMs = nowMs + + // 直接操作底层的 _stablePlayerState,避开 updateStablePlayerState 中的耗时逻辑 + _stablePlayerState.update { current -> + // 只有当振幅发生有意义的改变时才更新,避免无意义的重组 + if (current.audioAmplitude != amplitude) { + current.copy(audioAmplitude = amplitude) + } else { + current + } + } + } + } + // Internal State private var isSeeking = false private var remoteSeekUnlockJob: Job? = null @@ -670,11 +701,13 @@ class PlaybackStateHolder @Inject constructor( } _stablePlayerState.update { state -> + val finalAmplitude = if (isRemotePlaying || mediaController?.isPlaying == true) state.audioAmplitude else 0f if ( state.totalDuration == duration && state.isPlaying == isRemotePlaying && state.playWhenReady == remotePlayWhenReady && - state.isBuffering == (remotePlayback?.isBuffering ?: false) + state.isBuffering == (remotePlayback?.isBuffering ?: false) && + state.audioAmplitude == finalAmplitude ) { state } else { @@ -682,7 +715,8 @@ class PlaybackStateHolder @Inject constructor( totalDuration = duration, isPlaying = isRemotePlaying, playWhenReady = remotePlayWhenReady, - isBuffering = remotePlayback?.isBuffering ?: false + isBuffering = remotePlayback?.isBuffering ?: false, + audioAmplitude = finalAmplitude, ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StablePlayerState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StablePlayerState.kt index d93cf924c..d76010cb6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StablePlayerState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StablePlayerState.kt @@ -17,5 +17,6 @@ data class StablePlayerState( val repeatMode: Int = Player.REPEAT_MODE_OFF, val isLoadingLyrics: Boolean = false, val lyrics: Lyrics? = null, - val isBuffering: Boolean = false + val isBuffering: Boolean = false, + val audioAmplitude: Float = 0f ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 39fc3e17e..36ec9e9f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -150,6 +150,8 @@ androidx-media3-exoplayer-midi = { module = "androidx.media3:media3-exoplayer-mi androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Session" } androidx-media3-transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3Transformer" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Session" } +androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3Session" } +androidx-media3-common-ktx = { module = "androidx.media3:media3-common-ktx", version.ref = "media3Session" } androidx-media3-exoplayer-ffmpeg = { module = "org.jellyfin.media3:media3-ffmpeg-decoder", version = "1.9.0+1" } androidx-media-router = { module = "androidx.mediarouter:mediarouter", version.ref = "mediaRouter" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } From e8fa3c310b384b84783fa2d9e0948d88709f83e9 Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 21 Jun 2026 11:03:32 +0800 Subject: [PATCH 3/3] fix(ui): resolve EQ visualizer freeze and optimize rendering - AudioRmsSink: Introduce exponential decay for `maxRms` to prevent the visualizer from getting stuck after loud volume spikes. - AudioRmsSink: Add support for parsing 32-bit Float PCM encoding to fix amplitude calculation in Hi-Fi mode. - PlayingEqIconV2: Remove redundant `derivedStateOf` and `remember` wrappers around high-frequency data classes to eliminate recomposition overhead and animation stutters. --- .../pixelplay/data/media/AudioRmsSink.kt | 62 +++++++++++++------ .../components/subcomps/PlayingEqIconV2.kt | 7 +-- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/media/AudioRmsSink.kt b/app/src/main/java/com/theveloper/pixelplay/data/media/AudioRmsSink.kt index a3640399c..0e6dcdb5b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/media/AudioRmsSink.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/media/AudioRmsSink.kt @@ -1,6 +1,6 @@ package com.theveloper.pixelplay.data.media -import androidx.media3.common.audio.AudioProcessor.AudioFormat +import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.audio.TeeAudioProcessor import java.nio.ByteBuffer @@ -11,39 +11,65 @@ class AudioRmsSink( private val onRmsChanged: (Float) -> Unit ) : TeeAudioProcessor.AudioBufferSink { - private var maxRms = 0f // 用于归一化 + // 给一个合理的初始底噪下限,防止刚开始静音时被无限放大 + private var maxRms = 1000f + private var currentEncoding = C.ENCODING_PCM_16BIT override fun flush(sampleRateHz: Int, channelCount: Int, encoding: Int) { - // 重置状态 - maxRms = 0f + currentEncoding = encoding + maxRms = 1000f onRmsChanged(0f) } override fun handleBuffer(buffer: ByteBuffer) { if (!buffer.hasRemaining()) return - // 假设标准的 16-bit PCM 编码 - val shortBuffer = buffer.asShortBuffer() var sumSquares = 0.0 - val sampleCount = shortBuffer.remaining() + var sampleCount = 0 - if (sampleCount == 0) return - - while (shortBuffer.hasRemaining()) { - val sample = shortBuffer.get().toDouble() - sumSquares += sample * sample + when (currentEncoding) { + C.ENCODING_PCM_16BIT -> { + val shortBuffer = buffer.asShortBuffer() + sampleCount = shortBuffer.remaining() + if (sampleCount == 0) return + while (shortBuffer.hasRemaining()) { + val sample = shortBuffer.get().toDouble() + sumSquares += sample * sample + } + } + C.ENCODING_PCM_FLOAT -> { + val floatBuffer = buffer.asFloatBuffer() + sampleCount = floatBuffer.remaining() + if (sampleCount == 0) return + while (floatBuffer.hasRemaining()) { + val sample = floatBuffer.get().toDouble() + // Float 范围是 -1.0 到 1.0,乘以 32768 对齐到 16-bit 级别,保证计算口径统一 + val scaled = sample * 32768.0 + sumSquares += scaled * scaled + } + } + else -> return // 其他非常规编码直接忽略 } - // 计算均方根 (RMS) + if (sampleCount == 0) return + val rms = sqrt(sumSquares / sampleCount).toFloat() - // 动态调整最大基准值以适应不同音量的歌曲 - if (rms > maxRms) maxRms = rms + // 【核心修复】不仅要记录最大值,还要让它缓慢衰减 + if (rms > maxRms) { + maxRms = rms + } else { + // 每次缓冲平滑衰减,使其能适应接下来的低潮片段 + maxRms *= 0.995f + } + + // 钳制最低基准,防止将纯静音里的微弱底噪放大成强烈的跳动 + maxRms = maxRms.coerceAtLeast(1000f) - // 归一化到 0.0f ~ 1.0f 之间 - val normalizedRms = if (maxRms > 0) (rms / maxRms).coerceIn(0f, 1f) else 0f + // 归一化,并加入一个极小的死区(低于 5% 视作静音停止跳动) + var normalizedRms = (rms / maxRms).coerceIn(0f, 1f) + if (normalizedRms < 0.05f) normalizedRms = 0f - // 将数据回调出去 onRmsChanged(normalizedRms) } } \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIconV2.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIconV2.kt index 821b25527..97abd963a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIconV2.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIconV2.kt @@ -31,14 +31,11 @@ fun PlayingEqIconV2( val stablePlayerState by stablePlayerStateFlow.collectAsStateWithLifecycle() - val audioAmplitude by remember(stablePlayerState) { - derivedStateOf { stablePlayerState.audioAmplitude } - } - + val audioAmplitude = stablePlayerState.audioAmplitude // 使用 tween 加快反应速度,使动画紧跟音乐节奏,同时保持一定的平滑过渡 val animatedAmp by animateFloatAsState( targetValue = if (isPlaying) audioAmplitude else 0.1f, - animationSpec = tween(durationMillis = 60), + animationSpec = tween(durationMillis = 80), label = "eq_amplitude" )