scopes styles

This commit is contained in:
Boof2015
2026-07-13 00:54:18 -04:00
parent bf4dc83138
commit a34d9cba89
10 changed files with 963 additions and 128 deletions
+117 -13
View File
@@ -3,12 +3,14 @@ import {
useMemo,
useRef
} from 'react';
import { useReducedMotion } from 'react-native-reanimated';
import {
PaintStyle,
Skia,
SkiaPictureView,
StrokeCap,
StrokeJoin,
TileMode,
type SkPath,
type SkPicture
} from '@shopify/react-native-skia';
@@ -27,6 +29,7 @@ interface OscilloscopeWaveProps {
lineWidth?: number;
glow?: boolean;
edgeFade?: boolean;
edgeFadeWidth?: number;
}
type SkiaViewApiShape = {
@@ -36,6 +39,11 @@ type SkiaViewApiShape = {
const values = new Float32Array(OSCILLOSCOPE_POINTS);
// Deactivation decay: pull the last live frame toward the rest line over
// ~250ms instead of snapping flat, so pausing reads as powering down.
const DECAY_PER_FRAME = 0.72;
const REST_EPSILON = 0.004;
function skiaViewApi(): SkiaViewApiShape | null {
const globalWithSkia = globalThis as typeof globalThis & { SkiaViewApi?: SkiaViewApiShape };
return globalWithSkia.SkiaViewApi ?? null;
@@ -59,6 +67,34 @@ function makeStrokePaint(color: string, width: number, alpha = 1) {
return paint;
}
const EDGE_FADE_WIDTH = 28;
/**
* Edge fade baked into the stroke paint: a horizontal gradient shader whose
* alpha ramps in from transparent at both ends, so the trace dissolves at its
* edges over any background — solid screen or blurred artwork.
*/
function makeFadedStrokeShader(
color: string,
alpha: number,
width: number,
fadeWidth: number
) {
const f = Math.min(fadeWidth, width * 0.5);
return Skia.Shader.MakeLinearGradient(
{ x: 0, y: 0 },
{ x: width, y: 0 },
[
Skia.Color(withAlpha(color, 0)),
Skia.Color(withAlpha(color, alpha)),
Skia.Color(withAlpha(color, alpha)),
Skia.Color(withAlpha(color, 0)),
],
[0, f / width, 1 - f / width, 1],
TileMode.Clamp
);
}
function writeWavePath(
samples: Float32Array,
sampleCount: number,
@@ -98,7 +134,9 @@ function buildPicture(
color: string,
lineWidth: number,
glow: boolean,
gain: number
gain: number,
edgeFade: boolean,
edgeFadeWidth: number
): SkPicture {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
@@ -106,9 +144,17 @@ function buildPicture(
writeWavePath(samples, sampleCount, width, height, lineWidth, gain, path);
if (glow) {
canvas.drawPath(path, makeStrokePaint(color, lineWidth * 3, 0.18));
const glowPaint = makeStrokePaint(color, lineWidth * 3, 0.18);
if (edgeFade) {
glowPaint.setShader(makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth));
}
canvas.drawPath(path, glowPaint);
}
canvas.drawPath(path, makeStrokePaint(color, lineWidth));
const strokePaint = makeStrokePaint(color, lineWidth);
if (edgeFade) {
strokePaint.setShader(makeFadedStrokeShader(color, 1, width, edgeFadeWidth));
}
canvas.drawPath(path, strokePaint);
return recorder.finishRecordingAsPicture();
}
@@ -130,10 +176,12 @@ export function OscilloscopeWave({
color: colorProp,
lineWidth = 2,
glow = false,
edgeFade: _edgeFade = false,
edgeFade = false,
edgeFadeWidth = EDGE_FADE_WIDTH,
}: OscilloscopeWaveProps) {
const themeColors = useColors();
const color = colorProp ?? themeColors.accent;
const reduceMotion = useReducedMotion();
const viewRef = useRef<SkiaPictureView | null>(null);
const initialPicture = useMemo(
() =>
@@ -145,9 +193,11 @@ export function OscilloscopeWave({
color,
lineWidth,
glow,
DEFAULT_OSC_GAIN
DEFAULT_OSC_GAIN,
edgeFade,
edgeFadeWidth
),
[color, glow, height, lineWidth, width]
[color, edgeFade, edgeFadeWidth, glow, height, lineWidth, width]
);
useEffect(() => {
@@ -164,6 +214,10 @@ export function OscilloscopeWave({
// was measurable GC/JSI churn at 60fps.
const strokePaint = makeStrokePaint(color, lineWidth);
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, 0.18) : null;
if (edgeFade) {
strokePaint.setShader(makeFadedStrokeShader(color, 1, width, edgeFadeWidth));
glowPaint?.setShader(makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth));
}
const bounds = Skia.XYWHRect(0, 0, width, height);
const path = Skia.Path.Make();
@@ -178,10 +232,52 @@ export function OscilloscopeWave({
api.requestRedraw(view.nativeId);
};
const cleanup = () => {
mounted = false;
cancelAnimationFrame(raf);
};
if (!active) {
// Deactivation (pause, occlusion): decay whatever the tap last wrote
// toward the rest line, then settle flat and schedule nothing.
let peak = 0;
for (let i = 0; i < values.length; i++) {
const a = Math.abs(values[i]);
if (a > peak) peak = a;
}
if (reduceMotion || peak < REST_EPSILON) {
values.fill(0);
draw(values.length);
return cleanup;
}
const decayTick = (t: number) => {
if (!mounted) return;
if (drawThreshold > 0 && t - lastDraw < drawThreshold) {
raf = requestAnimationFrame(decayTick);
return;
}
lastDraw = t;
let max = 0;
for (let i = 0; i < values.length; i++) {
const v = values[i] * DECAY_PER_FRAME;
values[i] = v;
const a = Math.abs(v);
if (a > max) max = a;
}
if (max < REST_EPSILON) {
values.fill(0);
draw(values.length);
return;
}
draw(values.length);
raf = requestAnimationFrame(decayTick);
};
raf = requestAnimationFrame(decayTick);
return cleanup;
}
values.fill(0);
draw(values.length);
// Inactive: leave the flat line and schedule nothing instead of idling a rAF.
if (!active) return;
const tick = (t: number) => {
if (!mounted) return;
@@ -196,11 +292,19 @@ export function OscilloscopeWave({
};
raf = requestAnimationFrame(tick);
return () => {
mounted = false;
cancelAnimationFrame(raf);
};
}, [active, color, frameMs, glow, height, lineWidth, width]);
return cleanup;
}, [
active,
color,
edgeFade,
edgeFadeWidth,
frameMs,
glow,
height,
lineWidth,
reduceMotion,
width,
]);
if (width <= 0 || height <= 0) return null;
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
+146 -69
View File
@@ -3,7 +3,9 @@ import {
useMemo,
useRef
} from 'react';
import { useReducedMotion } from 'react-native-reanimated';
import {
BlendMode,
PaintStyle,
Skia,
SkiaPictureView,
@@ -41,7 +43,6 @@ interface SpectrumCurveProps {
glow?: boolean;
glowOpacity?: number;
edgeFade?: boolean;
edgeFadeColor?: string;
edgeFadeWidth?: number;
}
@@ -59,6 +60,10 @@ const MIN_FREQUENCY = 20;
const MAX_FREQUENCY = 20000;
const TILT_DB_PER_OCT = 3.5;
const TILT_REFERENCE_HZ = 1000;
// Deactivation decay: let the last live curve fall to the floor over ~250ms
// instead of freezing mid-song, so pausing reads as powering down.
const DECAY_PER_FRAME = 0.72;
const REST_EPSILON = 0.004;
const spectrumBins = new Float32Array(SPECTRUM_BINS);
function skiaViewApi(): SkiaViewApiShape | null {
@@ -85,37 +90,77 @@ function makeStrokePaint(color: string, width: number, alpha = 1) {
return paint;
}
function makeFillPaint(color: string, height: number, opacity: number) {
/**
* Edge fade baked into the paints: a horizontal alpha ramp so the curve
* dissolves at its ends over any background — solid screen or blurred artwork.
*/
function makeFadedStrokeShader(
color: string,
alpha: number,
width: number,
fadeWidth: number
) {
const f = Math.min(fadeWidth, width * 0.5);
return Skia.Shader.MakeLinearGradient(
{ x: 0, y: 0 },
{ x: width, y: 0 },
[
Skia.Color(withAlpha(color, 0)),
Skia.Color(withAlpha(color, alpha)),
Skia.Color(withAlpha(color, alpha)),
Skia.Color(withAlpha(color, 0)),
],
[0, f / width, 1 - f / width, 1],
TileMode.Clamp
);
}
/** White-with-alpha horizontal ramp; Modulate-blending it onto another shader
* multiplies alphas while leaving color untouched. */
function makeFadeMaskShader(width: number, fadeWidth: number) {
const f = Math.min(fadeWidth, width * 0.5);
return Skia.Shader.MakeLinearGradient(
{ x: 0, y: 0 },
{ x: width, y: 0 },
[
Skia.Color('rgba(255, 255, 255, 0)'),
Skia.Color('rgba(255, 255, 255, 1)'),
Skia.Color('rgba(255, 255, 255, 1)'),
Skia.Color('rgba(255, 255, 255, 0)'),
],
[0, f / width, 1 - f / width, 1],
TileMode.Clamp
);
}
function makeFillPaint(
color: string,
height: number,
opacity: number,
fade: { width: number; fadeWidth: number } | null = null
) {
const paint = Skia.Paint();
paint.setAntiAlias(true);
paint.setStyle(PaintStyle.Fill);
paint.setShader(
Skia.Shader.MakeLinearGradient(
{ x: 0, y: 0 },
{ x: 0, y: height },
[
Skia.Color(withAlpha(color, 0.38 * opacity)),
Skia.Color(withAlpha(color, 0.08 * opacity)),
Skia.Color(withAlpha(color, 0)),
],
null,
TileMode.Clamp
)
const vertical = Skia.Shader.MakeLinearGradient(
{ x: 0, y: 0 },
{ x: 0, y: height },
[
Skia.Color(withAlpha(color, 0.38 * opacity)),
Skia.Color(withAlpha(color, 0.08 * opacity)),
Skia.Color(withAlpha(color, 0)),
],
null,
TileMode.Clamp
);
return paint;
}
function makeFadePaint(color: string, startAlpha: number, endAlpha: number, x0: number, x1: number) {
const paint = Skia.Paint();
paint.setStyle(PaintStyle.Fill);
paint.setShader(
Skia.Shader.MakeLinearGradient(
{ x: x0, y: 0 },
{ x: x1, y: 0 },
[Skia.Color(withAlpha(color, startAlpha)), Skia.Color(withAlpha(color, endAlpha))],
null,
TileMode.Clamp
)
fade
? Skia.Shader.MakeBlend(
BlendMode.Modulate,
vertical,
makeFadeMaskShader(fade.width, fade.fadeWidth)
)
: vertical
);
return paint;
}
@@ -172,7 +217,6 @@ function buildPicture(
glow: boolean,
glowOpacity: number,
edgeFade: boolean,
edgeFadeColor: string,
edgeFadeWidth: number
): SkPicture {
const recorder = Skia.PictureRecorder();
@@ -180,23 +224,20 @@ function buildPicture(
const { line, fill } = buildPaths(values, width, height, lineWidth);
if (values.length >= 2 && width > 0 && height > 0) {
canvas.drawPath(fill, makeFillPaint(color, height, fillOpacity));
const fade = edgeFade && edgeFadeWidth > 0 ? { width, fadeWidth: edgeFadeWidth } : null;
canvas.drawPath(fill, makeFillPaint(color, height, fillOpacity, fade));
if (glow) {
canvas.drawPath(line, makeStrokePaint(color, lineWidth * 3, glowOpacity));
const glowPaint = makeStrokePaint(color, lineWidth * 3, glowOpacity);
if (fade) {
glowPaint.setShader(makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth));
}
canvas.drawPath(line, glowPaint);
}
canvas.drawPath(line, makeStrokePaint(color, lineWidth, lineOpacity));
}
if (edgeFade && width > 0 && height > 0 && edgeFadeWidth > 0) {
const fadeWidth = Math.min(edgeFadeWidth, width * 0.5);
canvas.drawRect(
Skia.XYWHRect(0, 0, fadeWidth, height),
makeFadePaint(edgeFadeColor, 1, 0, 0, fadeWidth)
);
canvas.drawRect(
Skia.XYWHRect(width - fadeWidth, 0, fadeWidth, height),
makeFadePaint(edgeFadeColor, 0, 1, width - fadeWidth, width)
);
const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity);
if (fade) {
strokePaint.setShader(makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth));
}
canvas.drawPath(line, strokePaint);
}
return recorder.finishRecordingAsPicture();
@@ -326,13 +367,15 @@ export function SpectrumCurve({
glow = false,
glowOpacity = 0.18,
edgeFade = false,
edgeFadeColor: edgeFadeColorProp,
edgeFadeWidth = 28,
}: SpectrumCurveProps) {
const themeColors = useColors();
const color = colorProp ?? themeColors.accent;
const edgeFadeColor = edgeFadeColorProp ?? themeColors.bgPrimary;
const reduceMotion = useReducedMotion();
const viewRef = useRef<SkiaPictureView | null>(null);
// Last live curve, kept across effect re-runs so deactivation can decay it
// to the floor instead of freezing the final frame.
const lastLiveValuesRef = useRef<Float32Array | null>(null);
// Half a point per pixel, capped: the quadTo midpoint smoothing makes denser
// sampling visually indistinguishable while doubling per-frame path cost.
const activePointCount = Math.min(160, Math.max(96, Math.floor(width / 2)));
@@ -354,13 +397,11 @@ export function SpectrumCurve({
glow,
glowOpacity,
edgeFade,
edgeFadeColor,
edgeFadeWidth
),
[
color,
edgeFade,
edgeFadeColor,
edgeFadeWidth,
fillOpacity,
glow,
@@ -374,10 +415,12 @@ export function SpectrumCurve({
);
useEffect(() => {
if (!active) return;
const view = viewRef.current;
const api = skiaViewApi();
if (!view || !api || width <= 0 || height <= 0 || resolvedPointCount < 2) return;
const priorLive = lastLiveValuesRef.current;
// Static usage (values prop, never went live): leave the initial picture.
if (!active && !priorLive) return;
let mounted = true;
let raf = 0;
@@ -387,24 +430,22 @@ export function SpectrumCurve({
const drawThreshold = frameMs > 0 ? Math.max(0, frameMs - 0.5) : 0;
const analysisMs = analysisFrameMs ?? frameMs;
const analysisThreshold = analysisMs > 0 ? Math.max(0, analysisMs - 0.5) : 0;
const renderValues = new Float32Array(resolvedPointCount);
const renderValues =
!active && priorLive && priorLive.length === resolvedPointCount
? priorLive
: new Float32Array(resolvedPointCount);
const pointOptions = { dbMin, dbMax, tiltDbPerOctave };
// Paints, shaders, and paths live for the whole effect run: allocating them
// (and the gradient shaders) per frame was measurable GC/JSI churn at 60fps.
const fade = edgeFade && edgeFadeWidth > 0 ? { width, fadeWidth: edgeFadeWidth } : null;
const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity);
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, glowOpacity) : null;
const fillPaint = makeFillPaint(color, height, fillOpacity);
const fadeWidth = Math.min(edgeFadeWidth, width * 0.5);
const fade =
edgeFade && fadeWidth > 0
? {
leftRect: Skia.XYWHRect(0, 0, fadeWidth, height),
leftPaint: makeFadePaint(edgeFadeColor, 1, 0, 0, fadeWidth),
rightRect: Skia.XYWHRect(width - fadeWidth, 0, fadeWidth, height),
rightPaint: makeFadePaint(edgeFadeColor, 0, 1, width - fadeWidth, width),
}
: null;
if (fade) {
strokePaint.setShader(makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth));
glowPaint?.setShader(makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth));
}
const fillPaint = makeFillPaint(color, height, fillOpacity, fade);
const bounds = Skia.XYWHRect(0, 0, width, height);
const linePath = Skia.Path.Make();
const fillPath = Skia.Path.Make();
@@ -416,14 +457,53 @@ export function SpectrumCurve({
canvas.drawPath(fillPath, fillPaint);
if (glowPaint) canvas.drawPath(linePath, glowPaint);
canvas.drawPath(linePath, strokePaint);
if (fade) {
canvas.drawRect(fade.leftRect, fade.leftPaint);
canvas.drawRect(fade.rightRect, fade.rightPaint);
}
api.setJsiProperty(view.nativeId, 'picture', recorder.finishRecordingAsPicture());
api.requestRedraw(view.nativeId);
};
const cleanup = () => {
mounted = false;
cancelAnimationFrame(raf);
};
if (!active) {
// Deactivation: decay the last live curve to the floor, then rest.
lastLiveValuesRef.current = null;
let peak = 0;
for (let i = 0; i < renderValues.length; i++) {
if (renderValues[i] > peak) peak = renderValues[i];
}
if (reduceMotion || peak < REST_EPSILON) {
renderValues.fill(0);
draw();
return cleanup;
}
const decayTick = (t: number) => {
if (!mounted) return;
if (drawThreshold > 0 && t - lastDraw < drawThreshold) {
raf = requestAnimationFrame(decayTick);
return;
}
lastDraw = t;
let max = 0;
for (let i = 0; i < renderValues.length; i++) {
const v = renderValues[i] * DECAY_PER_FRAME;
renderValues[i] = v;
if (v > max) max = v;
}
if (max < REST_EPSILON) {
renderValues.fill(0);
draw();
return;
}
draw();
raf = requestAnimationFrame(decayTick);
};
raf = requestAnimationFrame(decayTick);
return cleanup;
}
lastLiveValuesRef.current = renderValues;
renderValues.fill(0);
draw();
@@ -449,10 +529,7 @@ export function SpectrumCurve({
};
raf = requestAnimationFrame(tick);
return () => {
mounted = false;
cancelAnimationFrame(raf);
};
return cleanup;
}, [
active,
analysisFrameMs,
@@ -460,7 +537,6 @@ export function SpectrumCurve({
dbMax,
dbMin,
edgeFade,
edgeFadeColor,
edgeFadeWidth,
fillOpacity,
frameMs,
@@ -469,6 +545,7 @@ export function SpectrumCurve({
height,
lineOpacity,
lineWidth,
reduceMotion,
resolvedPointCount,
source,
tiltDbPerOctave,
+36 -4
View File
@@ -22,6 +22,7 @@ import { AstraLogo } from '@/components/AstraLogo';
import { Text } from '@/components/Text';
import { ScanProgress } from '@/components/library/ScanProgress';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards';
import { formatFolderCount, formatTrackCount } from '@/components/settings/SettingsPanels';
import { radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
@@ -29,12 +30,13 @@ import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { BaseThemeId } from '@/theme/resolve';
import { useLibraryStore } from '@/stores/libraryStore';
import { useSettingsStore, type NowPlayingScopeStyle } from '@/stores/settingsStore';
import { useThemeStore } from '@/stores/themeStore';
type IoniconName = ComponentProps<typeof Ionicons>['name'];
type StepId = 'welcome' | 'library' | 'theme' | 'done';
type StepId = 'welcome' | 'library' | 'theme' | 'player' | 'done';
const STEP_ORDER: StepId[] = ['welcome', 'library', 'theme', 'done'];
const STEP_ORDER: StepId[] = ['welcome', 'library', 'theme', 'player', 'done'];
const WIZARD_THEME_OPTIONS: { id: BaseThemeId; title: string }[] = [
{ id: 'system', title: 'System' },
@@ -60,6 +62,13 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
const step = STEP_ORDER[stepIndex];
const foldersCount = useLibraryStore((s) => s.folders.length);
const isScanning = useLibraryStore((s) => s.isScanning);
// Deliberately unset until tapped: preselecting a card would bias the
// pre-release style feedback. Skipping through keeps the store default.
const [scopeStyleChoice, setScopeStyleChoice] = useState<NowPlayingScopeStyle | null>(null);
const chooseScopeStyle = (style: NowPlayingScopeStyle) => {
setScopeStyleChoice(style);
void useSettingsStore.getState().setNowPlayingScopeStyle(style);
};
const goNext = () => {
if (stepIndex < STEP_ORDER.length - 1) setStepIndex((i) => i + 1);
@@ -67,7 +76,7 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
};
const goBack = () => setStepIndex((i) => Math.max(0, i - 1));
const canGoBack = step === 'library' || step === 'theme';
const canGoBack = step === 'library' || step === 'theme' || step === 'player';
const primaryLabel =
step === 'welcome'
? 'Get started'
@@ -78,7 +87,7 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
foldersCount > 0 || isScanning
? 'Continue'
: 'Skip for now'
: step === 'theme'
: step === 'theme' || step === 'player'
? 'Continue'
: 'Start listening';
@@ -114,6 +123,9 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
{step === 'welcome' ? <WelcomeStep /> : null}
{step === 'library' ? <LibraryStep /> : null}
{step === 'theme' ? <ThemeStep /> : null}
{step === 'player' ? (
<PlayerStep choice={scopeStyleChoice} onChoose={chooseScopeStyle} />
) : null}
{step === 'done' ? <DoneStep /> : null}
</Animated.View>
</ScrollView>
@@ -278,6 +290,26 @@ function ThemeStep() {
);
}
function PlayerStep({
choice,
onChoose,
}: {
choice: NowPlayingScopeStyle | null;
onChoose: (style: NowPlayingScopeStyle) => void;
}) {
const styles = useStyles();
return (
<View style={styles.stepBody}>
<StepHeader
icon="pulse-outline"
title="Pick your player look"
subtitle="The player can show live scopes of the music. Choose where they live — you can change this anytime in Settings."
/>
<ScopeStyleCards value={choice} onChange={onChoose} />
</View>
);
}
function DoneStep() {
const styles = useStyles();
const colors = useColors();
+206 -36
View File
@@ -16,6 +16,8 @@ import Animated, {
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming
} from 'react-native-reanimated';
@@ -34,6 +36,7 @@ import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker';
import { QueueTray } from '@/components/queue/QueueTray';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import { TactilePressable } from '@/components/player/TactilePressable';
import { ScopeRack } from '@/components/player/ScopeRack';
import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane';
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
@@ -51,6 +54,7 @@ import {
NOW_PLAYING_CONTENT_TOP_PADDING,
NOW_PLAYING_HEADER_HEIGHT,
NOW_PLAYING_PLAY_BUTTON_SIZE,
NOW_PLAYING_SCOPE_RAIL_BOTTOM_GAP,
NOW_PLAYING_SUB_BUTTON_SIZE,
NOW_PLAYING_WAVEFORM_TOUCH_PADDING,
NOW_PLAYING_WIDE_PANE_GAP,
@@ -64,7 +68,7 @@ import { usePlayerStore } from '@/stores/playerStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { usePlayerUiStore } from '@/stores/playerUiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore';
import type { DbTrack } from '@/types/library';
import {
cycleRepeat,
@@ -124,6 +128,7 @@ export function NowPlayingOverlay() {
const scopeMode = useSettingsStore((s) => s.scopeMode);
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
const scopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle);
const lyricsVisible = useSettingsStore((s) => s.lyricsVisible);
const setLyricsVisible = useSettingsStore((s) => s.setLyricsVisible);
const nowPlayingCompanion = useSettingsStore((s) => s.nowPlayingCompanion);
@@ -170,15 +175,19 @@ export function NowPlayingOverlay() {
);
const availableHeight = windowHeight - insets.top - insets.bottom;
const effectiveWidth = windowWidth - insets.left - insets.right;
// The rack style swaps the art card's face in place, so only the rail style
// reserves stage height for a scope strip below the art.
const railStyle = scopeStyle === 'rail';
const layoutScopeVisible = isDesktopTarget ? false : scopeStageVisible && railStyle;
const standardLayout = getNowPlayingLayout(
effectiveWidth,
availableHeight,
isDesktopTarget ? false : scopeStageVisible
layoutScopeVisible
);
const tabletCompanionLayout = getTabletCompanionLayout(
effectiveWidth,
availableHeight,
isDesktopTarget ? false : scopeStageVisible
layoutScopeVisible
);
const hasTabletCompanion = tabletCompanionLayout !== null;
const lyricPeekEnabled = !isDesktopTarget && availableHeight >= 720;
@@ -255,6 +264,11 @@ export function NowPlayingOverlay() {
setQueueOpen(true);
};
const swapScopeMode = () =>
useSettingsStore
.getState()
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum');
const navigateToArtist = (targetArtist = artistName, credit = false) => {
if (!targetArtist) return;
// Slide the overlay away while the library detail loads underneath.
@@ -323,12 +337,21 @@ export function NowPlayingOverlay() {
const translateY = useSharedValue(windowHeight);
const menuProgress = useSharedValue(0);
const trackProgress = useSharedValue(1);
// ∿ engagement, shared by both scope styles: rail = art shrink + strip fade,
// rack = art face crossfading to the instrument rack. The scope surface stays
// mounted either way (its frame loops idle while hidden), so visibility is
// purely this value — no mount state to juggle.
const stageProgress = useSharedValue(scopeStageVisible ? 1 : 0);
useEffect(() => {
if (!transitionTrackKey) return;
trackProgress.value = 0;
trackProgress.value = withTiming(1, { ...motion.snap, duration: 200 });
}, [trackProgress, transitionTrackKey]);
useEffect(() => {
stageProgress.value = withTiming(scopeStageVisible ? 1 : 0, motion.snap);
}, [scopeStageVisible, stageProgress]);
// Closing is a store toggle, not navigation. Reset the inner layers so a
// reopen starts from the plain player (parity with the old per-open mount).
const dismiss = () => {
@@ -447,6 +470,51 @@ export function NowPlayingOverlay() {
transform: [{ translateY: 4 * (1 - trackProgress.value) }],
}));
// Rail choreography (standard presentation only): the art box is laid out at
// its scope-off size and driven to the scope-on size/position by
// stageProgress, so the ∿ toggle animates as one move instead of snapping
// layout. Wide windows keep the in-flow snap — their stage height is
// state-dependent by design.
const railChoreographed = railStyle && !layout.isWide && !isDesktopTarget;
const artBoxSize = railChoreographed ? layout.artSizeScopeOff : layout.artSize;
const railArtScale =
railChoreographed && layout.artSizeScopeOff > 0
? layout.artSizeScopeOn / layout.artSizeScopeOff
: 1;
const railArtShift = railChoreographed
? layout.artSizeScopeOn / 2 - layout.mediaStackHeight / 2
: 0;
const artStageTransitionStyle = useAnimatedStyle(() => ({
opacity: 0.75 + trackProgress.value * 0.25,
transform: [
{ translateY: stageProgress.value * railArtShift },
{
scale:
(0.985 + trackProgress.value * 0.015) *
(1 + stageProgress.value * (railArtScale - 1)),
},
],
}));
const railSurfaceStyle = useAnimatedStyle(() => ({
opacity: stageProgress.value,
transform: [{ translateY: (1 - stageProgress.value) * 10 }],
}));
// Rack face flip: the art face fades out while the instrument rack settles in.
const rackFaceStyle = useAnimatedStyle(() => ({
opacity: stageProgress.value,
transform: [{ scale: 0.98 + stageProgress.value * 0.02 }],
}));
// Always write the artwork opacity. Removing an animated style after Rack
// faded it to zero leaves that native value behind until the view remounts.
// Rail therefore explicitly restores the face instead of relying on the
// absence of Rack's fade style.
const artFaceStyle = useAnimatedStyle(
() => ({
opacity: railStyle ? 1 : 1 - stageProgress.value,
}),
[railStyle]
);
return (
<View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'auto' : 'none'}>
<GestureDetector gesture={pan}>
@@ -860,7 +928,7 @@ export function NowPlayingOverlay() {
<View
style={[
styles.middleStack,
!layout.isWide && !scopeStageVisible && styles.middleStackCentered,
!layout.isWide && styles.middleStackCentered,
layout.isWide
? { width: layout.leftPaneWidth, justifyContent: 'center' }
: {
@@ -873,19 +941,20 @@ export function NowPlayingOverlay() {
<Animated.View
style={[
styles.artButton,
artworkTransitionStyle,
artStageTransitionStyle,
{
width: layout.artSize,
height: layout.artSize,
width: artBoxSize,
height: artBoxSize,
},
]}
>
<View
<Animated.View
style={[
styles.artCard,
artFaceStyle,
{
width: layout.artSize,
height: layout.artSize,
width: artBoxSize,
height: artBoxSize,
},
]}
>
@@ -897,12 +966,48 @@ export function NowPlayingOverlay() {
transition={reduceMotion ? null : 200}
/>
) : (
<AstraLogo size={Math.round(layout.artSize * 0.4)} />
<AstraLogo size={Math.round(artBoxSize * 0.4)} />
)}
</View>
</Animated.View>
{!railStyle && (
<Animated.View
pointerEvents="none"
style={[styles.rackFace, rackFaceStyle]}
>
<ScopeRack
size={artBoxSize}
stripWidth={layout.scopeWidth}
artworkUri={track.artworkData ?? null}
paused={!playerOpen || queueOpen || !scopeStageVisible}
/>
</Animated.View>
)}
</Animated.View>
{scopeStageVisible && (
{railStyle && !layout.isWide && (
<Animated.View
pointerEvents={scopeStageVisible ? 'auto' : 'none'}
style={[
styles.scopeRailFloating,
railSurfaceStyle,
{
width: layout.scopeWidth,
height: layout.scopeHeight,
bottom: NOW_PLAYING_SCOPE_RAIL_BOTTOM_GAP,
},
]}
>
<ScopeRail
width={layout.scopeWidth}
height={layout.scopeHeight}
mode={scopeMode}
paused={!playerOpen || queueOpen || !scopeStageVisible}
revealed={scopeStageVisible}
onSwap={swapScopeMode}
/>
</Animated.View>
)}
{railStyle && layout.isWide && scopeStageVisible && (
<View
style={[
styles.scopeRail,
@@ -914,34 +1019,14 @@ export function NowPlayingOverlay() {
},
]}
>
<Visualizer
<ScopeRail
width={layout.scopeWidth}
height={layout.scopeHeight}
interactive={false}
showChrome={false}
mode={scopeMode}
edgeFade
paused={!playerOpen || queueOpen}
revealed={scopeStageVisible}
onSwap={swapScopeMode}
/>
<TactilePressable
onPress={() =>
useSettingsStore
.getState()
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum')
}
haptic="selection"
hitSlop={12}
style={styles.scopeSwap} android_ripple={ripple.icon(24)}
accessibilityRole="button"
accessibilityLabel={`Showing ${
scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope'
}. Tap to switch.`}
>
<Text variant="caption" style={styles.scopeSwapLabel}>
{scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
</Text>
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
</TactilePressable>
</View>
)}
</View>
@@ -1267,6 +1352,72 @@ export function NowPlayingOverlay() {
);
}
interface ScopeRailProps {
width: number;
height: number;
mode: ScopeMode;
paused: boolean;
/** Whether the rail is currently shown — drives the transient label hint. */
revealed: boolean;
onSwap: () => Promise<void>;
}
/**
* Rail-style scope strip. The whole surface swaps SPECTRUM ⇄ SCOPE on tap; the
* mode label only appears transiently (on reveal and on swap) so the rail reads
* as an instrument, not a labelled widget.
*/
function ScopeRail({ width, height, mode, paused, revealed, onSwap }: ScopeRailProps) {
const styles = useStyles();
const colors = useColors();
const labelOpacity = useSharedValue(0);
// Swap presses bump the nonce; the flash itself lives in the effect because
// the compiler forbids direct shared-value writes after an effect uses one.
const [flashNonce, setFlashNonce] = useState(0);
useEffect(() => {
if (!revealed) return;
void flashNonce;
labelOpacity.value = withSequence(
withTiming(1, { duration: 160 }),
withDelay(1400, withTiming(0, { duration: 420 }))
);
}, [flashNonce, labelOpacity, revealed]);
const labelStyle = useAnimatedStyle(() => ({ opacity: labelOpacity.value }));
return (
<TactilePressable
onPress={() => {
void onSwap();
setFlashNonce((n) => n + 1);
}}
haptic="selection"
pressedScale={0.99}
style={[styles.scopeRailSurface, { width, height }]}
accessibilityRole="button"
accessibilityLabel={`Showing ${
mode === 'spectrum' ? 'spectrum' : 'oscilloscope'
}. Tap to switch.`}
>
<Visualizer
width={width}
height={height}
interactive={false}
showChrome={false}
mode={mode}
edgeFade
paused={paused}
/>
<Animated.View pointerEvents="none" style={[styles.scopeSwap, labelStyle]}>
<Text variant="caption" style={styles.scopeSwapLabel}>
{mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
</Text>
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
</Animated.View>
</TactilePressable>
);
}
const useStyles = createThemedStyles((colors) => ({
content: {
flex: 1,
@@ -1417,6 +1568,25 @@ const useStyles = createThemedStyles((colors) => ({
justifyContent: 'center',
overflow: 'hidden',
},
// Standard-presentation rail: pinned to the stage bottom so it can stay
// mounted (and positioned) while its reveal animates. The parent's
// alignItems centers it horizontally.
scopeRailFloating: {
position: 'absolute',
overflow: 'hidden',
},
scopeRailSurface: {
justifyContent: 'center',
},
// Rack-style face over the art card frame. Deliberately unclipped: the
// backdrop rounds/clips itself, while the strips overflow the card.
rackFace: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
scopeSwap: {
position: 'absolute',
top: spacing.xs,
+142
View File
@@ -0,0 +1,142 @@
import { StyleSheet, View } from 'react-native';
import {
Blur,
BlurMask,
Canvas,
Group,
Image as SkiaImage,
Mask,
RoundedRect,
useImage,
} from '@shopify/react-native-skia';
import { OscilloscopeWave } from '@/components/OscilloscopeWave';
import { SpectrumCurve } from '@/components/SpectrumCurve';
import { getScopeHeight } from '@/components/player/nowPlayingLayout';
import { useScopeActive } from '@/scope/scopeStore';
import { spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
// 60fps cap, matching Visualizer — display-sync starved the JS thread on
// high-refresh devices.
const STAGE_FRAME_MS = 16;
// The rack artwork is atmosphere, not a second cover card. It bleeds beyond
// the old frame, stays softly defocused, and contributes restrained color.
const BACKDROP_BLUR_RADIUS = 10;
const BACKDROP_BLEED = 40;
const BACKDROP_IMAGE_OPACITY = 0.66;
const BACKDROP_MASK_INSET_RATIO = 0.16;
const BACKDROP_MASK_BLUR_RATIO = 0.08;
// Wide dissolve so the overflowing strips melt out instead of hard-stopping.
const STRIP_EDGE_FADE_WIDTH = 56;
interface ScopeRackProps {
/** Former art-card edge length; the ambient backdrop bleeds beyond it. */
size: number;
/** Strip span (the rail's width): wider than the card, overflowing it. */
stripWidth: number;
artworkUri: string | null;
/** Freeze the scopes without unmounting (overlay closed / queue open). */
paused?: boolean;
}
/**
* Rack-style scope face: both instruments stacked at their natural wide aspect
* (oscilloscope above, spectrum grounded below). The blurred, dimmed artwork
* backdrop dissolves past the former card frame, while the strips span the
* full rail width and independently fade at their ends.
*/
export function ScopeRack({ size, stripWidth, artworkUri, paused = false }: ScopeRackProps) {
const styles = useStyles();
const artwork = useImage(artworkUri);
const active = useScopeActive() && !paused;
const width = Math.max(0, stripWidth);
const stripHeight = getScopeHeight(width);
const backdropSize = size + BACKDROP_BLEED * 2;
const maskInset = backdropSize * BACKDROP_MASK_INSET_RATIO;
const maskBlur = backdropSize * BACKDROP_MASK_BLUR_RATIO;
return (
<View style={{ width: size, height: size }}>
<View
style={[
styles.backdrop,
{
top: -BACKDROP_BLEED,
left: -BACKDROP_BLEED,
width: backdropSize,
height: backdropSize,
},
]}
>
{artwork ? (
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Mask
mode="alpha"
mask={
<RoundedRect
x={maskInset}
y={maskInset}
width={backdropSize - maskInset * 2}
height={backdropSize - maskInset * 2}
r={maskInset * 0.6}
color="white"
>
<BlurMask blur={maskBlur} style="normal" />
</RoundedRect>
}
>
<Group opacity={BACKDROP_IMAGE_OPACITY}>
<SkiaImage
image={artwork}
x={0}
y={0}
width={backdropSize}
height={backdropSize}
fit="cover"
>
<Blur blur={BACKDROP_BLUR_RADIUS} mode="clamp" />
</SkiaImage>
</Group>
</Mask>
</Canvas>
) : null}
</View>
<View style={[styles.strips, { width, left: (size - width) / 2 }]}>
<OscilloscopeWave
active={active}
frameMs={STAGE_FRAME_MS}
width={width}
height={stripHeight}
glow
edgeFade
edgeFadeWidth={STRIP_EDGE_FADE_WIDTH}
/>
<SpectrumCurve
active={active}
frameMs={STAGE_FRAME_MS}
width={width}
height={stripHeight}
glow
edgeFade
edgeFadeWidth={STRIP_EDGE_FADE_WIDTH}
/>
</View>
</View>
);
}
const useStyles = createThemedStyles(() => ({
backdrop: {
position: 'absolute',
},
strips: {
position: 'absolute',
top: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xl,
},
}));
export default ScopeRack;
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
getNowPlayingLayout,
getScopeHeight,
getTabletCompanionLayout,
} from './nowPlayingLayout.ts';
@@ -88,6 +89,35 @@ test('adds the companion only to roomy tablet canvases', () => {
}
});
test('returns both art sizes, state-independent, matching the resolved size', () => {
for (const [width, height] of [
[320, 568],
[360, 640],
[393, 852],
[412, 915],
[600, 840],
[800, 600],
]) {
const hidden = getNowPlayingLayout(width, height, false);
const visible = getNowPlayingLayout(width, height, true);
// The pair is the same regardless of the current toggle state...
assert.equal(hidden.artSizeScopeOn, visible.artSizeScopeOn);
assert.equal(hidden.artSizeScopeOff, visible.artSizeScopeOff);
// ...and the state-resolved artSize picks the matching member.
assert.equal(hidden.artSize, hidden.artSizeScopeOff);
assert.equal(visible.artSize, visible.artSizeScopeOn);
assert.ok(hidden.artSizeScopeOn <= hidden.artSizeScopeOff);
}
});
test('clamps scope strip height to its band across widths', () => {
assert.equal(getScopeHeight(0), 84);
assert.equal(getScopeHeight(300), 84);
assert.equal(getScopeHeight(336), Math.round(336 * 0.28));
assert.equal(getScopeHeight(448), 108);
assert.equal(getScopeHeight(10000), 108);
});
test('keeps calculated dimensions finite and non-negative', () => {
for (const [width, height] of [
[320, 568],
+18 -6
View File
@@ -22,6 +22,9 @@ const VISUALIZER_BOTTOM_GAP = spacing.sm;
const VISUALIZER_HEIGHT_MIN = 84;
const VISUALIZER_HEIGHT_MAX = 108;
const VISUALIZER_HEIGHT_RATIO = 0.28;
/** Fixed rail offset from the stage bottom, state-independent so the rail can
* stay mounted (and positioned) while its visibility animates. */
export const NOW_PLAYING_SCOPE_RAIL_BOTTOM_GAP = VISUALIZER_BOTTOM_GAP;
export const NOW_PLAYING_HEADER_HEIGHT = 32;
export const NOW_PLAYING_CONTENT_TOP_PADDING = spacing.sm;
export const NOW_PLAYING_CONTENT_BOTTOM_PADDING = spacing.lg;
@@ -61,6 +64,10 @@ export interface NowPlayingLayout {
waveformHeight: number;
mediaStackHeight: number;
artSize: number;
/** Art size with the scope rail shown / hidden, regardless of the current
* state — both are returned so the rail toggle can animate between them. */
artSizeScopeOn: number;
artSizeScopeOff: number;
scopeWidth: number;
scopeHeight: number;
visualizerTopGap: number;
@@ -82,7 +89,8 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function getScopeHeight(scopeWidth: number): number {
/** Natural scope strip height for a given width (~3.6:1, clamped). */
export function getScopeHeight(scopeWidth: number): number {
return Math.round(
clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX)
);
@@ -122,11 +130,11 @@ export function getNowPlayingLayout(
NOW_PLAYING_CONTENT_BOTTOM_PADDING -
NOW_PLAYING_HEADER_HEIGHT -
spacing.md;
const artHeightBudget =
verticalBudget - (showVisualizer ? scopeHeight + visualizerTopGap : 0);
const artSize = Math.round(
clamp(Math.min(leftPaneWidth, artHeightBudget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX)
);
const wideArt = (budget: number) =>
Math.round(clamp(Math.min(leftPaneWidth, budget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX));
const artSizeScopeOn = wideArt(verticalBudget - (scopeHeight + VISUALIZER_TOP_GAP));
const artSizeScopeOff = wideArt(verticalBudget);
const artSize = showVisualizer ? artSizeScopeOn : artSizeScopeOff;
const controlsGap = availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg;
return {
presentation: 'wide',
@@ -142,6 +150,8 @@ export function getNowPlayingLayout(
? artSize + visualizerTopGap + scopeHeight
: artSize,
artSize,
artSizeScopeOn,
artSizeScopeOff,
scopeWidth,
scopeHeight,
visualizerTopGap,
@@ -214,6 +224,8 @@ export function getNowPlayingLayout(
waveformHeight,
mediaStackHeight,
artSize,
artSizeScopeOn: scopeOnArt,
artSizeScopeOff: scopeOffArt,
scopeWidth,
scopeHeight,
visualizerTopGap,
+234
View File
@@ -0,0 +1,234 @@
import { Pressable, StyleSheet, View } from 'react-native';
import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { NowPlayingScopeStyle } from '@/stores/settingsStore';
interface ScopeStyleCardsProps {
/** null renders neither card selected (onboarding: no preselection bias). */
value: NowPlayingScopeStyle | null;
onChange: (style: NowPlayingScopeStyle) => void;
}
// Static waveform-bar heights for the sketch's seekbar row.
const WAVE_BAR_HEIGHTS = [7, 11, 15, 10, 14, 8, 12, 16, 9, 13, 7, 10];
/**
* Side-by-side chooser for where the now-playing scopes live. Each card is a
* wireframe of the whole player screen art, titles, seekbar, transport so
* the placement makes sense even before someone has seen the real thing. The
* accent-colored strips are the scopes; everything else stays neutral.
*/
export function ScopeStyleCards({ value, onChange }: ScopeStyleCardsProps) {
const styles = useStyles();
return (
<View style={styles.row}>
<StyleCard
variant="rail"
title="Below artwork"
description="One scope at a time, in a strip under the cover."
selected={value === 'rail'}
onPress={() => onChange('rail')}
/>
<StyleCard
variant="rack"
title="In artwork"
description="Both scopes stacked inside the cover frame."
selected={value === 'rack'}
onPress={() => onChange('rack')}
/>
</View>
);
}
function StyleCard({
variant,
title,
description,
selected,
onPress,
}: {
variant: NowPlayingScopeStyle;
title: string;
description: string;
selected: boolean;
onPress: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
return (
<Pressable
android_ripple={ripple.bounded}
style={[styles.card, selected && styles.cardSelected]}
onPress={onPress}
accessibilityRole="radio"
accessibilityState={{ selected }}
accessibilityLabel={`${title}. ${description}`}
>
<PlayerSketch variant={variant} />
<Text
variant="label"
color={selected ? colors.accentTextStrong : colors.textPrimary}
style={styles.cardTitle}
>
{title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.cardDescription}>
{description}
</Text>
</Pressable>
);
}
/** Miniature now-playing screen; only the scope strips carry the accent. */
function PlayerSketch({ variant }: { variant: NowPlayingScopeStyle }) {
const styles = useStyles();
return (
<View style={styles.sketch}>
{variant === 'rail' ? (
<>
<View style={styles.sketchArt} />
<View style={styles.sketchScopeStrip} />
</>
) : (
<View style={[styles.sketchArt, styles.sketchArtFilled]}>
<View style={styles.sketchInnerStrip} />
<View style={styles.sketchInnerStrip} />
</View>
)}
<View style={styles.sketchTextBlock}>
<View style={styles.sketchTitleLine} />
<View style={styles.sketchArtistLine} />
</View>
<View style={styles.sketchWaveRow}>
{WAVE_BAR_HEIGHTS.map((barHeight, index) => (
<View key={index} style={[styles.sketchWaveBar, { height: barHeight }]} />
))}
</View>
<View style={styles.sketchTransport}>
<View style={styles.sketchSideButton} />
<View style={styles.sketchPlayButton} />
<View style={styles.sketchSideButton} />
</View>
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
row: {
flexDirection: 'row',
gap: spacing.md,
},
card: {
flex: 1,
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
cardSelected: {
borderWidth: 1,
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
},
cardTitle: {
textAlign: 'center',
},
cardDescription: {
textAlign: 'center',
marginTop: 2,
},
// Fixed-footprint mini player so both cards line up regardless of variant.
sketch: {
height: 168,
alignItems: 'center',
justifyContent: 'center',
gap: 7,
marginBottom: spacing.sm,
},
sketchArt: {
width: 64,
height: 64,
borderRadius: 8,
borderWidth: 1.5,
borderColor: colors.textTertiary,
},
sketchArtFilled: {
backgroundColor: colors.bgTertiary,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
sketchScopeStrip: {
width: 64,
height: 9,
borderRadius: 3,
backgroundColor: colors.accent,
},
sketchInnerStrip: {
width: 46,
height: 7,
borderRadius: 3,
backgroundColor: colors.accent,
},
sketchTextBlock: {
alignSelf: 'flex-start',
marginLeft: spacing.md,
gap: 4,
marginTop: 2,
},
sketchTitleLine: {
width: 52,
height: 6,
borderRadius: 3,
backgroundColor: colors.textTertiary,
},
sketchArtistLine: {
width: 32,
height: 4,
borderRadius: 2,
backgroundColor: colors.glassBorder,
},
sketchWaveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 2.5,
height: 16,
},
sketchWaveBar: {
width: 3,
borderRadius: 1.5,
backgroundColor: colors.textTertiary,
opacity: 0.7,
},
sketchTransport: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginTop: 1,
},
sketchSideButton: {
width: 8,
height: 8,
borderRadius: 4,
borderWidth: 1.5,
borderColor: colors.textTertiary,
},
sketchPlayButton: {
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: colors.textSecondary,
},
}));
export default ScopeStyleCards;
@@ -9,6 +9,7 @@ import { EQSlider } from '@/components/eq/EQSlider';
import { ScanProgress } from '@/components/library/ScanProgress';
import { SegmentedControl } from '@/components/SegmentedControl';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards';
import {
SettingsCard,
SettingsSectionLabel,
@@ -97,6 +98,8 @@ export function AppearanceSettingsPanel() {
(option) => option.id !== 'materialYou' || materialYouAvailable
);
const accentApplies = !resolvedId.startsWith('materialYou');
const nowPlayingScopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle);
const setNowPlayingScopeStyle = useSettingsStore((s) => s.setNowPlayingScopeStyle);
return (
<>
@@ -147,6 +150,12 @@ export function AppearanceSettingsPanel() {
<AccentSwatchRow value={accentId} onChange={(id) => void setAccent(id)} />
</View>
) : null}
<SettingsSectionLabel spaced>NOW PLAYING SCOPES</SettingsSectionLabel>
<ScopeStyleCards
value={nowPlayingScopeStyle}
onChange={(style) => void setNowPlayingScopeStyle(style)}
/>
</>
);
}
+25
View File
@@ -16,12 +16,20 @@ const ARTIST_GROUPING_KEY = 'artist_grouping_mode';
const INCLUDE_SINGLES_KEY = 'album_include_singles';
const SCOPE_MODE_KEY = 'scope_mode';
const SCOPE_STAGE_VISIBLE_KEY = 'scope_stage_visible';
const SCOPE_STYLE_KEY = 'now_playing_scope_style';
const LYRICS_VISIBLE_KEY = 'lyrics_visible';
const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion';
/** Which visualizer the now-playing scope stage shows. */
export type ScopeMode = 'spectrum' | 'scope';
/**
* Where the now-playing scopes live: 'rail' keeps a strip below the artwork
* (art shrinks to fit), 'rack' flips the art card's face to both scopes
* stacked over a dimmed artwork backdrop (art size never changes).
*/
export type NowPlayingScopeStyle = 'rail' | 'rack';
function parseGroupingMode(value: string | null): ArtistGroupingMode {
return value === 'fileTags' ? 'fileTags' : 'astra';
}
@@ -30,6 +38,10 @@ function parseScopeMode(value: string | null): ScopeMode {
return value === 'scope' ? 'scope' : 'spectrum';
}
function parseScopeStyle(value: string | null): NowPlayingScopeStyle {
return value === 'rack' ? 'rack' : 'rail';
}
function parseBoolean(value: string | null): boolean {
return value === 'true';
}
@@ -40,6 +52,7 @@ interface SettingsStore {
includeSingles: boolean;
scopeMode: ScopeMode;
scopeStageVisible: boolean;
nowPlayingScopeStyle: NowPlayingScopeStyle;
/** Whether the now-playing top half shows lyrics instead of art/scope. */
lyricsVisible: boolean;
nowPlayingCompanion: NowPlayingCompanion;
@@ -49,6 +62,7 @@ interface SettingsStore {
setIncludeSingles: (include: boolean) => Promise<void>;
setScopeMode: (mode: ScopeMode) => Promise<void>;
setScopeStageVisible: (visible: boolean) => Promise<void>;
setNowPlayingScopeStyle: (style: NowPlayingScopeStyle) => Promise<void>;
setLyricsVisible: (visible: boolean) => Promise<void>;
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
}
@@ -58,6 +72,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
includeSingles: false,
scopeMode: 'spectrum',
scopeStageVisible: false,
nowPlayingScopeStyle: 'rail',
lyricsVisible: false,
nowPlayingCompanion: 'queue',
loaded: false,
@@ -70,6 +85,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
includeSingles,
scope,
scopeStageVisible,
scopeStyle,
lyricsVisible,
nowPlayingCompanion,
] = await Promise.all([
@@ -77,6 +93,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
getSetting(db, INCLUDE_SINGLES_KEY),
getSetting(db, SCOPE_MODE_KEY),
getSetting(db, SCOPE_STAGE_VISIBLE_KEY),
getSetting(db, SCOPE_STYLE_KEY),
getSetting(db, LYRICS_VISIBLE_KEY),
getSetting(db, NOW_PLAYING_COMPANION_KEY),
]);
@@ -85,6 +102,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
includeSingles: parseBoolean(includeSingles),
scopeMode: parseScopeMode(scope),
scopeStageVisible: parseBoolean(scopeStageVisible),
nowPlayingScopeStyle: parseScopeStyle(scopeStyle),
lyricsVisible: parseBoolean(lyricsVisible),
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
loaded: true,
@@ -119,6 +137,13 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
await setSetting(db, SCOPE_STAGE_VISIBLE_KEY, visible ? 'true' : 'false');
},
setNowPlayingScopeStyle: async (style) => {
if (get().nowPlayingScopeStyle === style) return;
set({ nowPlayingScopeStyle: style });
const db = await openLibraryDb();
await setSetting(db, SCOPE_STYLE_KEY, style);
},
setLyricsVisible: async (visible) => {
if (get().lyricsVisible === visible) return;
set({ lyricsVisible: visible });