refactor(waveform): co-locate seekbar feature into features/waveform

This commit is contained in:
Psychotoxical
2026-06-29 22:30:53 +02:00
parent d9f8d01be9
commit 6d7464fddf
31 changed files with 66 additions and 55 deletions
@@ -14,7 +14,7 @@ import { backdropFromConfig } from '../../cover/artistBackdrop';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { useFsIdleFade } from '../../hooks/useFsIdleFade'; import { useFsIdleFade } from '../../hooks/useFsIdleFade';
import { useQueueTrackAt } from '../../hooks/useQueueTracks'; import { useQueueTrackAt } from '../../hooks/useQueueTracks';
import WaveformSeek from '../WaveformSeek'; import { WaveformSeek } from '@/features/waveform';
import { FsQueueModal } from './FsQueueModal'; import { FsQueueModal } from './FsQueueModal';
import { FsLyricsApple } from './FsLyricsApple'; import { FsLyricsApple } from './FsLyricsApple';
import { FsPlayBtn } from './FsPlayBtn'; import { FsPlayBtn } from './FsPlayBtn';
@@ -3,7 +3,7 @@ import type { TFunction } from 'i18next';
import type { RadioMetadata } from '../../hooks/useRadioMetadata'; import type { RadioMetadata } from '../../hooks/useRadioMetadata';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { formatTrackTime, playbarMinuteFieldWidth } from '../../utils/format/formatDuration'; import { formatTrackTime, playbarMinuteFieldWidth } from '../../utils/format/formatDuration';
import WaveformSeek from '../WaveformSeek'; import { WaveformSeek } from '@/features/waveform';
import { PlaybackTime, ToggleClock } from './PlaybackClock'; import { PlaybackTime, ToggleClock } from './PlaybackClock';
interface Props { interface Props {
+1 -1
View File
@@ -16,7 +16,7 @@ import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle'; import { SettingsToggle } from './SettingsToggle';
import { SettingsSubCard, SettingsField, SettingsValue } from './SettingsSubCard'; import { SettingsSubCard, SettingsField, SettingsValue } from './SettingsSubCard';
import { SettingsSegmented, type SegmentedOption } from './SettingsSegmented'; import { SettingsSegmented, type SegmentedOption } from './SettingsSegmented';
import { SeekbarPreview } from '../WaveformSeekPreview'; import { SeekbarPreview } from '@/features/waveform';
import WindowButtonPreview from '../WindowButtonPreview'; import WindowButtonPreview from '../WindowButtonPreview';
export function AppearanceTab() { export function AppearanceTab() {
@@ -25,7 +25,7 @@ vi.mock('@/api/subsonic', () => ({
})); }));
import WaveformSeek from './WaveformSeek'; import WaveformSeek from '@/features/waveform/components/WaveformSeek';
import { renderWithProviders } from '@/test/helpers/renderWithProviders'; import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore'; import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
@@ -1,17 +1,17 @@
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/store/playbackProgress';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '@/store/playerStore';
import { usePreviewStore } from '../store/previewStore'; import { usePreviewStore } from '@/store/previewStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '@/store/authStore';
import { import {
ANIMATED_STYLES, AnimState, ANIMATED_STYLES, AnimState,
STATIC_REDRAW_FORCE_MS, STATIC_REDRAW_MIN_MS, STATIC_REDRAW_FORCE_MS, STATIC_REDRAW_MIN_MS,
fmt, invalidateColorCache, isBarQuantizedSeekStyle, makeAnimState, fmt, invalidateColorCache, isBarQuantizedSeekStyle, makeAnimState,
quantizeProgressByBars, quantizeProgressByBars,
} from '../utils/waveform/waveformSeekHelpers'; } from '@/features/waveform/utils/waveformSeekHelpers';
import { drawSeekbar } from '../utils/waveform/waveformSeekRenderers'; import { drawSeekbar } from '@/features/waveform/utils/waveformSeekRenderers';
import { useWaveformHeights } from '../hooks/useWaveformHeights'; import { useWaveformHeights } from '@/features/waveform/hooks/useWaveformHeights';
import { useWaveformInterpolation } from '../hooks/useWaveformInterpolation'; import { useWaveformInterpolation } from '@/features/waveform/hooks/useWaveformInterpolation';
// ── main component ──────────────────────────────────────────────────────────── // ── main component ────────────────────────────────────────────────────────────
// //
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import type { SeekbarStyle } from '../store/authStoreTypes'; import type { SeekbarStyle } from '@/store/authStoreTypes';
import { makeAnimState, makeHeights } from '../utils/waveform/waveformSeekHelpers'; import { makeAnimState, makeHeights } from '@/features/waveform/utils/waveformSeekHelpers';
import { drawSeekbar } from '../utils/waveform/waveformSeekRenderers'; import { drawSeekbar } from '@/features/waveform/utils/waveformSeekRenderers';
interface Props { interface Props {
style: SeekbarStyle; style: SeekbarStyle;
@@ -1,12 +1,12 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import type { SeekbarStyle } from '../store/authStoreTypes'; import type { SeekbarStyle } from '@/store/authStoreTypes';
import type { AnimState } from '../utils/waveform/waveformSeekHelpers'; import type { AnimState } from '@/features/waveform/utils/waveformSeekHelpers';
import { import {
ANIMATED_STYLES, BAR_COUNT, FLAT_WAVE_NORM, WAVE_MORPH_MS, ANIMATED_STYLES, BAR_COUNT, FLAT_WAVE_NORM, WAVE_MORPH_MS,
binsToHeights, easeOutCubic, heightsNearlyEqual, binsToHeights, easeOutCubic, heightsNearlyEqual,
makeFlatWaveHeights, makeHeights, makeFlatWaveHeights, makeHeights,
} from '../utils/waveform/waveformSeekHelpers'; } from '@/features/waveform/utils/waveformSeekHelpers';
import { drawSeekbar } from '../utils/waveform/waveformSeekRenderers'; import { drawSeekbar } from '@/features/waveform/utils/waveformSeekRenderers';
interface Args { interface Args {
trackId: string | undefined; trackId: string | undefined;
@@ -1,11 +1,11 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import type { SeekbarStyle } from '../store/authStoreTypes'; import type { SeekbarStyle } from '@/store/authStoreTypes';
import { getPlaybackProgressSnapshot } from '../store/playbackProgress'; import { getPlaybackProgressSnapshot } from '@/store/playbackProgress';
import { import {
ANIMATED_STYLES, AnimState, INTERPOLATION_PAINT_MIN_MS, ANIMATED_STYLES, AnimState, INTERPOLATION_PAINT_MIN_MS,
isBarQuantizedSeekStyle, quantizeProgressByBars, isBarQuantizedSeekStyle, quantizeProgressByBars,
} from '../utils/waveform/waveformSeekHelpers'; } from '@/features/waveform/utils/waveformSeekHelpers';
import { drawSeekbar } from '../utils/waveform/waveformSeekRenderers'; import { drawSeekbar } from '@/features/waveform/utils/waveformSeekRenderers';
interface Args { interface Args {
duration: number; duration: number;
+11
View File
@@ -0,0 +1,11 @@
/**
* Waveform seekbar feature — the canvas seekbar, its waveform-bin loading, and
* the per-track refresh generation guard. Co-located mirror of `cover/` /
* `music-network/`. `waveformSilence` stays in `utils/waveform/` (crossfade /
* auto-dj concern, not seekbar rendering).
*/
export { default as WaveformSeek } from './components/WaveformSeek';
export { SeekbarPreview } from './components/WaveformSeekPreview';
export { fetchWaveformBins, refreshWaveformForTrack } from './store/waveformRefresh';
export type { WaveformCachePayload } from './store/waveformRefresh';
export { bumpWaveformRefreshGen, getWaveformRefreshGen } from './store/waveformRefreshGen';
@@ -22,18 +22,18 @@ const hoisted = vi.hoisted(() => ({
})); }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock })); vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('../utils/waveform/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock })); vi.mock('@/features/waveform/utils/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock }));
vi.mock('./playerStore', () => ({ vi.mock('@/store/playerStore', () => ({
usePlayerStore: { usePlayerStore: {
getState: () => hoisted.playerSnapshot, getState: () => hoisted.playerSnapshot,
setState: hoisted.playerSetStateMock, setState: hoisted.playerSetStateMock,
}, },
})); }));
vi.mock('./waveformRefreshGen', () => ({ vi.mock('@/features/waveform/store/waveformRefreshGen', () => ({
getWaveformRefreshGen: hoisted.getGenMock, getWaveformRefreshGen: hoisted.getGenMock,
})); }));
import { refreshWaveformForTrack } from './waveformRefresh'; import { refreshWaveformForTrack } from '@/features/waveform/store/waveformRefresh';
beforeEach(() => { beforeEach(() => {
hoisted.invokeMock.mockReset(); hoisted.invokeMock.mockReset();
@@ -1,8 +1,8 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { coerceWaveformBins } from '../utils/waveform/waveformParse'; import { coerceWaveformBins } from '@/features/waveform/utils/waveformParse';
import { getPlaybackIndexKey } from '../utils/playback/playbackServer'; import { getPlaybackIndexKey } from '@/utils/playback/playbackServer';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from '@/store/playerStore';
import { getWaveformRefreshGen } from './waveformRefreshGen'; import { getWaveformRefreshGen } from '@/features/waveform/store/waveformRefreshGen';
/** Subsonic-server waveform-cache row as Rust hands it back. */ /** Subsonic-server waveform-cache row as Rust hands it back. */
export type WaveformCachePayload = { export type WaveformCachePayload = {
@@ -3,7 +3,7 @@ import {
_resetWaveformRefreshGenForTest, _resetWaveformRefreshGenForTest,
bumpWaveformRefreshGen, bumpWaveformRefreshGen,
getWaveformRefreshGen, getWaveformRefreshGen,
} from './waveformRefreshGen'; } from '@/features/waveform/store/waveformRefreshGen';
afterEach(() => { afterEach(() => {
_resetWaveformRefreshGenForTest(); _resetWaveformRefreshGenForTest();
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { coerceWaveformBins, waveformBlobLenOk } from './waveformParse'; import { coerceWaveformBins, waveformBlobLenOk } from '@/features/waveform/utils/waveformParse';
describe('waveformBlobLenOk', () => { describe('waveformBlobLenOk', () => {
it('accepts the legacy single-curve length (500)', () => { it('accepts the legacy single-curve length (500)', () => {
@@ -1,4 +1,4 @@
import type { SeekbarStyle } from '../../store/authStoreTypes'; import type { SeekbarStyle } from '@/store/authStoreTypes';
export function fmt(s: number): string { export function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00'; if (!s || isNaN(s)) return '0:00';
@@ -1,12 +1,12 @@
import type { SeekbarStyle } from '../../store/authStoreTypes'; import type { SeekbarStyle } from '@/store/authStoreTypes';
import { bumpPerfCounter } from '../perf/perfTelemetry'; import { bumpPerfCounter } from '@/utils/perf/perfTelemetry';
import { AnimState, makeAnimState } from './waveformSeekHelpers'; import { AnimState, makeAnimState } from '@/features/waveform/utils/waveformSeekHelpers';
import { import {
drawBar, drawLineDot, drawNeon, drawSegmented, drawThick, drawWaveform, drawBar, drawLineDot, drawNeon, drawSegmented, drawThick, drawWaveform,
} from './waveformSeekRenderersStatic'; } from '@/features/waveform/utils/waveformSeekRenderersStatic';
import { import {
drawLiquidFill, drawParticleTrail, drawPulseWave, drawRetroTape, drawLiquidFill, drawParticleTrail, drawPulseWave, drawRetroTape,
} from './waveformSeekRenderersAnimated'; } from '@/features/waveform/utils/waveformSeekRenderersAnimated';
export function drawSeekbar( export function drawSeekbar(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
@@ -1,4 +1,4 @@
import { AnimState, getColors, setShadowBlur, setupCanvas } from './waveformSeekHelpers'; import { AnimState, getColors, setShadowBlur, setupCanvas } from '@/features/waveform/utils/waveformSeekHelpers';
export function drawPulseWave( export function drawPulseWave(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
@@ -1,7 +1,7 @@
import { import {
BAR_COUNT, SEG_COUNT, BAR_COUNT, SEG_COUNT,
getColors, setShadowBlur, setupCanvas, waveformBarThickness, getColors, setShadowBlur, setupCanvas, waveformBarThickness,
} from './waveformSeekHelpers'; } from '@/features/waveform/utils/waveformSeekHelpers';
export function drawWaveform( export function drawWaveform(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
+1 -1
View File
@@ -20,7 +20,7 @@ import {
type QueueUndoSnapshot, type QueueUndoSnapshot,
} from './queueUndo'; } from './queueUndo';
import { refreshLoudnessForTrack } from './loudnessRefresh'; import { refreshLoudnessForTrack } from './loudnessRefresh';
import { refreshWaveformForTrack } from './waveformRefresh'; import { refreshWaveformForTrack } from '@/features/waveform';
import { stopRadio } from './radioPlayer'; import { stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers'; import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { syncUserQueueMutationToServer } from './queueSync'; import { syncUserQueueMutationToServer } from './queueSync';
+1 -1
View File
@@ -27,7 +27,7 @@ vi.mock('./pausedRestorePrepare', () => ({
preparePausedRestoreOnStartup: vi.fn(), preparePausedRestoreOnStartup: vi.fn(),
})); }));
vi.mock('./waveformRefresh', () => ({ vi.mock('@/features/waveform', () => ({
refreshWaveformForTrack: vi.fn(), refreshWaveformForTrack: vi.fn(),
})); }));
+1 -1
View File
@@ -9,7 +9,7 @@ import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { preparePausedRestoreOnStartup } from './pausedRestorePrepare'; import { preparePausedRestoreOnStartup } from './pausedRestorePrepare';
import { pushQueueUndoFromGetter } from './queueUndo'; import { pushQueueUndoFromGetter } from './queueUndo';
import { refreshWaveformForTrack } from './waveformRefresh'; import { refreshWaveformForTrack } from '@/features/waveform';
import { import {
getIdlePullGeneration, getIdlePullGeneration,
isIdleQueuePullSuspended, isIdleQueuePullSuspended,
+1 -1
View File
@@ -87,7 +87,7 @@ import {
getSeekTarget, getSeekTarget,
getSeekTargetSetAt, getSeekTargetSetAt,
} from './seekTargetState'; } from './seekTargetState';
import { refreshWaveformForTrack } from './waveformRefresh'; import { refreshWaveformForTrack } from '@/features/waveform';
import { analyzeBoundary, computeWaveformSilence } from '../utils/waveform/waveformSilence'; import { analyzeBoundary, computeWaveformSilence } from '../utils/waveform/waveformSilence';
import { autodjMaxOverlapCapSec } from '../utils/playback/autodjOverlapCap'; import { autodjMaxOverlapCapSec } from '../utils/playback/autodjOverlapCap';
import { import {
@@ -24,8 +24,8 @@ import {
markNormalizationUiUpdate, markNormalizationUiUpdate,
} from '../playbackThrottles'; } from '../playbackThrottles';
import { usePlayerStore } from '../playerStore'; import { usePlayerStore } from '../playerStore';
import { refreshWaveformForTrack } from '../waveformRefresh'; import { refreshWaveformForTrack } from '@/features/waveform';
import { bumpWaveformRefreshGen } from '../waveformRefreshGen'; import { bumpWaveformRefreshGen } from '@/features/waveform';
import { setBytePreloadingId } from '../gaplessPreloadState'; import { setBytePreloadingId } from '../gaplessPreloadState';
type PreloadEventPayload = { type PreloadEventPayload = {
@@ -7,8 +7,8 @@ import { invokeAudioSetNormalizationDeduped } from '../normalizationIpcDedupe';
import { usePlayerStore } from '../playerStore'; import { usePlayerStore } from '../playerStore';
import { clearLoudnessCacheStateForTrackId } from '../loudnessGainCache'; import { clearLoudnessCacheStateForTrackId } from '../loudnessGainCache';
import { refreshLoudnessForTrack } from '../loudnessRefresh'; import { refreshLoudnessForTrack } from '../loudnessRefresh';
import { refreshWaveformForTrack } from '../waveformRefresh'; import { refreshWaveformForTrack } from '@/features/waveform';
import { bumpWaveformRefreshGen } from '../waveformRefreshGen'; import { bumpWaveformRefreshGen } from '@/features/waveform';
/** /**
* Keeps the Rust audio engine in sync whenever the auth store changes * Keeps the Rust audio engine in sync whenever the auth store changes
@@ -5,7 +5,7 @@ import { emitNormalizationDebug } from '../normalizationDebug';
import { invokeAudioSetNormalizationDeduped } from '../normalizationIpcDedupe'; import { invokeAudioSetNormalizationDeduped } from '../normalizationIpcDedupe';
import { usePlayerStore } from '../playerStore'; import { usePlayerStore } from '../playerStore';
import { refreshLoudnessForTrack } from '../loudnessRefresh'; import { refreshLoudnessForTrack } from '../loudnessRefresh';
import { refreshWaveformForTrack } from '../waveformRefresh'; import { refreshWaveformForTrack } from '@/features/waveform';
/** /**
* One-shot startup sync: pushes the persisted audio settings to the Rust engine * One-shot startup sync: pushes the persisted audio settings to the Rust engine
+1 -1
View File
@@ -15,7 +15,7 @@ import {
import { getBytePreloadingId, setBytePreloadingId } from './gaplessPreloadState'; import { getBytePreloadingId, setBytePreloadingId } from './gaplessPreloadState';
import { refreshLoudnessForTrack } from './loudnessRefresh'; import { refreshLoudnessForTrack } from './loudnessRefresh';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { fetchWaveformBins } from './waveformRefresh'; import { fetchWaveformBins } from '@/features/waveform';
// Crossfade pre-buffer budget: begin downloading the next track this many // Crossfade pre-buffer budget: begin downloading the next track this many
// seconds before it needs to play (the crossfade start), so a large lossless // seconds before it needs to play (the crossfade start), so a large lossless
+1 -1
View File
@@ -39,7 +39,7 @@ vi.mock('./playerStore', () => ({
setState: hoisted.playerSetStateMock, setState: hoisted.playerSetStateMock,
}, },
})); }));
vi.mock('./waveformRefreshGen', () => ({ vi.mock('@/features/waveform', () => ({
bumpWaveformRefreshGen: hoisted.bumpWaveformRefreshGenMock, bumpWaveformRefreshGen: hoisted.bumpWaveformRefreshGenMock,
})); }));
vi.mock('./loudnessGainCache', () => ({ vi.mock('./loudnessGainCache', () => ({
+1 -1
View File
@@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
import { getPlaybackIndexKey } from '../utils/playback/playbackServer'; import { getPlaybackIndexKey } from '../utils/playback/playbackServer';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { bumpWaveformRefreshGen } from './waveformRefreshGen'; import { bumpWaveformRefreshGen } from '@/features/waveform';
import { clearLoudnessCacheStateForTrackId } from './loudnessGainCache'; import { clearLoudnessCacheStateForTrackId } from './loudnessGainCache';
import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState'; import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
+1 -1
View File
@@ -51,7 +51,7 @@ import {
loudnessGainDbForEngineBind, loudnessGainDbForEngineBind,
} from './loudnessGainCache'; } from './loudnessGainCache';
import { refreshLoudnessForTrack } from './loudnessRefresh'; import { refreshLoudnessForTrack } from './loudnessRefresh';
import { fetchWaveformBins, refreshWaveformForTrack } from './waveformRefresh'; import { fetchWaveformBins, refreshWaveformForTrack } from '@/features/waveform';
import { deriveNormalizationSnapshot } from './normalizationSnapshot'; import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import { useOrbitStore } from './orbitStore'; import { useOrbitStore } from './orbitStore';
import { import {
+1 -1
View File
@@ -11,7 +11,7 @@ import { clearSeekDebounce } from './seekDebounce';
import { clearSeekFallbackRetry } from './seekFallbackState'; import { clearSeekFallbackRetry } from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState'; import { clearSeekTarget } from './seekTargetState';
import { tryAcquireTogglePlayLock } from './togglePlayLock'; import { tryAcquireTogglePlayLock } from './togglePlayLock';
import { refreshWaveformForTrack } from './waveformRefresh'; import { refreshWaveformForTrack } from '@/features/waveform';
import { clearAutodjTransitionUi } from './autodjTransitionUi'; import { clearAutodjTransitionUi } from './autodjTransitionUi';
type SetState = ( type SetState = (