mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
reduce graphics memory and hidden render work
This commit is contained in:
+9
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
-12
@@ -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<AstraWidgetRecentItem>,
|
||||
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<String, Bitmap?>(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 = "")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <jni.h>
|
||||
#include <memory>
|
||||
|
||||
-#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, "<init>", "(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<WindowContext> _surfaceHolder = nullptr;
|
||||
std::shared_ptr<RNSkPlatformContext> _platformContext;
|
||||
- jobject _jSurfaceTexture = nullptr;
|
||||
- jmethodID _updateTexImageMethod = nullptr;
|
||||
};
|
||||
} // namespace RNSkia
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
+15
-5
@@ -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<SheetKind>('none');
|
||||
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null);
|
||||
const [pendingCurrentAction, setPendingCurrentAction] = useState<CurrentPresetAction | null>(null);
|
||||
@@ -238,7 +248,7 @@ export default function EQScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
const graphEl = (
|
||||
const graphEl = renderEqGraphics ? (
|
||||
<EQGraph
|
||||
bands={eq.bands}
|
||||
activeBandId={eq.activeBandId}
|
||||
@@ -251,15 +261,15 @@ export default function EQScreen() {
|
||||
}}
|
||||
onChangeBand={(id, updates) => 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 ? (
|
||||
<View style={styles.graphicEditor}>
|
||||
<GraphicEQPanel gains={eq.graphicGains} enabled={eq.enabled} onChangeGain={eq.setGraphicGain} />
|
||||
</View>
|
||||
);
|
||||
) : null;
|
||||
|
||||
const stripEl = (
|
||||
<BandStrip
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Track as RntpTrack } from 'react-native-track-player';
|
||||
import type { Track } from '@/types/audio';
|
||||
import { streamUrlForTrack } from '@/services/remoteUrls';
|
||||
import { artworkThumbFromSource, playerBackdropArtworkSource } from '@/library/artwork';
|
||||
|
||||
/**
|
||||
* M0 verification tracks. Streamed from a public royalty-free source so playback
|
||||
@@ -43,13 +44,20 @@ export function toRntpTrack(track: Track): RntpTrack {
|
||||
// playing match the `tracks` row. Local tracks already play from their path.
|
||||
const isRemote = !!track.sourceType && track.sourceType !== 'local';
|
||||
const url = isRemote ? (streamUrlForTrack(track) ?? track.path) : track.path;
|
||||
// RNTP hands `artwork` to MediaSession/notification code, which eagerly
|
||||
// decodes local files as native Bitmaps. Supplying the full cover here can
|
||||
// retain tens of MiB even while Now Playing is closed. Keep that surface
|
||||
// bounded and carry Astra's display source separately for the sharp cover.
|
||||
const notificationArtwork = isRemote
|
||||
? playerBackdropArtworkSource(track)
|
||||
: artworkThumbFromSource(track.artworkData);
|
||||
return {
|
||||
id: track.id,
|
||||
url,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
artwork: track.artworkData,
|
||||
artwork: notificationArtwork ?? undefined,
|
||||
duration: track.duration,
|
||||
// Custom fields preserved by RNTP and read back in `rntpToTrack`.
|
||||
format: track.format,
|
||||
@@ -61,6 +69,7 @@ export function toRntpTrack(track: Track): RntpTrack {
|
||||
sourceId: track.sourceId,
|
||||
sourceTrackId: track.sourceTrackId,
|
||||
artworkSourceId: track.artworkSourceId,
|
||||
astraArtworkData: track.artworkData,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,7 +83,12 @@ export function rntpToTrack(rt: RntpTrack): Track {
|
||||
artist: rt.artist ?? 'Unknown artist',
|
||||
album: rt.album ?? '',
|
||||
duration: typeof rt.duration === 'number' ? rt.duration : 0,
|
||||
artworkData: typeof rt.artwork === 'string' ? rt.artwork : undefined,
|
||||
artworkData:
|
||||
typeof rt.astraArtworkData === 'string'
|
||||
? rt.astraArtworkData
|
||||
: typeof rt.artwork === 'string'
|
||||
? rt.artwork
|
||||
: undefined,
|
||||
format: (rt.format as string) ?? 'PCM',
|
||||
sampleRate: rt.sampleRate as number | undefined,
|
||||
bitDepth: rt.bitDepth as number | undefined,
|
||||
|
||||
@@ -2,7 +2,7 @@ import TrackPlayer, { State, type Track as RntpTrack } from 'react-native-track-
|
||||
|
||||
import type { PlaybackState, Track } from '@/types/audio';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
import { artworkThumbUri } from '@/library/artwork';
|
||||
import { artworkThumbFromSource, artworkThumbUri } from '@/library/artwork';
|
||||
import { AstraWidget, type AstraWidgetRecentItem } from '../../modules/astra-widget';
|
||||
|
||||
function mapRntpState(state?: State): PlaybackState {
|
||||
@@ -54,7 +54,10 @@ export function setWidgetNowPlaying(
|
||||
): void {
|
||||
const title = track?.title ?? null;
|
||||
const artist = track?.artist ?? null;
|
||||
const artworkUri = track?.artworkData ?? null;
|
||||
// RemoteViews never needs the full player cover. Feeding it the existing
|
||||
// thumbnail also avoids reading the full artwork file into a temporary byte
|
||||
// array for every play/pause widget refresh.
|
||||
const artworkUri = artworkThumbFromSource(track?.artworkData) ?? null;
|
||||
const hasTrack = Boolean(track);
|
||||
|
||||
const coreSame =
|
||||
|
||||
@@ -24,6 +24,7 @@ import { skipToNext, togglePlay } from '@/audio/playbackController';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { artworkThumbFromSource } from '@/library/artwork';
|
||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
||||
import { useAppForeground } from '@/lib/useAppForeground';
|
||||
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
|
||||
import {
|
||||
getDesktopPlaybackPresentation,
|
||||
@@ -81,6 +82,7 @@ export function MiniPlayer() {
|
||||
const connectDesktop = useDesktopRemoteStore((s) => 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 = () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <NowPlayingOverlay />;
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<Image
|
||||
source={{ uri: track.artworkData }}
|
||||
style={styles.artImage}
|
||||
contentFit="cover"
|
||||
transition={reduceMotion ? null : 200}
|
||||
/>
|
||||
) : (
|
||||
<AstraLogo size={Math.round(artBoxSize * 0.4)} />
|
||||
)}
|
||||
{renderArtworkFace ? (
|
||||
track.artworkData ? (
|
||||
<Image
|
||||
source={{ uri: track.artworkData }}
|
||||
style={styles.artImage}
|
||||
contentFit="cover"
|
||||
cachePolicy="disk"
|
||||
allowDownscaling
|
||||
transition={reduceMotion ? null : 200}
|
||||
/>
|
||||
) : (
|
||||
<AstraLogo size={Math.round(artBoxSize * 0.4)} />
|
||||
)
|
||||
) : null}
|
||||
</Animated.View>
|
||||
{!railStyle && (
|
||||
{!railStyle && renderScopeSurfaces && (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[styles.rackFace, rackFaceStyle]}
|
||||
@@ -935,16 +951,16 @@ export function NowPlayingOverlay() {
|
||||
<ScopeRack
|
||||
size={artBoxSize}
|
||||
stripWidth={layout.scopeWidth}
|
||||
artworkUri={track.artworkData ?? null}
|
||||
paused={!playerOpen || queueOpen || !scopeStageVisible}
|
||||
artworkUri={backdropArtworkUri}
|
||||
paused={!playerOpen || queueOpen || !effectiveScopeStageVisible}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{railStyle && !layout.isWide && (
|
||||
{railStyle && !layout.isWide && renderScopeSurfaces && (
|
||||
<Animated.View
|
||||
pointerEvents={scopeStageVisible ? 'auto' : 'none'}
|
||||
pointerEvents={effectiveScopeStageVisible ? 'auto' : 'none'}
|
||||
style={[
|
||||
styles.scopeRailFloating,
|
||||
railSurfaceStyle,
|
||||
@@ -959,13 +975,13 @@ export function NowPlayingOverlay() {
|
||||
width={layout.scopeWidth}
|
||||
height={layout.scopeHeight}
|
||||
mode={scopeMode}
|
||||
paused={!playerOpen || queueOpen || !scopeStageVisible}
|
||||
revealed={scopeStageVisible}
|
||||
paused={!playerOpen || queueOpen || !effectiveScopeStageVisible}
|
||||
revealed={effectiveScopeStageVisible}
|
||||
onSwap={swapScopeMode}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
{railStyle && layout.isWide && scopeStageVisible && (
|
||||
{railStyle && layout.isWide && renderScopeSurfaces && (
|
||||
<View
|
||||
style={[
|
||||
styles.scopeRail,
|
||||
@@ -981,8 +997,8 @@ export function NowPlayingOverlay() {
|
||||
width={layout.scopeWidth}
|
||||
height={layout.scopeHeight}
|
||||
mode={scopeMode}
|
||||
paused={!playerOpen || queueOpen}
|
||||
revealed={scopeStageVisible}
|
||||
paused={!playerOpen || queueOpen || !effectiveScopeStageVisible}
|
||||
revealed={effectiveScopeStageVisible}
|
||||
onSwap={swapScopeMode}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
+29
-5
@@ -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/<hash>` 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/<hash>` 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'
|
||||
|
||||
@@ -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, 'sourceType' | 'sourceId' | 'artworkSourceId'>
|
||||
track: Pick<Track, 'sourceType' | 'sourceId' | 'artworkSourceId'>,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<PlayerUiStore>((set) => ({
|
||||
playerOpen: false,
|
||||
everOpened: false,
|
||||
openPlayer: () => set({ playerOpen: true, everOpened: true }),
|
||||
openPlayer: () => set({ playerOpen: true }),
|
||||
closePlayer: () => set({ playerOpen: false }),
|
||||
prewarm: () => set({ everOpened: true }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user