reduce graphics memory and hidden render work

This commit is contained in:
Boof2015
2026-07-14 01:06:15 -04:00
parent a8c1f4fd22
commit c9d065e662
22 changed files with 868 additions and 147 deletions
+15 -5
View File
@@ -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
+16 -2
View File
@@ -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,
+5 -2
View File
@@ -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 =
+3 -1
View File
@@ -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 = () => {
+65 -16
View File
@@ -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) {
+83 -30
View File
@@ -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) {
+56
View File
@@ -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);
});
+51
View File
@@ -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);
}
+14 -15
View File
@@ -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 />;
}
+49 -33
View File
@@ -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>
+7
View File
@@ -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;
+26
View File
@@ -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
View File
@@ -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'
+8 -3
View File
@@ -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;
}
+1 -1
View File
@@ -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();
+4 -11
View File
@@ -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 }),
}));