mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
scopes styles
This commit is contained in:
@@ -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 }} />;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user