diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index 452d8c0..ca9e0f6 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -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 ( { 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); } }; diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index b15c8ea..b5c0436 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -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 ( + + + + ); +} + 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({ {track.album ? `${track.artist} / ${track.album}` : track.artist} - - - + void skipToPrevious()}> @@ -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(null); @@ -483,8 +486,6 @@ export default function HomeScreen() { router.push('/now-playing')} /> @@ -501,8 +502,6 @@ export default function HomeScreen() { router.push('/now-playing')} /> ) : randomAlbum ? ( diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index 2bd2a78..bfcaaeb 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -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(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 ? ( @@ -945,9 +942,6 @@ export default function NowPlayingScreen() { {queueOpen && ( isDesktopTarget ? ( - setQueueOpen(false)} /> + ) : ( - setQueueOpen(false)} /> + ) )} { 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 | 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 | 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), ); } diff --git a/src/audio/queueLoader.ts b/src/audio/queueLoader.ts index a4b18a6..e94a926 100644 --- a/src/audio/queueLoader.ts +++ b/src/audio/queueLoader.ts @@ -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; diff --git a/src/audio/trackPlayer.ts b/src/audio/trackPlayer.ts index 06d095b..4c55323 100644 --- a/src/audio/trackPlayer.ts +++ b/src/audio/trackPlayer.ts @@ -25,6 +25,21 @@ async function doSetup(options: { allowBackgroundSetup?: boolean }): Promise= 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 } } : {}), diff --git a/src/audio/useNormalizationSync.ts b/src/audio/useNormalizationSync.ts index 7e6caf1..eeb6ab6 100644 --- a/src/audio/useNormalizationSync.ts +++ b/src/audio/useNormalizationSync.ts @@ -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 | 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(); diff --git a/src/audio/usePlaybackSync.ts b/src/audio/usePlaybackSync.ts index e67c69d..bca52f2 100644 --- a/src/audio/usePlaybackSync.ts +++ b/src/audio/usePlaybackSync.ts @@ -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 = { diff --git a/src/audio/widgetSync.ts b/src/audio/widgetSync.ts index 96888fa..757cd25 100644 --- a/src/audio/widgetSync.ts +++ b/src/audio/widgetSync.ts @@ -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 | 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( diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index a12a6cc..08d14c1 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -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 ; +} + /** * 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) { {presentation.artworkUri ? ( - + ) : ( )} @@ -193,11 +203,15 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) { {presentation.hasTrack ? ( - + isDesktop ? ( + + ) : ( + + ) ) : null} (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 ; diff --git a/src/components/SpectrumCurve.tsx b/src/components/SpectrumCurve.tsx index 8e7fe7d..31779f8 100644 --- a/src/components/SpectrumCurve.tsx +++ b/src/components/SpectrumCurve.tsx @@ -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, width: number, height: number, pad: number) { - const line = Skia.Path.Make(); +function writePaths( + values: ArrayLike, + 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, 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, 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(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); }; diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index 471028e..9899c4a 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -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; diff --git a/src/components/Visualizer.tsx b/src/components/Visualizer.tsx index aad8a59..be90716 100644 --- a/src/components/Visualizer.tsx +++ b/src/components/Visualizer.tsx @@ -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('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({ ) : ( 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(null); const [barWidth, setBarWidth] = useState(0); const pendingSeek = usePlayerStore((s) => s.pendingSeek); diff --git a/src/components/eq/EQGraph.tsx b/src/components/eq/EQGraph.tsx index 9ff214d..f86b607 100644 --- a/src/components/eq/EQGraph.tsx +++ b/src/components/eq/EQGraph.tsx @@ -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} diff --git a/src/components/lyrics/LyricsView.tsx b/src/components/lyrics/LyricsView.tsx index 8fa8394..5f99e31 100644 --- a/src/components/lyrics/LyricsView.tsx +++ b/src/components/lyrics/LyricsView.tsx @@ -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; diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index a0a9adb..45c8748 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -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 - 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 ? ( + 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 ? ( @@ -649,7 +679,7 @@ export function QueueTray({ onClose }: QueueTrayProps) { ) : null} ); -} +}); 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', diff --git a/src/playback/playbackTargetPresentation.ts b/src/playback/playbackTargetPresentation.ts index 6629117..c108de1 100644 --- a/src/playback/playbackTargetPresentation.ts +++ b/src/playback/playbackTargetPresentation.ts @@ -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', diff --git a/src/scope/useScopeLifecycle.ts b/src/scope/useScopeLifecycle.ts index ec96a02..d90d5d8 100644 --- a/src/scope/useScopeLifecycle.ts +++ b/src/scope/useScopeLifecycle.ts @@ -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); };