diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index 7aedcb6..fee6281 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -101,6 +101,15 @@ class AstraLibraryScannerModule : Module() { } } + // Fast waveform preview for first paint: sparse short-window decode across + // the file. The JS side shows this immediately but only persists the full + // extractWaveform result. + AsyncFunction("extractWaveformPreview") Coroutine { uri: String, bins: Int -> + waveformSemaphore.withPermit { + withContext(Dispatchers.IO) { decodeWaveformPreview(uri, if (bins > 0) bins else 96) } + } + } + // Fast loudness (M4): decodes only a few short windows spread across the track // (not the whole file) + gated K-weighting -> integrated LUFS + sample peak. // Waveform peaks stay lazy/full-decode (extractWaveform), decoupled from this. @@ -540,6 +549,172 @@ class AstraLibraryScannerModule : Module() { } } + private data class PcmEnergy( + val sumSquares: Double, + val sampleCount: Long, + val frameCount: Long + ) + + // Sparse preview waveform: seek to a bounded number of points, decode a very + // short audio window at each point, and normalize those RMS samples. This is + // intentionally approximate; decodeAndAnalyze remains the accurate cache fill. + private fun decodeWaveformPreview(uriStr: String, bins: Int): FloatArray { + val context = requireContext() + val previewBins = bins.coerceIn(16, 128) + val uri = Uri.parse(uriStr) + val extractor = MediaExtractor() + var codec: MediaCodec? = null + try { + extractor.setDataSource(context, uri, null) + + var trackFormat: MediaFormat? = null + var trackIndex = -1 + for (i in 0 until extractor.trackCount) { + val f = extractor.getTrackFormat(i) + if (f.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) { + trackFormat = f; trackIndex = i; break + } + } + val format = trackFormat ?: return FloatArray(0) + extractor.selectTrack(trackIndex) + + val mime = format.getString(MediaFormat.KEY_MIME) ?: return FloatArray(0) + val durationUs = + if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L + if (durationUs <= 0L) return FloatArray(0) + + val sampleRate = + if (format.containsKey(MediaFormat.KEY_SAMPLE_RATE)) format.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100 + var channelCount = + if (format.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) else 2 + var pcmFloat = false + val windowFrames = max(512L, (sampleRate * 0.018).roundToInt().toLong()) + val peaks = FloatArray(previewBins) + + codec = MediaCodec.createDecoderByType(mime) + codec.configure(format, null, null, 0) + codec.start() + val info = MediaCodec.BufferInfo() + + for (bin in 0 until previewBins) { + val targetUs = ((durationUs.toDouble() * bin) / previewBins).toLong() + .coerceIn(0L, max(0L, durationUs - 1)) + try { + extractor.seekTo(targetUs, MediaExtractor.SEEK_TO_CLOSEST_SYNC) + codec.flush() + } catch (_: Throwable) { + continue + } + + var sawInputEOS = false + var frames = 0L + var sumSquares = 0.0 + var sampleCount = 0L + var safety = 0 + + while (frames < windowFrames && safety++ < 180) { + if (!sawInputEOS) { + val inIndex = codec.dequeueInputBuffer(2_000) + if (inIndex >= 0) { + val inBuf = codec.getInputBuffer(inIndex)!! + val size = extractor.readSampleData(inBuf, 0) + if (size < 0) { + codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + sawInputEOS = true + } else { + codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0) + extractor.advance() + } + } + } + + when (val outIndex = codec.dequeueOutputBuffer(info, 2_000)) { + MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + val nf = codec.outputFormat + if (nf.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) channelCount = nf.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + if (nf.containsKey(MediaFormat.KEY_PCM_ENCODING)) { + pcmFloat = nf.getInteger(MediaFormat.KEY_PCM_ENCODING) == AudioFormat.ENCODING_PCM_FLOAT + } + } + MediaCodec.INFO_TRY_AGAIN_LATER -> { + if (sawInputEOS) break + } + else -> if (outIndex >= 0) { + if (info.size > 0) { + val out = codec.getOutputBuffer(outIndex)!! + out.position(info.offset) + out.limit(info.offset + info.size) + out.order(ByteOrder.nativeOrder()) + val energy = collectEnergy(out, pcmFloat, channelCount, windowFrames - frames) + frames += energy.frameCount + sumSquares += energy.sumSquares + sampleCount += energy.sampleCount + } + val ended = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 + codec.releaseOutputBuffer(outIndex, false) + if (ended) break + } + } + } + + if (sampleCount > 0) { + peaks[bin] = sqrt(sumSquares / sampleCount).toFloat() + } + } + + var globalMax = 0f + for (value in peaks) if (value > globalMax) globalMax = value + if (globalMax > 0f) { + for (i in peaks.indices) peaks[i] /= globalMax + } + return peaks + } catch (_: Throwable) { + return FloatArray(0) + } finally { + try { codec?.stop() } catch (_: Throwable) {} + try { codec?.release() } catch (_: Throwable) {} + try { extractor.release() } catch (_: Throwable) {} + } + } + + private fun collectEnergy( + out: java.nio.ByteBuffer, + pcmFloat: Boolean, + channelCount: Int, + maxFrames: Long + ): PcmEnergy { + if (maxFrames <= 0) return PcmEnergy(0.0, 0L, 0L) + var sumSquares = 0.0 + var sampleCount = 0L + var frameCount = 0L + if (pcmFloat) { + val fb = out.asFloatBuffer() + while (fb.hasRemaining() && frameCount < maxFrames) { + var c = 0 + while (c < channelCount && fb.hasRemaining()) { + val s = fb.get().toDouble() + sumSquares += s * s + sampleCount++ + c++ + } + frameCount++ + } + } else { + val sb = out.asShortBuffer() + while (sb.hasRemaining() && frameCount < maxFrames) { + var c = 0 + while (c < channelCount && sb.hasRemaining()) { + val s = sb.get() / 32768.0 + sumSquares += s * s + sampleCount++ + c++ + } + frameCount++ + } + } + return PcmEnergy(sumSquares, sampleCount, frameCount) + } + // Integrated gated loudness over the WHOLE file (accurate — subset sampling caused // too much loudness inconsistency). Decodes the full track and feeds the gated // K-weighting meter. Measured on the fly per track (current + queue lookahead) and diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index ce330c4..e98577a 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -68,6 +68,11 @@ declare class AstraLibraryScannerModuleType extends NativeModule; + /** + * Decode short windows across the file and return approximate RMS peaks for + * immediate seek-bar paint. Cheap preview only; callers should not persist it. + */ + extractWaveformPreview(uri: string, bins: number): Promise; /** * Fast integrated loudness (M4): decodes only a few short windows across the * track + gated K-weighting -> integrated LUFS + absolute sample peak. Null on diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index b724af5..6d3f291 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -603,9 +603,9 @@ export default function NowPlayingScreen() { void seekTo(seconds)} /> diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index 1215b96..68ae79e 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -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 { + 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 { 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 { 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 => TrackPlayer.play(); +export async function play(): Promise { + usePlayerStore.getState().setPlaybackState('playing'); + try { + await TrackPlayer.play(); + } catch (err) { + await reconcilePlayerFromNative(); + throw err; + } +} export async function playForCar(): Promise { await ensurePlayerReady({ allowBackgroundSetup: true }); - await TrackPlayer.play(); + await play(); +} +export async function pause(): Promise { + usePlayerStore.getState().setPlaybackState('paused'); + try { + await TrackPlayer.pause(); + } catch (err) { + await reconcilePlayerFromNative(); + throw err; + } +} + +export async function seekTo(seconds: number): Promise { + 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 => TrackPlayer.pause(); -export const seekTo = (seconds: number): Promise => TrackPlayer.seekTo(seconds); export async function togglePlay(): Promise { - 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 { + 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 { + 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 { // 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[] }> { diff --git a/src/audio/usePlaybackSync.ts b/src/audio/usePlaybackSync.ts index 1f35961..e67c69d 100644 --- a/src/audio/usePlaybackSync.ts +++ b/src/audio/usePlaybackSync.ts @@ -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({ 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]); } diff --git a/src/audio/useSmoothPlaybackTime.ts b/src/audio/useSmoothPlaybackTime.ts new file mode 100644 index 0000000..0da6e02 --- /dev/null +++ b/src/audio/useSmoothPlaybackTime.ts @@ -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; +} diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 53ebdb5..df2493b 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -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 ( + + + + ); +} + /** * 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) { - - - + ); } diff --git a/src/components/WaveformSeekBar.tsx b/src/components/WaveformSeekBar.tsx index a0cd146..5d7aa51 100644 --- a/src/components/WaveformSeekBar.tsx +++ b/src/components/WaveformSeekBar.tsx @@ -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(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(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; diff --git a/src/scope/waveform.ts b/src/scope/waveform.ts index 8b81bbd..31eabce 100644 --- a/src/scope/waveform.ts +++ b/src/scope/waveform.ts @@ -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>(); +const previewInflight = new Map>(); -export function getWaveform(trackPath: string): Promise { - 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 { + if (!isLocalWaveformPath(trackPath)) return Promise.resolve(null); + return loadWaveform(trackPath, options); } -async function loadWaveform(trackPath: string): Promise { +async function loadWaveform( + trackPath: string, + options: WaveformLoadOptions +): Promise { 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 { let raw: number[]; try { raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS); @@ -34,12 +57,36 @@ async function loadWaveform(trackPath: string): Promise { 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 { + 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 { + 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 diff --git a/src/stores/playerStore.ts b/src/stores/playerStore.ts index 6061be3..e048509 100644 --- a/src/stores/playerStore.ts +++ b/src/stores/playerStore.ts @@ -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((set) => ({ playbackState: 'stopped', currentTime: 0, duration: 0, + pendingSeek: null, volume: 1, isMuted: false, shuffle: false, @@ -43,10 +52,18 @@ export const usePlayerStore = create((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, + }), }));