mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
improve now playing UI speed
This commit is contained in:
@@ -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
@@ -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[] }> {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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; // 0–1
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user