mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
import { create } from 'zustand'
|
|
import { SCOPE_KINDS, type ScopeKind } from '../../types/scope'
|
|
import type { VectorscopeMode } from '../visualizers/Vectorscope'
|
|
import type { SpectrogramClarityMode, SpectrogramScaleMode } from '../../types/spectrogram'
|
|
import type { VUMeterMode, VUMeterOrientation } from '../../types/vumeter'
|
|
import type { LUFSMeterMode } from '../../types/lufsmeter'
|
|
|
|
// Per-scope settings (mirrors Astra's AnalyzerProfileScopeSettings)
|
|
export interface ScopeSettings {
|
|
spectrum: {
|
|
fftSize: number
|
|
tiltDbPerOctave: number
|
|
heatmap: boolean
|
|
heatmapTiltDbPerOctave: number
|
|
showGrid: boolean
|
|
smoothing: number
|
|
fillGradient: boolean
|
|
}
|
|
oscilloscope: {
|
|
pitchLock: boolean
|
|
underfillEnabled: boolean
|
|
showGrid: boolean
|
|
lineWidth: number
|
|
}
|
|
vectorscope: {
|
|
mode: VectorscopeMode
|
|
multiband: boolean
|
|
showGrid: boolean
|
|
persistence: number
|
|
lineWidth: number
|
|
}
|
|
spectrogram: {
|
|
fftSize: number
|
|
scrollSpeed: number
|
|
clarityMode: SpectrogramClarityMode
|
|
scaleMode: SpectrogramScaleMode
|
|
colorScheme: 'heat' | 'mono'
|
|
}
|
|
vumeter: {
|
|
mode: VUMeterMode
|
|
orientation: VUMeterOrientation
|
|
}
|
|
lufsmeter: {
|
|
mode: LUFSMeterMode
|
|
}
|
|
waveform: {
|
|
scrollSpeed: number
|
|
gainDb: number
|
|
multiband: boolean
|
|
}
|
|
}
|
|
|
|
const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
|
|
spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true },
|
|
oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 },
|
|
vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 },
|
|
spectrogram: { fftSize: 2048, scrollSpeed: 2, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' },
|
|
vumeter: { mode: 'bar', orientation: 'horizontal' },
|
|
lufsmeter: { mode: 'bar' },
|
|
waveform: { scrollSpeed: 1, gainDb: 0, multiband: false },
|
|
}
|
|
|
|
const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
|
|
|
|
const STORAGE_KEY = 'prism:settings'
|
|
const PROFILES_STORAGE_KEY = 'prism:profiles'
|
|
const ACTIVE_PROFILE_KEY = 'prism:activeProfile'
|
|
const DEFAULT_PROFILE_ID = 'profile_default'
|
|
|
|
export interface Profile {
|
|
name: string
|
|
scopeOrder: ScopeKind[]
|
|
hiddenScopes: ScopeKind[]
|
|
widthWeights: Record<ScopeKind, number>
|
|
scopeSettings: ScopeSettings
|
|
windowBounds?: { x: number; y: number; width: number; height: number }
|
|
}
|
|
|
|
function loadProfiles(): Record<string, Profile> {
|
|
try {
|
|
const raw = localStorage.getItem(PROFILES_STORAGE_KEY)
|
|
if (raw) return JSON.parse(raw)
|
|
} catch { /* ignore */ }
|
|
return {}
|
|
}
|
|
|
|
function saveProfiles(profiles: Record<string, Profile>): void {
|
|
try {
|
|
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles))
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
function loadActiveProfileId(): string | null {
|
|
try {
|
|
return localStorage.getItem(ACTIVE_PROFILE_KEY)
|
|
} catch { return null }
|
|
}
|
|
|
|
function saveActiveProfileId(id: string | null): void {
|
|
try {
|
|
if (id) {
|
|
localStorage.setItem(ACTIVE_PROFILE_KEY, id)
|
|
} else {
|
|
localStorage.removeItem(ACTIVE_PROFILE_KEY)
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
interface SettingsState {
|
|
scopeOrder: ScopeKind[]
|
|
hiddenScopes: Set<ScopeKind>
|
|
widthWeights: Record<ScopeKind, number>
|
|
scopeSettings: ScopeSettings
|
|
|
|
// Derived
|
|
visibleScopes: () => ScopeKind[]
|
|
|
|
// Profiles
|
|
profiles: Record<string, Profile>
|
|
activeProfileId: string | null
|
|
|
|
// Actions
|
|
toggleScope: (kind: ScopeKind) => void
|
|
moveScope: (kind: ScopeKind, direction: 'left' | 'right') => void
|
|
setScopeWidthWeight: (kind: ScopeKind, weight: number) => void
|
|
updateScopeSettings: <K extends ScopeKind>(kind: K, settings: Partial<ScopeSettings[K]>) => void
|
|
saveProfile: (name: string) => string
|
|
saveProfileAs: (name: string) => string
|
|
updateActiveProfile: () => void
|
|
loadProfile: (id: string) => void
|
|
deleteProfile: (id: string) => void
|
|
renameProfile: (id: string, name: string) => void
|
|
}
|
|
|
|
function loadFromStorage(): Partial<{ scopeOrder: ScopeKind[]; hiddenScopes: ScopeKind[]; widthWeights: Record<ScopeKind, number>; scopeSettings: ScopeSettings }> {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY)
|
|
if (raw) return JSON.parse(raw)
|
|
} catch { /* ignore */ }
|
|
return {}
|
|
}
|
|
|
|
function saveToStorage(state: SettingsState): void {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
scopeOrder: state.scopeOrder,
|
|
hiddenScopes: Array.from(state.hiddenScopes),
|
|
widthWeights: state.widthWeights,
|
|
scopeSettings: state.scopeSettings,
|
|
}))
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
function isScopeKind(value: unknown): value is ScopeKind {
|
|
return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind)
|
|
}
|
|
|
|
function normalizeScopeOrder(raw: unknown): ScopeKind[] {
|
|
if (!Array.isArray(raw)) return [...SCOPE_KINDS]
|
|
const valid = raw.filter(isScopeKind)
|
|
const seen = new Set<ScopeKind>()
|
|
const normalized: ScopeKind[] = []
|
|
|
|
for (const kind of valid) {
|
|
if (seen.has(kind)) continue
|
|
seen.add(kind)
|
|
normalized.push(kind)
|
|
}
|
|
|
|
for (const kind of SCOPE_KINDS) {
|
|
if (!seen.has(kind)) {
|
|
normalized.push(kind)
|
|
}
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
function normalizeHiddenScopes(raw: unknown): ScopeKind[] {
|
|
if (!Array.isArray(raw)) {
|
|
return SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind))
|
|
}
|
|
return raw.filter(isScopeKind)
|
|
}
|
|
|
|
function mergeScopeSettings(raw: unknown): ScopeSettings {
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<ScopeSettings>
|
|
: {}
|
|
|
|
return {
|
|
spectrum: { ...DEFAULT_SCOPE_SETTINGS.spectrum, ...(parsed.spectrum ?? {}) },
|
|
oscilloscope: { ...DEFAULT_SCOPE_SETTINGS.oscilloscope, ...(parsed.oscilloscope ?? {}) },
|
|
vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) },
|
|
spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...(parsed.spectrogram ?? {}) },
|
|
vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, ...(parsed.vumeter ?? {}) },
|
|
lufsmeter: { ...DEFAULT_SCOPE_SETTINGS.lufsmeter, ...(parsed.lufsmeter ?? {}) },
|
|
waveform: { ...DEFAULT_SCOPE_SETTINGS.waveform, ...(parsed.waveform ?? {}) },
|
|
}
|
|
}
|
|
|
|
const stored = loadFromStorage()
|
|
|
|
const defaultWeights: Record<ScopeKind, number> = {
|
|
spectrum: 1, oscilloscope: 1, vectorscope: 1, spectrogram: 1,
|
|
vumeter: 0.5, lufsmeter: 0.5, waveform: 1,
|
|
}
|
|
|
|
// Ensure a default profile always exists
|
|
function ensureDefaultProfile(profiles: Record<string, Profile>): Record<string, Profile> {
|
|
if (profiles[DEFAULT_PROFILE_ID]) return profiles
|
|
const defaultProfile: Profile = {
|
|
name: 'Default',
|
|
scopeOrder: [...SCOPE_KINDS],
|
|
hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)),
|
|
widthWeights: { ...defaultWeights },
|
|
scopeSettings: JSON.parse(JSON.stringify(DEFAULT_SCOPE_SETTINGS)),
|
|
}
|
|
const updated = { [DEFAULT_PROFILE_ID]: defaultProfile, ...profiles }
|
|
saveProfiles(updated)
|
|
return updated
|
|
}
|
|
|
|
const initialProfiles = ensureDefaultProfile(loadProfiles())
|
|
const initialActiveProfileId = loadActiveProfileId()
|
|
|
|
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|
scopeOrder: normalizeScopeOrder(stored.scopeOrder),
|
|
hiddenScopes: new Set<ScopeKind>(
|
|
normalizeHiddenScopes(stored.hiddenScopes)
|
|
),
|
|
widthWeights: stored.widthWeights ?? { ...defaultWeights },
|
|
scopeSettings: mergeScopeSettings(stored.scopeSettings),
|
|
profiles: initialProfiles,
|
|
activeProfileId: initialActiveProfileId,
|
|
|
|
visibleScopes: () => {
|
|
const { scopeOrder, hiddenScopes } = get()
|
|
return scopeOrder.filter((k) => !hiddenScopes.has(k))
|
|
},
|
|
|
|
toggleScope: (kind: ScopeKind) => {
|
|
set((state) => {
|
|
const next = new Set(state.hiddenScopes)
|
|
if (next.has(kind)) {
|
|
next.delete(kind)
|
|
} else {
|
|
// Don't allow hiding all scopes
|
|
const visibleCount = state.scopeOrder.filter((k) => !next.has(k)).length
|
|
if (visibleCount <= 1) return state
|
|
next.add(kind)
|
|
}
|
|
const newState = { ...state, hiddenScopes: next }
|
|
saveToStorage(newState as SettingsState)
|
|
return newState
|
|
})
|
|
},
|
|
|
|
moveScope: (kind: ScopeKind, direction: 'left' | 'right') => {
|
|
set((state) => {
|
|
const order = [...state.scopeOrder]
|
|
const idx = order.indexOf(kind)
|
|
if (idx === -1) return state
|
|
const swap = direction === 'left' ? idx - 1 : idx + 1
|
|
if (swap < 0 || swap >= order.length) return state
|
|
;[order[idx], order[swap]] = [order[swap], order[idx]]
|
|
const newState = { ...state, scopeOrder: order }
|
|
saveToStorage(newState as SettingsState)
|
|
return newState
|
|
})
|
|
},
|
|
|
|
setScopeWidthWeight: (kind: ScopeKind, weight: number) => {
|
|
set((state) => {
|
|
const newState = { ...state, widthWeights: { ...state.widthWeights, [kind]: Math.max(0.1, weight) } }
|
|
saveToStorage(newState as SettingsState)
|
|
return newState
|
|
})
|
|
},
|
|
|
|
updateScopeSettings: <K extends ScopeKind>(kind: K, settings: Partial<ScopeSettings[K]>) => {
|
|
set((state) => {
|
|
const newState = {
|
|
...state,
|
|
scopeSettings: {
|
|
...state.scopeSettings,
|
|
[kind]: { ...state.scopeSettings[kind], ...settings },
|
|
},
|
|
}
|
|
saveToStorage(newState as SettingsState)
|
|
return newState
|
|
})
|
|
},
|
|
|
|
saveProfile: (name: string) => {
|
|
const state = get()
|
|
const id = `profile_${Date.now()}`
|
|
const profile: Profile = {
|
|
name,
|
|
scopeOrder: [...state.scopeOrder],
|
|
hiddenScopes: Array.from(state.hiddenScopes),
|
|
widthWeights: { ...state.widthWeights },
|
|
scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)),
|
|
}
|
|
// Capture window bounds asynchronously
|
|
window.electronAPI.getWindowBounds().then((bounds) => {
|
|
if (bounds) {
|
|
const profiles = get().profiles
|
|
const updated = { ...profiles, [id]: { ...profiles[id], windowBounds: bounds } }
|
|
saveProfiles(updated)
|
|
set({ profiles: updated })
|
|
}
|
|
})
|
|
const profiles = { ...state.profiles, [id]: profile }
|
|
saveProfiles(profiles)
|
|
saveActiveProfileId(id)
|
|
set({ profiles, activeProfileId: id })
|
|
return id
|
|
},
|
|
|
|
saveProfileAs: (name: string) => {
|
|
// Same as saveProfile but always creates a new entry
|
|
return get().saveProfile(name)
|
|
},
|
|
|
|
updateActiveProfile: () => {
|
|
const state = get()
|
|
const id = state.activeProfileId
|
|
if (!id || !state.profiles[id]) return
|
|
const updated: Profile = {
|
|
...state.profiles[id],
|
|
scopeOrder: [...state.scopeOrder],
|
|
hiddenScopes: Array.from(state.hiddenScopes),
|
|
widthWeights: { ...state.widthWeights },
|
|
scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)),
|
|
}
|
|
// Capture window bounds asynchronously
|
|
window.electronAPI.getWindowBounds().then((bounds) => {
|
|
if (bounds) {
|
|
const profiles = get().profiles
|
|
const withBounds = { ...profiles, [id]: { ...profiles[id], windowBounds: bounds } }
|
|
saveProfiles(withBounds)
|
|
set({ profiles: withBounds })
|
|
}
|
|
})
|
|
const profiles = { ...state.profiles, [id]: updated }
|
|
saveProfiles(profiles)
|
|
set({ profiles })
|
|
},
|
|
|
|
loadProfile: (id: string) => {
|
|
const state = get()
|
|
const profile = state.profiles[id]
|
|
if (!profile) return
|
|
const newState = {
|
|
...state,
|
|
scopeOrder: normalizeScopeOrder(profile.scopeOrder),
|
|
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
|
|
widthWeights: profile.widthWeights ?? { ...defaultWeights },
|
|
scopeSettings: mergeScopeSettings(profile.scopeSettings),
|
|
activeProfileId: id,
|
|
}
|
|
saveToStorage(newState as SettingsState)
|
|
saveActiveProfileId(id)
|
|
set(newState)
|
|
|
|
if (profile.windowBounds) {
|
|
window.electronAPI.setWindowBounds(profile.windowBounds)
|
|
}
|
|
},
|
|
|
|
deleteProfile: (id: string) => {
|
|
// Prevent deleting the default profile
|
|
if (id === DEFAULT_PROFILE_ID) return
|
|
const state = get()
|
|
const profiles = { ...state.profiles }
|
|
delete profiles[id]
|
|
saveProfiles(profiles)
|
|
const nextActiveId = state.activeProfileId === id ? null : state.activeProfileId
|
|
saveActiveProfileId(nextActiveId)
|
|
set({
|
|
profiles,
|
|
activeProfileId: nextActiveId,
|
|
})
|
|
},
|
|
|
|
renameProfile: (id: string, name: string) => {
|
|
if (id === DEFAULT_PROFILE_ID) return
|
|
const state = get()
|
|
const profile = state.profiles[id]
|
|
if (!profile) return
|
|
const profiles = { ...state.profiles, [id]: { ...profile, name } }
|
|
saveProfiles(profiles)
|
|
set({ profiles })
|
|
},
|
|
}))
|
|
|
|
// On startup, restore last active profile's settings (but not window bounds — those are handled by Electron)
|
|
if (initialActiveProfileId && initialProfiles[initialActiveProfileId]) {
|
|
const profile = initialProfiles[initialActiveProfileId]
|
|
useSettingsStore.setState({
|
|
scopeOrder: normalizeScopeOrder(profile.scopeOrder),
|
|
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
|
|
widthWeights: profile.widthWeights ?? { ...defaultWeights },
|
|
scopeSettings: mergeScopeSettings(profile.scopeSettings),
|
|
})
|
|
}
|