Android: library background = static XMB still, drop the MP4 (perf)

The looping MP4 wallpaper hurt in-library performance (sbro review). Replace it with a bundled static XMB-wave still (R.drawable.library_bg_xmb, drawn edge-to-edge via ArmsBackdrop's backgroundLayer). Remove VideoBackground.kt, res/raw/xmb_wave.mp4, and the video option from the background picker.
This commit is contained in:
jpolo1224
2026-07-12 08:34:35 -04:00
parent c3b2d91f5e
commit 4a52347407
5 changed files with 20 additions and 140 deletions
@@ -6,8 +6,6 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import android.net.Uri
import androidx.compose.runtime.key
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -160,26 +158,23 @@ fun HomeScreen(
CompositionLocalProvider(LocalCustomCoverMap provides customCoverMap) {
ArmsBackdrop(
// Full-bleed wallpaper: the library video/image + readability scrim, drawn
// edge-to-edge (behind the gesture bar) so it never leaves an exposed strip at
// the bottom — that strip was the "blue bar" in landscape.
// Full-bleed wallpaper: the library image + readability scrim, drawn edge-to-edge
// (behind the gesture bar) so it never leaves an exposed strip at the bottom —
// that strip was the "blue bar" in landscape.
backgroundLayer = {
val libraryBg = LibraryBackground.uri.value
val bgVideoUri = when {
// Default (no user pick): the bundled PS3 XMB-wave video background.
libraryBg == null -> defaultBackgroundVideoUri(context)
// User picked a video file — play it as a moving background.
isVideoBackground(context, libraryBg) -> Uri.parse(libraryBg)
// User picked a still image / GIF — Coil handles it below.
else -> null
}
if (bgVideoUri != null) {
// key() so swapping the URI rebuilds the player (the AndroidView
// factory only runs once).
key(bgVideoUri.toString()) {
VideoBackground(bgVideoUri, Modifier.fillMaxSize())
}
if (libraryBg == null) {
// Default: the bundled PS3 XMB-wave STILL. It used to be a looping MP4,
// but the continuous video decode cost in-library performance (sbro
// review), so it's a static image now.
Image(
painter = painterResource(R.drawable.library_bg_xmb),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
// User-picked still image / GIF (Coil handles both).
AsyncImage(
model = ImageRequest.Builder(context).data(libraryBg).crossfade(true).build(),
contentDescription = null,
@@ -365,7 +360,7 @@ fun HomeScreen(
onOpenNavigation = onOpenMenu,
onSort = viewModel::setSort,
onToggleCoverStyle = { CoverArtStyle.set(!CoverArtStyle.use3d.value) },
onChooseBackground = { backgroundPicker.launch(arrayOf("image/*", "video/*")) },
onChooseBackground = { backgroundPicker.launch(arrayOf("image/*")) },
onClearBackground = LibraryBackground::clear,
)
}
@@ -8,10 +8,11 @@ import com.armsx2.runtime.MainActivityRuntime
/**
* Optional user-chosen library background (#9). Stores a persisted content URI;
* when unset the library falls back to the bundled default XMB-wave video (see
* HomeScreen / [defaultBackgroundVideoUri]). The user can pick a still image,
* an animated GIF/WebP (Coil handles those), or an MP4/other video (played via
* [VideoBackground]); `clear()` reverts to the default video.
* when unset the library falls back to the bundled default XMB-wave still image
* (R.drawable.library_bg_xmb, drawn in HomeScreen). The user can pick a still image
* or an animated GIF/WebP (Coil handles both); `clear()` reverts to the default.
* (The background used to be a looping MP4 but that hurt performance, so it's a
* static image now.)
*/
object LibraryBackground {
private const val PREF = "library.background.uri"
@@ -1,116 +0,0 @@
package com.armsx2.ui.home
import android.content.Context
import android.graphics.Matrix
import android.graphics.SurfaceTexture
import android.media.MediaPlayer
import android.net.Uri
import android.view.Surface
import android.view.TextureView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.armsx2.R
/** Resource URI for the bundled default XMB-wave library background video. */
fun defaultBackgroundVideoUri(context: Context): Uri =
Uri.parse("android.resource://${context.packageName}/${R.raw.xmb_wave}")
/**
* True if the URI points at a video we should play as a moving background, vs a
* still image (or animated GIF/WebP) that Coil handles. Prefers the resolver
* MIME type, falls back to the file extension for providers that don't report one.
*/
fun isVideoBackground(context: Context, uriString: String): Boolean {
val uri = runCatching { Uri.parse(uriString) }.getOrNull() ?: return false
runCatching { context.contentResolver.getType(uri) }.getOrNull()?.let { mime ->
if (mime.startsWith("video/")) return true
if (mime.startsWith("image/")) return false
}
val path = uriString.substringBefore('?').lowercase()
return path.endsWith(".mp4") || path.endsWith(".m4v") || path.endsWith(".webm") ||
path.endsWith(".mkv") || path.endsWith(".3gp") || path.endsWith(".mov")
}
/**
* Looping, muted, centre-cropped video background. Mirrors the boot-splash
* VideoView pattern but uses a TextureView so it can crop-to-fill (VideoView only
* letterboxes) and sit behind Compose content. The MediaPlayer is released when
* this leaves composition (e.g. a game launches), so no codec is held while a
* title runs. Errors are swallowed — on failure the app backdrop shows through.
*
* Recreate the whole composable (wrap the call in `key(uri)`) when the URI
* changes; the AndroidView factory only runs once.
*/
@Composable
fun VideoBackground(uri: Uri, modifier: Modifier = Modifier) {
val player = remember { MediaPlayer() }
DisposableEffect(Unit) {
onDispose {
runCatching { player.stop() }
runCatching { player.release() }
}
}
AndroidView(
modifier = modifier,
factory = { ctx ->
TextureView(ctx).apply {
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(st: SurfaceTexture, width: Int, height: Int) {
runCatching {
player.reset()
player.setDataSource(ctx, uri)
player.setSurface(Surface(st))
player.isLooping = true
player.setVolume(0f, 0f)
player.setOnVideoSizeChangedListener { _, vw, vh -> applyCenterCrop(this@apply, vw, vh) }
player.setOnPreparedListener { it.start() }
player.setOnErrorListener { _, _, _ -> true }
player.prepareAsync()
}
}
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, width: Int, height: Int) {
applyCenterCrop(this@apply, player.videoWidth, player.videoHeight)
}
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean {
runCatching { player.stop() }
return true
}
override fun onSurfaceTextureUpdated(st: SurfaceTexture) {}
}
}
},
)
}
/**
* Scale the TextureView content so the video covers the whole view (centre-crop),
* matching ContentScale.Crop for still images. TextureView stretches the video to
* its bounds by default; we scale the over-fitting axis back up around the centre.
*/
private fun applyCenterCrop(view: TextureView, videoW: Int, videoH: Int) {
val vw = view.width
val vh = view.height
if (videoW <= 0 || videoH <= 0 || vw == 0 || vh == 0) return
val viewAspect = vw.toFloat() / vh.toFloat()
val videoAspect = videoW.toFloat() / videoH.toFloat()
val scaleX: Float
val scaleY: Float
if (videoAspect > viewAspect) {
// Video is wider than the view: fill height, let width overflow (cropped).
scaleX = videoAspect / viewAspect
scaleY = 1f
} else {
// Video is taller than the view: fill width, let height overflow (cropped).
scaleX = 1f
scaleY = viewAspect / videoAspect
}
val matrix = Matrix()
matrix.setScale(scaleX, scaleY, vw / 2f, vh / 2f)
view.setTransform(matrix)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.