mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
performance improvements
This commit is contained in:
+27
-11
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } }
|
||||
: {}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user