Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5cfca74
fix(player): suppress STATE_ENDED during track transitions (#2022)
maxrave-dev May 26, 2026
86343bf
refactor: migrate metadata API to official OAuth2 flow
maxrave-dev May 27, 2026
fa419c9
fix(player): improve auto crossfade duration logic and suppress durin…
maxrave-dev May 27, 2026
77ec749
feat(desktop): port auto crossfade duration with gap factors, fix VLC…
maxrave-dev May 27, 2026
7471337
feat(ui): apply Apple Music redesign to Local Playlist screen on Andr…
maxrave-dev Jun 1, 2026
0b72f85
feat: upgrade version
maxrave-dev Jun 2, 2026
a22cd99
fix(scraper): bump core for YouTube API fixes and curl logger
maxrave-dev Jun 2, 2026
c21be9a
fix(deps): align lifecycle 2.11.0-beta01 with Compose Multiplatform 1…
maxrave-dev Jun 3, 2026
4cd1331
fix(build): force generateResClass to always for the app module
maxrave-dev Jun 3, 2026
0f87894
fix(desktop): run single-instance check before startKoin to avoid Dat…
maxrave-dev Jun 3, 2026
dc9a9ed
feat(windows): reinstall offline installer in place without manual un…
maxrave-dev Jun 3, 2026
904acb4
feat(scraper): bump core for TIDAL remote credential config
maxrave-dev Jun 4, 2026
04c62b0
feat(ui): liquid glass components + Apple Music-style sliding tab bar…
maxrave-dev Jun 4, 2026
ed52409
fix(ci): make mac DMG wrapping resilient to the SimpMusic volume race
maxrave-dev Jun 4, 2026
04835db
feat(ui): liquid glass press interaction and bottom-nav reselect fix
maxrave-dev Jun 4, 2026
1960405
feat: show podcast shows on home and open podcast screen
maxrave-dev Jun 4, 2026
9c18b58
fix(ui): tune liquid glass press bulge, blur and per-surface scale
maxrave-dev Jun 5, 2026
c117c9a
chore: disable jetifier
maxrave-dev Jun 5, 2026
827d105
refactor: display chart item name from API
maxrave-dev Jun 5, 2026
53f9762
feat(version): bump version
maxrave-dev Jun 6, 2026
c3b9f0e
fix(desktop): pin ProGuard 7.8.1 to read Java 25 bytecode (PipePipeEx…
maxrave-dev Jun 6, 2026
4a89380
fix(desktop): bundle org.json (20231013) for PipePipe on JVM and keep…
maxrave-dev Jun 6, 2026
8b73ee0
fix(desktop): restore desktop release build (PipePipe c3139c584d, org…
maxrave-dev Jun 6, 2026
f7b4f29
fix(spotify): accept any locale in login success URL
maxrave-dev Jun 7, 2026
079e08c
chore: update v1.4.0 changelog (52.txt)
maxrave-dev Jun 7, 2026
cc18524
fix(player): bump core for playback resume on stream retry
maxrave-dev Jun 7, 2026
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
8 changes: 8 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ plugins {
alias(libs.plugins.vlc.setup)
}

// composeApp uses the `android.kotlin.multiplatform.library` plugin, so with the
// default `generateResClass = auto` Compose skips generating the `Res` class
// (it treats a KMP *library* module as not owning the public resource class).
// Force it to `always` so `Res` is generated for this app module.
compose.resources {
generateResClass = always
}

kotlin {
compilerOptions {
freeCompilerArgs.add("-Xwhen-guards")
Expand Down
11 changes: 11 additions & 0 deletions composeApp/proguard-desktop-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@
-keep class com.grack.nanojson.** { *; }
-dontwarn com.grack.nanojson.**

# org.json (JSON-Java): Android-provided, added as an explicit JVM-desktop dependency because
# PipePipeExtractor references org.json.* (comment/stream extractors). Keep it.
-keep class org.json.** { *; }

# Brave bundles BitChute / json2java4nanojson model classes referenced by extractor constructors
# kept via `-keep class org.schabi.newpipe.extractor.** { *; }`. Without explicit keeps, proguard
# can't resolve the descriptor types and aborts with "unresolved reference" warnings.
Expand All @@ -332,4 +336,11 @@
# signatures (no newer versions available yet). Suppress so ProGuard does not
# abort. Runtime risk: NoSuchMethodError if affected code paths are hit.
-dontwarn dev.chrisbanes.haze.**
-keep class io.github.alexzhirkevich.compottie.** { *; }
# Compottie's skiko shader helper references org.jetbrains.skia.GradientStyle, removed in the current
# Skiko. Class is gone (can't be kept/added), so suppress the unresolved-reference warning.
-dontwarn io.github.alexzhirkevich.compottie.**

# JNA references the signature-polymorphic java.lang.invoke.MethodHandle.invoke(...) overloads, which
# ProGuard can't resolve as concrete methods. JNA itself is kept above; suppress these warnings.
-dontwarn com.sun.jna.**
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,10 @@ actual fun Modifier.drawBackdropCustomShape(
val l = (luminanceAnimation * 2f - 1f).let { sign(it) * it * it }
vibrancy()
colorControls(
brightness =
if (l > 0f) {
lerp(0.1f, 0.5f, l)
} else {
lerp(0.1f, -0.2f, -l)
},
contrast =
if (l > 0f) {
lerp(1f, 0f, l)
} else {
1f
},
// Neutral brightness/contrast: the old curve washed the glass out to white on bright
// backgrounds ("đục trắng"). Darkening is done in onDrawSurface (keeps "đục đen").
brightness = 0.05f,
contrast = 1f,
saturation = 1.5f,
)
blur(
Expand All @@ -67,6 +59,9 @@ actual fun Modifier.drawBackdropCustomShape(
layer.record { drawBackdrop() }
},
shape = { shape },
onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.1f)) }
onDrawSurface = {
val darken = lerp(0.12f, 0.5f, ((luminanceAnimation - 0.3f) / 0.5f).coerceIn(0f, 1f))
drawRect(Color.Black.copy(alpha = darken))
},
)
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
package com.maxrave.simpmusic.ui.component

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastFirstOrNull
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.colorControls
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.maxrave.simpmusic.expect.ui.PlatformBackdrop
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.sign

@Composable
actual fun Modifier.liquidGlass(
backdrop: PlatformBackdrop,
shape: Shape,
interactive: Boolean,
): Modifier {
val layer = rememberGraphicsLayer()
val interaction = rememberGlassInteraction()
return this.drawInteractiveGlass(
backdrop = backdrop,
layer = layer,
luminanceAnimation = 0.5f,
shape = shape,
interaction = if (interactive) interaction else null,
)
}

@Composable
actual fun Modifier.liquidGlass(
backdrop: PlatformBackdrop,
layer: GraphicsLayer,
luminanceAnimation: Float,
shape: Shape,
interactive: Boolean,
): Modifier {
val interaction = rememberGlassInteraction()
return this.drawInteractiveGlass(
backdrop = backdrop,
layer = layer,
luminanceAnimation = luminanceAnimation,
shape = shape,
interaction = if (interactive) interaction else null,
// MiniPlayer (the only caller of this layer + luminance overload) is a wide surface, so the
// shared 1.12 press scale bulges too hard; use a gentler scale here.
pressedScale = 1.04f,
)
}

/**
* Press/hold state holder for a single liquid-glass surface.
*
* Project-local, dependency-light reimplementation of Kyant's catalog
* `InteractiveHighlight`: instead of the library-internal AGSL highlight shader
* (whose public helpers are not exposed in backdrop 2.0.0-alpha03) we drive a
* plain [Brush.radialGradient] from [touchPosition] and a spring-animated
* [pressProgress]. The drag detection is observe-only so wrapped buttons keep
* receiving their own clicks.
*/
class GlassInteraction(
private val animationScope: CoroutineScope,
) {
private val pressSpec = spring(dampingRatio = 0.5f, stiffness = 300f, visibilityThreshold = 0.001f)
private val pressAnimation = Animatable(0f, 0.001f)

/** 0f at rest, animating to 1f while pressed. Read in draw/effect/layer blocks. */
val pressProgress: Float get() = pressAnimation.value

/** Local-space touch point used as the centre of the press glow. */
var touchPosition by mutableStateOf(Offset.Zero)
private set

suspend fun detectPress(pointer: PointerInputScope) =
with(pointer) {
inspectDragGestures(
onDragStart = { down ->
touchPosition = down.position
animationScope.launch { pressAnimation.animateTo(1f, pressSpec) }
},
onDragEnd = { animationScope.launch { pressAnimation.animateTo(0f, pressSpec) } },
onDragCancel = { animationScope.launch { pressAnimation.animateTo(0f, pressSpec) } },
) { change, _ ->
touchPosition = change.position
}
}
}

@Composable
fun rememberGlassInteraction(): GlassInteraction {
val scope = rememberCoroutineScope()
return remember(scope) { GlassInteraction(scope) }
}

/**
* Draws the liquid-glass effect with the same look as the legacy
* `drawBackdropCustomShape`, plus an optional press response driven by
* [interaction]: the surface scales up a touch, the refraction/blur deepen and a
* radial glow follows the finger. Pass `interaction = null` for a static surface.
*
* [luminanceAnimation] keeps the brightness/contrast curve of the original
* wrapper (the bottom navigation bar animates it; static surfaces pass `0.5f`).
*/
fun Modifier.drawInteractiveGlass(
backdrop: LayerBackdrop,
layer: GraphicsLayer,
luminanceAnimation: Float,
shape: Shape,
interaction: GlassInteraction?,
pressedScale: Float = 1.12f,
): Modifier =
this
.drawBackdrop(
backdrop = backdrop,
shape = { shape },
effects = {
val l = (luminanceAnimation * 2f - 1f).let { sign(it) * it * it }
val press = interaction?.pressProgress ?: 0f
vibrancy()
colorControls(
// Neutral brightness/contrast: the old curve brightened + washed the glass out
// to white on bright backgrounds ("đục trắng"). Darkening is done in onDrawSurface.
brightness = 0.05f,
contrast = 1f,
saturation = 1.5f,
)
blur(
(
if (l > 0f) {
lerp(8f.dp.toPx(), 16f.dp.toPx(), l)
} else {
lerp(8f.dp.toPx(), 2f.dp.toPx(), -l)
}
) + 2f.dp.toPx() * press,
)
// refractionHeight stays below the stadium inradius (minDimension / 2) so the
// top and bottom refraction never meet at the medial axis — that meeting point on
// a wide pill is what produced the dark horizontal seam. depthEffect is off to
// match the crisp Kyant demo look and avoid the radial discontinuity at the centre.
lens(size.minDimension / 4f + 2f.dp.toPx() * press, size.minDimension / 2f, false)
},
onDrawBackdrop = { drawBackdrop ->
drawBackdrop()
layer.record { drawBackdrop() }
},
onDrawSurface = {
// Stay "đục đen": darken more as the background brightens so the glass never washes
// out to white (shared by the bottom bar capsule, search FAB and detail-screen pills).
val darken = lerp(0.12f, 0.5f, ((luminanceAnimation - 0.3f) / 0.5f).coerceIn(0f, 1f))
drawRect(Color.Black.copy(alpha = darken))
val press = interaction?.pressProgress ?: 0f
if (press > 0f) {
drawRect(
brush =
Brush.radialGradient(
colors =
listOf(
Color.White.copy(alpha = 0.18f * press),
Color.Transparent,
),
center = interaction?.touchPosition ?: Offset(size.width / 2f, size.height / 2f),
radius = size.minDimension * 1.2f,
),
blendMode = BlendMode.Plus,
)
}
},
layerBlock =
if (interaction != null) {
{
val scale = lerp(1f, pressedScale, interaction.pressProgress)
scaleX = scale
scaleY = scale
}
} else {
null
},
).then(
if (interaction != null) {
Modifier.pointerInput(interaction) { interaction.detectPress(this) }
} else {
Modifier
},
)

/**
* Observe-only drag/press recogniser ported from Kyant's catalog
* `DragGestureInspector`. It never consumes events, so a glass surface can react
* to a press while the buttons it wraps still handle their own taps.
*/
internal suspend fun PointerInputScope.inspectDragGestures(
onDragStart: (down: PointerInputChange) -> Unit = {},
onDragEnd: (change: PointerInputChange) -> Unit = {},
onDragCancel: () -> Unit = {},
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)

val down = awaitFirstDown(requireUnconsumed = false)

onDragStart(down)
onDrag(down, Offset.Zero)
val upEvent =
drag(
pointerId = down.id,
onDrag = { onDrag(it, it.positionChange()) },
)
if (upEvent == null) {
onDragCancel()
} else {
onDragEnd(upEvent)
}
}
}

private suspend inline fun AwaitPointerEventScope.drag(
pointerId: PointerId,
onDrag: (PointerInputChange) -> Unit,
): PointerInputChange? {
val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true
if (isPointerUp) {
return null
}
var pointer = pointerId
while (true) {
val change = awaitDragOrUp(pointer) ?: return null
if (change.isConsumed) {
return null
}
if (change.changedToUpIgnoreConsumed()) {
return change
}
onDrag(change)
pointer = change.id
}
}

private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
pointerId: PointerId,
): PointerInputChange? {
var pointer = pointerId
while (true) {
val event = awaitPointerEvent()
val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
if (dragEvent.changedToUpIgnoreConsumed()) {
val otherDown = event.changes.fastFirstOrNull { it.pressed }
if (otherDown == null) {
return dragEvent
} else {
pointer = otherDown.id
}
} else {
val hasDragged = dragEvent.previousPosition != dragEvent.position
if (hasDragged) {
return dragEvent
}
}
}
}
Loading
Loading