improve now playing UI speed

This commit is contained in:
Boof2015
2026-07-05 16:33:44 -04:00
parent 3a25966dde
commit 006e55422b
10 changed files with 562 additions and 67 deletions
+1 -1
View File
@@ -603,9 +603,9 @@ export default function NowPlayingScreen() {
<WaveformSeekBar
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
height={layout.waveformHeight}
touchPadding={WAVEFORM_TOUCH_PADDING}
trackKey={track.id}
trackPath={track.path}
onSeek={(seconds) => void seekTo(seconds)}
/>
+133 -18
View File
@@ -1,13 +1,13 @@
import TrackPlayer, {
isPlaying,
RepeatMode,
State,
type Track as RntpTrack,
} from 'react-native-track-player';
import type { Track } from '@/types/audio';
import type { PlaybackState, Track } from '@/types/audio';
import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
import { useQueueStore } from '@/stores/queueStore';
import { setupPlayer } from './trackPlayer';
import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
import { SAMPLE_TRACKS, rntpToTrack, toRntpTrack } from './sampleTracks';
import {
absoluteIndexToNative,
appendUpcomingChunked,
@@ -50,10 +50,52 @@ function toRntpRepeat(mode: RepeatModeStr): RepeatMode {
}
}
function mapRntpState(state?: State): PlaybackState {
switch (state) {
case State.Playing:
return 'playing';
case State.Buffering:
case State.Loading:
return 'loading';
case State.Paused:
case State.Ready:
return 'paused';
default:
return 'stopped';
}
}
function rntpTrackId(track: RntpTrack): string {
return String(track.id ?? track.url);
}
function setOptimisticTrack(track: RntpTrack | undefined, playbackState?: PlaybackState): void {
if (!track) return;
const current = rntpToTrack(track);
const player = usePlayerStore.getState();
player.setCurrentTrack(current);
player.setProgress(0, current.duration);
player.clearPendingSeek();
if (playbackState) player.setPlaybackState(playbackState);
}
async function reconcilePlayerFromNative(): Promise<void> {
try {
const [activeTrack, playbackState, progress] = await Promise.all([
TrackPlayer.getActiveTrack(),
TrackPlayer.getPlaybackState(),
TrackPlayer.getProgress(),
]);
const player = usePlayerStore.getState();
player.setCurrentTrack(activeTrack ? rntpToTrack(activeTrack) : null);
player.setPlaybackState(mapRntpState(playbackState.state));
player.setProgress(progress.position, progress.duration);
player.clearPendingSeek();
} catch {
// PlaybackSync will reconcile on the next native event/tick.
}
}
async function getQueueSnapshot(): Promise<{ queue: RntpTrack[]; activeIndex: number }> {
const store = useQueueStore.getState();
@@ -126,8 +168,15 @@ async function playTracksInternal(
}
const queueTracks = ordered.map(toRntpTrack);
useQueueStore.getState().setSnapshot(queueTracks, startIndex);
await loadQueueChunked(queueTracks, startIndex);
await TrackPlayer.play();
setOptimisticTrack(queueTracks[startIndex], 'loading');
try {
await loadQueueChunked(queueTracks, startIndex);
await TrackPlayer.play();
usePlayerStore.getState().setPlaybackState('playing');
} catch (err) {
await reconcilePlayerFromNative();
throw err;
}
}
/** Shuffle a context and play from the top (the library/album "Shuffle" buttons). */
@@ -138,8 +187,15 @@ export async function shuffleTracks(tracks: Track[]): Promise<void> {
usePlayerStore.getState().setShuffle(true);
const queueTracks = shuffleArray(tracks).map(toRntpTrack);
useQueueStore.getState().setSnapshot(queueTracks, 0);
await loadQueueChunked(queueTracks, 0);
await TrackPlayer.play();
setOptimisticTrack(queueTracks[0], 'loading');
try {
await loadQueueChunked(queueTracks, 0);
await TrackPlayer.play();
usePlayerStore.getState().setPlaybackState('playing');
} catch (err) {
await reconcilePlayerFromNative();
throw err;
}
}
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
@@ -152,45 +208,95 @@ export async function playSample(): Promise<void> {
await TrackPlayer.add(sampleQueue);
originalOrder = SAMPLE_TRACKS.map((t) => t.id);
useQueueStore.getState().setSnapshot(sampleQueue, 0);
setOptimisticTrack(sampleQueue[0], 'loading');
} else {
const activeIndex = await TrackPlayer.getActiveTrackIndex();
useQueueStore.getState().setSnapshot(queue, activeIndex);
setOptimisticTrack(queue[activeIndex ?? 0], 'loading');
}
try {
await TrackPlayer.play();
usePlayerStore.getState().setPlaybackState('playing');
} catch (err) {
await reconcilePlayerFromNative();
throw err;
}
await TrackPlayer.play();
}
export const play = (): Promise<void> => TrackPlayer.play();
export async function play(): Promise<void> {
usePlayerStore.getState().setPlaybackState('playing');
try {
await TrackPlayer.play();
} catch (err) {
await reconcilePlayerFromNative();
throw err;
}
}
export async function playForCar(): Promise<void> {
await ensurePlayerReady({ allowBackgroundSetup: true });
await TrackPlayer.play();
await play();
}
export async function pause(): Promise<void> {
usePlayerStore.getState().setPlaybackState('paused');
try {
await TrackPlayer.pause();
} catch (err) {
await reconcilePlayerFromNative();
throw err;
}
}
export async function seekTo(seconds: number): Promise<void> {
const duration = usePlayerStore.getState().duration;
usePlayerStore.getState().setPendingSeek(seconds);
usePlayerStore.getState().setProgress(seconds, duration);
try {
await TrackPlayer.seekTo(seconds);
} catch (err) {
usePlayerStore.getState().clearPendingSeek();
await reconcilePlayerFromNative();
throw err;
}
}
export const pause = (): Promise<void> => TrackPlayer.pause();
export const seekTo = (seconds: number): Promise<void> => TrackPlayer.seekTo(seconds);
export async function togglePlay(): Promise<void> {
const { playing } = await isPlaying();
const playing = usePlayerStore.getState().playbackState === 'playing';
if (playing) {
await TrackPlayer.pause();
await pause();
} else {
await ensurePlayerReady();
await TrackPlayer.play();
await play();
}
}
export async function skipToNext(): Promise<void> {
const { tracks, activeIndex } = useQueueStore.getState();
const nextIndex = activeIndex >= 0 ? activeIndex + 1 : -1;
if (nextIndex >= 0 && nextIndex < tracks.length) {
useQueueStore.getState().setActiveIndex(nextIndex);
setOptimisticTrack(tracks[nextIndex], usePlayerStore.getState().playbackState);
}
try {
await TrackPlayer.skipToNext();
await refreshActiveIndexFromNative();
} catch {
await reconcilePlayerFromNative();
// no next track — ignore
}
}
export async function skipToPrevious(): Promise<void> {
const { tracks, activeIndex } = useQueueStore.getState();
const previousIndex = activeIndex > 0 ? activeIndex - 1 : -1;
if (previousIndex >= 0 && previousIndex < tracks.length) {
useQueueStore.getState().setActiveIndex(previousIndex);
setOptimisticTrack(tracks[previousIndex], usePlayerStore.getState().playbackState);
}
try {
await TrackPlayer.skipToPrevious();
await refreshActiveIndexFromNative();
} catch {
await reconcilePlayerFromNative();
// no previous track — ignore
}
}
@@ -350,14 +456,23 @@ export async function jumpToQueueIndex(index: number): Promise<void> {
// Mid-fill, the tapped row may not be in the native queue yet (or may sit at
// a shifted native index while the head is still prepending) — translate,
// waiting out the fill only when the target isn't loaded.
const queuedTrack = useQueueStore.getState().tracks[index];
useQueueStore.getState().setActiveIndex(index);
setOptimisticTrack(queuedTrack, 'playing');
let nativeIndex = absoluteIndexToNative(index);
while (nativeIndex == null) {
await queueLoadSettled();
nativeIndex = absoluteIndexToNative(index);
}
await TrackPlayer.skip(nativeIndex);
useQueueStore.getState().setActiveIndex(index);
await TrackPlayer.play();
try {
await TrackPlayer.skip(nativeIndex);
useQueueStore.getState().setActiveIndex(index);
await TrackPlayer.play();
usePlayerStore.getState().setPlaybackState('playing');
} catch (err) {
await reconcilePlayerFromNative();
throw err;
}
}
async function getUpcoming(): Promise<{ activeIndex: number; upcoming: RntpTrack[] }> {
+65 -5
View File
@@ -12,6 +12,8 @@ import { rntpToTrack } from './sampleTracks';
import { buildWidgetRecentItems, setWidgetNowPlaying } from './widgetSync';
const RECENT_PLAY_THRESHOLD_MS = 15_000;
const SEEK_ACK_EPS = 0.75;
const SEEK_ACK_TIMEOUT_MS = 3000;
interface RecentPlayCandidate {
path: string | null;
@@ -20,6 +22,11 @@ interface RecentPlayCandidate {
recorded: boolean;
}
interface StablePlaybackState {
path: string | null;
state: PlaybackState;
}
function mapState(state?: State): PlaybackState {
switch (state) {
case State.Playing:
@@ -35,6 +42,22 @@ function mapState(state?: State): PlaybackState {
}
}
function resolveTransientLoading(
rawState: PlaybackState,
activeTrackPath: string | null,
stable: StablePlaybackState
): PlaybackState {
if (
rawState === 'loading' &&
activeTrackPath != null &&
activeTrackPath === stable.path &&
(stable.state === 'playing' || stable.state === 'paused')
) {
return stable.state;
}
return rawState;
}
/**
* Mirrors RNTP's playback state into `playerStore` so the whole UI reads from
* one Zustand source (matching the desktop pattern). Mount once, near the root.
@@ -43,13 +66,17 @@ export function usePlaybackSync(): void {
const activeTrack = useActiveTrack();
const progress = useProgress(500);
const playbackState = usePlaybackState();
const mappedPlaybackState = mapState(playbackState.state);
const recentPlayCandidate = useRef<RecentPlayCandidate>({
path: null,
accumulatedMs: 0,
playingSinceMs: null,
recorded: false,
});
const stablePlayback = useRef<{ path: string | null; state: PlaybackState }>({
path: null,
state: 'stopped',
});
const rawPlaybackState = mapState(playbackState.state);
const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack);
const setProgress = usePlayerStore((s) => s.setProgress);
@@ -58,16 +85,39 @@ export function usePlaybackSync(): void {
const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks);
useEffect(() => {
setCurrentTrack(activeTrack ? rntpToTrack(activeTrack) : null);
const nextTrack = activeTrack ? rntpToTrack(activeTrack) : null;
if (usePlayerStore.getState().currentTrack?.path !== nextTrack?.path) {
usePlayerStore.getState().clearPendingSeek();
}
setCurrentTrack(nextTrack);
}, [activeTrack, setCurrentTrack]);
useEffect(() => {
const pendingSeek = usePlayerStore.getState().pendingSeek;
if (pendingSeek) {
const acknowledged = Math.abs(progress.position - pendingSeek.target) <= SEEK_ACK_EPS;
const timedOut = Date.now() - pendingSeek.startedAt > SEEK_ACK_TIMEOUT_MS;
if (!acknowledged && !timedOut) return;
usePlayerStore.getState().clearPendingSeek();
}
setProgress(progress.position, progress.duration);
}, [progress.position, progress.duration, setProgress]);
useEffect(() => {
const activeTrackPath = activeTrack ? rntpToTrack(activeTrack).path : null;
const mappedPlaybackState = resolveTransientLoading(
rawPlaybackState,
activeTrackPath,
stablePlayback.current
);
setPlaybackState(mappedPlaybackState);
}, [mappedPlaybackState, setPlaybackState]);
if (mappedPlaybackState !== 'loading') {
stablePlayback.current = {
path: activeTrackPath,
state: mappedPlaybackState,
};
}
}, [activeTrack, rawPlaybackState, setPlaybackState]);
// Push the widget now-playing (incl. the recents list) on track/state/recents change only
// — NOT on every 500ms progress tick. The widget shows no position, so per-tick updates
@@ -78,17 +128,27 @@ export function usePlaybackSync(): void {
// pushed from here at all (the MediaSession extrapolates position between those events).
useEffect(() => {
const track = activeTrack ? rntpToTrack(activeTrack) : null;
const mappedPlaybackState = resolveTransientLoading(
rawPlaybackState,
track?.path ?? null,
stablePlayback.current
);
setWidgetNowPlaying(
track,
mappedPlaybackState,
buildWidgetRecentItems(recentlyPlayedTracks, track?.path),
);
}, [activeTrack, mappedPlaybackState, recentlyPlayedTracks]);
}, [activeTrack, rawPlaybackState, recentlyPlayedTracks]);
useEffect(() => {
// Use the identity path (subsonic://|jellyfin:// for remote; the file URI for
// local) so history matches `tracks.path` — activeTrack.url is the stream URL.
const path = activeTrack ? rntpToTrack(activeTrack).path : null;
const mappedPlaybackState = resolveTransientLoading(
rawPlaybackState,
path,
stablePlayback.current
);
const now = Date.now();
const candidate = recentPlayCandidate.current;
@@ -128,5 +188,5 @@ export function usePlaybackSync(): void {
void recordTrackPlayed(path).catch((err) => {
console.warn('[library] playback history update failed', err);
});
}, [activeTrack, mappedPlaybackState, progress.position, recordTrackPlayed]);
}, [activeTrack, rawPlaybackState, progress.position, recordTrackPlayed]);
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from 'react';
const DISPLAY_FRAME_MS = 66;
function clampTime(value: number, duration: number): number {
if (!Number.isFinite(value)) return 0;
if (duration <= 0) return Math.max(0, value);
return Math.min(duration, Math.max(0, value));
}
/**
* Interpolates displayed playback time between RNTP progress snapshots. RNTP
* remains authoritative; this only makes the visible timeline move smoothly
* instead of stepping at the store mirror cadence.
*/
export function useSmoothPlaybackTime(
currentTime: number,
duration: number,
isPlaying: boolean
): number {
const [displayTime, setDisplayTime] = useState(() => clampTime(currentTime, duration));
const anchorRef = useRef({
time: clampTime(currentTime, duration),
timestamp: 0,
});
useEffect(() => {
const next = clampTime(currentTime, duration);
anchorRef.current = { time: next, timestamp: Date.now() };
const raf = requestAnimationFrame(() => setDisplayTime(next));
return () => cancelAnimationFrame(raf);
}, [currentTime, duration]);
useEffect(() => {
if (!isPlaying || duration <= 0) return;
let raf = 0;
let lastPaint = 0;
const tick = (frameTime: number) => {
raf = requestAnimationFrame(tick);
if (frameTime - lastPaint < DISPLAY_FRAME_MS) return;
lastPaint = frameTime;
const anchor = anchorRef.current;
const elapsed = (Date.now() - anchor.timestamp) / 1000;
setDisplayTime(clampTime(anchor.time + elapsed, duration));
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [duration, isPlaying]);
return displayTime;
}
+20 -4
View File
@@ -19,6 +19,7 @@ import {
import { usePlayerStore } from '@/stores/playerStore';
import { skipToNext, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
const PILL_HEIGHT = 56;
const ART = 42;
@@ -28,6 +29,24 @@ interface MiniPlayerProps {
visible?: boolean;
}
function MiniProgress({
currentTime,
duration,
isPlaying,
}: {
currentTime: number;
duration: number;
isPlaying: boolean;
}) {
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
const progress = duration > 0 ? Math.min(1, smoothTime / duration) : 0;
return (
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
);
}
/**
* Persistent floating mini-player (M3 redesign): a rounded pill above the tab
* bar with the live filled-line spectrum drifting behind the metadata. Tapping
@@ -47,7 +66,6 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
const liveScopeActive = visible && scopeActive;
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
@@ -109,9 +127,7 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
</Pressable>
</View>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
<MiniProgress currentTime={currentTime} duration={duration} isPlaying={isPlaying} />
</Pressable>
);
}
+34 -27
View File
@@ -21,23 +21,22 @@ import { Text } from './Text';
import { colors, spacing } from '@/theme';
import { formatDuration } from '@/lib/format';
import { downsampleWaveform, getWaveform } from '@/scope/waveform';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { usePlayerStore } from '@/stores/playerStore';
const CANVAS_HEIGHT = 58;
const BAR_WIDTH = 3;
const BAR_GAP = 2;
const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver
// While a seek is pending, keep showing the target until the player's reported
// position moves off the pre-seek value (`from`) — i.e. the seek has landed.
const HOLD_EPS = 0.75;
type WaveformQuality = 'preview' | 'accurate';
interface WaveformSeekBarProps {
currentTime: number;
duration: number;
isPlaying?: boolean;
onSeek: (seconds: number) => void;
height?: number;
touchPadding?: number;
/** Identity of the playing track; a pending seek only applies to its own track. */
trackKey?: string | number;
/** Track file URI used to load/cache the offline waveform peaks. */
trackPath?: string;
}
@@ -53,33 +52,48 @@ const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction));
export function WaveformSeekBar({
currentTime,
duration,
isPlaying = false,
onSeek,
height = CANVAS_HEIGHT,
touchPadding = spacing.md,
trackKey,
trackPath,
}: WaveformSeekBarProps) {
const [scrubFraction, setScrubFraction] = useState<number | null>(null);
const [barWidth, setBarWidth] = useState(0);
const [pendingSeek, setPendingSeek] = useState<{
target: number;
from: number;
key?: string | number;
} | null>(null);
const pendingSeek = usePlayerStore((s) => s.pendingSeek);
// Peaks tagged with the path they belong to, so a track change drops the old
// waveform as a pure derivation (no synchronous setState in the effect).
const [loaded, setLoaded] = useState<{ path: string; peaks: Float32Array | null } | null>(null);
const [loaded, setLoaded] = useState<{
path: string;
peaks: Float32Array | null;
quality: WaveformQuality;
} | null>(null);
const widthRef = useRef(0);
const scrubRef = useRef<number | null>(null);
const grantRef = useRef({ fraction: 0, pageX: 0 });
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
// Load (cache-first) the offline peaks whenever the track changes.
useEffect(() => {
if (!trackPath) return;
let cancelled = false;
void getWaveform(trackPath).then((peaks) => {
if (!cancelled) setLoaded({ path: trackPath, peaks });
void getWaveform(trackPath, {
onPreview: (peaks) => {
if (cancelled) return;
setLoaded((current) => {
if (current?.path === trackPath && current.quality === 'accurate' && current.peaks) {
return current;
}
return { path: trackPath, peaks, quality: 'preview' };
});
},
}).then((peaks) => {
if (cancelled) return;
setLoaded((current) => {
if (!peaks && current?.path === trackPath && current.quality === 'preview') return current;
return { path: trackPath, peaks, quality: 'accurate' };
});
});
return () => {
cancelled = true;
@@ -112,23 +126,16 @@ export function WaveformSeekBar({
const handleRelease = () => {
const fraction = scrubRef.current ?? grantRef.current.fraction;
const target = fraction * duration;
// Capture the pre-seek position so we can hold the target until the player
// moves off it. Using `from` (not the target) means the hold releases when
// the seek lands and can never re-engage as playback advances past target.
setPendingSeek({ target, from: currentTime, key: trackKey });
onSeek(target);
setScrub(null);
};
// Displayed position: scrub > held seek target > live progress. Hold while the
// player still reports the stale pre-seek position; release once it jumps.
const holdSeek =
pendingSeek != null &&
pendingSeek.key === trackKey &&
duration > 0 &&
Math.abs(currentTime - pendingSeek.from) < HOLD_EPS;
const liveFraction = duration > 0 ? Math.min(1, currentTime / duration) : 0;
const heldFraction = holdSeek ? clamp(pendingSeek.target / duration) : null;
// Displayed position: scrub > pending seek target > live progress. The player
// store clears pendingSeek only after native progress acknowledges the target
// or the guard times out, so stale RNTP progress cannot bounce the UI back.
const liveTime = isPlaying ? smoothTime : currentTime;
const liveFraction = duration > 0 ? Math.min(1, liveTime / duration) : 0;
const heldFraction = pendingSeek && duration > 0 ? clamp(pendingSeek.target / duration) : null;
const fraction = scrubFraction ?? heldFraction ?? liveFraction;
const shownTime = fraction * duration;
+58 -11
View File
@@ -1,30 +1,53 @@
// Waveform peaks for the seek bar: cache-first, decode-on-miss, store. The heavy
// native decode (AstraLibraryScanner.extractWaveform) runs once per track and the
// result is cached in SQLite; downsampleWaveform shapes the cached high-res peaks
// to the display's bar count at render time (ported from desktop waveformExtractor).
// Waveform peaks for the seek bar: cache-first, preview-on-miss, accurate
// decode-on-miss, store. The heavy native decode (extractWaveform) still runs
// once per track and persists; extractWaveformPreview gives uncached local
// tracks a fast first paint.
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
import { openLibraryDb } from '@/db/database';
import { getWaveformPeaks, putWaveformPeaks } from '@/db/waveformQueries';
export const WAVEFORM_BINS = 512;
export const WAVEFORM_PREVIEW_BINS = 96;
export interface WaveformLoadOptions {
onPreview?: (peaks: Float32Array) => void;
}
// Dedupe concurrent requests for the same track (e.g. mini-player + now-playing).
const inflight = new Map<string, Promise<Float32Array | null>>();
const previewInflight = new Map<string, Promise<Float32Array | null>>();
export function getWaveform(trackPath: string): Promise<Float32Array | null> {
const existing = inflight.get(trackPath);
if (existing) return existing;
const task = loadWaveform(trackPath).finally(() => inflight.delete(trackPath));
inflight.set(trackPath, task);
return task;
export function getWaveform(
trackPath: string,
options: WaveformLoadOptions = {}
): Promise<Float32Array | null> {
if (!isLocalWaveformPath(trackPath)) return Promise.resolve(null);
return loadWaveform(trackPath, options);
}
async function loadWaveform(trackPath: string): Promise<Float32Array | null> {
async function loadWaveform(
trackPath: string,
options: WaveformLoadOptions
): Promise<Float32Array | null> {
const db = await openLibraryDb();
const cached = await getWaveformPeaks(db, trackPath);
if (cached && cached.length > 0) return cached;
if (options.onPreview) {
void getWaveformPreview(trackPath).then((preview) => {
if (preview && preview.length > 0) options.onPreview?.(preview);
});
}
const existing = inflight.get(trackPath);
if (existing) return existing;
const task = decodeAccurateWaveform(trackPath).finally(() => inflight.delete(trackPath));
inflight.set(trackPath, task);
return task;
}
async function decodeAccurateWaveform(trackPath: string): Promise<Float32Array | null> {
let raw: number[];
try {
raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS);
@@ -34,12 +57,36 @@ async function loadWaveform(trackPath: string): Promise<Float32Array | null> {
if (!raw || raw.length === 0) return null;
const peaks = Float32Array.from(raw);
const db = await openLibraryDb();
await putWaveformPeaks(db, trackPath, peaks).catch(() => {
/* cache write failure is non-fatal */
});
return peaks;
}
function getWaveformPreview(trackPath: string): Promise<Float32Array | null> {
const existing = previewInflight.get(trackPath);
if (existing) return existing;
const task = decodePreviewWaveform(trackPath).finally(() => previewInflight.delete(trackPath));
previewInflight.set(trackPath, task);
return task;
}
async function decodePreviewWaveform(trackPath: string): Promise<Float32Array | null> {
let raw: number[];
try {
raw = await AstraLibraryScanner.extractWaveformPreview(trackPath, WAVEFORM_PREVIEW_BINS);
} catch {
return null;
}
if (!raw || raw.length === 0) return null;
return Float32Array.from(raw);
}
function isLocalWaveformPath(trackPath: string): boolean {
return trackPath.startsWith('content://') || trackPath.startsWith('file://');
}
/**
* Downsample high-res peaks to `barCount` bars with a power curve and two
* smoothing passes. Ported verbatim from desktop waveformExtractor.ts so the
+18 -1
View File
@@ -3,6 +3,11 @@ import type { PlaybackState, Track } from '@/types/audio';
export type RepeatMode = 'none' | 'one' | 'all';
interface PendingSeek {
target: number;
startedAt: number;
}
/**
* Player state — the UI's single source of truth, mirrored from the playback
* engine (RNTP at M0) by `usePlaybackSync`. Field names match desktop
@@ -14,6 +19,7 @@ interface PlayerStore {
playbackState: PlaybackState;
currentTime: number;
duration: number;
pendingSeek: PendingSeek | null;
volume: number; // 01
isMuted: boolean;
// Field names mirror desktop playerStore so queue/transport logic stays consistent.
@@ -23,6 +29,8 @@ interface PlayerStore {
setCurrentTrack: (track: Track | null) => void;
setPlaybackState: (state: PlaybackState) => void;
setProgress: (currentTime: number, duration: number) => void;
setPendingSeek: (target: number) => void;
clearPendingSeek: () => void;
setVolume: (volume: number) => void;
setMuted: (isMuted: boolean) => void;
setShuffle: (shuffle: boolean) => void;
@@ -35,6 +43,7 @@ export const usePlayerStore = create<PlayerStore>((set) => ({
playbackState: 'stopped',
currentTime: 0,
duration: 0,
pendingSeek: null,
volume: 1,
isMuted: false,
shuffle: false,
@@ -43,10 +52,18 @@ export const usePlayerStore = create<PlayerStore>((set) => ({
setCurrentTrack: (currentTrack) => set({ currentTrack }),
setPlaybackState: (playbackState) => set({ playbackState }),
setProgress: (currentTime, duration) => set({ currentTime, duration }),
setPendingSeek: (target) => set({ pendingSeek: { target, startedAt: Date.now() } }),
clearPendingSeek: () => set({ pendingSeek: null }),
setVolume: (volume) => set({ volume }),
setMuted: (isMuted) => set({ isMuted }),
setShuffle: (shuffle) => set({ shuffle }),
setRepeat: (repeat) => set({ repeat }),
reset: () =>
set({ currentTrack: null, playbackState: 'stopped', currentTime: 0, duration: 0 }),
set({
currentTrack: null,
playbackState: 'stopped',
currentTime: 0,
duration: 0,
pendingSeek: null,
}),
}));