Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -1095,6 +1096,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 @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
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