mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
performance improvements
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { usePathname, useRouter } from 'expo-router';
|
||||
import { Text } from './Text';
|
||||
import { AstraLogo } from './AstraLogo';
|
||||
import { SpectrumCurve } from './SpectrumCurve';
|
||||
@@ -21,6 +21,7 @@ import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||
import { skipToNext, togglePlay } from '@/audio/playbackController';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { artworkThumbFromSource } from '@/library/artwork';
|
||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
||||
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
|
||||
import {
|
||||
@@ -56,6 +57,13 @@ function MiniProgress({
|
||||
);
|
||||
}
|
||||
|
||||
/** Phone-target progress: subscribes here so the 2Hz tick skips the whole pill. */
|
||||
function PhoneMiniProgress({ isPlaying }: { isPlaying: boolean }) {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
return <MiniProgress currentTime={currentTime} duration={duration} isPlaying={isPlaying} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent floating mini-player (M3 redesign): a rounded pill above the tab
|
||||
* bar with the live filled-line spectrum drifting behind the metadata. Tapping
|
||||
@@ -65,11 +73,10 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
||||
const track = usePlayerStore((s) => s.currentTrack);
|
||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const desktopConnection = useDesktopRemoteStore((s) => s.connection);
|
||||
const desktopConnectionState = useDesktopRemoteStore((s) => s.connectionState);
|
||||
const desktopSnapshot = useDesktopRemoteStore((s) => s.snapshot);
|
||||
@@ -83,8 +90,6 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
|
||||
const phonePresentation = getPhonePlaybackPresentation({
|
||||
track,
|
||||
playbackState,
|
||||
currentTime,
|
||||
duration,
|
||||
});
|
||||
const desktopPresentation = getDesktopPlaybackPresentation({
|
||||
connection: desktopConnection,
|
||||
@@ -102,7 +107,9 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
|
||||
const isDesktop = presentation.target === 'desktop';
|
||||
const isPlaying = presentation.playbackState === 'playing';
|
||||
const isLoading = presentation.playbackState === 'loading';
|
||||
const liveScopeActive = visible && scopeActive && !isDesktop;
|
||||
// The pill sits underneath the now-playing transparentModal; don't burn a
|
||||
// second live-scope frame loop while it's fully occluded.
|
||||
const liveScopeActive = visible && scopeActive && !isDesktop && pathname !== '/now-playing';
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
|
||||
const onTogglePlay = () => {
|
||||
@@ -137,7 +144,6 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
|
||||
<SpectrumCurve
|
||||
active={liveScopeActive}
|
||||
pointCount={CURVE_POINTS}
|
||||
analysisFrameMs={0}
|
||||
dbMin={-84}
|
||||
dbMax={-20}
|
||||
width={pillWidth}
|
||||
@@ -155,7 +161,11 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
|
||||
<View style={styles.row}>
|
||||
<View style={styles.art}>
|
||||
{presentation.artworkUri ? (
|
||||
<Image source={{ uri: presentation.artworkUri }} style={styles.artImage} contentFit="cover" />
|
||||
<Image
|
||||
source={{ uri: artworkThumbFromSource(presentation.artworkUri) ?? presentation.artworkUri }}
|
||||
style={styles.artImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<AstraLogo size={20} />
|
||||
)}
|
||||
@@ -193,11 +203,15 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
|
||||
</View>
|
||||
|
||||
{presentation.hasTrack ? (
|
||||
<MiniProgress
|
||||
currentTime={presentation.currentTime}
|
||||
duration={presentation.duration}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
isDesktop ? (
|
||||
<MiniProgress
|
||||
currentTime={presentation.currentTime}
|
||||
duration={presentation.duration}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
) : (
|
||||
<PhoneMiniProgress isPlaying={isPlaying} />
|
||||
)
|
||||
) : null}
|
||||
</Pressable>
|
||||
<PlaybackTargetPicker
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SkiaPictureView,
|
||||
StrokeCap,
|
||||
StrokeJoin,
|
||||
type SkPath,
|
||||
type SkPicture
|
||||
} from '@shopify/react-native-skia';
|
||||
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
|
||||
@@ -20,6 +21,8 @@ interface OscilloscopeWaveProps {
|
||||
active: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Live render cadence; 0 means display-sync. */
|
||||
frameMs?: number;
|
||||
color?: string;
|
||||
lineWidth?: number;
|
||||
glow?: boolean;
|
||||
@@ -56,6 +59,37 @@ function makeStrokePaint(color: string, width: number, alpha = 1) {
|
||||
return paint;
|
||||
}
|
||||
|
||||
function writeWavePath(
|
||||
samples: Float32Array,
|
||||
sampleCount: number,
|
||||
width: number,
|
||||
height: number,
|
||||
lineWidth: number,
|
||||
gain: number,
|
||||
path: SkPath
|
||||
) {
|
||||
path.reset();
|
||||
const n = Math.min(sampleCount, samples.length);
|
||||
if (n < 2 || width <= 0 || height <= 0) return;
|
||||
|
||||
const mid = height / 2;
|
||||
const amp = mid - lineWidth;
|
||||
const xAt = (i: number) => (i / (n - 1)) * width;
|
||||
const yAt = (i: number) => {
|
||||
let v = samples[i] * gain;
|
||||
// Per-track gain targets ~85% of full scale, so this only catches the rare
|
||||
// intra-track peak that runs a touch hotter than the analyzed sample peak.
|
||||
if (v < -1) v = -1;
|
||||
else if (v > 1) v = 1;
|
||||
return mid - v * amp;
|
||||
};
|
||||
|
||||
path.moveTo(0, yAt(0));
|
||||
for (let i = 1; i < n; i++) {
|
||||
path.lineTo(xAt(i), yAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
function buildPicture(
|
||||
samples: Float32Array,
|
||||
sampleCount: number,
|
||||
@@ -68,32 +102,13 @@ function buildPicture(
|
||||
): SkPicture {
|
||||
const recorder = Skia.PictureRecorder();
|
||||
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
|
||||
const n = Math.min(sampleCount, samples.length);
|
||||
const path = Skia.Path.Make();
|
||||
writeWavePath(samples, sampleCount, width, height, lineWidth, gain, path);
|
||||
|
||||
if (n >= 2 && width > 0 && height > 0) {
|
||||
const path = Skia.Path.Make();
|
||||
const mid = height / 2;
|
||||
const amp = mid - lineWidth;
|
||||
const xAt = (i: number) => (i / (n - 1)) * width;
|
||||
const yAt = (i: number) => {
|
||||
let v = samples[i] * gain;
|
||||
// Per-track gain targets ~85% of full scale, so this only catches the rare
|
||||
// intra-track peak that runs a touch hotter than the analyzed sample peak.
|
||||
if (v < -1) v = -1;
|
||||
else if (v > 1) v = 1;
|
||||
return mid - v * amp;
|
||||
};
|
||||
|
||||
path.moveTo(0, yAt(0));
|
||||
for (let i = 1; i < n; i++) {
|
||||
path.lineTo(xAt(i), yAt(i));
|
||||
}
|
||||
|
||||
if (glow) {
|
||||
canvas.drawPath(path, makeStrokePaint(color, lineWidth * 3, 0.18));
|
||||
}
|
||||
canvas.drawPath(path, makeStrokePaint(color, lineWidth));
|
||||
if (glow) {
|
||||
canvas.drawPath(path, makeStrokePaint(color, lineWidth * 3, 0.18));
|
||||
}
|
||||
canvas.drawPath(path, makeStrokePaint(color, lineWidth));
|
||||
|
||||
return recorder.finishRecordingAsPicture();
|
||||
}
|
||||
@@ -111,6 +126,7 @@ export function OscilloscopeWave({
|
||||
active,
|
||||
width,
|
||||
height,
|
||||
frameMs = 16,
|
||||
color: colorProp,
|
||||
lineWidth = 2,
|
||||
glow = false,
|
||||
@@ -141,24 +157,42 @@ export function OscilloscopeWave({
|
||||
|
||||
let mounted = true;
|
||||
let raf = 0;
|
||||
let lastDraw = 0;
|
||||
const drawThreshold = frameMs > 0 ? Math.max(0, frameMs - 0.5) : 0;
|
||||
|
||||
// Paints and the path live for the whole effect run; per-frame allocation
|
||||
// was measurable GC/JSI churn at 60fps.
|
||||
const strokePaint = makeStrokePaint(color, lineWidth);
|
||||
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, 0.18) : null;
|
||||
const bounds = Skia.XYWHRect(0, 0, width, height);
|
||||
const path = Skia.Path.Make();
|
||||
|
||||
const draw = (sampleCount: number) => {
|
||||
const gain = useScopeStore.getState().oscGain;
|
||||
const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow, gain);
|
||||
api.setJsiProperty(view.nativeId, 'picture', picture);
|
||||
writeWavePath(values, sampleCount, width, height, lineWidth, gain, path);
|
||||
const recorder = Skia.PictureRecorder();
|
||||
const canvas = recorder.beginRecording(bounds);
|
||||
if (glowPaint) canvas.drawPath(path, glowPaint);
|
||||
canvas.drawPath(path, strokePaint);
|
||||
api.setJsiProperty(view.nativeId, 'picture', recorder.finishRecordingAsPicture());
|
||||
api.requestRedraw(view.nativeId);
|
||||
};
|
||||
|
||||
values.fill(0);
|
||||
draw(values.length);
|
||||
// Inactive: leave the flat line and schedule nothing instead of idling a rAF.
|
||||
if (!active) return;
|
||||
|
||||
const tick = () => {
|
||||
const tick = (t: number) => {
|
||||
if (!mounted) return;
|
||||
raf = requestAnimationFrame(tick);
|
||||
if (!active) return;
|
||||
if (drawThreshold > 0 && t - lastDraw < drawThreshold) return;
|
||||
|
||||
const n = AstraScope.getOscilloscopeFrame(values);
|
||||
if (n > 0) draw(n);
|
||||
if (n > 0) {
|
||||
lastDraw = t;
|
||||
draw(n);
|
||||
}
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
@@ -166,7 +200,7 @@ export function OscilloscopeWave({
|
||||
mounted = false;
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [active, color, glow, height, lineWidth, width]);
|
||||
}, [active, color, frameMs, glow, height, lineWidth, width]);
|
||||
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
StrokeCap,
|
||||
StrokeJoin,
|
||||
TileMode,
|
||||
type SkPath,
|
||||
type SkPicture
|
||||
} from '@shopify/react-native-skia';
|
||||
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
|
||||
@@ -119,10 +120,18 @@ function makeFadePaint(color: string, startAlpha: number, endAlpha: number, x0:
|
||||
return paint;
|
||||
}
|
||||
|
||||
function buildPaths(values: ArrayLike<number>, width: number, height: number, pad: number) {
|
||||
const line = Skia.Path.Make();
|
||||
function writePaths(
|
||||
values: ArrayLike<number>,
|
||||
width: number,
|
||||
height: number,
|
||||
pad: number,
|
||||
line: SkPath,
|
||||
fill: SkPath
|
||||
) {
|
||||
line.reset();
|
||||
fill.reset();
|
||||
const n = values.length;
|
||||
if (n < 2 || width <= 0 || height <= 0) return { line, fill: line.copy() };
|
||||
if (n < 2 || width <= 0 || height <= 0) return;
|
||||
|
||||
const usableH = height - pad * 2;
|
||||
const xAt = (i: number) => (i / (n - 1)) * width;
|
||||
@@ -139,11 +148,16 @@ function buildPaths(values: ArrayLike<number>, width: number, height: number, pa
|
||||
}
|
||||
line.lineTo(xAt(n - 1), yAt(n - 1));
|
||||
|
||||
const fill = line.copy();
|
||||
fill.addPath(line);
|
||||
fill.lineTo(width, height);
|
||||
fill.lineTo(0, height);
|
||||
fill.close();
|
||||
}
|
||||
|
||||
function buildPaths(values: ArrayLike<number>, width: number, height: number, pad: number) {
|
||||
const line = Skia.Path.Make();
|
||||
const fill = Skia.Path.Make();
|
||||
writePaths(values, width, height, pad, line, fill);
|
||||
return { line, fill };
|
||||
}
|
||||
|
||||
@@ -319,7 +333,9 @@ export function SpectrumCurve({
|
||||
const color = colorProp ?? themeColors.accent;
|
||||
const edgeFadeColor = edgeFadeColorProp ?? themeColors.bgPrimary;
|
||||
const viewRef = useRef<SkiaPictureView | null>(null);
|
||||
const activePointCount = Math.max(2, Math.floor(width));
|
||||
// Half a point per pixel, capped: the quadTo midpoint smoothing makes denser
|
||||
// sampling visually indistinguishable while doubling per-frame path cost.
|
||||
const activePointCount = Math.min(160, Math.max(96, Math.floor(width / 2)));
|
||||
const resolvedPointCount = pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS);
|
||||
const staticValues = useMemo(
|
||||
() => values ?? new Float32Array(resolvedPointCount),
|
||||
@@ -374,22 +390,37 @@ export function SpectrumCurve({
|
||||
const renderValues = new Float32Array(resolvedPointCount);
|
||||
const pointOptions = { dbMin, dbMax, tiltDbPerOctave };
|
||||
|
||||
// Paints, shaders, and paths live for the whole effect run: allocating them
|
||||
// (and the gradient shaders) per frame was measurable GC/JSI churn at 60fps.
|
||||
const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity);
|
||||
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, glowOpacity) : null;
|
||||
const fillPaint = makeFillPaint(color, height, fillOpacity);
|
||||
const fadeWidth = Math.min(edgeFadeWidth, width * 0.5);
|
||||
const fade =
|
||||
edgeFade && fadeWidth > 0
|
||||
? {
|
||||
leftRect: Skia.XYWHRect(0, 0, fadeWidth, height),
|
||||
leftPaint: makeFadePaint(edgeFadeColor, 1, 0, 0, fadeWidth),
|
||||
rightRect: Skia.XYWHRect(width - fadeWidth, 0, fadeWidth, height),
|
||||
rightPaint: makeFadePaint(edgeFadeColor, 0, 1, width - fadeWidth, width),
|
||||
}
|
||||
: null;
|
||||
const bounds = Skia.XYWHRect(0, 0, width, height);
|
||||
const linePath = Skia.Path.Make();
|
||||
const fillPath = Skia.Path.Make();
|
||||
|
||||
const draw = () => {
|
||||
const picture = buildPicture(
|
||||
renderValues,
|
||||
width,
|
||||
height,
|
||||
color,
|
||||
lineWidth,
|
||||
lineOpacity,
|
||||
fillOpacity,
|
||||
glow,
|
||||
glowOpacity,
|
||||
edgeFade,
|
||||
edgeFadeColor,
|
||||
edgeFadeWidth
|
||||
);
|
||||
api.setJsiProperty(view.nativeId, 'picture', picture);
|
||||
writePaths(renderValues, width, height, lineWidth, linePath, fillPath);
|
||||
const recorder = Skia.PictureRecorder();
|
||||
const canvas = recorder.beginRecording(bounds);
|
||||
canvas.drawPath(fillPath, fillPaint);
|
||||
if (glowPaint) canvas.drawPath(linePath, glowPaint);
|
||||
canvas.drawPath(linePath, strokePaint);
|
||||
if (fade) {
|
||||
canvas.drawRect(fade.leftRect, fade.leftPaint);
|
||||
canvas.drawRect(fade.rightRect, fade.rightPaint);
|
||||
}
|
||||
api.setJsiProperty(view.nativeId, 'picture', recorder.finishRecordingAsPicture());
|
||||
api.requestRedraw(view.nativeId);
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ import { commitHaptic, tickHaptic } from '@/lib/haptics';
|
||||
type IconName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
const SWIPE_ACTIVE_OFFSET_X = 10;
|
||||
const SWIPE_FAIL_OFFSET_Y = 30;
|
||||
// Scroll-slop-sized: at 30 every vertical drag starting on a row had to travel
|
||||
// 30px before the pan failed and the surrounding scrollable could win.
|
||||
const SWIPE_FAIL_OFFSET_Y = 12;
|
||||
|
||||
export interface SwipeAction {
|
||||
icon: IconName;
|
||||
|
||||
@@ -12,7 +12,9 @@ import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
|
||||
const CANVAS_HEIGHT = 96;
|
||||
const STAGE_FRAME_MS = 0; // display-sync
|
||||
// 60fps cap: display-sync (0) pinned the JS thread at 120Hz on high-refresh
|
||||
// devices and starved every other animation.
|
||||
const STAGE_FRAME_MS = 16;
|
||||
|
||||
type Mode = 'spectrum' | 'scope';
|
||||
|
||||
@@ -23,6 +25,8 @@ interface VisualizerProps {
|
||||
showChrome?: boolean;
|
||||
mode?: Mode;
|
||||
edgeFade?: boolean;
|
||||
/** Freeze the live scopes without unmounting (e.g. while occluded by an overlay). */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,12 +41,13 @@ export function Visualizer({
|
||||
showChrome = true,
|
||||
mode: controlledMode,
|
||||
edgeFade = false,
|
||||
paused = false,
|
||||
}: VisualizerProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const [uncontrolledMode, setUncontrolledMode] = useState<Mode>('spectrum');
|
||||
const mode = controlledMode ?? uncontrolledMode;
|
||||
const scopeActive = useScopeActive();
|
||||
const scopeActive = useScopeActive() && !paused;
|
||||
const spectrumActive = scopeActive && mode === 'spectrum';
|
||||
const scopeWaveActive = scopeActive && mode === 'scope';
|
||||
|
||||
@@ -75,6 +80,7 @@ export function Visualizer({
|
||||
) : (
|
||||
<OscilloscopeWave
|
||||
active={scopeWaveActive}
|
||||
frameMs={STAGE_FRAME_MS}
|
||||
width={width}
|
||||
height={height}
|
||||
glow
|
||||
|
||||
@@ -31,9 +31,6 @@ const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver
|
||||
type WaveformQuality = 'preview' | 'accurate';
|
||||
|
||||
interface WaveformSeekBarProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying?: boolean;
|
||||
onSeek: (seconds: number) => void;
|
||||
height?: number;
|
||||
touchPadding?: number;
|
||||
@@ -48,11 +45,11 @@ const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction));
|
||||
* played/unplayed split, draggable playhead) on Skia, while keeping SeekBar's
|
||||
* tap/drag + pending-seek "hold" state machine verbatim so seeking behaves
|
||||
* identically. Peaks load offline (getWaveform) and fall back to flat bars.
|
||||
*
|
||||
* Phone-target only: progress comes straight from the player store so the 2Hz
|
||||
* tick re-renders this leaf, not the whole now-playing tree.
|
||||
*/
|
||||
export function WaveformSeekBar({
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying = false,
|
||||
onSeek,
|
||||
height = CANVAS_HEIGHT,
|
||||
touchPadding = spacing.md,
|
||||
@@ -60,6 +57,9 @@ export function WaveformSeekBar({
|
||||
}: WaveformSeekBarProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const isPlaying = usePlayerStore((s) => s.playbackState === 'playing');
|
||||
const [scrubFraction, setScrubFraction] = useState<number | null>(null);
|
||||
const [barWidth, setBarWidth] = useState(0);
|
||||
const pendingSeek = usePlayerStore((s) => s.pendingSeek);
|
||||
|
||||
@@ -151,7 +151,7 @@ export function EQGraph({
|
||||
active={spectrumActive}
|
||||
width={width}
|
||||
height={height}
|
||||
frameMs={0}
|
||||
frameMs={16}
|
||||
color={colors.accent}
|
||||
lineOpacity={0.22}
|
||||
fillOpacity={0.5}
|
||||
|
||||
@@ -15,14 +15,13 @@ import { SeekBar } from '@/components/SeekBar';
|
||||
import { LyricsBand } from './LyricsBand';
|
||||
import { spacing, radius } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useLyricsStore } from '@/stores/lyricsStore';
|
||||
import { getLyricsPayloadSourceLabel } from '@/lyrics/presentation';
|
||||
import type { Track } from '@/types/audio';
|
||||
|
||||
interface LyricsViewProps {
|
||||
track: Track;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isLoading: boolean;
|
||||
isFavorite: boolean;
|
||||
@@ -37,8 +36,6 @@ interface LyricsViewProps {
|
||||
|
||||
export function LyricsView({
|
||||
track,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
isFavorite,
|
||||
@@ -52,6 +49,10 @@ export function LyricsView({
|
||||
}: LyricsViewProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
// Lyrics mode is phone-target only, so progress comes straight from the
|
||||
// player store — the 2Hz tick re-renders this takeover, not the whole screen.
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const result = useLyricsStore((s) => s.byPath[track.path]?.result ?? null);
|
||||
const sourceLabel = result?.status === 'hit' ? getLyricsPayloadSourceLabel(result.lyrics) : null;
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { artworkThumbFromSource } from '@/library/artwork';
|
||||
import { dragArmHaptic, tickHaptic } from '@/lib/haptics';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
import {
|
||||
@@ -86,7 +87,9 @@ function trackArtist(track: RntpTrack): string {
|
||||
}
|
||||
|
||||
function artworkUri(track: RntpTrack): string | undefined {
|
||||
return typeof track.artwork === 'string' ? track.artwork : undefined;
|
||||
// RNTP tracks carry the full-size cover; 42px rows want the generated thumb.
|
||||
if (typeof track.artwork !== 'string') return undefined;
|
||||
return artworkThumbFromSource(track.artwork) ?? undefined;
|
||||
}
|
||||
|
||||
function queueCountLabel(count: number): string {
|
||||
@@ -130,7 +133,12 @@ function reconcileQueueEntries(
|
||||
return tracks.map((track) => {
|
||||
const identity = rntpKey(track);
|
||||
const reused = available.get(identity)?.shift();
|
||||
if (reused) return { ...reused, track, identity };
|
||||
if (reused) {
|
||||
// Same track object → same entry object, so memo'd rows bail out when
|
||||
// only other parts of the queue changed (e.g. a track advance).
|
||||
if (reused.track === track) return reused;
|
||||
return { ...reused, track, identity };
|
||||
}
|
||||
|
||||
const key = `${identity}:${nextSerial.current}`;
|
||||
nextSerial.current += 1;
|
||||
@@ -142,7 +150,9 @@ interface QueueTrayProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function QueueTray({ onClose }: QueueTrayProps) {
|
||||
// memo: the parent now-playing screen re-renders on store changes; the tray's
|
||||
// ~15-hook body shouldn't re-execute unless its own inputs change.
|
||||
export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -156,6 +166,24 @@ export function QueueTray({ onClose }: QueueTrayProps) {
|
||||
// freeze. Clamping the list container to the window height caps the viewport
|
||||
// no matter what the sheet reports; both snap points stay unaffected.
|
||||
const listClampStyle = useMemo(() => ({ maxHeight: windowHeight }), [windowHeight]);
|
||||
// Same bug, milder symptom: a viewport measured during the open animation can
|
||||
// stick at the clamp height (taller than the sheet's real content area), which
|
||||
// silently shortens the scroll range — the last few rows become unreachable.
|
||||
// Mounting the list only after the sheet settles removes the bad window.
|
||||
const [listReady, setListReady] = useState(false);
|
||||
const onSheetChange = useCallback((index: number) => {
|
||||
if (index >= 0) setListReady(true);
|
||||
}, []);
|
||||
// Bottom padding clears the gesture-nav inset so the last row is fully
|
||||
// scrollable into view at the 100% snap.
|
||||
const listContentStyle = useMemo(
|
||||
() => [styles.listContent, { paddingBottom: spacing.xxl + insets.bottom }],
|
||||
[styles, insets.bottom]
|
||||
);
|
||||
const listContentEditStyle = useMemo(
|
||||
() => [styles.listContent, { paddingBottom: spacing.xxl * 2 + insets.bottom }],
|
||||
[styles, insets.bottom]
|
||||
);
|
||||
|
||||
const { tracks, activeIndex, hasSnapshot, refresh } = useQueue(true);
|
||||
const currentTrack = activeIndex >= 0 ? tracks[activeIndex] : undefined;
|
||||
@@ -551,6 +579,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
|
||||
enablePanDownToClose
|
||||
enableContentPanningGesture={!editMode}
|
||||
enableHandlePanningGesture
|
||||
onChange={onSheetChange}
|
||||
onClose={onClose}
|
||||
backdropComponent={renderBackdrop}
|
||||
backgroundStyle={styles.sheetBg}
|
||||
@@ -603,23 +632,24 @@ export function QueueTray({ onClose }: QueueTrayProps) {
|
||||
Up next
|
||||
</Text>
|
||||
|
||||
<FlashList
|
||||
data={entries}
|
||||
scrollEnabled={!editMode}
|
||||
style={listClampStyle}
|
||||
keyExtractor={(item) => item.key}
|
||||
drawDistance={QUEUE_ROW_HEIGHT * 12}
|
||||
maintainVisibleContentPosition={{ disabled: true }}
|
||||
renderScrollComponent={renderFlashListScrollComponent}
|
||||
renderItem={renderItem}
|
||||
extraData={listExtraData}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
editMode && selectedCount > 0 ? styles.listContentWithActionBar : null,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
/>
|
||||
{listReady ? (
|
||||
<FlashList
|
||||
data={entries}
|
||||
scrollEnabled={!editMode}
|
||||
style={listClampStyle}
|
||||
keyExtractor={(item) => item.key}
|
||||
drawDistance={QUEUE_ROW_HEIGHT * 12}
|
||||
maintainVisibleContentPosition={{ disabled: true }}
|
||||
renderScrollComponent={renderFlashListScrollComponent}
|
||||
renderItem={renderItem}
|
||||
extraData={listExtraData}
|
||||
contentContainerStyle={
|
||||
editMode && selectedCount > 0 ? listContentEditStyle : listContentStyle
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{editMode && selectedCount > 0 ? (
|
||||
<View style={[styles.actionBar, { paddingBottom: insets.bottom + spacing.sm }]}>
|
||||
@@ -649,7 +679,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
|
||||
) : null}
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const Artwork = memo(function Artwork({ uri, title }: { uri?: string; title?: string }) {
|
||||
const styles = useStyles();
|
||||
@@ -967,12 +997,8 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: spacing.xxl,
|
||||
flexGrow: 1,
|
||||
},
|
||||
listContentWithActionBar: {
|
||||
paddingBottom: spacing.xxl * 2,
|
||||
},
|
||||
empty: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
Reference in New Issue
Block a user