From a34d9cba8968cefdecc59e18550037682d2f5916 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:54:18 -0400 Subject: [PATCH] scopes styles --- src/components/OscilloscopeWave.tsx | 130 +++++++++- src/components/SpectrumCurve.tsx | 215 +++++++++++----- src/components/onboarding/OnboardingFlow.tsx | 40 ++- src/components/player/NowPlayingOverlay.tsx | 242 +++++++++++++++--- src/components/player/ScopeRack.tsx | 142 ++++++++++ .../player/nowPlayingLayout.test.mts | 30 +++ src/components/player/nowPlayingLayout.ts | 24 +- src/components/settings/ScopeStyleCards.tsx | 234 +++++++++++++++++ src/components/settings/SettingsPanels.tsx | 9 + src/stores/settingsStore.ts | 25 ++ 10 files changed, 963 insertions(+), 128 deletions(-) create mode 100644 src/components/player/ScopeRack.tsx create mode 100644 src/components/settings/ScopeStyleCards.tsx diff --git a/src/components/OscilloscopeWave.tsx b/src/components/OscilloscopeWave.tsx index 6865a93..f0c52d0 100644 --- a/src/components/OscilloscopeWave.tsx +++ b/src/components/OscilloscopeWave.tsx @@ -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(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 ; diff --git a/src/components/SpectrumCurve.tsx b/src/components/SpectrumCurve.tsx index 31779f8..2523556 100644 --- a/src/components/SpectrumCurve.tsx +++ b/src/components/SpectrumCurve.tsx @@ -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(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(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, diff --git a/src/components/onboarding/OnboardingFlow.tsx b/src/components/onboarding/OnboardingFlow.tsx index 67753c4..677b230 100644 --- a/src/components/onboarding/OnboardingFlow.tsx +++ b/src/components/onboarding/OnboardingFlow.tsx @@ -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['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(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' ? : null} {step === 'library' ? : null} {step === 'theme' ? : null} + {step === 'player' ? ( + + ) : null} {step === 'done' ? : null} @@ -278,6 +290,26 @@ function ThemeStep() { ); } +function PlayerStep({ + choice, + onChoose, +}: { + choice: NowPlayingScopeStyle | null; + onChoose: (style: NowPlayingScopeStyle) => void; +}) { + const styles = useStyles(); + return ( + + + + + ); +} + function DoneStep() { const styles = useStyles(); const colors = useColors(); diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index abc5b98..5b00031 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -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 ( @@ -860,7 +928,7 @@ export function NowPlayingOverlay() { - @@ -897,12 +966,48 @@ export function NowPlayingOverlay() { transition={reduceMotion ? null : 200} /> ) : ( - + )} - + + {!railStyle && ( + + + + )} - {scopeStageVisible && ( + {railStyle && !layout.isWide && ( + + + + )} + {railStyle && layout.isWide && scopeStageVisible && ( - - - 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.`} - > - - {scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'} - - - )} @@ -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; +} + +/** + * 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 ( + { + 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.`} + > + + + + {mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'} + + + + + ); +} + 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, diff --git a/src/components/player/ScopeRack.tsx b/src/components/player/ScopeRack.tsx new file mode 100644 index 0000000..d154793 --- /dev/null +++ b/src/components/player/ScopeRack.tsx @@ -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 ( + + + {artwork ? ( + + + + + } + > + + + + + + + + ) : null} + + + + + + + ); +} + +const useStyles = createThemedStyles(() => ({ + backdrop: { + position: 'absolute', + }, + strips: { + position: 'absolute', + top: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xl, + }, +})); + +export default ScopeRack; diff --git a/src/components/player/nowPlayingLayout.test.mts b/src/components/player/nowPlayingLayout.test.mts index 148d6d4..472baea 100644 --- a/src/components/player/nowPlayingLayout.test.mts +++ b/src/components/player/nowPlayingLayout.test.mts @@ -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], diff --git a/src/components/player/nowPlayingLayout.ts b/src/components/player/nowPlayingLayout.ts index f339729..a2b90a6 100644 --- a/src/components/player/nowPlayingLayout.ts +++ b/src/components/player/nowPlayingLayout.ts @@ -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, diff --git a/src/components/settings/ScopeStyleCards.tsx b/src/components/settings/ScopeStyleCards.tsx new file mode 100644 index 0000000..9bd4968 --- /dev/null +++ b/src/components/settings/ScopeStyleCards.tsx @@ -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 ( + + onChange('rail')} + /> + onChange('rack')} + /> + + ); +} + +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 ( + + + + {title} + + + {description} + + + ); +} + +/** Miniature now-playing screen; only the scope strips carry the accent. */ +function PlayerSketch({ variant }: { variant: NowPlayingScopeStyle }) { + const styles = useStyles(); + return ( + + {variant === 'rail' ? ( + <> + + + + ) : ( + + + + + )} + + + + + + + + {WAVE_BAR_HEIGHTS.map((barHeight, index) => ( + + ))} + + + + + + + + + ); +} + +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; diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx index 6c33e9d..d9f479c 100644 --- a/src/components/settings/SettingsPanels.tsx +++ b/src/components/settings/SettingsPanels.tsx @@ -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() { void setAccent(id)} /> ) : null} + + NOW PLAYING SCOPES + void setNowPlayingScopeStyle(style)} + /> ); } diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 0d01221..a4f13a0 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -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; setScopeMode: (mode: ScopeMode) => Promise; setScopeStageVisible: (visible: boolean) => Promise; + setNowPlayingScopeStyle: (style: NowPlayingScopeStyle) => Promise; setLyricsVisible: (visible: boolean) => Promise; setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise; } @@ -58,6 +72,7 @@ export const useSettingsStore = create((set, get) => ({ includeSingles: false, scopeMode: 'spectrum', scopeStageVisible: false, + nowPlayingScopeStyle: 'rail', lyricsVisible: false, nowPlayingCompanion: 'queue', loaded: false, @@ -70,6 +85,7 @@ export const useSettingsStore = create((set, get) => ({ includeSingles, scope, scopeStageVisible, + scopeStyle, lyricsVisible, nowPlayingCompanion, ] = await Promise.all([ @@ -77,6 +93,7 @@ export const useSettingsStore = create((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((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((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 });