m3, ui/ux, and more

This commit is contained in:
Boof2015
2026-06-17 16:12:51 -04:00
parent 0afbb37c08
commit 9f2ce3bb75
533 changed files with 5933 additions and 147 deletions
+83 -38
View File
@@ -1,17 +1,25 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { useState } from 'react';
import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Text } from './Text';
import { AstraLogo } from './AstraLogo';
import { colors, layout, radius, spacing } from '@/theme';
import { SpectrumCurve } from './SpectrumCurve';
import { colors, radius, spacing } from '@/theme';
import { usePlayerStore } from '@/stores/playerStore';
import { togglePlay } from '@/audio/playbackController';
import { skipToNext, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore';
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
const PILL_HEIGHT = 56;
const ART = 42;
const CURVE_POINTS = 64;
/**
* Persistent mini-player, rendered above the tab bar. Tapping the bar opens the
* full now-playing screen. The artwork box is where the spectrum "pulse"
* is-playing indicator will live at M3.
* Persistent floating mini-player (M3 redesign): a rounded pill above the tab
* bar with the live filled-line spectrum drifting behind the metadata. Tapping
* opens the full now-playing screen.
*/
export function MiniPlayer() {
const router = useRouter();
@@ -20,24 +28,38 @@ export function MiniPlayer() {
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
const scopeActive = useScopeActive();
const values = useSpectrumCurve(CURVE_POINTS, scopeActive);
const [pillWidth, setPillWidth] = useState(0);
if (!track) return null;
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
return (
<View style={styles.container}>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
<Pressable style={styles.row} onPress={() => router.push('/now-playing')}>
return (
<Pressable style={styles.pill} onPress={() => router.push('/now-playing')} onLayout={onLayout}>
{scopeActive && pillWidth > 0 && (
<View pointerEvents="none" style={styles.spectrum}>
<SpectrumCurve
values={values}
width={pillWidth}
height={PILL_HEIGHT}
lineWidth={1.5}
fillOpacity={0.5}
/>
</View>
)}
<View style={styles.row}>
<View style={styles.art}>
{track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.artImage} contentFit="cover" />
) : (
<AstraLogo size={22} />
<AstraLogo size={20} />
)}
</View>
@@ -50,45 +72,56 @@ export function MiniPlayer() {
</Text>
</View>
<Pressable hitSlop={12} onPress={togglePlay} style={styles.playButton}>
<Pressable hitSlop={10} onPress={togglePlay} style={styles.control}>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={26}
size={24}
color={colors.accent}
/>
</Pressable>
</Pressable>
</View>
<Pressable hitSlop={10} onPress={skipToNext} style={styles.control}>
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
</Pressable>
</View>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
height: layout.miniPlayerHeight,
backgroundColor: colors.bgSecondary,
borderTopColor: colors.glassBorder,
borderTopWidth: StyleSheet.hairlineWidth,
pill: {
height: PILL_HEIGHT,
marginHorizontal: spacing.md,
marginTop: spacing.sm,
marginBottom: spacing.sm,
borderRadius: radius.lg,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
justifyContent: 'center',
},
progressTrack: {
height: 2,
backgroundColor: colors.glassBorder,
},
progressFill: {
height: 2,
backgroundColor: colors.accent,
spectrum: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
row: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
gap: spacing.md,
paddingHorizontal: spacing.sm,
gap: spacing.sm,
},
art: {
width: 44,
height: 44,
width: ART,
height: ART,
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
backgroundColor: colors.bgSecondary,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
@@ -103,12 +136,24 @@ const styles = StyleSheet.create({
title: {
fontSize: 15,
},
playButton: {
width: 40,
height: 40,
control: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
},
progressTrack: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 2,
backgroundColor: colors.glassBorder,
},
progressFill: {
height: 2,
backgroundColor: colors.accent,
},
});
export default MiniPlayer;
+114
View File
@@ -0,0 +1,114 @@
import { useMemo } from 'react';
import { Canvas, Group, LinearGradient, Path, Skia, vec } from '@shopify/react-native-skia';
import { colors } from '@/theme';
interface SpectrumCurveProps {
/** Normalized magnitudes in [0,1], one per point (see useSpectrumCurve). */
values: number[];
width: number;
height: number;
/** Hex line/fill color (e.g. theme accent). Defaults to the cyan accent. */
color?: string;
lineWidth?: number;
/** 0..1 multiplier on the gradient fill under the line. */
fillOpacity?: number;
/** Adds a soft wider stroke under the line for a glow. */
glow?: boolean;
}
/** #rrggbb -> rgba() with the given alpha. */
function withAlpha(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Builds a smooth (quadratic-through-midpoints) path for the line, plus a copy
* closed to the baseline for the gradient fill. Same curve the desktop spectrum
* draws, ported to Skia.
*/
function buildPaths(values: number[], width: number, height: number, pad: number) {
const line = Skia.Path.Make();
const n = values.length;
if (n < 2 || width <= 0 || height <= 0) return { line, fill: line.copy() };
const usableH = height - pad * 2;
const xAt = (i: number) => (i / (n - 1)) * width;
const yAt = (i: number) => {
const v = values[i] < 0 ? 0 : values[i] > 1 ? 1 : values[i];
return pad + (1 - v) * usableH;
};
line.moveTo(xAt(0), yAt(0));
for (let i = 1; i < n; i++) {
const midX = (xAt(i - 1) + xAt(i)) * 0.5;
const midY = (yAt(i - 1) + yAt(i)) * 0.5;
line.quadTo(xAt(i - 1), yAt(i - 1), midX, midY);
}
line.lineTo(xAt(n - 1), yAt(n - 1));
const fill = line.copy();
fill.lineTo(width, height);
fill.lineTo(0, height);
fill.close();
return { line, fill };
}
/**
* Filled-line spectrum (the desktop "CURVE" look): a smooth line over a vertical
* gradient fill. Source-agnostic — give it normalized values and a size.
*/
export function SpectrumCurve({
values,
width,
height,
color = colors.accent,
lineWidth = 2,
fillOpacity = 1,
glow = false,
}: SpectrumCurveProps) {
const pad = lineWidth;
const { line, fill } = useMemo(
() => buildPaths(values, width, height, pad),
[values, width, height, pad]
);
if (width <= 0 || height <= 0) return null;
return (
<Canvas style={{ width, height }}>
<Group opacity={fillOpacity}>
<Path path={fill}>
<LinearGradient
start={vec(0, 0)}
end={vec(0, height)}
colors={[withAlpha(color, 0.38), withAlpha(color, 0.08), withAlpha(color, 0)]}
/>
</Path>
</Group>
{glow && (
<Path
path={line}
style="stroke"
strokeWidth={lineWidth * 3}
strokeJoin="round"
strokeCap="round"
color={withAlpha(color, 0.18)}
/>
)}
<Path
path={line}
style="stroke"
strokeWidth={lineWidth}
strokeJoin="round"
strokeCap="round"
color={color}
/>
</Canvas>
);
}
export default SpectrumCurve;
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from './Text';
import { SpectrumCurve } from './SpectrumCurve';
import { colors, spacing } from '@/theme';
import { useScopeActive } from '@/scope/scopeStore';
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
const CANVAS_HEIGHT = 96;
const POINTS = 120;
type Mode = 'spectrum' | 'scope';
/**
* Inline visualizer for the now-playing screen — no card chrome, it just lives
* in the layout. Tap anywhere on it to switch between the live filled-line
* Spectrum and the Scope (oscilloscope, placeholder until its native path lands).
*/
export function Visualizer({ width }: { width: number }) {
const [mode, setMode] = useState<Mode>('spectrum');
const scopeActive = useScopeActive();
const spectrumActive = scopeActive && mode === 'spectrum';
const values = useSpectrumCurve(POINTS, spectrumActive);
const toggle = () => setMode((m) => (m === 'spectrum' ? 'scope' : 'spectrum'));
return (
<Pressable
onPress={toggle}
style={[styles.wrap, { width }]}
accessibilityRole="button"
accessibilityLabel={`Visualizer showing ${mode}. Tap to switch.`}
>
<View style={styles.caption}>
<Text variant="caption" style={styles.captionText}>
{mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
</Text>
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
</View>
<View style={{ width, height: CANVAS_HEIGHT }}>
{mode === 'spectrum' ? (
<SpectrumCurve values={values} width={width} height={CANVAS_HEIGHT} glow />
) : (
<View style={styles.placeholder}>
<Ionicons name="pulse-outline" size={20} color={colors.textTertiary} />
<Text variant="caption" style={styles.placeholderText}>
OSCILLOSCOPE · COMING SOON
</Text>
</View>
)}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
wrap: {
paddingVertical: spacing.xs,
},
caption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.xs,
},
captionText: {
color: colors.textTertiary,
letterSpacing: 1.5,
fontSize: 10,
},
placeholder: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
},
placeholderText: {
color: colors.textTertiary,
letterSpacing: 1.5,
fontSize: 10,
},
});
export default Visualizer;
+194
View File
@@ -0,0 +1,194 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native';
import { Canvas, Group, Path, Skia, rect } from '@shopify/react-native-skia';
import { Text } from './Text';
import { colors, spacing } from '@/theme';
import { formatDuration } from '@/lib/format';
import { downsampleWaveform, getWaveform } from '@/scope/waveform';
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;
interface WaveformSeekBarProps {
currentTime: number;
duration: number;
onSeek: (seconds: number) => void;
/** 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;
}
const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction));
/**
* Waveform seek bar (M3) — ports desktop WaveformSeekBar's look (RMS bars, a
* played/unplayed split, draggable playhead) on Skia, while keeping SeekBar's
* tap/drag + pending-seek "hold" state machine verbatim so seeking behaves
* identically. Peaks load offline (getWaveform) and fall back to flat bars.
*/
export function WaveformSeekBar({
currentTime,
duration,
onSeek,
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);
// 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 widthRef = useRef(0);
const scrubRef = useRef<number | null>(null);
const grantRef = useRef({ fraction: 0, pageX: 0 });
// 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 });
});
return () => {
cancelled = true;
};
}, [trackPath]);
const source = loaded && loaded.path === trackPath ? loaded.peaks : null;
const setScrub = (fraction: number | null) => {
scrubRef.current = fraction;
setScrubFraction(fraction);
};
const onLayout = (event: LayoutChangeEvent) => {
widthRef.current = event.nativeEvent.layout.width;
setBarWidth(event.nativeEvent.layout.width);
};
const handleGrant = (event: GestureResponderEvent) => {
const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current));
grantRef.current = { fraction, pageX: event.nativeEvent.pageX };
setScrub(fraction);
};
const handleMove = (event: GestureResponderEvent) => {
const delta = (event.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current);
setScrub(clamp(grantRef.current.fraction + delta));
};
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;
const fraction = scrubFraction ?? heldFraction ?? liveFraction;
const shownTime = fraction * duration;
const barCount = Math.max(1, Math.floor(barWidth / (BAR_WIDTH + BAR_GAP)));
// Build one Skia path of all bars (rounded rects). Drawn twice with a clip
// split at the playhead: played in accent, unplayed in glassBorder.
const barsPath = useMemo(() => {
const path = Skia.Path.Make();
if (barWidth <= 0) return path;
const display = source
? downsampleWaveform(source, barCount)
: new Float32Array(barCount).fill(MIN_BAR);
const r = BAR_WIDTH / 2;
for (let i = 0; i < barCount; i++) {
const amp = Math.max(MIN_BAR, display[i] ?? MIN_BAR);
const h = amp * CANVAS_HEIGHT;
const x = i * (BAR_WIDTH + BAR_GAP);
const y = (CANVAS_HEIGHT - h) / 2;
path.addRRect(Skia.RRectXY(Skia.XYWHRect(x, y, BAR_WIDTH, h), r, r));
}
return path;
}, [source, barCount, barWidth]);
const splitX = fraction * barWidth;
return (
<View>
<View
style={styles.touchArea}
onLayout={onLayout}
onStartShouldSetResponder={() => duration > 0}
onMoveShouldSetResponder={() => duration > 0}
onResponderTerminationRequest={() => false}
onResponderGrant={handleGrant}
onResponderMove={handleMove}
onResponderRelease={handleRelease}
onResponderTerminate={() => setScrub(null)}
accessibilityRole="adjustable"
accessibilityLabel="Seek"
accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }}
>
<Canvas style={{ width: '100%', height: CANVAS_HEIGHT }}>
<Group clip={rect(0, 0, splitX, CANVAS_HEIGHT)}>
<Path path={barsPath} color={colors.accent} />
</Group>
<Group clip={rect(splitX, 0, Math.max(0, barWidth - splitX), CANVAS_HEIGHT)}>
<Path path={barsPath} color={colors.glassBorder} />
</Group>
</Canvas>
</View>
<View style={styles.times}>
<Text variant="mono" style={[styles.time, scrubFraction != null && styles.timeActive]}>
{formatDuration(shownTime)}
</Text>
<Text variant="mono" style={styles.time}>
{formatDuration(duration)}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
touchArea: {
justifyContent: 'center',
height: CANVAS_HEIGHT + spacing.md * 2, // generous touch target around the canvas
},
times: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: spacing.xs,
},
time: {
color: colors.textTertiary,
fontSize: 13,
},
timeActive: {
color: colors.accentText,
},
});
export default WaveformSeekBar;