From c9d065e662b788bb1b40678dbb1bd6dc06ce2103 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:06:15 -0400 Subject: [PATCH] reduce graphics memory and hidden render work --- .../modules/astrawidget/AstraWidgetModule.kt | 20 +- .../modules/astrawidget/AstraWidgetUpdater.kt | 83 +++++++-- package.json | 1 + .../@shopify+react-native-skia+2.6.2.patch | 124 +++++++++++++ scripts/android-memory-profile.mjs | 175 ++++++++++++++++++ scripts/android-memory-profile.test.mjs | 56 ++++++ src/app/(tabs)/eq.tsx | 20 +- src/audio/sampleTracks.ts | 18 +- src/audio/widgetSync.ts | 7 +- src/components/MiniPlayer.tsx | 4 +- src/components/OscilloscopeWave.tsx | 81 ++++++-- src/components/SpectrumCurve.tsx | 113 ++++++++--- src/components/delayedPresence.test.mts | 56 ++++++ src/components/delayedPresence.ts | 51 +++++ src/components/player/NowPlayingHost.tsx | 29 ++- src/components/player/NowPlayingOverlay.tsx | 82 ++++---- src/components/renderPresenceTiming.ts | 7 + src/lib/useAppForeground.ts | 26 +++ src/library/artwork.ts | 34 +++- src/services/remoteUrls.ts | 11 +- src/session/SessionLifecycle.tsx | 2 +- src/stores/playerUiStore.ts | 15 +- 22 files changed, 868 insertions(+), 147 deletions(-) create mode 100644 patches/@shopify+react-native-skia+2.6.2.patch create mode 100644 scripts/android-memory-profile.mjs create mode 100644 scripts/android-memory-profile.test.mjs create mode 100644 src/components/delayedPresence.test.mts create mode 100644 src/components/delayedPresence.ts create mode 100644 src/components/renderPresenceTiming.ts create mode 100644 src/lib/useAppForeground.ts diff --git a/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetModule.kt b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetModule.kt index 77d9d9c..81359ce 100644 --- a/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetModule.kt +++ b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetModule.kt @@ -62,18 +62,16 @@ class AstraWidgetModule : Module() { } else { previous.recentlyPlayed } - AstraWidgetStateStore.save( - context, - AstraWidgetState( - title = state.title, - artist = state.artist, - artworkUri = if (state.hasTrack) state.artworkUri else null, - playbackState = state.playbackState, - hasTrack = state.hasTrack, - recentlyPlayed = recentlyPlayed, - ), + val next = AstraWidgetState( + title = state.title, + artist = state.artist, + artworkUri = if (state.hasTrack) state.artworkUri else null, + playbackState = state.playbackState, + hasTrack = state.hasTrack, + recentlyPlayed = recentlyPlayed, ) - AstraWidgetUpdater.updateAll(context) + AstraWidgetStateStore.save(context, next) + if (previous != next) AstraWidgetUpdater.updateAll(context) } } diff --git a/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetUpdater.kt b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetUpdater.kt index a09fd05..0d5ed26 100644 --- a/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetUpdater.kt +++ b/modules/astra-widget/android/src/main/java/expo/modules/astrawidget/AstraWidgetUpdater.kt @@ -20,6 +20,7 @@ import android.util.SizeF import android.view.View import android.widget.RemoteViews import java.io.File +import java.util.LinkedHashMap object AstraWidgetUpdater { private const val REQUEST_OPEN_APP = 100 @@ -30,6 +31,8 @@ object AstraWidgetUpdater { private const val FOUR_CELL_MIN_WIDTH_DP = 300 private const val FIVE_CELL_MIN_WIDTH_DP = 370 private const val ARTWORK_CORNER_RADIUS_DP = 8f + private const val MAX_PREPARED_ARTWORK = 16 + private val artworkBitmaps = WidgetArtworkBitmaps() private val RECENT_IMAGE_IDS = intArrayOf( R.id.astra_widget_recent_1_image, @@ -67,30 +70,42 @@ object AstraWidgetUpdater { val state = AstraWidgetStateStore.load(context) appWidgetIds.forEach { appWidgetId -> val options = manager.getAppWidgetOptions(appWidgetId) - manager.updateAppWidget(appWidgetId, buildRemoteViews(context, state, options)) + manager.updateAppWidget( + appWidgetId, + buildRemoteViews(context, state, options, artworkBitmaps), + ) } } fun updateWidget(context: Context, manager: AppWidgetManager, appWidgetId: Int, options: Bundle) { val state = AstraWidgetStateStore.load(context) - manager.updateAppWidget(appWidgetId, buildRemoteViews(context, state, options)) + manager.updateAppWidget( + appWidgetId, + buildRemoteViews(context, state, options, artworkBitmaps), + ) } - private fun buildRemoteViews(context: Context, state: AstraWidgetState, options: Bundle): RemoteViews { + private fun buildRemoteViews( + context: Context, + state: AstraWidgetState, + options: Bundle, + artwork: WidgetArtworkBitmaps, + ): RemoteViews { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - return buildResponsiveRemoteViews(context, state) + return buildResponsiveRemoteViews(context, state, artwork) } - return buildBucketRemoteViews(context, state, WidgetLayoutBucket.fromOptions(options)) + return buildBucketRemoteViews(context, state, WidgetLayoutBucket.fromOptions(options), artwork) } private fun buildResponsiveRemoteViews( context: Context, state: AstraWidgetState, + artwork: WidgetArtworkBitmaps, ): RemoteViews { val mapping = WidgetLayoutBucket.entries.associate { bucket -> - bucket.minSize to buildBucketRemoteViews(context, state, bucket) + bucket.minSize to buildBucketRemoteViews(context, state, bucket, artwork) } return RemoteViews(mapping) @@ -100,6 +115,7 @@ object AstraWidgetUpdater { context: Context, state: AstraWidgetState, bucket: WidgetLayoutBucket, + artwork: WidgetArtworkBitmaps, ): RemoteViews { val views = RemoteViews(context.packageName, bucket.layoutRes) val title = if (state.hasTrack) state.title.cleanOrFallback("Unknown title") else "Astra" @@ -108,7 +124,7 @@ object AstraWidgetUpdater { views.setTextViewText(R.id.astra_widget_title, title) views.setTextViewText(R.id.astra_widget_artist, artist) - setImageView(context, views, R.id.astra_widget_art, state.artworkUri, 320) + setImageView(context, artwork, views, R.id.astra_widget_art, state.artworkUri, 320) views.setOnClickPendingIntent(R.id.astra_widget_root, openAppPendingIntent(context)) if (bucket.hasPlayPause) { @@ -128,7 +144,7 @@ object AstraWidgetUpdater { ) } if (bucket.recentCount > 0) { - bindRecentlyPlayed(context, views, state.recentlyPlayed, bucket) + bindRecentlyPlayed(context, views, state.recentlyPlayed, bucket, artwork) } return views @@ -172,6 +188,7 @@ object AstraWidgetUpdater { views: RemoteViews, recentlyPlayed: List, bucket: WidgetLayoutBucket, + artwork: WidgetArtworkBitmaps, ) { val openRecents = openRecentlyPlayedPendingIntent(context) views.setOnClickPendingIntent(R.id.astra_widget_recent_container, openRecents) @@ -179,7 +196,7 @@ object AstraWidgetUpdater { for (index in 0 until bucket.recentCount) { val item = recentlyPlayed.getOrNull(index) val imageId = RECENT_IMAGE_IDS[index] - setImageView(context, views, imageId, item?.artworkUri, 128) + setImageView(context, artwork, views, imageId, item?.artworkUri, 128) views.setContentDescription(imageId, item?.title.cleanOrFallback("Recently played")) views.setOnClickPendingIntent(imageId, openRecents) @@ -193,12 +210,13 @@ object AstraWidgetUpdater { private fun setImageView( context: Context, + artwork: WidgetArtworkBitmaps, views: RemoteViews, viewId: Int, uri: String?, maxPx: Int, ) { - val bitmap = decodeBitmap(context, uri, maxPx)?.let { prepareArtworkBitmap(context, it) } + val bitmap = artwork.get(context, uri, maxPx) if (bitmap != null) { views.setImageViewBitmap(viewId, bitmap) } else { @@ -206,10 +224,22 @@ object AstraWidgetUpdater { } } - private fun prepareArtworkBitmap(context: Context, bitmap: Bitmap): Bitmap { + private fun prepareArtworkBitmap(context: Context, bitmap: Bitmap, maxPx: Int): Bitmap { val square = cropCenterSquare(bitmap) + val scaled = + if (maxPx > 0 && (square.width > maxPx || square.height > maxPx)) { + Bitmap.createScaledBitmap(square, maxPx, maxPx, true) + } else { + square + } val radiusPx = ARTWORK_CORNER_RADIUS_DP * context.resources.displayMetrics.density - return roundBitmap(square, radiusPx) + val rounded = roundBitmap(scaled, radiusPx) + // The rounded output owns its pixels. Release decode/crop/scale + // intermediates immediately instead of waiting for native Bitmap GC. + listOf(bitmap, square, scaled).distinct().forEach { intermediate -> + if (intermediate !== rounded && !intermediate.isRecycled) intermediate.recycle() + } + return rounded } private fun cropCenterSquare(bitmap: Bitmap): Bitmap { @@ -292,6 +322,35 @@ object AstraWidgetUpdater { }.getOrNull() } + /** + * Android 12 responsive RemoteViews builds all size buckets at once. Cache + * each prepared cover by source/size so responsive buckets and later + * play/pause refreshes reuse the same bounded native Bitmaps. Content-keyed + * artwork paths make eviction safe when the queue/recents change. + */ + private class WidgetArtworkBitmaps { + private val prepared = LinkedHashMap(MAX_PREPARED_ARTWORK, 0.75f, true) + + @Synchronized + fun get(context: Context, uri: String?, maxPx: Int): Bitmap? { + if (uri.isNullOrBlank()) return null + val key = "$maxPx:$uri" + if (prepared.containsKey(key)) return prepared[key] + val bitmap = decodeBitmap(context, uri, maxPx)?.let { + prepareArtworkBitmap(context, it, maxPx) + } + prepared[key] = bitmap + while (prepared.size > MAX_PREPARED_ARTWORK) { + val eldest = prepared.entries.iterator().next() + prepared.remove(eldest.key) + eldest.value?.let { evicted -> + if (!evicted.isRecycled) evicted.recycle() + } + } + return bitmap + } + } + private fun readImageBytes(context: Context, value: String): ByteArray? { if (value.startsWith("data:image", ignoreCase = true)) { val encoded = value.substringAfter(',', missingDelimiterValue = "") diff --git a/package.json b/package.json index dceef5e..ccdba5d 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts", "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts", + "test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts", "test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts", diff --git a/patches/@shopify+react-native-skia+2.6.2.patch b/patches/@shopify+react-native-skia+2.6.2.patch new file mode 100644 index 0000000..32bfaa4 --- /dev/null +++ b/patches/@shopify+react-native-skia+2.6.2.patch @@ -0,0 +1,124 @@ +diff --git a/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/OpenGLContext.h b/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/OpenGLContext.h +index a3b0e0e..3826fea 100644 +--- a/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/OpenGLContext.h ++++ b/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/OpenGLContext.h +@@ -201,6 +201,13 @@ private: + if (_directContext == nullptr) { + throw std::runtime_error("GrDirectContexts::MakeGL failed"); + } ++ ++ // This singleton is thread-local: every simultaneous TextureView can own a ++ // separate Ganesh context. Budget one eighth of Astra's 64 MiB aggregate ++ // cache ceiling per context (wash + waveform + rack + EQ transitions can ++ // briefly reach eight) instead of accidentally granting each one 64 MiB. ++ constexpr size_t kAstraGaneshCachePerContext = 8ULL * 1024ULL * 1024ULL; ++ _directContext->setResourceCacheLimit(kAstraGaneshCachePerContext); + } + }; + +diff --git a/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp b/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp +index e8e4c03..0ce542c 100644 +--- a/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp ++++ b/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp +@@ -5,8 +5,6 @@ + #include + #include + +-#include "RNSkLog.h" +- + #if defined(SK_GRAPHITE) + #include "RNDawnContext.h" + #else +@@ -50,18 +48,6 @@ bool RNSkOpenGLCanvasProvider::renderToCanvas( + if (_surfaceHolder != nullptr && cb != nullptr) { + // Get the surface + auto surface = _surfaceHolder->getSurface(); +- if (_jSurfaceTexture) { +- JNIEnv *env = facebook::jni::Environment::current(); +- env->CallVoidMethod(_jSurfaceTexture, _updateTexImageMethod); +- +- // Check for exceptions +- if (env->ExceptionCheck()) { +- RNSkLogger::logToConsole( +- "updateAndRelease() failed. The exception above " +- "can safely be ignored"); +- env->ExceptionClear(); +- } +- } + if (surface) { + // Draw into canvas using callback + cb(surface->getCanvas()); +@@ -86,7 +72,10 @@ void RNSkOpenGLCanvasProvider::surfaceAvailable(jobject jSurfaceTexture, + ANativeWindow *window = nullptr; + JNIEnv *env = facebook::jni::Environment::current(); + if (!opaque) { +- _jSurfaceTexture = env->NewGlobalRef(jSurfaceTexture); ++ // The framework-owned TextureView is the SurfaceTexture consumer. Calling ++ // updateTexImage() here from RNSkia's producer context fails every frame ++ // with "EGLConsumer is not attached" and retains the failed JNI work. ++ // Presenting the ANativeWindow already queues buffers for TextureView. + jclass surfaceClass = env->FindClass("android/view/Surface"); + jmethodID surfaceConstructor = env->GetMethodID( + surfaceClass, "", "(Landroid/graphics/SurfaceTexture;)V"); +@@ -95,15 +84,10 @@ void RNSkOpenGLCanvasProvider::surfaceAvailable(jobject jSurfaceTexture, + env->NewObject(surfaceClass, surfaceConstructor, jSurfaceTexture); + window = ANativeWindow_fromSurface(env, jSurface); + +- jclass surfaceTextureClass = env->GetObjectClass(_jSurfaceTexture); +- _updateTexImageMethod = +- env->GetMethodID(surfaceTextureClass, "updateTexImage", "()V"); +- + // Acquire the native window from the Surface + // Clean up local references + env->DeleteLocalRef(jSurface); + env->DeleteLocalRef(surfaceClass); +- env->DeleteLocalRef(surfaceTextureClass); + } else { + window = ANativeWindow_fromSurface(env, jSurfaceTexture); + } +@@ -112,6 +96,12 @@ void RNSkOpenGLCanvasProvider::surfaceAvailable(jobject jSurfaceTexture, + #else + _surfaceHolder = OpenGLContext::getInstance().MakeWindow(window); + #endif ++ // ANativeWindow_fromSurface() returns an acquired reference and the window ++ // context acquires its own. Drop the caller's reference now so TextureView ++ // destruction can release the final native window/buffer queue. ++ if (window != nullptr) { ++ ANativeWindow_release(window); ++ } + + // Post redraw request to ensure we paint in the next draw cycle. + _requestRedraw(); +@@ -120,11 +110,15 @@ void RNSkOpenGLCanvasProvider::surfaceDestroyed() { + // destroy the renderer (a unique pointer so the dtor will be called + // immediately.) + _surfaceHolder = nullptr; +- if (_jSurfaceTexture) { +- JNIEnv *env = facebook::jni::Environment::current(); +- env->DeleteGlobalRef(_jSurfaceTexture); +- _jSurfaceTexture = nullptr; +- } ++#if !defined(SK_GRAPHITE) ++ // The window surface is gone, so switch back to the shared pbuffer before ++ // evicting resources that were unlocked by its destruction. This prevents ++ // dead TextureViews from lingering in Ganesh's cache until process death. ++ auto &context = OpenGLContext::getInstance(); ++ context.makeCurrent(); ++ context.getDirectContext()->purgeUnlockedResources( ++ GrPurgeResourceOptions::kAllResources); ++#endif + } + + void RNSkOpenGLCanvasProvider::surfaceSizeChanged(jobject jSurface, int width, +diff --git a/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.h b/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.h +index ca5768d..0b3c5fa 100644 +--- a/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.h ++++ b/node_modules/@shopify/react-native-skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.h +@@ -36,7 +36,5 @@ public: + private: + std::unique_ptr _surfaceHolder = nullptr; + std::shared_ptr _platformContext; +- jobject _jSurfaceTexture = nullptr; +- jmethodID _updateTexImageMethod = nullptr; + }; + } // namespace RNSkia diff --git a/scripts/android-memory-profile.mjs b/scripts/android-memory-profile.mjs new file mode 100644 index 0000000..e7f5734 --- /dev/null +++ b/scripts/android-memory-profile.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const DEFAULT_PACKAGE = 'io.github.boof2015.astra'; + +function numberMatch(text, pattern, group = 1) { + const match = text.match(pattern); + return match ? Number(match[group]) : null; +} + +function appSummaryBucket(text, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = text.match(new RegExp(`^\\s*${escaped}:\\s+(\\d+)(?:\\s+(\\d+))?`, 'm')); + return match ? { pssKb: Number(match[1]), rssKb: match[2] ? Number(match[2]) : null } : null; +} + +export function parseMeminfo(text) { + const total = text.match( + /TOTAL PSS:\s*(\d+)\s+TOTAL RSS:\s*(\d+)\s+TOTAL SWAP PSS:\s*(\d+)/ + ); + const bitmapMalloced = text.match(/^\s*Bitmap \(malloced\):\s+(\d+)\s+(\d+)/m); + const bitmapNonmalloced = text.match(/^\s*Bitmap \(nonmalloced\):\s+(\d+)\s+(\d+)/m); + const mallocedKb = bitmapMalloced ? Number(bitmapMalloced[2]) : 0; + const nonmallocedKb = bitmapNonmalloced ? Number(bitmapNonmalloced[2]) : 0; + + return { + totalPssKb: total ? Number(total[1]) : null, + totalRssKb: total ? Number(total[2]) : null, + totalSwapPssKb: total ? Number(total[3]) : null, + buckets: { + javaHeap: appSummaryBucket(text, 'Java Heap'), + nativeHeap: appSummaryBucket(text, 'Native Heap'), + graphics: appSummaryBucket(text, 'Graphics'), + privateOther: appSummaryBucket(text, 'Private Other'), + system: appSummaryBucket(text, 'System'), + }, + mtrack: { + eglKb: numberMatch(text, /^\s*EGL mtrack\s+(\d+)/m), + glKb: numberMatch(text, /^\s*GL mtrack\s+(\d+)/m), + }, + bitmaps: { + mallocedCount: bitmapMalloced ? Number(bitmapMalloced[1]) : 0, + mallocedKb, + nonmallocedCount: bitmapNonmalloced ? Number(bitmapNonmalloced[1]) : 0, + nonmallocedKb, + totalKb: mallocedKb + nonmallocedKb, + }, + }; +} + +export function parseGfxinfo(text) { + const textureViews = [...text.matchAll(/^TextureView:\s*(\d+)x(\d+)\s*$/gm)].map( + (match) => ({ width: Number(match[1]), height: Number(match[2]) }) + ); + return { + textureViewCount: textureViews.length, + textureViews, + graphicBufferAllocatedKb: numberMatch( + text, + /Total allocated by GraphicBufferAllocator \(estimate\):\s*([\d.]+) KB/ + ), + gpuMemoryBytes: numberMatch( + text, + /Total GPU memory usage:\s*\n\s*(\d+) bytes/ + ), + glLayerCount: numberMatch(text, /Layers Total\s+[\d.]+ KB \(numLayers = (\d+)\)/), + }; +} + +export function buildMemoryProfile(meminfo, gfxinfo, metadata = {}) { + const memory = parseMeminfo(meminfo); + const graphics = parseGfxinfo(gfxinfo); + return { + capturedAt: new Date().toISOString(), + package: metadata.package ?? DEFAULT_PACKAGE, + label: metadata.label ?? null, + serial: metadata.serial ?? null, + memory, + graphics, + acceptance: { + stretchPssAtOrBelow300Mb: + memory.totalPssKb !== null ? memory.totalPssKb <= 300 * 1024 : null, + hardPssBelow400Mb: + memory.totalPssKb !== null ? memory.totalPssKb < 400 * 1024 : null, + graphicsBelow150Mb: + memory.buckets.graphics?.pssKb !== undefined && + memory.buckets.graphics?.pssKb !== null + ? memory.buckets.graphics.pssKb < 150 * 1024 + : null, + }, + }; +} + +function mb(kb) { + return kb === null || kb === undefined ? 'n/a' : `${(kb / 1024).toFixed(1)} MB`; +} + +function printHuman(profile) { + const { memory, graphics } = profile; + const rows = [ + ['Total PSS', mb(memory.totalPssKb)], + ['Total RSS', mb(memory.totalRssKb)], + ['Swap PSS', mb(memory.totalSwapPssKb)], + ['Java heap PSS', mb(memory.buckets.javaHeap?.pssKb)], + ['Native heap PSS', mb(memory.buckets.nativeHeap?.pssKb)], + ['Graphics PSS', mb(memory.buckets.graphics?.pssKb)], + ['EGL mtrack', mb(memory.mtrack.eglKb)], + ['GL mtrack', mb(memory.mtrack.glKb)], + ['Tracked bitmaps', mb(memory.bitmaps.totalKb)], + ['TextureViews', String(graphics.textureViewCount)], + ['Graphic buffers', mb(graphics.graphicBufferAllocatedKb)], + ['GPU cache/layers', graphics.gpuMemoryBytes === null ? 'n/a' : mb(graphics.gpuMemoryBytes / 1024)], + ]; + const width = Math.max(...rows.map(([name]) => name.length)); + + console.log(`Astra Android memory profile${profile.label ? ` — ${profile.label}` : ''}`); + console.log(`${profile.package}${profile.serial ? ` on ${profile.serial}` : ''}`); + for (const [name, value] of rows) console.log(`${name.padEnd(width)} ${value}`); + if (graphics.textureViews.length > 0) { + console.log(`TextureView sizes${' '.repeat(Math.max(1, width - 16))} ${graphics.textureViews.map((v) => `${v.width}x${v.height}`).join(', ')}`); + } + console.log( + `Gates${' '.repeat(Math.max(1, width - 5))} stretch<=300MB=${profile.acceptance.stretchPssAtOrBelow300Mb} ` + + `hard<400MB=${profile.acceptance.hardPssBelow400Mb} graphics<150MB=${profile.acceptance.graphicsBelow150Mb}` + ); +} + +function parseArgs(args) { + const result = { package: DEFAULT_PACKAGE, label: null, serial: null, json: false }; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--json') result.json = true; + else if (arg === '--package') result.package = args[++i]; + else if (arg === '--label') result.label = args[++i]; + else if (arg === '--serial') result.serial = args[++i]; + else if (arg === '--help' || arg === '-h') result.help = true; + else throw new Error(`Unknown argument: ${arg}`); + } + return result; +} + +function adbOutput(serial, commandArgs) { + const serialArgs = serial ? ['-s', serial] : []; + return execFileSync('adb', [...serialArgs, 'shell', 'dumpsys', ...commandArgs], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }); +} + +export function main(args = process.argv.slice(2)) { + const options = parseArgs(args); + if (options.help) { + console.log('Usage: node scripts/android-memory-profile.mjs [--package id] [--serial id] [--label name] [--json]'); + return; + } + const meminfo = adbOutput(options.serial, ['meminfo', options.package]); + if (!meminfo.includes('TOTAL PSS:')) { + throw new Error(`No running process found for ${options.package}`); + } + const gfxinfo = adbOutput(options.serial, ['gfxinfo', options.package]); + const profile = buildMemoryProfile(meminfo, gfxinfo, options); + if (options.json) console.log(JSON.stringify(profile, null, 2)); + else printHuman(profile); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/scripts/android-memory-profile.test.mjs b/scripts/android-memory-profile.test.mjs new file mode 100644 index 0000000..378e836 --- /dev/null +++ b/scripts/android-memory-profile.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildMemoryProfile, parseGfxinfo, parseMeminfo } from './android-memory-profile.mjs'; + +const MEMINFO = ` + EGL mtrack 80004 80004 0 0 80004 + GL mtrack 395072 395072 0 0 395072 + App Summary + Java Heap: 63956 101768 + Native Heap: 81616 104420 + Graphics: 475076 475076 + Private Other: 116172 + System: 232359 + TOTAL PSS: 1036907 TOTAL RSS: 959676 TOTAL SWAP PSS: 224950 + Native Allocations + Bitmap (malloced): 38 35541 +Bitmap (nonmalloced): 5 25671 +`; + +const GFXINFO = ` + Layers Total 14188.36 KB (numLayers = 4) +Total GPU memory usage: + 36152084 bytes, 34.48 MB (2.05 KB is purgeable) +TextureView: 1440x562 +TextureView: 1560x1298 +TextureView: 1350x378 +Total allocated by GraphicBufferAllocator (estimate): 109727.50 KB +`; + +test('parses Android PSS, RSS, heap, graphics, swap, and bitmap buckets', () => { + const parsed = parseMeminfo(MEMINFO); + assert.equal(parsed.totalPssKb, 1036907); + assert.equal(parsed.totalRssKb, 959676); + assert.equal(parsed.totalSwapPssKb, 224950); + assert.equal(parsed.buckets.javaHeap?.pssKb, 63956); + assert.equal(parsed.buckets.nativeHeap?.pssKb, 81616); + assert.equal(parsed.buckets.graphics?.pssKb, 475076); + assert.equal(parsed.mtrack.glKb, 395072); + assert.equal(parsed.bitmaps.totalKb, 61212); +}); + +test('parses TextureViews, GPU bytes, and GraphicBufferAllocator totals', () => { + const parsed = parseGfxinfo(GFXINFO); + assert.equal(parsed.textureViewCount, 3); + assert.deepEqual(parsed.textureViews[0], { width: 1440, height: 562 }); + assert.equal(parsed.gpuMemoryBytes, 36152084); + assert.equal(parsed.graphicBufferAllocatedKb, 109727.5); + assert.equal(parsed.glLayerCount, 4); +}); + +test('reports the agreed memory acceptance gates', () => { + const profile = buildMemoryProfile(MEMINFO, GFXINFO); + assert.equal(profile.acceptance.stretchPssAtOrBelow300Mb, false); + assert.equal(profile.acceptance.hardPssBelow400Mb, false); + assert.equal(profile.acceptance.graphicsBelow150Mb, false); +}); diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index ccbb2be..d0783ca 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -8,7 +8,7 @@ import { useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useFocusEffect, useRouter } from 'expo-router'; +import { useFocusEffect, usePathname, useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as DocumentPicker from 'expo-document-picker'; import * as Sharing from 'expo-sharing'; @@ -28,6 +28,8 @@ import { EqSheet, EqSheetItem } from '@/components/eq/EqSheet'; import { EQModeSwitcher } from '@/components/eq/EQModeSwitcher'; import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet'; import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel'; +import { useDelayedUnmountPresence } from '@/components/delayedPresence'; +import { EQ_GRAPH_UNMOUNT_DELAY_MS } from '@/components/renderPresenceTiming'; import { PresetSheet } from '@/components/eq/PresetSheet'; import { SavePresetSheet } from '@/components/eq/SavePresetSheet'; import { EQPresetNameSheet } from '@/components/eq/EQPresetNameSheet'; @@ -41,6 +43,7 @@ import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { hapticForToggle } from '@/lib/hapticCatalog'; import { playHaptic } from '@/lib/haptics'; +import { useAppForeground } from '@/lib/useAppForeground'; import { isWideWindow } from '@/theme/adaptive'; import { useEQStore } from '@/stores/eqStore'; import { useScopeActive } from '@/scope/scopeStore'; @@ -81,9 +84,16 @@ export default function EQScreen() { const ripple = useRipple(); const colors = useColors(); const router = useRouter(); + const pathname = usePathname(); const eq = useEQStore(); const scopeActive = useScopeActive(); + const foreground = useAppForeground(); const [focused, setFocused] = useState(false); + const renderEqGraphics = useDelayedUnmountPresence( + pathname === '/eq', + EQ_GRAPH_UNMOUNT_DELAY_MS, + !foreground + ); const [sheet, setSheet] = useState('none'); const [editingValue, setEditingValue] = useState(null); const [pendingCurrentAction, setPendingCurrentAction] = useState(null); @@ -238,7 +248,7 @@ export default function EQScreen() { ); - const graphEl = ( + const graphEl = renderEqGraphics ? ( eq.updateBand(id, updates)} /> - ); + ) : null; // Graphic editor card — the panel draws its response curve behind the sliders // in the tracks' own coordinate space, so it stays glued to the thumbs. - const graphicEditorEl = ( + const graphicEditorEl = renderEqGraphics ? ( - ); + ) : null; const stripEl = ( s.connect); const scopeActive = useScopeActive(); + const foreground = useAppForeground(); const [pillWidth, setPillWidth] = useState(0); const [targetPickerOpen, setTargetPickerOpen] = useState(false); @@ -106,7 +108,7 @@ export function MiniPlayer() { const isLoading = presentation.playbackState === 'loading'; // The pill sits underneath the now-playing overlay; don't burn a second // live-scope frame loop while it's fully occluded. - const liveScopeActive = scopeActive && !isDesktop && !playerOpen; + const liveScopeActive = scopeActive && foreground && !isDesktop && !playerOpen; const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width); const onTogglePlay = () => { diff --git a/src/components/OscilloscopeWave.tsx b/src/components/OscilloscopeWave.tsx index f0c52d0..f0fd3d7 100644 --- a/src/components/OscilloscopeWave.tsx +++ b/src/components/OscilloscopeWave.tsx @@ -1,5 +1,6 @@ import { useEffect, + useLayoutEffect, useMemo, useRef } from 'react'; @@ -44,6 +45,12 @@ const values = new Float32Array(OSCILLOSCOPE_POINTS); const DECAY_PER_FRAME = 0.72; const REST_EPSILON = 0.004; +type SkiaDisposable = { dispose: () => void }; + +function disposeSkiaResources(resources: readonly (SkiaDisposable | null)[]) { + for (let i = resources.length - 1; i >= 0; i--) resources[i]?.dispose(); +} + function skiaViewApi(): SkiaViewApiShape | null { const globalWithSkia = globalThis as typeof globalThis & { SkiaViewApi?: SkiaViewApiShape }; return globalWithSkia.SkiaViewApi ?? null; @@ -141,22 +148,32 @@ function buildPicture( const recorder = Skia.PictureRecorder(); const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height)); const path = Skia.Path.Make(); + const resources: SkiaDisposable[] = [recorder, path]; writeWavePath(samples, sampleCount, width, height, lineWidth, gain, path); - if (glow) { - const glowPaint = makeStrokePaint(color, lineWidth * 3, 0.18); - if (edgeFade) { - glowPaint.setShader(makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth)); + try { + if (glow) { + const glowPaint = makeStrokePaint(color, lineWidth * 3, 0.18); + resources.push(glowPaint); + if (edgeFade) { + const glowShader = makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth); + resources.push(glowShader); + glowPaint.setShader(glowShader); + } + canvas.drawPath(path, glowPaint); } - canvas.drawPath(path, glowPaint); + const strokePaint = makeStrokePaint(color, lineWidth); + resources.push(strokePaint); + if (edgeFade) { + const strokeShader = makeFadedStrokeShader(color, 1, width, edgeFadeWidth); + resources.push(strokeShader); + strokePaint.setShader(strokeShader); + } + canvas.drawPath(path, strokePaint); + return recorder.finishRecordingAsPicture(); + } finally { + disposeSkiaResources(resources); } - const strokePaint = makeStrokePaint(color, lineWidth); - if (edgeFade) { - strokePaint.setShader(makeFadedStrokeShader(color, 1, width, edgeFadeWidth)); - } - canvas.drawPath(path, strokePaint); - - return recorder.finishRecordingAsPicture(); } /** @@ -200,7 +217,20 @@ export function OscilloscopeWave({ [color, edgeFade, edgeFadeWidth, glow, height, lineWidth, width] ); - useEffect(() => { + useEffect(() => () => initialPicture.dispose(), [initialPicture]); + + useLayoutEffect( + () => () => { + const view = viewRef.current; + const api = skiaViewApi(); + if (!view || !api) return; + api.setJsiProperty(view.nativeId, 'picture', null); + api.requestRedraw(view.nativeId); + }, + [] + ); + + useLayoutEffect(() => { const view = viewRef.current; const api = skiaViewApi(); if (!view || !api || width <= 0 || height <= 0) return; @@ -214,12 +244,22 @@ export function OscilloscopeWave({ // was measurable GC/JSI churn at 60fps. const strokePaint = makeStrokePaint(color, lineWidth); const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, 0.18) : null; + const effectResources: SkiaDisposable[] = [strokePaint]; + if (glowPaint) effectResources.push(glowPaint); if (edgeFade) { - strokePaint.setShader(makeFadedStrokeShader(color, 1, width, edgeFadeWidth)); - glowPaint?.setShader(makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth)); + const strokeShader = makeFadedStrokeShader(color, 1, width, edgeFadeWidth); + effectResources.push(strokeShader); + strokePaint.setShader(strokeShader); + if (glowPaint) { + const glowShader = makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth); + effectResources.push(glowShader); + glowPaint.setShader(glowShader); + } } const bounds = Skia.XYWHRect(0, 0, width, height); const path = Skia.Path.Make(); + effectResources.push(path); + let currentPicture: SkPicture | null = null; const draw = (sampleCount: number) => { const gain = useScopeStore.getState().oscGain; @@ -228,13 +268,22 @@ export function OscilloscopeWave({ const canvas = recorder.beginRecording(bounds); if (glowPaint) canvas.drawPath(path, glowPaint); canvas.drawPath(path, strokePaint); - api.setJsiProperty(view.nativeId, 'picture', recorder.finishRecordingAsPicture()); + const nextPicture = recorder.finishRecordingAsPicture(); + recorder.dispose(); + api.setJsiProperty(view.nativeId, 'picture', nextPicture); api.requestRedraw(view.nativeId); + currentPicture?.dispose(); + currentPicture = nextPicture; }; const cleanup = () => { mounted = false; cancelAnimationFrame(raf); + api.setJsiProperty(view.nativeId, 'picture', null); + api.requestRedraw(view.nativeId); + currentPicture?.dispose(); + currentPicture = null; + disposeSkiaResources(effectResources); }; if (!active) { diff --git a/src/components/SpectrumCurve.tsx b/src/components/SpectrumCurve.tsx index 2523556..c5a86fc 100644 --- a/src/components/SpectrumCurve.tsx +++ b/src/components/SpectrumCurve.tsx @@ -1,5 +1,6 @@ import { useEffect, + useLayoutEffect, useMemo, useRef } from 'react'; @@ -66,6 +67,12 @@ const DECAY_PER_FRAME = 0.72; const REST_EPSILON = 0.004; const spectrumBins = new Float32Array(SPECTRUM_BINS); +type SkiaDisposable = { dispose: () => void }; + +function disposeSkiaResources(resources: readonly (SkiaDisposable | null)[]) { + for (let i = resources.length - 1; i >= 0; i--) resources[i]?.dispose(); +} + function skiaViewApi(): SkiaViewApiShape | null { const globalWithSkia = globalThis as typeof globalThis & { SkiaViewApi?: SkiaViewApiShape }; return globalWithSkia.SkiaViewApi ?? null; @@ -153,16 +160,16 @@ function makeFillPaint( null, TileMode.Clamp ); - paint.setShader( - fade - ? Skia.Shader.MakeBlend( - BlendMode.Modulate, - vertical, - makeFadeMaskShader(fade.width, fade.fadeWidth) - ) - : vertical - ); - return paint; + const shaders: SkiaDisposable[] = [vertical]; + if (fade) { + const mask = makeFadeMaskShader(fade.width, fade.fadeWidth); + const blended = Skia.Shader.MakeBlend(BlendMode.Modulate, vertical, mask); + shaders.push(mask, blended); + paint.setShader(blended); + } else { + paint.setShader(vertical); + } + return { paint, shaders }; } function writePaths( @@ -222,25 +229,37 @@ function buildPicture( const recorder = Skia.PictureRecorder(); const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height)); const { line, fill } = buildPaths(values, width, height, lineWidth); + const resources: SkiaDisposable[] = [recorder, line, fill]; - if (values.length >= 2 && width > 0 && height > 0) { - const fade = edgeFade && edgeFadeWidth > 0 ? { width, fadeWidth: edgeFadeWidth } : null; - canvas.drawPath(fill, makeFillPaint(color, height, fillOpacity, fade)); - if (glow) { - const glowPaint = makeStrokePaint(color, lineWidth * 3, glowOpacity); - if (fade) { - glowPaint.setShader(makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth)); + try { + if (values.length >= 2 && width > 0 && height > 0) { + const fade = edgeFade && edgeFadeWidth > 0 ? { width, fadeWidth: edgeFadeWidth } : null; + const fillResources = makeFillPaint(color, height, fillOpacity, fade); + resources.push(fillResources.paint, ...fillResources.shaders); + canvas.drawPath(fill, fillResources.paint); + if (glow) { + const glowPaint = makeStrokePaint(color, lineWidth * 3, glowOpacity); + resources.push(glowPaint); + if (fade) { + const glowShader = makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth); + resources.push(glowShader); + glowPaint.setShader(glowShader); + } + canvas.drawPath(line, glowPaint); } - canvas.drawPath(line, glowPaint); + const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity); + resources.push(strokePaint); + if (fade) { + const strokeShader = makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth); + resources.push(strokeShader); + strokePaint.setShader(strokeShader); + } + canvas.drawPath(line, strokePaint); } - const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity); - if (fade) { - strokePaint.setShader(makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth)); - } - canvas.drawPath(line, strokePaint); + return recorder.finishRecordingAsPicture(); + } finally { + disposeSkiaResources(resources); } - - return recorder.finishRecordingAsPicture(); } function lerp(a: number, b: number, t: number): number { @@ -414,7 +433,20 @@ export function SpectrumCurve({ ] ); - useEffect(() => { + useEffect(() => () => initialPicture.dispose(), [initialPicture]); + + useLayoutEffect( + () => () => { + const view = viewRef.current; + const api = skiaViewApi(); + if (!view || !api) return; + api.setJsiProperty(view.nativeId, 'picture', null); + api.requestRedraw(view.nativeId); + }, + [] + ); + + useLayoutEffect(() => { const view = viewRef.current; const api = skiaViewApi(); if (!view || !api || width <= 0 || height <= 0 || resolvedPointCount < 2) return; @@ -441,14 +473,26 @@ export function SpectrumCurve({ const fade = edgeFade && edgeFadeWidth > 0 ? { width, fadeWidth: edgeFadeWidth } : null; const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity); const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, glowOpacity) : null; + const effectResources: SkiaDisposable[] = [strokePaint]; + if (glowPaint) effectResources.push(glowPaint); if (fade) { - strokePaint.setShader(makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth)); - glowPaint?.setShader(makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth)); + const strokeShader = makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth); + effectResources.push(strokeShader); + strokePaint.setShader(strokeShader); + if (glowPaint) { + const glowShader = makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth); + effectResources.push(glowShader); + glowPaint.setShader(glowShader); + } } - const fillPaint = makeFillPaint(color, height, fillOpacity, fade); + const fillResources = makeFillPaint(color, height, fillOpacity, fade); + const fillPaint = fillResources.paint; + effectResources.push(fillPaint, ...fillResources.shaders); const bounds = Skia.XYWHRect(0, 0, width, height); const linePath = Skia.Path.Make(); const fillPath = Skia.Path.Make(); + effectResources.push(linePath, fillPath); + let currentPicture: SkPicture | null = null; const draw = () => { writePaths(renderValues, width, height, lineWidth, linePath, fillPath); @@ -457,13 +501,22 @@ export function SpectrumCurve({ canvas.drawPath(fillPath, fillPaint); if (glowPaint) canvas.drawPath(linePath, glowPaint); canvas.drawPath(linePath, strokePaint); - api.setJsiProperty(view.nativeId, 'picture', recorder.finishRecordingAsPicture()); + const nextPicture = recorder.finishRecordingAsPicture(); + recorder.dispose(); + api.setJsiProperty(view.nativeId, 'picture', nextPicture); api.requestRedraw(view.nativeId); + currentPicture?.dispose(); + currentPicture = nextPicture; }; const cleanup = () => { mounted = false; cancelAnimationFrame(raf); + api.setJsiProperty(view.nativeId, 'picture', null); + api.requestRedraw(view.nativeId); + currentPicture?.dispose(); + currentPicture = null; + disposeSkiaResources(effectResources); }; if (!active) { diff --git a/src/components/delayedPresence.test.mts b/src/components/delayedPresence.test.mts new file mode 100644 index 0000000..ed447d3 --- /dev/null +++ b/src/components/delayedPresence.test.mts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + delayedPresenceReducer, + scheduleDelayedPresenceHide, +} from './delayedPresence.ts'; +import { + EQ_GRAPH_UNMOUNT_DELAY_MS, + NOW_PLAYING_CLOSE_UNMOUNT_MS, +} from './renderPresenceTiming.ts'; + +const wait = (delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs)); + +test('Now Playing remains mounted through close timing, then releases', async () => { + let retained = false; + retained = delayedPresenceReducer(retained, 'show'); + assert.equal(retained, true); + scheduleDelayedPresenceHide(NOW_PLAYING_CLOSE_UNMOUNT_MS, () => { + retained = delayedPresenceReducer(retained, 'hide'); + }); + await wait(NOW_PLAYING_CLOSE_UNMOUNT_MS - 20); + assert.equal(retained, true, 'surface must survive the close animation'); + await wait(35); + assert.equal(retained, false); +}); + +test('rapid close/reopen cancels the pending release', async () => { + let retained = delayedPresenceReducer(false, 'show'); + const cancelHide = scheduleDelayedPresenceHide(20, () => { + retained = delayedPresenceReducer(retained, 'hide'); + }); + cancelHide(); + retained = delayedPresenceReducer(retained, 'show'); + await wait(30); + assert.equal(retained, true); +}); + +test('background drop releases immediately and foreground open restores', () => { + let retained = delayedPresenceReducer(false, 'show'); + retained = delayedPresenceReducer(retained, 'drop'); + assert.equal(retained, false); + retained = delayedPresenceReducer(retained, 'show'); + assert.equal(retained, true); +}); + +test('focused EQ surface remains through the tab settling window', async () => { + assert.equal(EQ_GRAPH_UNMOUNT_DELAY_MS, 190); + let retained = delayedPresenceReducer(false, 'show'); + scheduleDelayedPresenceHide(EQ_GRAPH_UNMOUNT_DELAY_MS, () => { + retained = delayedPresenceReducer(retained, 'hide'); + }); + await wait(EQ_GRAPH_UNMOUNT_DELAY_MS - 20); + assert.equal(retained, true); + await wait(35); + assert.equal(retained, false); +}); diff --git a/src/components/delayedPresence.ts b/src/components/delayedPresence.ts new file mode 100644 index 0000000..92f7d1a --- /dev/null +++ b/src/components/delayedPresence.ts @@ -0,0 +1,51 @@ +import { useEffect, useReducer } from 'react'; + +export type DelayedPresenceEvent = 'show' | 'hide' | 'drop'; + +/** + * Tiny state machine shared by heavyweight render surfaces. `hide` is emitted + * only after the caller's linger timer, while `drop` releases the surface + * immediately (for example when Android backgrounds the activity). + */ +export function delayedPresenceReducer( + retained: boolean, + event: DelayedPresenceEvent +): boolean { + if (event === 'show') return true; + if (event === 'hide' || event === 'drop') return false; + return retained; +} + +export function scheduleDelayedPresenceHide(delayMs: number, onHide: () => void) { + const timer = setTimeout(onHide, delayMs); + return () => clearTimeout(timer); +} + +/** + * Keep a subtree mounted briefly after `active` turns false so its exit + * animation can finish, then release it. Re-activation cancels the pending + * release. `drop` bypasses the delay for background/low-visibility teardown. + */ +export function useDelayedUnmountPresence( + active: boolean, + delayMs: number, + drop = false +): boolean { + const [retained, dispatch] = useReducer(delayedPresenceReducer, active && !drop); + + useEffect(() => { + if (drop) { + dispatch('drop'); + return undefined; + } + if (active) { + dispatch('show'); + return undefined; + } + if (!retained) return undefined; + + return scheduleDelayedPresenceHide(delayMs, () => dispatch('hide')); + }, [active, delayMs, drop, retained]); + + return !drop && (active || retained); +} diff --git a/src/components/player/NowPlayingHost.tsx b/src/components/player/NowPlayingHost.tsx index 47a6cd0..63f15ba 100644 --- a/src/components/player/NowPlayingHost.tsx +++ b/src/components/player/NowPlayingHost.tsx @@ -1,25 +1,24 @@ -import { useEffect } from 'react'; import { NowPlayingOverlay } from '@/components/player/NowPlayingOverlay'; +import { useDelayedUnmountPresence } from '@/components/delayedPresence'; +import { NOW_PLAYING_CLOSE_UNMOUNT_MS } from '@/components/renderPresenceTiming'; +import { useAppForeground } from '@/lib/useAppForeground'; import { usePlayerUiStore } from '@/stores/playerUiStore'; -import { usePlayerStore } from '@/stores/playerStore'; - -const PREWARM_DELAY_MS = 2000; /** - * Mount gate for the always-mounted now-playing overlay. Nothing mounts until a - * track exists (cold start unchanged); shortly after playback first starts the - * overlay pre-warms hidden so even the FIRST open is a pure slide, no mount cost. + * Presence gate for the heavyweight now-playing tree. It stays alive just past + * the 200 ms close animation, but never remains hidden indefinitely. Android + * backgrounding drops it immediately so TextureViews and decoded art release. */ export function NowPlayingHost() { - const everOpened = usePlayerUiStore((s) => s.everOpened); - const hasTrack = usePlayerStore((s) => Boolean(s.currentTrack)); + const playerOpen = usePlayerUiStore((s) => s.playerOpen); + const foreground = useAppForeground(); - useEffect(() => { - if (everOpened || !hasTrack) return; - const timer = setTimeout(() => usePlayerUiStore.getState().prewarm(), PREWARM_DELAY_MS); - return () => clearTimeout(timer); - }, [everOpened, hasTrack]); + const renderOverlay = useDelayedUnmountPresence( + playerOpen, + NOW_PLAYING_CLOSE_UNMOUNT_MS, + !foreground + ); - if (!everOpened) return null; + if (!renderOverlay) return null; return ; } diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 62853cf..4a78218 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -40,6 +40,7 @@ import { ScopeRack } from '@/components/player/ScopeRack'; import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane'; import { PlayerStateIcon } from '@/components/player/PlayerStateIcon'; import { CachedLyricPeek } from '@/components/player/CachedLyricPeek'; +import { useDelayedUnmountPresence } from '@/components/delayedPresence'; import { radius, spacing, @@ -61,7 +62,10 @@ import { } from '@/components/player/nowPlayingLayout'; import { resolveNavigationArtist, splitCollaborators } from '@/library/artistGrouping'; import { buildArtistNameTokens } from '@/shared/library/artistCredits'; -import { artworkThumbFromSource } from '@/library/artwork'; +import { + artworkThumbFromSource, + playerBackdropArtworkSource, +} from '@/library/artwork'; import { useLibraryStore } from '@/stores/libraryStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlayerStore } from '@/stores/playerStore'; @@ -129,6 +133,7 @@ export function NowPlayingOverlay() { const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible); const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible); const scopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle); + const railStyle = scopeStyle === 'rail'; const lyricsVisible = useSettingsStore((s) => s.lyricsVisible); const setLyricsVisible = useSettingsStore((s) => s.setLyricsVisible); const nowPlayingCompanion = useSettingsStore((s) => s.nowPlayingCompanion); @@ -147,7 +152,6 @@ export function NowPlayingOverlay() { const desktopQueue = useDesktopRemoteStore((s) => s.queue); const sendDesktopControl = useDesktopRemoteStore((s) => s.sendControl); const reconnectDesktop = useDesktopRemoteStore((s) => s.reconnect); - const phonePresentation = getPhonePlaybackPresentation({ track, playbackState, @@ -163,6 +167,15 @@ export function NowPlayingOverlay() { desktop: desktopPresentation, }); const isDesktopTarget = activePresentation.target === 'desktop'; + const effectiveScopeStageVisible = !isDesktopTarget && scopeStageVisible; + const renderScopeSurfaces = useDelayedUnmountPresence( + effectiveScopeStageVisible, + motion.snap.duration + ); + const renderArtworkFace = useDelayedUnmountPresence( + railStyle || !effectiveScopeStageVisible, + motion.snap.duration + ); const activeTrack = desktopSnapshot?.currentTrack ?? null; const transitionTrackKey = isDesktopTarget ? activeTrack?.id ?? '' : track?.id ?? ''; const isPlaying = activePresentation.playbackState === 'playing'; @@ -170,15 +183,15 @@ export function NowPlayingOverlay() { // Wash off a low-res thumbnail (like the album/artist detail headers do) so the // blur reads as pure colors — full-res art keeps its detail at any blur radius. // currentTrack only carries the full-size artworkData, so derive the thumb from it. - const washArtworkUri = artworkThumbFromSource( - isDesktopTarget ? activePresentation.artworkUri : track?.artworkData ?? null - ); + const backdropArtworkUri = isDesktopTarget + ? artworkThumbFromSource(activePresentation.artworkUri) + : playerBackdropArtworkSource(track); + const washArtworkUri = backdropArtworkUri; const availableHeight = windowHeight - insets.top - insets.bottom; const effectiveWidth = windowWidth - insets.left - insets.right; // The rack style swaps the art card's face in place, so only the rail style // reserves stage height for a scope strip below the art. - const railStyle = scopeStyle === 'rail'; - const layoutScopeVisible = isDesktopTarget ? false : scopeStageVisible && railStyle; + const layoutScopeVisible = effectiveScopeStageVisible && railStyle; const standardLayout = getNowPlayingLayout( effectiveWidth, availableHeight, @@ -338,10 +351,9 @@ export function NowPlayingOverlay() { const menuProgress = useSharedValue(0); const trackProgress = useSharedValue(1); // ∿ engagement, shared by both scope styles: rail = art shrink + strip fade, - // rack = art face crossfading to the instrument rack. The scope surface stays - // mounted either way (its frame loops idle while hidden), so visibility is - // purely this value — no mount state to juggle. - const stageProgress = useSharedValue(scopeStageVisible ? 1 : 0); + // rack = art face crossfading to the instrument rack. The presence gates keep + // both faces for the 220 ms transition, then release the invisible surface. + const stageProgress = useSharedValue(effectiveScopeStageVisible ? 1 : 0); useEffect(() => { if (!transitionTrackKey) return; @@ -350,8 +362,8 @@ export function NowPlayingOverlay() { }, [trackProgress, transitionTrackKey]); useEffect(() => { - stageProgress.value = withTiming(scopeStageVisible ? 1 : 0, motion.snap); - }, [scopeStageVisible, stageProgress]); + stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap); + }, [effectiveScopeStageVisible, stageProgress]); // Closing is a store toggle, not navigation. Reset the inner layers so a // reopen starts from the plain player (parity with the old per-open mount). const dismiss = () => { @@ -916,18 +928,22 @@ export function NowPlayingOverlay() { }, ]} > - {track.artworkData ? ( - - ) : ( - - )} + {renderArtworkFace ? ( + track.artworkData ? ( + + ) : ( + + ) + ) : null} - {!railStyle && ( + {!railStyle && renderScopeSurfaces && ( )} - {railStyle && !layout.isWide && ( + {railStyle && !layout.isWide && renderScopeSurfaces && ( )} - {railStyle && layout.isWide && scopeStageVisible && ( + {railStyle && layout.isWide && renderScopeSurfaces && ( diff --git a/src/components/renderPresenceTiming.ts b/src/components/renderPresenceTiming.ts new file mode 100644 index 0000000..01c375c --- /dev/null +++ b/src/components/renderPresenceTiming.ts @@ -0,0 +1,7 @@ +import { TAB_TRANSITION_SETTLE_MS } from '../navigation/tabTransition.ts'; + +/** Slightly longer than the overlay's 200 ms direct-close animation. */ +export const NOW_PLAYING_CLOSE_UNMOUNT_MS = 220; + +/** Keep the EQ surface through the native tab spring's settling window. */ +export const EQ_GRAPH_UNMOUNT_DELAY_MS = TAB_TRANSITION_SETTLE_MS + 30; diff --git a/src/lib/useAppForeground.ts b/src/lib/useAppForeground.ts new file mode 100644 index 0000000..a6dba7f --- /dev/null +++ b/src/lib/useAppForeground.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; + +export function isForegroundAppState(state: AppStateStatus | null): boolean { + return state === 'active'; +} + +/** + * Explicit foreground signal for render loops and native-backed surfaces. + * React Native normally suspends animation frames in the background, but + * unmounting these surfaces also releases their TextureViews and GPU backing. + */ +export function useAppForeground(): boolean { + const [foreground, setForeground] = useState(() => + isForegroundAppState(AppState.currentState) + ); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + setForeground(isForegroundAppState(state)); + }); + return () => subscription.remove(); + }, []); + + return foreground; +} diff --git a/src/library/artwork.ts b/src/library/artwork.ts index ce77483..34b5d98 100644 --- a/src/library/artwork.ts +++ b/src/library/artwork.ts @@ -3,6 +3,7 @@ import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; import { artworkUrlForTrack } from '@/services/remoteUrls'; +import type { Track } from '@/types/audio'; import type { Album, DbTrack } from '@/types/library'; let artworkDir: string | null = null; @@ -49,11 +50,11 @@ export function artworkThumbUri(hash: string): string { /** * Low-res thumbnail for a live artwork source. The player store's currentTrack - * (rebuilt from RNTP via `rntpToTrack`) only carries the full-size `artworkData` - * — `file://…/artwork/` for local tracks — not the hash. Recover the cached - * file name from the path and point at the generated thumb so callers can blur it - * down to pure color. Remote URLs and base64 data URLs have no local thumb, so - * they pass through unchanged. + * (rebuilt from RNTP via `rntpToTrack`) carries the full-size `artworkData` in a + * custom queue field — `file://…/artwork/` for local tracks — but not the + * hash. Recover the cached file name from the path and point at the generated + * thumb. Remote URLs and base64 data URLs have no local thumb, so they pass + * through unchanged. */ export function artworkThumbFromSource(source: string | null | undefined): string | null { if (!source) return null; @@ -67,6 +68,29 @@ export function artworkThumbFromSource(source: string | null | undefined): strin } } +const PLAYER_BACKDROP_ARTWORK_SIZE = 256; + +/** + * Memory-bounded artwork for blurred/dim player atmosphere. Local tracks use + * the existing 128 px derivative; remote servers are asked for a 256 px cover. + * The visible cover continues to use the normal display source. + */ +export function playerBackdropArtworkSource( + track: Pick< + Track, + 'artworkData' | 'sourceType' | 'sourceId' | 'artworkSourceId' + > | null | undefined +): string | null { + if (!track) return null; + if (track.sourceType && track.sourceType !== 'local') { + return ( + artworkUrlForTrack(track, { size: PLAYER_BACKDROP_ARTWORK_SIZE }) ?? + artworkThumbFromSource(track.artworkData) + ); + } + return artworkThumbFromSource(track.artworkData); +} + type TrackArtworkFields = Pick< DbTrack, 'source_type' | 'source_id' | 'artwork_source_id' | 'artwork_hash' diff --git a/src/services/remoteUrls.ts b/src/services/remoteUrls.ts index b9a17e3..036093e 100644 --- a/src/services/remoteUrls.ts +++ b/src/services/remoteUrls.ts @@ -58,7 +58,8 @@ export function buildCoverArtUrlTemplate(sourceId: number): string | null { /** Build the cover-art URL for a remote track, or null if unavailable. */ export function artworkUrlForTrack( - track: Pick + track: Pick, + options: { size?: number } = {} ): string | null { if (!track.sourceType || track.sourceType === 'local') return null; if (track.sourceId == null || !track.artworkSourceId) return null; @@ -66,11 +67,15 @@ export function artworkUrlForTrack( if (!cfg) return null; if (cfg.type === 'subsonic') { - return buildSubsonicCoverArtUrl(connection(cfg), track.artworkSourceId); + return buildSubsonicCoverArtUrl(connection(cfg), track.artworkSourceId, { + size: options.size, + }); } if (cfg.type === 'jellyfin') { if (!cfg.accessToken) return null; - return buildJellyfinCoverArtUrl(connection(cfg), track.artworkSourceId, cfg.accessToken); + return buildJellyfinCoverArtUrl(connection(cfg), track.artworkSourceId, cfg.accessToken, { + maxWidth: options.size, + }); } return null; } diff --git a/src/session/SessionLifecycle.tsx b/src/session/SessionLifecycle.tsx index 206babf..07eef68 100644 --- a/src/session/SessionLifecycle.tsx +++ b/src/session/SessionLifecycle.tsx @@ -95,7 +95,7 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) { // Every relaunch begins at rest even when a React activity was rebuilt // inside a still-live JS process. - usePlayerUiStore.setState({ playerOpen: false, everOpened: false }); + usePlayerUiStore.setState({ playerOpen: false }); useSearchStore.getState().closeQuickSearch(); const liveNativeSession = await hasActiveNativePlaybackSession(); diff --git a/src/stores/playerUiStore.ts b/src/stores/playerUiStore.ts index 21f3946..486e5b9 100644 --- a/src/stores/playerUiStore.ts +++ b/src/stores/playerUiStore.ts @@ -1,25 +1,18 @@ import { create } from 'zustand'; /** - * Now-playing overlay gate. The player is an always-mounted overlay above the - * navigator (not a route): `everOpened` latches the first mount so cold start - * pays nothing, `playerOpen` drives the UI-thread slide open/close. Session - * state only — never persisted. + * Now-playing overlay gate. The player is an overlay above the navigator (not + * a route); its host retains it only long enough to finish the close animation. + * Session state only — never persisted. */ interface PlayerUiStore { playerOpen: boolean; - /** Mount latch: once true the overlay stays mounted (hidden) for instant reopen. */ - everOpened: boolean; openPlayer: () => void; closePlayer: () => void; - /** Mount the overlay hidden (e.g. shortly after playback starts) so even the first open is instant. */ - prewarm: () => void; } export const usePlayerUiStore = create((set) => ({ playerOpen: false, - everOpened: false, - openPlayer: () => set({ playerOpen: true, everOpened: true }), + openPlayer: () => set({ playerOpen: true }), closePlayer: () => set({ playerOpen: false }), - prewarm: () => set({ everOpened: true }), }));