mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
reduce graphics memory and hidden render work
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user