performance improvements

This commit is contained in:
Boof2015
2026-07-12 02:02:37 -04:00
parent 9c7d37772b
commit 5cc1099853
21 changed files with 453 additions and 165 deletions
+27 -11
View File
@@ -1,3 +1,4 @@
import { useMemo, useRef } from 'react';
import { Tabs } from 'expo-router';
// RN's Easing (not reanimated): the bottom-tabs scene transition runs on legacy
// Animated.timing and may use the native driver, so the easing must be serializable.
@@ -6,23 +7,32 @@ import { TabBar, type TabItem } from '@/components/TabBar';
import { useColors } from '@/theme/themed';
const TAB_TRANSITION_MS = 160;
const TAB_EASING = Easing.out(Easing.cubic);
export default function TabsLayout() {
const colors = useColors();
const lastSwitchAt = useRef(0);
// Stable screenOptions identity: handing the navigator a fresh options object
// mid-transition (e.g. on a Material You palette change) re-runs the scene
// animation effect and can strand the incoming scene at opacity 0.
const screenOptions = useMemo(
() => ({
headerShown: false,
freezeOnBlur: false,
sceneStyle: { backgroundColor: colors.bgPrimary },
// Directional slide + cross-fade between tabs, following tab order.
animation: 'shift' as const,
transitionSpec: {
animation: 'timing' as const,
config: { duration: TAB_TRANSITION_MS, easing: TAB_EASING },
},
}),
[colors.bgPrimary]
);
return (
<Tabs
detachInactiveScreens={false}
screenOptions={{
headerShown: false,
freezeOnBlur: false,
sceneStyle: { backgroundColor: colors.bgPrimary },
// Directional slide + cross-fade between tabs, following tab order.
animation: 'shift',
transitionSpec: {
animation: 'timing',
config: { duration: TAB_TRANSITION_MS, easing: Easing.out(Easing.cubic) },
},
}}
screenOptions={screenOptions}
tabBar={({ state, navigation }) => {
const items: TabItem[] = state.routes.map((route, index) => ({
key: route.key,
@@ -31,12 +41,18 @@ export default function TabsLayout() {
}));
const handlePress = (item: TabItem) => {
// Interrupting the native-driver shift animation can drop its
// completion frame and leave the incoming scene invisible; swallow
// taps until the current transition has finished.
const now = Date.now();
if (now - lastSwitchAt.current < TAB_TRANSITION_MS + 30) return;
const event = navigation.emit({
type: 'tabPress',
target: item.key,
canPreventDefault: true,
});
if (!item.focused && !event.defaultPrevented) {
lastSwitchAt.current = now;
navigation.navigate(item.name);
}
};
+14 -15
View File
@@ -136,24 +136,32 @@ function AlbumCover({ album, size }: { album: Album; size: number }) {
);
}
/** Progress strip subscribes here so the 2Hz tick skips the card and screen. */
function NowPlayingSeekStrip() {
const styles = useStyles();
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
return (
<View style={styles.seekTrack}>
<View style={[styles.seekFill, { width: `${progress * 100}%` }]} />
</View>
);
}
function NowPlayingCard({
track,
playbackState,
currentTime,
duration,
onOpen,
}: {
track: Track;
playbackState: PlaybackState;
currentTime: number;
duration: number;
onOpen: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
const scopeActive = useScopeActive();
const [cardSize, setCardSize] = useState({ width: 0, height: PLAYER_CARD_MIN_HEIGHT });
@@ -171,7 +179,6 @@ function NowPlayingCard({
<SpectrumCurve
active={scopeActive}
pointCount={CURVE_POINTS}
analysisFrameMs={0}
dbMin={-84}
dbMax={-20}
width={cardSize.width}
@@ -204,9 +211,7 @@ function NowPlayingCard({
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{track.album ? `${track.artist} / ${track.album}` : track.artist}
</Text>
<View style={styles.seekTrack}>
<View style={[styles.seekFill, { width: `${progress * 100}%` }]} />
</View>
<NowPlayingSeekStrip />
<View style={styles.placeholderControls}>
<Pressable hitSlop={10} onPress={() => void skipToPrevious()}>
<Ionicons name="play-skip-back" size={22} color={colors.textSecondary} />
@@ -377,8 +382,6 @@ export default function HomeScreen() {
const currentTrack = usePlayerStore((s) => s.currentTrack);
const currentPath = currentTrack?.path;
const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
const openQuickSearch = useSearchStore((s) => s.openQuickSearch);
const [randomAlbumKey, setRandomAlbumKey] = useState<string | null>(null);
@@ -483,8 +486,6 @@ export default function HomeScreen() {
<NowPlayingCard
track={currentTrack}
playbackState={playbackState}
currentTime={currentTime}
duration={duration}
onOpen={() => router.push('/now-playing')}
/>
</View>
@@ -501,8 +502,6 @@ export default function HomeScreen() {
<NowPlayingCard
track={currentTrack}
playbackState={playbackState}
currentTime={currentTime}
duration={duration}
onOpen={() => router.push('/now-playing')}
/>
) : randomAlbum ? (
+6 -12
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import {
View,
Pressable,
@@ -273,6 +273,8 @@ export default function NowPlayingScreen() {
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [queueOpen, setQueueOpen] = useState(false);
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
const closeQueue = useCallback(() => setQueueOpen(false), []);
const [menuOpen, setMenuOpen] = useState(false);
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
const [playlistActionTrack, setPlaylistActionTrack] = useState<DbTrack | null>(null);
@@ -286,8 +288,6 @@ export default function NowPlayingScreen() {
const libraryTracks = useLibraryStore((s) => s.tracks);
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 shuffle = usePlayerStore((s) => s.shuffle);
const repeat = usePlayerStore((s) => s.repeat);
const isFavorite = usePlaylistStore((s) => (track ? s.favoritePaths.has(track.path) : false));
@@ -302,8 +302,6 @@ export default function NowPlayingScreen() {
const phonePresentation = getPhonePlaybackPresentation({
track,
playbackState,
currentTime,
duration,
});
const desktopPresentation = getDesktopPlaybackPresentation({
connection: desktopConnection,
@@ -565,8 +563,6 @@ export default function NowPlayingScreen() {
{lyricsMode && track ? (
<LyricsView
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
isLoading={isLoading}
isFavorite={isFavorite}
@@ -875,6 +871,7 @@ export default function NowPlayingScreen() {
showChrome={false}
mode={scopeMode}
edgeFade
paused={queueOpen}
/>
<Pressable
onPress={() =>
@@ -945,9 +942,6 @@ export default function NowPlayingScreen() {
</View>
<WaveformSeekBar
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
height={layout.waveformHeight}
touchPadding={WAVEFORM_TOUCH_PADDING}
trackPath={track.path}
@@ -1103,9 +1097,9 @@ export default function NowPlayingScreen() {
/>
{queueOpen && (
isDesktopTarget ? (
<RemoteQueueSheet onClose={() => setQueueOpen(false)} />
<RemoteQueueSheet onClose={closeQueue} />
) : (
<QueueTray onClose={() => setQueueOpen(false)} />
<QueueTray onClose={closeQueue} />
)
)}
<PlaybackTargetPicker
+8 -2
View File
@@ -39,8 +39,14 @@ import { setFallbackGainNative, setTrackGainsNative } from '@/audio/eqNative';
/** Persisted fallback gain (dB) — pushed before the stats aggregate on cold start. */
const FALLBACK_DB_KEY = 'normalization_fallback_db';
/** The queue can change rapidly (drag-reorder); coalesce re-registrations. */
const REGISTER_DEBOUNCE_MS = 250;
/**
* The queue can change rapidly (drag-reorder); coalesce re-registrations. Kept
* past the start-of-playback transition: registering a large queue marshals a
* big IN() query + one large JSI map, and nothing needs it that early — the
* current track's gain is activated explicitly and the next few are prefetched;
* the whole-queue map only matters for far jumps (fallback covers the gap).
*/
const REGISTER_DEBOUNCE_MS = 500;
let started = false;
let generation = 0;
+32 -9
View File
@@ -24,35 +24,58 @@ export async function PlaybackService(): Promise<void> {
syncCarNowPlayingFromTrackPlayer(),
]);
// A seek/skip fires 2-3 events back-to-back (track change, buffering, playing),
// and each sync is TrackPlayer getter round-trips + widget RemoteViews/Binder +
// car MediaSession pushes on the main thread — landing exactly during the
// transition the user is watching. Trailing-coalesce the burst into one sync
// with the settled values; 150ms of extra latency on Auto/widget metadata is
// imperceptible.
let syncTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleSync = () => {
if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
syncTimer = null;
void syncNowPlaying();
}, 150);
};
// Deferred past the transition frame like the UI hook's recompute: the track
// already plays at its natively-registered (or fallback) gain from sample
// zero; this only late-corrects unanalyzed tracks. Rapid skips coalesce.
let normalizeTimer: ReturnType<typeof setTimeout> | null = null;
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, () => {
void syncNowPlaying();
scheduleSync();
// Apply normalization here too (not just in the UI hook) so playback started from
// Android Auto / Bluetooth with the app closed is still normalized.
void applyNormalizationForActiveTrack();
if (normalizeTimer) clearTimeout(normalizeTimer);
normalizeTimer = setTimeout(() => {
normalizeTimer = null;
void applyNormalizationForActiveTrack();
}, 300);
});
TrackPlayer.addEventListener(Event.PlaybackState, () => {
void syncNowPlaying();
scheduleSync();
});
TrackPlayer.addEventListener(Event.RemotePlay, () => {
void TrackPlayer.play().finally(() => syncNowPlaying());
void TrackPlayer.play().finally(scheduleSync);
});
TrackPlayer.addEventListener(Event.RemotePause, () => {
void TrackPlayer.pause().finally(() => syncNowPlaying());
void TrackPlayer.pause().finally(scheduleSync);
});
TrackPlayer.addEventListener(Event.RemoteStop, () => {
void TrackPlayer.stop().finally(() => syncNowPlaying());
void TrackPlayer.stop().finally(scheduleSync);
});
TrackPlayer.addEventListener(Event.RemoteNext, () => {
void TrackPlayer.skipToNext()
.catch(() => {})
.finally(() => syncNowPlaying());
.finally(scheduleSync);
});
TrackPlayer.addEventListener(Event.RemotePrevious, () => {
void TrackPlayer.skipToPrevious()
.catch(() => {})
.finally(() => syncNowPlaying());
.finally(scheduleSync);
});
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) =>
TrackPlayer.seekTo(position).finally(() => syncNowPlaying()),
TrackPlayer.seekTo(position).finally(scheduleSync),
);
}
+8 -3
View File
@@ -10,9 +10,14 @@ import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player'
* native indices trail absolute (mirror) indices by `headRemaining`.
*/
const FIRST_CHUNK = 50;
const CHUNK = 200;
const YIELD_MS = 24;
// The first chunk's setQueue lands on the Android main thread at the exact
// moment of the play tap, so it stays tiny. Each background add() also occupies
// the main thread (= the UI thread) for time proportional to its size, so the
// chunks stay small with generous yields — a longer total fill is invisible,
// per-chunk frame drops are not.
const FIRST_CHUNK = 12;
const CHUNK = 50;
const YIELD_MS = 64;
interface QueueLoad {
generation: number;
+15
View File
@@ -25,6 +25,21 @@ async function doSetup(options: { allowBackgroundSetup?: boolean }): Promise<voi
try {
await TrackPlayer.setupPlayer({
autoHandleInterruptions: true,
// IMPORTANT: pass the WHOLE buffer set. Android reads absent keys as 0
// (Bundle.getDouble default) and then rejects setup on the
// `minBuffer >= playBuffer` validation — a partial set silently kills
// playback entirely. min/max are the ExoPlayer defaults spelled out.
minBuffer: 50,
maxBuffer: 50,
// Start/resume playback once 0.5s is buffered (rebuffer resume = 2×
// that). ExoPlayer's defaults are 2.5s/5s — waiting for 5s of buffered
// media was the audible gap after backward seeks. Local files fill 0.5s
// in milliseconds; LAN streams keep well ahead of it.
playBuffer: 0.5,
// Retain 30s behind the playhead so short backward seeks never rebuffer
// at all (the ExoPlayer default is 0 — ANY backward seek discarded the
// buffer and re-fetched from the source).
backBuffer: 30,
...(options.allowBackgroundSetup
? { android: { allowBackgroundSetup: true } }
: {}),
+13 -1
View File
@@ -132,8 +132,19 @@ export function useNormalizationSync(): void {
}, 250);
}
// Deferred past the transition frame: the track already plays at its
// natively-registered (or fallback) gain from sample zero, so recompute
// only late-corrects unanalyzed tracks — no need to compete with the
// skip/play burst. Rapid skips coalesce into one recompute.
let recomputeTimer: ReturnType<typeof setTimeout> | null = null;
const unsubTrack = usePlayerStore.subscribe((state, prev) => {
if (state.currentTrack?.path !== prev.currentTrack?.path) void recompute();
if (state.currentTrack?.path !== prev.currentTrack?.path) {
if (recomputeTimer) clearTimeout(recomputeTimer);
recomputeTimer = setTimeout(() => {
recomputeTimer = null;
void recompute();
}, 300);
}
});
const unsubQueue = useQueueStore.subscribe((state, prev) => {
// Re-warm when the upcoming order changes (reorder, add-next, remove, advance).
@@ -159,6 +170,7 @@ export function useNormalizationSync(): void {
return () => {
cancelled = true;
if (prefetchTimer) clearTimeout(prefetchTimer);
if (recomputeTimer) clearTimeout(recomputeTimer);
unsubTrack();
unsubQueue();
unsubSettings();
+46 -2
View File
@@ -7,7 +7,7 @@ import {
} from 'react-native-track-player';
import { usePlayerStore } from '@/stores/playerStore';
import { useLibraryStore } from '@/stores/libraryStore';
import type { PlaybackState } from '@/types/audio';
import type { PlaybackState, Track } from '@/types/audio';
import { rntpToTrack } from './sampleTracks';
import { buildWidgetRecentItems, setWidgetNowPlaying } from './widgetSync';
@@ -42,6 +42,33 @@ function mapState(state?: State): PlaybackState {
}
}
/**
* Field-exact equality over everything `rntpToTrack` emits. Both the optimistic
* controller write and the RNTP confirmation build tracks through it, so a
* match means the confirmation carries nothing new.
*/
function sameTrack(a: Track | null, b: Track | null): boolean {
if (a === b) return true;
if (!a || !b) return false;
return (
a.id === b.id &&
a.path === b.path &&
a.title === b.title &&
a.artist === b.artist &&
a.album === b.album &&
a.duration === b.duration &&
a.artworkData === b.artworkData &&
a.format === b.format &&
a.sampleRate === b.sampleRate &&
a.bitDepth === b.bitDepth &&
a.bitrate === b.bitrate &&
a.sourceType === b.sourceType &&
a.sourceId === b.sourceId &&
a.sourceTrackId === b.sourceTrackId &&
a.artworkSourceId === b.artworkSourceId
);
}
function resolveTransientLoading(
rawState: PlaybackState,
activeTrackPath: string | null,
@@ -86,9 +113,14 @@ export function usePlaybackSync(): void {
useEffect(() => {
const nextTrack = activeTrack ? rntpToTrack(activeTrack) : null;
if (usePlayerStore.getState().currentTrack?.path !== nextTrack?.path) {
const prevTrack = usePlayerStore.getState().currentTrack;
if (prevTrack?.path !== nextTrack?.path) {
usePlayerStore.getState().clearPendingSeek();
}
// RNTP usually just confirms the optimistic track the controller already
// wrote; skip the redundant store write (a full re-render wave of every
// currentTrack subscriber) when nothing actually changed.
if (sameTrack(prevTrack, nextTrack)) return;
setCurrentTrack(nextTrack);
}, [activeTrack, setCurrentTrack]);
@@ -110,6 +142,18 @@ export function usePlaybackSync(): void {
activeTrackPath,
stablePlayback.current
);
if (
mappedPlaybackState === 'loading' &&
(stablePlayback.current.state === 'playing' || stablePlayback.current.state === 'paused')
) {
// Cross-track loading (skip/advance): local transitions resolve almost
// instantly, so surfacing 'loading' immediately just flaps the play icon
// and re-renders every playbackState subscriber twice per skip. Hold the
// previous state and only show the spinner if the load actually drags
// (e.g. a slow remote stream). Cleanup cancels on the next state event.
const timer = setTimeout(() => setPlaybackState('loading'), 250);
return () => clearTimeout(timer);
}
setPlaybackState(mappedPlaybackState);
if (mappedPlaybackState !== 'loading') {
stablePlayback.current = {
+56 -4
View File
@@ -20,17 +20,61 @@ function mapRntpState(state?: State): PlaybackState {
}
}
// Last payload actually handed to the native module. Every widget push builds
// RemoteViews + a Binder IPC to the launcher on the main thread, and seek/skip
// fire several state events carrying identical resolved payloads — dedupe here
// so both callers (UI hook + headless service) collapse to real changes only.
let lastPushed: {
title: string | null;
artist: string | null;
artworkUri: string | null;
playbackState: PlaybackState;
hasTrack: boolean;
recents: AstraWidgetRecentItem[] | null;
} | null = null;
function sameRecents(a: AstraWidgetRecentItem[], b: AstraWidgetRecentItem[] | null): boolean {
if (!b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (
a[i].title !== b[i].title ||
a[i].artist !== b[i].artist ||
a[i].artworkUri !== b[i].artworkUri
) {
return false;
}
}
return true;
}
export function setWidgetNowPlaying(
track: Pick<Track, 'title' | 'artist' | 'artworkData'> | null,
playbackState: PlaybackState,
recentlyPlayed?: AstraWidgetRecentItem[],
): void {
const title = track?.title ?? null;
const artist = track?.artist ?? null;
const artworkUri = track?.artworkData ?? null;
const hasTrack = Boolean(track);
const coreSame =
lastPushed != null &&
lastPushed.title === title &&
lastPushed.artist === artist &&
lastPushed.artworkUri === artworkUri &&
lastPushed.playbackState === playbackState &&
lastPushed.hasTrack === hasTrack;
// `recentlyPlayed === undefined` means "leave the recents as they are".
const recentsSame =
recentlyPlayed === undefined || (lastPushed != null && sameRecents(recentlyPlayed, lastPushed.recents));
if (coreSame && recentsSame) return;
AstraWidget.setNowPlaying({
title: track?.title ?? null,
artist: track?.artist ?? null,
artworkUri: track?.artworkData ?? null,
title,
artist,
artworkUri,
playbackState,
hasTrack: Boolean(track),
hasTrack,
...(recentlyPlayed === undefined
? {}
: {
@@ -38,6 +82,14 @@ export function setWidgetNowPlaying(
replaceRecentlyPlayed: true,
}),
});
lastPushed = {
title,
artist,
artworkUri,
playbackState,
hasTrack,
recents: recentlyPlayed === undefined ? (lastPushed?.recents ?? null) : recentlyPlayed,
};
}
export function setWidgetNowPlayingFromRntpTrack(
+27 -13
View File
@@ -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
+64 -30
View File
@@ -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 }} />;
+51 -20
View File
@@ -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);
};
+3 -1
View File
@@ -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;
+8 -2
View File
@@ -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
+6 -6
View File
@@ -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);
+1 -1
View File
@@ -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}
+5 -4
View File
@@ -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;
+51 -25
View File
@@ -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',
+7 -4
View File
@@ -67,13 +67,16 @@ export function hostFromBaseUrl(baseUrl: string): string {
export function getPhonePlaybackPresentation({
track,
playbackState,
currentTime,
duration,
// Live progress is subscribed by leaf components (MiniProgress, seek bars) so
// parents don't re-render on the 2Hz tick; only the desktop presentation
// carries snapshot-fed progress through here.
currentTime = 0,
duration = 0,
}: {
track: Track | null;
playbackState: PlaybackState;
currentTime: number;
duration: number;
currentTime?: number;
duration?: number;
}): PlaybackPresentation {
return {
target: 'phone',
+5
View File
@@ -14,10 +14,15 @@ export function useScopeLifecycle(): void {
useEffect(() => {
let reduceMotion = false;
let appActive = AppState.currentState === 'active';
// recompute runs on every playerStore change (incl. 2Hz progress writes);
// only touch the native tap + store when the gate actually flips.
let lastOn: boolean | null = null;
const recompute = () => {
const playing = usePlayerStore.getState().playbackState === 'playing';
const on = playing && appActive && !reduceMotion;
if (on === lastOn) return;
lastOn = on;
AstraScope.setActive(on);
useScopeStore.getState().setActive(on);
};