Skip to content
Closed
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
14 changes: 13 additions & 1 deletion app/src/main/kotlin/moe/rukamori/archivetune/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ import moe.rukamori.archivetune.ui.component.StarDialog
import moe.rukamori.archivetune.ui.component.TopAppBarOverflowMenu
import moe.rukamori.archivetune.ui.component.TopSearch
import moe.rukamori.archivetune.ui.component.TvNavigationRail
import moe.rukamori.archivetune.ui.component.backdropSnapshotSource
import moe.rukamori.archivetune.ui.component.rememberBackdropSnapshot
import moe.rukamori.archivetune.ui.component.rememberBottomSheetState
import moe.rukamori.archivetune.ui.component.shimmer.ShimmerTheme
import moe.rukamori.archivetune.ui.menu.YouTubeSongMenu
Expand Down Expand Up @@ -1010,6 +1012,12 @@ class MainActivity : ComponentActivity() {
val menuAccountImageUrl by homeViewModel.accountImageUrl.collectAsStateWithLifecycle()
val menuHazeState = rememberHazeState()
val floatingBarsHazeState = rememberHazeState()
val floatingBarsBackdrop = rememberBackdropSnapshot(blurRadius = 10f)
val menuBackdrop = rememberBackdropSnapshot(blurRadius = 18f)
LaunchedEffect(Unit) { floatingBarsBackdrop.active.value = true }
LaunchedEffect(topAppBarOverflowExpanded) {
menuBackdrop.active.value = topAppBarOverflowExpanded
}
val navigationBarBackgroundStyle by rememberEnumPreference(
key = NavigationBarBackgroundStyleKey,
defaultValue = NavigationBarBackgroundStyle.THEME,
Expand Down Expand Up @@ -2026,6 +2034,7 @@ class MainActivity : ComponentActivity() {
pureBlack = pureBlack,
hasUnreadNews = hasUnreadNews,
hasUpdateAvailable = hasUpdateAvailable,
backdrop = menuBackdrop,
onDismissRequest = mainMenuViewModel::dismiss,
onSelect = { destination ->
newsTooltipState.dismiss()
Expand Down Expand Up @@ -2342,6 +2351,7 @@ class MainActivity : ComponentActivity() {
pureBlack = pureBlack,
backgroundStyle = navigationBarBackgroundStyle,
hazeState = floatingBarsHazeState,
backdrop = floatingBarsBackdrop,
miniPlayerProximityProvider = navigationProximityProvider,
navigationBarMode = navigationBarMode,
modifier =
Expand Down Expand Up @@ -2498,7 +2508,8 @@ class MainActivity : ComponentActivity() {
}
}
},
modifier = Modifier.fillMaxSize().hazeSource(menuHazeState),
modifier = Modifier.fillMaxSize().hazeSource(menuHazeState)
.backdropSnapshotSource(menuBackdrop),
) {
var transitionDirection =
AnimatedContentTransitionScope.SlideDirection.Left
Expand Down Expand Up @@ -2621,6 +2632,7 @@ class MainActivity : ComponentActivity() {
modifier =
Modifier
.hazeSource(floatingBarsHazeState)
.backdropSnapshotSource(floatingBarsBackdrop)
.then(
if (isTvDevice) {
Modifier
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* ArchiveTune (2026)
* © Rukamori — github.com/rukamori
* GPL-3.0 License | Contributors: see git history
* Do not remove or alter this notice. - Per GPL-3.0 Section 4 & Section 5
*/

package moe.rukamori.archivetune.ui.component

import android.graphics.Bitmap
import android.os.Build
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlin.math.max
import moe.rukamori.archivetune.utils.ImageBlurUtils

// Longest side of a stored snapshot. Keeps read-back and stack blur cheap on the
// older (API < 31) devices this is for; the result is blurred anyway.
private const val SnapshotMaxSize = 480
private const val IdlePollMs = 100L

/**
* Holds a downscaled, stack-blurred snapshot of backdrop content. Used to fake a
* backdrop blur for the floating bars and the overflow menu below API 31, where
* RenderEffect/Haze cannot blur live content.
*/
class BackdropSnapshot(
internal val blurRadius: Float,
internal val intervalMs: Long,
) {
/** Gate for the capture loop; consumers toggle it (e.g. only while a menu is open). */
val active = mutableStateOf(false)

var image: ImageBitmap? by mutableStateOf(null)
internal set
}

@Composable
fun rememberBackdropSnapshot(
blurRadius: Float,
captureIntervalMs: Long = 400L,
): BackdropSnapshot = remember { BackdropSnapshot(blurRadius, captureIntervalMs) }

/**
* Records this node's content into a graphics layer and periodically reads it back,
* downscales it and stack-blurs it into [holder]. No-op on API >= 31 so the modern
* Haze/RenderEffect path keeps full fidelity and zero overhead.
*/
@Composable
fun Modifier.backdropSnapshotSource(holder: BackdropSnapshot): Modifier {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return this

val layer = rememberGraphicsLayer()
LaunchedEffect(holder, layer) {
while (isActive) {
if (holder.active.value) {
val status = holder.capture(layer)
Log.d(TAG, "backdrop capture (${holder.blurRadius}px): $status")
delay(holder.intervalMs)
} else {
delay(IdlePollMs)
}
}
}
return drawWithContent {
layer.record { this@drawWithContent.drawContent() }
drawLayer(layer)
}
}

private const val TAG = "BackdropSnap"

private suspend fun BackdropSnapshot.capture(layer: GraphicsLayer): String {
val snapshot =
try {
layer.toImageBitmap()
} catch (e: Exception) {
return "toImageBitmap failed: $e"
}
if (snapshot.width == 0 || snapshot.height == 0) {
return "empty bitmap ${snapshot.width}x${snapshot.height}"
}
return withContext(Dispatchers.Default) {
val src = snapshot.asAndroidBitmap()
// toImageBitmap() yields a HARDWARE bitmap, which forbids pixel access;
// materialize a software copy before downscaling/stack-blurring.
val software =
if (src.config == Bitmap.Config.HARDWARE) {
src.copy(Bitmap.Config.ARGB_8888, false)
?: return@withContext "hardware->software copy failed"
} else {
src
}
val maxDim = max(software.width, software.height)
val small =
if (maxDim > SnapshotMaxSize) {
val f = SnapshotMaxSize.toFloat() / maxDim
Bitmap.createScaledBitmap(
software,
(software.width * f).toInt().coerceAtLeast(1),
(software.height * f).toInt().coerceAtLeast(1),
true,
)
} else {
software
}
// Sample the downscaled capture so the log shows whether real content was captured.
val sample = IntArray(4)
val n = minOf(small.width, sample.size)
small.getPixels(sample, 0, n, 0, 0, n, 1)
val alphas = sample.joinToString(",") { ((it ushr 24) and 0xFF).toString() }
val blurred = ImageBlurUtils.blur(small, blurRadius).asImageBitmap()
image = blurred
"ok ${small.width}x${small.height} (src=${src.config}) alpha[0..$n]=[$alphas] -> ${blurred.width}x${blurred.height}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,18 @@

package moe.rukamori.archivetune.ui.component

import android.os.Build
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeInput
import dev.chrisbanes.haze.HazeState
Expand All @@ -26,8 +32,30 @@ fun FloatingBarBlurBackground(
isAcrylic: Boolean,
pureBlack: Boolean,
modifier: Modifier = Modifier,
backdrop: BackdropSnapshot? = null,
) {
val surfaceColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainerHigh
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && backdrop != null) {
// Below API 31 Haze cannot blur live content; approximate it with a
// stack-blurred snapshot of what is behind the bar.
val snapshot = backdrop.image
Box(modifier) {
if (snapshot != null) {
Image(
bitmap = snapshot,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.BottomCenter,
modifier = Modifier.fillMaxSize(),
)
}
Box(
Modifier.fillMaxSize()
.background(surfaceColor.copy(alpha = if (isAcrylic) 0.72f else 0.62f)),
)
}
return
}
val hazeInput = remember(hazeState) { HazeInput.Sources(hazeState) }
val blurStyle = remember(isAcrylic, surfaceColor) {
HazeBlurStyle {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ fun FloatingNavigationToolbar(
backgroundStyle: NavigationBarBackgroundStyle,
hazeState: HazeState,
modifier: Modifier = Modifier,
backdrop: BackdropSnapshot? = null,
miniPlayerProximityProvider: () -> Float = { 0f },
navigationBarMode: NavigationBarMode = NavigationBarMode.CONNECTED,
isSelected: (Screens) -> Boolean,
Expand Down Expand Up @@ -124,6 +125,7 @@ fun FloatingNavigationToolbar(
isAcrylic = backgroundStyle == NavigationBarBackgroundStyle.ACRYLIC,
pureBlack = pureBlack,
modifier = Modifier.fillMaxSize(),
backdrop = backdrop,
)
}
Row(
Expand Down
Loading
Loading