Skip to content
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -298,6 +300,8 @@ dependencies {
exclude(group = "androidx.compose.runtime")
exclude(group = "androidx.compose.ui")
}
implementation(libs.haze)
implementation(libs.haze.materials)

// Projects
implementation(project(":shared"))
Expand Down
89 changes: 74 additions & 15 deletions app/src/main/java/com/theveloper/pixelplay/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -193,6 +203,12 @@ class MainActivity : ComponentActivity() {
// Handle the result in onResume
}

companion object {
val LocalHazeState = staticCompositionLocalOf<HazeState> {
error("No HazeState provided")
}
}

@CallSuper
override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(AppLocaleManager.wrapContext(newBase))
Expand Down Expand Up @@ -306,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) {
Expand Down Expand Up @@ -681,7 +699,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()
)
Expand Down Expand Up @@ -774,7 +792,8 @@ class MainActivity : ComponentActivity() {

CompositionLocalProvider(
LocalAppHapticsConfig provides appHapticsConfig,
LocalHapticFeedback provides scopedHapticFeedback
LocalHapticFeedback provides scopedHapticFeedback,
LocalHazeState provides hazeState
) {
AppSidebarDrawer(
drawerState = drawerState,
Expand Down Expand Up @@ -857,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)
Expand All @@ -872,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
},
Expand All @@ -902,7 +945,12 @@ class MainActivity : ComponentActivity() {
compactMode = navBarCompactMode,
bottomBarPadding = bottomBarPadding,
onSearchIconDoubleTap = onSearchIconDoubleTap,
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxSize()
.hazeEffect(
state = LocalHazeState.current,
style = HazeMaterials.ultraThin()
)
)
}
}
Expand Down Expand Up @@ -948,17 +996,23 @@ class MainActivity : ComponentActivity() {
Box(
modifier = Modifier
.fillMaxSize()
// .hazeSource(hazeState)
.graphicsLayer {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (disableBlurAllOver) {
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)
}
}
Expand Down Expand Up @@ -1008,7 +1062,8 @@ class MainActivity : ComponentActivity() {
hideMiniPlayer = shouldHideMiniPlayer,
containerHeight = containerHeight,
navController = navController,
isNavBarHidden = isNavBarEffectivelyHidden
isNavBarHidden = isNavBarEffectivelyHidden,
hazeState = LocalHazeState.current
)

val dismissUndoBarSlice by remember {
Expand Down Expand Up @@ -1041,7 +1096,11 @@ 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.theveloper.pixelplay.data.media

import androidx.media3.common.C
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 = 1000f
private var currentEncoding = C.ENCODING_PCM_16BIT

override fun flush(sampleRateHz: Int, channelCount: Int, encoding: Int) {
currentEncoding = encoding
maxRms = 1000f
onRmsChanged(0f)
}

override fun handleBuffer(buffer: ByteBuffer) {
if (!buffer.hasRemaining()) return

var sumSquares = 0.0
var sampleCount = 0

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 // 其他非常规编码直接忽略
}

if (sampleCount == 0) return

val rms = sqrt(sumSquares / sampleCount).toFloat()

// 【核心修复】不仅要记录最大值,还要让它缓慢衰减
if (rms > maxRms) {
maxRms = rms
} else {
// 每次缓冲平滑衰减,使其能适应接下来的低潮片段
maxRms *= 0.995f
}

// 钳制最低基准,防止将纯静音里的微弱底噪放大成强烈的跳动
maxRms = maxRms.coerceAtLeast(1000f)

// 归一化,并加入一个极小的死区(低于 5% 视作静音停止跳动)
var normalizedRms = (rms / maxRms).coerceIn(0f, 1f)
if (normalizedRms < 0.05f) normalizedRms = 0f

onRmsChanged(normalizedRms)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -1113,6 +1114,13 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) {
dataStore.edit { it[PreferencesKeys.IMMERSIVE_LYRICS_TIMEOUT] = timeout }
}

val controlsButtonEnabledFlow: Flow<Boolean> =
pref { it[PreferencesKeys.CONTROLS_BUTTONS_ENABLED] ?: true }

suspend fun setControlsButtonEnabled(enabled: Boolean) {
dataStore.edit { it[PreferencesKeys.CONTROLS_BUTTONS_ENABLED] = enabled }
}

val useAnimatedLyricsFlow: Flow<Boolean> =
pref { it[PreferencesKeys.USE_ANIMATED_LYRICS] ?: false }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,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

Expand Down Expand Up @@ -144,6 +145,8 @@ class MusicService : MediaLibraryService() {
@Inject
lateinit var engine: DualPlayerEngine
@Inject
lateinit var playbackStateHolder: PlaybackStateHolder
@Inject
lateinit var controller: TransitionController
@Inject
lateinit var musicRepository: MusicRepository
Expand Down Expand Up @@ -437,6 +440,10 @@ class MusicService : MediaLibraryService() {
registerSystemVolumeObserver()

// Handle player swaps (crossfade) to keep MediaSession in sync
engine.setOnAmplitudeUpdateListener { amplitude ->
playbackStateHolder.updateAudioAmplitude(amplitude)
}

engine.setOnPlayerAboutToBeReleasedListener { oldPlayer ->
oldPlayer.removeListener(playerListener)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 -> {
Expand Down Expand Up @@ -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,
Expand All @@ -1049,6 +1063,7 @@ class DualPlayerEngine @Inject constructor(
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
.setAudioProcessorChain(
DefaultAudioSink.DefaultAudioProcessorChain(
teeAudioProcessor,
HiResSampleRateCapAudioProcessor(),
SurroundDownmixProcessor()
)
Expand Down
Loading