mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
m3, ui/ux, and more
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Whether the visualizers should run. Set by useScopeLifecycle (foreground +
|
||||
* playing + not reduced-motion) and read by the scope components so they only
|
||||
* spin their frame loop when something is actually visible and moving.
|
||||
*/
|
||||
interface ScopeStore {
|
||||
active: boolean;
|
||||
setActive: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export const useScopeStore = create<ScopeStore>((set) => ({
|
||||
active: false,
|
||||
setActive: (active) => set({ active }),
|
||||
}));
|
||||
|
||||
export const useScopeActive = (): boolean => useScopeStore((s) => s.active);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AccessibilityInfo, AppState } from 'react-native';
|
||||
import { AstraScope } from '../../modules/astra-scope';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useScopeStore } from './scopeStore';
|
||||
|
||||
/**
|
||||
* Single owner of the scope on/off gate. Visualizers run only when the app is
|
||||
* foregrounded, audio is playing, and reduced-motion is off — which also stops
|
||||
* the native PCM tap (AstraScope.setActive) so a backgrounded/paused app pays
|
||||
* ~nothing in the audio callback. Mount once near the root.
|
||||
*/
|
||||
export function useScopeLifecycle(): void {
|
||||
useEffect(() => {
|
||||
let reduceMotion = false;
|
||||
let appActive = AppState.currentState === 'active';
|
||||
|
||||
const recompute = () => {
|
||||
const playing = usePlayerStore.getState().playbackState === 'playing';
|
||||
const on = playing && appActive && !reduceMotion;
|
||||
AstraScope.setActive(on);
|
||||
useScopeStore.getState().setActive(on);
|
||||
};
|
||||
|
||||
const appSub = AppState.addEventListener('change', (state) => {
|
||||
appActive = state === 'active';
|
||||
recompute();
|
||||
});
|
||||
const rmSub = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled) => {
|
||||
reduceMotion = enabled;
|
||||
recompute();
|
||||
});
|
||||
const unsubPlayer = usePlayerStore.subscribe(recompute);
|
||||
void AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
|
||||
reduceMotion = enabled;
|
||||
recompute();
|
||||
});
|
||||
recompute();
|
||||
|
||||
return () => {
|
||||
appSub.remove();
|
||||
rmSub.remove();
|
||||
unsubPlayer();
|
||||
AstraScope.setActive(false);
|
||||
useScopeStore.getState().setActive(false);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
|
||||
|
||||
const FRAME_MS = 32; // ~30fps — ambient, battery-friendly
|
||||
|
||||
// Display window (dB). Tighter than the raw [-100,0] capture range so music
|
||||
// fills the curve with punch instead of hugging the floor.
|
||||
const DISPLAY_DB_MIN = -88;
|
||||
const DISPLAY_DB_MAX = -16;
|
||||
const DB_RANGE = DISPLAY_DB_MAX - DISPLAY_DB_MIN;
|
||||
|
||||
// Map points across a log-frequency (geometric bin) axis like the desktop
|
||||
// SpectrumAnalyzer, so the low end isn't squashed. Skip DC/rumble at the bottom.
|
||||
const BIN_LOW = 2;
|
||||
const BIN_HIGH = SPECTRUM_BINS - 1;
|
||||
// Gentle upward tilt (dB/octave) so the curve reads as a shape, not a downward
|
||||
// ramp dominated by bass — same idea as the desktop's spectrum tilt.
|
||||
const TILT_DB_PER_OCT = 2;
|
||||
|
||||
// Temporal smoothing: rise instantly, fall smoothly, for a fluid line.
|
||||
const RELEASE = 0.72;
|
||||
|
||||
// One reused buffer across all consumers: getSpectrumFrame fills it in place and
|
||||
// we read it out synchronously on the JS thread, so a module-level buffer is safe.
|
||||
const buffer = new Float32Array(SPECTRUM_BINS);
|
||||
|
||||
/**
|
||||
* Pulls the latest spectrum from the native tap on a JS-thread rAF loop (while
|
||||
* `active`) and returns `pointCount` magnitudes in [0,1] sampled on a
|
||||
* log-frequency axis, smoothed over time. Feeds the filled-line {@link
|
||||
* SpectrumCurve}. Returns all-zero (flat) points when inactive — no loop, no
|
||||
* setState — so callers render a clean baseline.
|
||||
*/
|
||||
export function useSpectrumCurve(pointCount: number, active: boolean): number[] {
|
||||
const [values, setValues] = useState<number[]>(() => new Array(pointCount).fill(0));
|
||||
const zeros = useMemo(() => new Array<number>(pointCount).fill(0), [pointCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return; // inactive: no loop, no setState; caller gets `zeros`
|
||||
let mounted = true;
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
|
||||
const smoothed = new Float32Array(pointCount);
|
||||
const logLow = Math.log(BIN_LOW);
|
||||
const logHigh = Math.log(BIN_HIGH);
|
||||
const binAt = (t: number) => Math.exp(logLow + t * (logHigh - logLow));
|
||||
const refBin = binAt(0.5); // tilt pivot (midband)
|
||||
|
||||
const tick = (t: number) => {
|
||||
if (!mounted) return;
|
||||
raf = requestAnimationFrame(tick);
|
||||
if (t - last < FRAME_MS) return;
|
||||
last = t;
|
||||
if (AstraScope.getSpectrumFrame(buffer) <= 0) return;
|
||||
|
||||
const out = new Array<number>(pointCount);
|
||||
for (let p = 0; p < pointCount; p++) {
|
||||
const b0 = binAt(p / pointCount);
|
||||
const b1 = binAt((p + 1) / pointCount);
|
||||
const lo = Math.max(BIN_LOW, Math.floor(b0));
|
||||
const hi = Math.min(BIN_HIGH, Math.max(lo, Math.ceil(b1)));
|
||||
|
||||
// Peak (loudest bin) across the band — punchier than an average.
|
||||
let db = -200;
|
||||
for (let i = lo; i <= hi; i++) if (buffer[i] > db) db = buffer[i];
|
||||
|
||||
const octaves = Math.log2(Math.max(1, (b0 + b1) * 0.5) / refBin);
|
||||
db += TILT_DB_PER_OCT * octaves;
|
||||
|
||||
let norm = (db - DISPLAY_DB_MIN) / DB_RANGE;
|
||||
if (norm < 0) norm = 0;
|
||||
else if (norm > 1) norm = 1;
|
||||
|
||||
const prev = smoothed[p];
|
||||
const next = norm >= prev ? norm : prev * RELEASE + norm * (1 - RELEASE);
|
||||
smoothed[p] = next;
|
||||
out[p] = next;
|
||||
}
|
||||
setValues(out);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
mounted = false;
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [active, pointCount]);
|
||||
|
||||
return active ? values : zeros;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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).
|
||||
|
||||
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getWaveformPeaks, putWaveformPeaks } from '@/db/waveformQueries';
|
||||
|
||||
export const WAVEFORM_BINS = 512;
|
||||
|
||||
// Dedupe concurrent requests for the same track (e.g. mini-player + now-playing).
|
||||
const inflight = 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;
|
||||
}
|
||||
|
||||
async function loadWaveform(trackPath: string): Promise<Float32Array | null> {
|
||||
const db = await openLibraryDb();
|
||||
const cached = await getWaveformPeaks(db, trackPath);
|
||||
if (cached && cached.length > 0) return cached;
|
||||
|
||||
let raw: number[];
|
||||
try {
|
||||
raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || raw.length === 0) return null;
|
||||
|
||||
const peaks = Float32Array.from(raw);
|
||||
await putWaveformPeaks(db, trackPath, peaks).catch(() => {
|
||||
/* cache write failure is non-fatal */
|
||||
});
|
||||
return peaks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downsample high-res peaks to `barCount` bars with a power curve and two
|
||||
* smoothing passes. Ported verbatim from desktop waveformExtractor.ts so the
|
||||
* mobile seek bar matches the desktop look.
|
||||
*/
|
||||
export function downsampleWaveform(source: Float32Array, barCount: number): Float32Array {
|
||||
if (source.length === 0 || barCount <= 0) return new Float32Array(0);
|
||||
const binsPerBar = source.length / barCount;
|
||||
const peaks = new Float32Array(barCount);
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const start = Math.floor(i * binsPerBar);
|
||||
const end = Math.max(start + 1, Math.floor((i + 1) * binsPerBar));
|
||||
let sum = 0;
|
||||
for (let j = start; j < end; j++) sum += source[j];
|
||||
peaks[i] = sum / (end - start);
|
||||
}
|
||||
|
||||
let max = 0;
|
||||
for (let i = 0; i < barCount; i++) if (peaks[i] > max) max = peaks[i];
|
||||
if (max > 0) for (let i = 0; i < barCount; i++) peaks[i] /= max;
|
||||
|
||||
// Power curve — exaggerate dynamic range.
|
||||
for (let i = 0; i < barCount; i++) peaks[i] = peaks[i] ** 2;
|
||||
|
||||
// Two smoothing passes.
|
||||
let current = peaks;
|
||||
for (let p = 0; p < 2; p++) {
|
||||
const smoothed = new Float32Array(current.length);
|
||||
smoothed[0] = current[0];
|
||||
smoothed[current.length - 1] = current[current.length - 1];
|
||||
for (let i = 1; i < current.length - 1; i++) {
|
||||
smoothed[i] = current[i - 1] * 0.25 + current[i] * 0.5 + current[i + 1] * 0.25;
|
||||
}
|
||||
current = smoothed;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
Reference in New Issue
Block a user