diff --git a/app/src/renderer/src/App.svelte b/app/src/renderer/src/App.svelte index 940b313..8389a26 100644 --- a/app/src/renderer/src/App.svelte +++ b/app/src/renderer/src/App.svelte @@ -21,7 +21,7 @@ import ConvertDialog from '$lib/components/ConvertDialog.svelte' import AnalysisDialog from '$lib/components/AnalysisDialog.svelte' import type { AnalysisSettings } from '$lib/analysis.svelte' - import type { SettingsSection } from '$lib/components/SettingsDialog.svelte' + import type { SettingsSection } from '$lib/components/settings/sections' const library = new Library() const decks = new Decks() diff --git a/app/src/renderer/src/app.css b/app/src/renderer/src/app.css index 8315c6b..b18ed10 100644 --- a/app/src/renderer/src/app.css +++ b/app/src/renderer/src/app.css @@ -87,3 +87,12 @@ @utility tabular { font-variant-numeric: tabular-nums; } + +@utility selected-row { + background-color: var(--color-select); + color: var(--color-fg); + + & .text-faint { + color: var(--color-muted); + } +} diff --git a/app/src/renderer/src/lib/analysis.svelte.ts b/app/src/renderer/src/lib/analysis.svelte.ts index 1cd93a4..e87bcd0 100644 --- a/app/src/renderer/src/lib/analysis.svelte.ts +++ b/app/src/renderer/src/lib/analysis.svelte.ts @@ -30,7 +30,7 @@ export interface AnalysisSettings { const STORAGE_KEY = 'analysisSettings' -const DEFAULTS: AnalysisSettings = { +export const DEFAULT_ANALYSIS_SETTINGS: Readonly = { auto: true, gridMode: 'normal', minBpm: 88, @@ -40,23 +40,24 @@ const DEFAULTS: AnalysisSettings = { key: true, concurrency: 3 } +const SETTING_KEYS = Object.keys(DEFAULT_ANALYSIS_SETTINGS) as (keyof AnalysisSettings)[] function load(): AnalysisSettings { try { const stored = JSON.parse(getSetting(STORAGE_KEY) ?? '{}') as Partial - const out = { ...DEFAULTS } - for (const key of Object.keys(DEFAULTS) as (keyof AnalysisSettings)[]) { - if (typeof stored[key] === typeof DEFAULTS[key]) Object.assign(out, { [key]: stored[key] }) + const out = { ...DEFAULT_ANALYSIS_SETTINGS } + for (const key of SETTING_KEYS) { + if (typeof stored[key] === typeof DEFAULT_ANALYSIS_SETTINGS[key]) Object.assign(out, { [key]: stored[key] }) } if (!BPM_RANGES.some(([lo, hi]) => lo === out.minBpm && hi === out.maxBpm)) { - out.minBpm = DEFAULTS.minBpm - out.maxBpm = DEFAULTS.maxBpm + out.minBpm = DEFAULT_ANALYSIS_SETTINGS.minBpm + out.maxBpm = DEFAULT_ANALYSIS_SETTINGS.maxBpm } - if (out.gridMode !== 'normal' && out.gridMode !== 'dynamic') out.gridMode = DEFAULTS.gridMode + if (out.gridMode !== 'normal' && out.gridMode !== 'dynamic') out.gridMode = DEFAULT_ANALYSIS_SETTINGS.gridMode out.concurrency = Math.max(1, Math.min(MAX_CONCURRENCY, Math.round(out.concurrency))) return out } catch { - return { ...DEFAULTS } + return { ...DEFAULT_ANALYSIS_SETTINGS } } } @@ -93,9 +94,22 @@ export class Analysis { set(key: K, value: AnalysisSettings[K]): void { this.settings[key] = value + this.save() + } + + private save(): void { setSetting(STORAGE_KEY, JSON.stringify($state.snapshot(this.settings))) } + get settingsAreDefault(): boolean { + return SETTING_KEYS.every((key) => this.settings[key] === DEFAULT_ANALYSIS_SETTINGS[key]) + } + + resetSettings(): void { + this.settings = { ...DEFAULT_ANALYSIS_SETTINGS } + this.save() + } + setRange(minBpm: number, maxBpm: number): void { this.set('minBpm', minBpm) this.set('maxBpm', maxBpm) diff --git a/app/src/renderer/src/lib/backups.svelte.ts b/app/src/renderer/src/lib/backups.svelte.ts index 35fa171..c98560f 100644 --- a/app/src/renderer/src/lib/backups.svelte.ts +++ b/app/src/renderer/src/lib/backups.svelte.ts @@ -56,11 +56,25 @@ export class Backups { } } + get settingsAreDefault(): boolean { + return (Object.keys(DEFAULT_BACKUP_SETTINGS) as (keyof BackupSettings)[]).every( + (key) => this.settings[key] === DEFAULT_BACKUP_SETTINGS[key] + ) + } + + resetSettings(): Promise { + return this.setSettings(DEFAULT_BACKUP_SETTINGS) + } + async setSettings(change: Partial): Promise { + const previous = this.settings + this.settings = { ...previous, ...change } try { this.settings = await api.setBackupSettings(change) - await this.refresh() + const mayHavePruned = change.keep !== undefined && change.keep < previous.keep + if (mayHavePruned) this.list = await api.backupList() } catch (e) { + this.settings = previous this.error = (e as Error).message } } diff --git a/app/src/renderer/src/lib/components/SettingsDialog.svelte b/app/src/renderer/src/lib/components/SettingsDialog.svelte deleted file mode 100644 index 1c193fc..0000000 --- a/app/src/renderer/src/lib/components/SettingsDialog.svelte +++ /dev/null @@ -1,1238 +0,0 @@ - - - - - { - if (!recording) return - e.preventDefault() - recording = null - }} - onclose={() => onclose()} - onclick={(e) => { - if (e.target === e.currentTarget) e.currentTarget.close() - }} -> -
- - -
-
-

{sectionLabel}

- {#if section === 'keyboard'} -
- - -
- {/if} - -
- -
- {#if section === 'about'} -
- -

Export Studio

-

A modern library browser for rekordbox

-

- Version {window.api.version} · Electron {window.api.electronVersion} -

- -
- {#if update === 'checking'} - - - Checking for updates… - - {:else if update} - - Download version {update.version} - - - {:else} - {#if checkedForUpdate} - No newer version found. - {/if} - - {/if} -
- -
- -

- Find Export Studio useful? A star on GitHub helps other DJs find it. -

- - - Star - -
- -
- {@render externalLink('Release notes', `${REPO_URL}/releases`)} - {@render externalLink('Report a problem', `${REPO_URL}/issues`)} -
-
- {:else if section === 'developer'} - {@render toggle( - 'Snap playhead', - 'Draw the playhead exactly where the audio engine reports it, instead of easing small corrections. Can look less smooth.', - prefs.snapPlayhead, - () => prefs.setSnapPlayhead(!prefs.snapPlayhead) - )} -
-

Waveform height

- {@render heightScaleSlider('Player waveform', 'Height of the scrolling waveform on each deck.', 'detail')} - {@render heightScaleSlider( - 'Overview waveforms', - 'Height of the whole-track waveform on each deck and in the browser.', - 'overview' - )} -
-
-

Waveform colors

- {#each BAND_NAMES_LOW_TO_HIGH as name, band (name)} - {#if band > 0} - {@render slider( - `${name} opacity`, - `How much of the bands beneath show through the ${name.toLowerCase()} band where they overlap.`, - { min: 0, max: 1, step: 0.05 }, - bandStyle.layeredOpacities[band], - `${Math.round(bandStyle.layeredOpacities[band] * 100)}%`, - (opacity) => bandStyle.setLayeredOpacity(band, opacity) - )} - {/if} - {/each} - {@render menu( - 'Blend', - 'How each band mixes with the bands beneath it where they overlap.', - BLEND_MODES, - bandStyle.blend, - (id) => bandStyle.setBlend(id) - )} -
- - Default look - - Put the band colors, opacities and blend back to their defaults. - - - -
-
- {:else if section === 'theme'} - {@render colorPicker( - 'Accent color', - 'Buttons, highlights, and progress.', - accent.color, - DEFAULT_ACCENT, - (color) => accent.set(color) - )} -
-

Color waveform

- {@render toggle( - 'Custom colors', - 'Color the waveform by sound, from low to high. Off uses the rekordbox colors.', - prefs.waveform.customRgbColors, - () => prefs.waveform.setCustomRgbColors(!prefs.waveform.customRgbColors) - )} - {#if prefs.waveform.customRgbColors} - {#each BAND_NAMES_LOW_TO_HIGH as name, band (name)} - {@render colorPicker( - `${name} color`, - `The color for the ${name.toLowerCase()} band.`, - rgbGradient.stopsLowToHigh[band], - DEFAULT_RGB_GRADIENT[band], - (color) => rgbGradient.setStop(band, color) - )} - {/each} - {/if} -
-
-

3-band waveform

- {#each BAND_NAMES_LOW_TO_HIGH as name, band (name)} - {@render colorPicker( - `${name} color`, - `Fill color for the ${name.toLowerCase()} band.`, - bandStyle.colors[band], - DEFAULT_BAND_STYLE.colors[band], - (color) => bandStyle.setColor(band, color) - )} - {/each} -
- {:else if section === 'display'} -
-

Browser

- {@render choice( - 'Track style', - 'Standard shows one line per track. Alternative combines the title and artist in a single line.', - TRACK_STYLES, - library.browserTrackStyle, - (id) => library.setBrowserTrackStyle(id as BrowserTrackStyle) - )} - {@render slider( - 'Track height', - 'Height of each track in the browser. Text, artwork and waveforms scale with it. Lower fits more tracks, higher is easier to read.', - { min: ROW_HEIGHT_MIN, max: ROW_HEIGHT_MAX, step: ROW_HEIGHT_STEP }, - library.browserRowHeight, - `${library.browserRowHeight}px`, - (height) => library.setBrowserRowHeight(height) - )} - {@render toggle( - 'Hide streaming tracks', - 'Leave tracks from streaming services out of the collection, playlists, histories and tags. They stay in rekordbox.', - library.hideStreaming, - () => library.setHideStreaming(!library.hideStreaming) - )} - {@render toggle( - 'Hide preset tracks', - 'Leave the sampler presets rekordbox installs, such as Groove Circuit, out of the collection, playlists, histories and tags. Samples you record stay listed. The presets stay in rekordbox.', - library.hidePresetTracks, - () => library.setHidePresetTracks(!library.hidePresetTracks) - )} -
- -
-

Hardware

- {@render menu( - 'Target', - 'Green plays on this player and every newer one. Yellow plays here, but not on a newer player. Red will not play here.', - PLAYER_CHOICES, - library.hardwareGreen, - (id) => library.setHardwareGreen(id) - )} -
- -
-

Waveforms

- {@render qualitySlider( - 'Color quality', - 'Draw the color detail waveform from the audio. Off uses the rekordbox analysis. Higher keeps more detail.', - 'rgb' - )} - {@render qualitySlider( - '3-band quality', - 'Draw the 3-band detail waveform from the audio. Off uses the rekordbox analysis. Higher keeps more detail.', - '3band' - )} - {@render slider( - 'Smoothing', - 'How gently the 3-band waveform eases down after each peak. Off shows hard steps.', - { min: 0, max: MAX_FALL_BLOCKS_PER_QUALITY, step: FALL_BLOCKS_PER_QUALITY_STEP }, - prefs.waveform.fallBlocksPerQuality, - prefs.waveform.fallBlocksPerQuality === 0 ? 'Off' : `${prefs.waveform.fallBlocksPerQuality.toFixed(2)}×`, - (blocks) => prefs.waveform.setFallBlocksPerQuality(blocks) - )} -
- {:else if section === 'mix'} - -
- {@render choice( - 'Crossfader curve', - 'How quickly a deck fades out as the fader moves away from it.', - CURVES, - mixPrefs.curveId, - (id) => mixPrefs.setCurve(id as CurveId) - )} - {@render choice( - 'Bass swap', - 'When Swap bass is on, how soon the incoming deck takes the bass during a fade.', - SWAP_TIMINGS, - mixPrefs.swapTimingId, - (id) => mixPrefs.setSwapTiming(id as SwapTimingId) - )} - {@render toggle( - 'Auto master', - 'The deck that alone has sound, playing and not faded out, becomes the master.', - mixPrefs.autoMaster, - () => mixPrefs.setAutoMaster(!mixPrefs.autoMaster) - )} - {@render choice( - 'Highlight matching keys', - 'Colours browser keys that mix with the master deck. Pick the loosest match to show.', - KEY_HIGHLIGHTS, - mixPrefs.keyHighlightId, - (id) => mixPrefs.setKeyHighlight(id as KeyHighlightId) - )} -
- {:else if section === 'general'} -
- {@render toggle( - 'Double-click to load', - 'Single-click only selects a track. Double-click loads it and starts playback.', - library.doubleClickToLoad, - () => library.setDoubleClickToLoad(!library.doubleClickToLoad) - )} - {@render toggle( - 'Quantise', - 'Scrubbing, the cue point and new cues snap to the nearest beat.', - prefs.quantise, - () => prefs.setQuantise(!prefs.quantise) - )} -
- - Place new cues at - - Where new hot cues and memory cues go, from the buttons or the keyboard. - - -
- {#each PLACEMENTS as p (p.id)} - - {/each} -
-
- {@render choice( - 'BPM and key readout', - `Whether the deck steppers show the BPM and key, or how far they have moved from the track. Hold ${layerModifierName(keybinds.layerModifier)} to see and change the other.`, - TEMPO_READOUTS, - prefs.tempoReadout, - (id) => prefs.setTempoReadout(id as TempoReadout) - )} -
- -
-

Reset

-
- - Factory settings - - Put every setting, shortcut and layout back to how it was on first launch. Your rekordbox library and - backups are not touched. - - - -
-
- {:else if section === 'database'} - {@const rekordboxOpen = !!library.info?.rekordboxRunning} -
-

Files

- {@render toggle( - 'Write track info to file tags', - 'When you edit title, artist, album and other text fields, also save them into the audio files’ own tags. Off keeps changes in rekordbox only. Rating and colour always stay in rekordbox.', - library.writeFileTags, - () => library.setWriteFileTags(!library.writeFileTags) - )} -
- -
-

Backups

- {@render toggle( - 'Automatic backups', - 'Back up the library before the first edit each time you unlock editing, and periodically while the app is open. Skipped when nothing has changed.', - backups.settings.auto, - () => void backups.setSettings({ auto: !backups.settings.auto }) - )} - {@render choice( - 'Minimum time between backups', - 'Automatic backups are skipped if one was already taken within this time.', - INTERVAL, - String(backups.settings.interval), - (id) => void backups.setSettings({ interval: Number(id) }) - )} - {@render choice( - 'Keep', - 'How many automatic backups to keep. Manual backups stay until you delete them.', - KEEP, - String(backups.settings.keep), - (id) => void backups.setSettings({ keep: Number(id) }) - )} -

- Backups hold the rekordbox database, playlists, cues, beatgrids, waveforms and artwork. Audio files are - not backed up. -

-
- -
- - - {#if backups.progress} - {@const p = backups.progress} - - - {progressLabel(p)} - {#if p.total} - - - - {/if} - - {/if} -
- - {#if backups.error} -

- {backups.error} -

- {/if} - -

Saved backups

- {#each backups.list as backup (backup.id)} -
- - {backupDate(backup)} - - {REASONS[backup.reason] ?? backup.reason} - {#if backup.tracks != null}· {backup.tracks.toLocaleString()} tracks{/if} - · {formatSize(backup.size)} - {#if backup.otherLibrary} · other library{/if} - {#if !backup.verified} - · unchecked{/if} - - - - -
- {:else} -

No backups yet.

- {/each} - {:else if section === 'analysis'} - {@const s = analysis.settings} -
-

Import

- {@render toggle( - 'Analyse imported tracks', - 'Work out the beatgrid, tempo and key of new tracks as soon as they are imported.', - s.auto, - () => analysis.set('auto', !s.auto) - )} - {@render toggle( - 'Import artwork', - 'Copy cover art embedded in the files into rekordbox.', - library.importArtwork, - () => library.setImportArtwork(!library.importArtwork) - )} -
- -
-

Beatgrid

- {@render choice( - 'Grid', - 'Normal fits one tempo to the whole track. Dynamic follows tempo changes, for live recordings and tracks that speed up.', - GRID_MODES, - s.gridMode, - (id) => analysis.set('gridMode', id as GridMode) - )} - {@render choice( - 'BPM range', - 'The tempo during track analysis is always picked inside this range.', - RANGES, - `${s.minBpm}-${s.maxBpm}`, - (id) => { - const [lo, hi] = id.split('-').map(Number) - analysis.setRange(lo, hi) - } - )} - {@render toggle( - 'High precision', - 'Put each beat on the start of the kick or snare, to the millisecond. Off places beats from the onset curve, which is a little less exact.', - s.highPrecision, - () => analysis.set('highPrecision', !s.highPrecision) - )} - {@render toggle( - 'Round BPM', - 'Snap the tempo to a whole number when it is within 0.05 BPM of one, as most produced music is.', - s.roundBpm, - () => analysis.set('roundBpm', !s.roundBpm) - )} -
- -
-

Key and more

- {@render toggle('Detect key', 'Work out the musical key as well as the beatgrid.', s.key, () => - analysis.set('key', !s.key) - )} - {@render choice( - 'Tracks analysed at once', - 'More is faster on machines with many cores, but leaves less for playback.', - CONCURRENCY, - String(s.concurrency), - (id) => analysis.set('concurrency', Number(id)) - )} -
- -

- This is Export Studio’s own analysis system, not rekordbox’s. -

- {:else if section === 'convert'} -
-

Formats

-

- These appear under Convert file type in the track menu. -

- {#each presets as preset (presetKey(preset))} -
- {presetLabel(preset)} - -
- {:else} -

No formats. Add one below.

- {/each} -
- -
-

Add a format

-
- - {#if isLossless(draftFormat)} - - - {:else} - - {/if} - -
-
- - - {:else} -
- - Layer modifier - - In mix mode the deck shortcuts control deck A. Hold this key with them to control deck B. It also swaps - what the BPM and key steppers show. - - - -
- {#each groups as { group, ids } (group)} -
-

{group}

- {#each ids as id (id)} - {@render shortcutRow(id)} - {/each} -
- {:else} -

No shortcuts match “{filter}”.

- {/each} - {/if} -
- - {#if section === 'keyboard'} -
- {#if recording} - Press a key combination. Esc cancels. - {:else if notice} - {notice} - {:else} - Click + to add a shortcut. Hover a shortcut to remove it. - {/if} - -
- {/if} -
-
-
- -{#if confirming} - {@const { kind, backup } = confirming} - {#if kind === 'restore'} - void backups.restore(backup)} - onclose={() => (confirming = null)} - /> - {:else} - void backups.remove(backup)} - onclose={() => (confirming = null)} - /> - {/if} -{/if} - -{#if confirmingFactoryReset} - void factoryReset()} - onclose={() => (confirmingFactoryReset = false)} - /> -{/if} - -{#snippet navItem(s: { id: SettingsSection; label: string; icon: typeof Keyboard })} - -{/snippet} - -{#snippet externalLink(label: string, href: string)} - - {label} - - -{/snippet} - -{#snippet slider( - label: string, - description: string, - range: { min: number; max: number; step: number }, - value: number, - valueText: string, - set: (value: number) => void -)} -
- - {label} - {description} - - set(Number(e.currentTarget.value))} - /> - {valueText} -
-{/snippet} - -{#snippet qualitySlider(label: string, description: string, mode: WaveformMode)} - {@const quality = prefs.waveform.qualities[mode]} - {@render slider( - label, - description, - { min: 0, max: MAX_QUALITY[mode], step: 1 }, - quality, - quality === 0 ? 'Off' : `${quality}%`, - (q) => prefs.waveform.setQuality(mode, q) - )} -{/snippet} - -{#snippet heightScaleSlider(label: string, description: string, view: WaveformView)} - {@const scale = prefs.waveform.heightScales[view]} - {@render slider( - label, - description, - { min: MIN_HEIGHT_SCALE, max: 1, step: HEIGHT_SCALE_STEP }, - scale, - `${Math.round(scale * 100)}%`, - (s) => prefs.waveform.setHeightScale(view, s) - )} -{/snippet} - -{#snippet toggle(label: string, description: string, on: boolean, flip: () => void)} - -{/snippet} - -{#snippet menu(label: string, description: string, options: Choice[], current: string, pick: (id: string) => void)} - -{/snippet} - -{#snippet colorPicker( - label: string, - description: string, - color: string, - defaultColor: string, - set: (color: string) => void -)} -
- - {label} - {description} - - set(e.currentTarget.value)} - onchange={(e) => set(e.currentTarget.value)} - /> - {color} - -
-{/snippet} - -{#snippet choice(label: string, description: string, options: Choice[], current: string, pick: (id: string) => void)} -
- - {label} - {description} - -
- {#each options as o (o.id)} - - {/each} -
-
-{/snippet} - -{#snippet shortcutRow(id: ActionId)} - {@const spec = ACTIONS[id]} - {@const chords = keybinds.bindings[id]} -
- - {spec.label} - {#if 'hold' in spec && spec.hold} - hold - {/if} - -
- {#each chords as chord, i (chord)} - {#if i > 0} - or - {/if} - - {#each chordKeys(chord) as key, k (k)} - {#if k > 0} - - {/if} - - {key} - - {/each} - - - {/each} - {#if recording === id} - - Press keys… - - {:else if !chords.length} - Not set - {/if} -
-
- - -
-
-{/snippet} diff --git a/app/src/renderer/src/lib/components/Sidebar.svelte b/app/src/renderer/src/lib/components/Sidebar.svelte index 9891457..adff30a 100644 --- a/app/src/renderer/src/lib/components/Sidebar.svelte +++ b/app/src/renderer/src/lib/components/Sidebar.svelte @@ -143,11 +143,8 @@ if (!draggedId || draggedId === node.id) return null const bounds = (e.currentTarget as HTMLElement).getBoundingClientRect() const position = (e.clientY - bounds.top) / bounds.height - const placement = node.kind === 'folder' && position >= 0.25 && position <= 0.75 - ? 'inside' - : position < 0.5 - ? 'before' - : 'after' + const placement = + node.kind === 'folder' && position >= 0.25 && position <= 0.75 ? 'inside' : position < 0.5 ? 'before' : 'after' const parent = placement === 'inside' ? node.id : node.parentId const siblings = library.playlists .filter((item) => item.parentId === parent && item.id !== draggedId) @@ -437,7 +434,7 @@ + {/if} + + +
+ +

+ Find Export Studio useful? A star on GitHub helps other DJs find it. +

+ + + Star + +
+ +
+ {@render externalLink('Release notes', `${REPO_URL}/releases`)} + {@render externalLink('Report a problem', `${REPO_URL}/issues`)} +
+ + + +{#snippet externalLink(label: string, href: string)} + + {label} + + +{/snippet} diff --git a/app/src/renderer/src/lib/components/settings/AnalysisSettings.svelte b/app/src/renderer/src/lib/components/settings/AnalysisSettings.svelte new file mode 100644 index 0000000..da750d6 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/AnalysisSettings.svelte @@ -0,0 +1,107 @@ + + + + + analysis.set('auto', !s.auto)} + /> + library.setImportArtwork(!library.importArtwork)} + /> + + + + analysis.set('gridMode', mode)} + /> + + analysis.set('highPrecision', !s.highPrecision)} + /> + analysis.set('roundBpm', !s.roundBpm)} + /> + + + + analysis.set('key', !s.key)} + /> + analysis.set('concurrency', Number(id))} + /> + + +

+ This is Export Studio’s own analysis system, not rekordbox’s. +

+
diff --git a/app/src/renderer/src/lib/components/settings/ConvertSettings.svelte b/app/src/renderer/src/lib/components/settings/ConvertSettings.svelte new file mode 100644 index 0000000..fb49957 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/ConvertSettings.svelte @@ -0,0 +1,107 @@ + + + + +

These appear under Convert file type in the track menu.

+ {#each presets as preset (presetKey(preset))} +
+ {presetLabel(preset)} + +
+ {:else} +

No formats. Add one below.

+ {/each} +
+ + +
+ + {#if isLossless(draftFormat)} + + + {:else} + + {/if} + +
+
+
diff --git a/app/src/renderer/src/lib/components/settings/DatabaseSettings.svelte b/app/src/renderer/src/lib/components/settings/DatabaseSettings.svelte new file mode 100644 index 0000000..9e68b56 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/DatabaseSettings.svelte @@ -0,0 +1,119 @@ + + + + + library.setWriteFileTags(!library.writeFileTags)} + /> + + + + void backups.setSettings({ auto: !backups.settings.auto })} + /> + void backups.setSettings({ interval: Number(id) })} + /> + void backups.setSettings({ keep: Number(id) })} + /> +

+ Backups hold the rekordbox database, playlists, cues, beatgrids, waveforms and artwork. Audio files are not backed + up. +

+ +
+ + + {#if backups.progress} + {@const p = backups.progress} + + + {progressLabel(p)} + {#if p.total} + + + + {/if} + + {/if} +
+ + {#if backups.error} +

+ {backups.error} +

+ {/if} +
+ + + + +
diff --git a/app/src/renderer/src/lib/components/settings/DeveloperSettings.svelte b/app/src/renderer/src/lib/components/settings/DeveloperSettings.svelte new file mode 100644 index 0000000..38dc456 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/DeveloperSettings.svelte @@ -0,0 +1,125 @@ + + + + +
+ prefs.setSnapPlayhead(!prefs.snapPlayhead)} + /> + prefs.setUnlimitedMemoryCues(!prefs.unlimitedMemoryCues)} + /> + + {@render heightScaleSlider('Player waveform', 'Height of the scrolling waveform on each deck.', 'detail')} + {@render heightScaleSlider( + 'Overview waveforms', + 'Height of the whole-track waveform on each deck and in the browser.', + 'overview' + )} + + + {#each BAND_NAMES_LOW_TO_HIGH as name, band (name)} + {#if band > 0} + bandStyle.setLayeredOpacity(band, opacity)} + /> + {/if} + {/each} + bandStyle.setBlend(mode)} + /> + +
+
+ +{#if confirming === 'turnOn'} + devOptions.setEnabled(true)} + onclose={() => (confirming = null)} + /> +{:else if confirming === 'turnOffAndReset'} + (confirming = null)} + /> +{/if} + +{#snippet heightScaleSlider(label: string, description: string, view: WaveformView)} + {@const scale = prefs.waveform.heightScales[view]} + prefs.waveform.setHeightScale(view, s)} + /> +{/snippet} diff --git a/app/src/renderer/src/lib/components/settings/DisplaySettings.svelte b/app/src/renderer/src/lib/components/settings/DisplaySettings.svelte new file mode 100644 index 0000000..d7a1790 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/DisplaySettings.svelte @@ -0,0 +1,157 @@ + + + + + library.setBrowserTrackStyle(style)} + /> + library.setBrowserRowHeight(height)} + /> + library.setHideStreaming(!library.hideStreaming)} + /> + library.setHidePresetTracks(!library.hidePresetTracks)} + /> + + + + library.setHardwareGreen(id)} + /> + + + + {@render qualitySlider( + 'Color quality', + 'Draw the color detail waveform from the audio. Off uses the rekordbox analysis. Higher keeps more detail.', + 'rgb' + )} + {@render qualitySlider( + '3-band quality', + 'Draw the 3-band detail waveform from the audio. Off uses the rekordbox analysis. Higher keeps more detail.', + '3band' + )} + waveform.setFallBlocksPerQuality(blocks)} + /> + + + +{#snippet qualitySlider(label: string, description: string, mode: WaveformMode)} + {@const quality = waveform.qualities[mode]} + waveform.setQuality(mode, q)} + /> +{/snippet} diff --git a/app/src/renderer/src/lib/components/settings/GeneralSettings.svelte b/app/src/renderer/src/lib/components/settings/GeneralSettings.svelte new file mode 100644 index 0000000..10bce61 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/GeneralSettings.svelte @@ -0,0 +1,123 @@ + + + +
+ library.setDoubleClickToLoad(!library.doubleClickToLoad)} + /> + prefs.setQuantise(!prefs.quantise)} + /> + prefs.setCuePlacement(p)} + /> + prefs.setTempoReadout(r)} + /> +
+ + +
+ + +
+
+
+ +{#if confirmingFactoryReset} + void factoryReset()} + onclose={() => (confirmingFactoryReset = false)} + /> +{/if} diff --git a/app/src/renderer/src/lib/components/settings/KeyboardSettings.svelte b/app/src/renderer/src/lib/components/settings/KeyboardSettings.svelte new file mode 100644 index 0000000..3537578 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/KeyboardSettings.svelte @@ -0,0 +1,144 @@ + + + + {#snippet headerTools()} +
+ + +
+ {/snippet} + + {#snippet footerHint()} + {#if recording} + Press a key combination. Esc cancels. + {:else if notice} + {notice} + {:else} + Click + to add a shortcut. Hover a shortcut to remove it. + {/if} + {/snippet} + +
+ keybinds.setLayerModifier(modifier)} + /> + {#each groups as { group, ids } (group)} + + {#each ids as id (id)} + toggleRecording(id)} /> + {/each} + + {:else} +

No shortcuts match “{filter}”.

+ {/each} +
+
diff --git a/app/src/renderer/src/lib/components/settings/MixSettings.svelte b/app/src/renderer/src/lib/components/settings/MixSettings.svelte new file mode 100644 index 0000000..027c677 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/MixSettings.svelte @@ -0,0 +1,78 @@ + + + + +
+ mixPrefs.setCurve(id)} + /> + mixPrefs.setSwapTiming(id)} + /> + mixPrefs.setAutoMaster(!mixPrefs.autoMaster)} + /> + mixPrefs.setKeyHighlight(id)} + /> +
+
diff --git a/app/src/renderer/src/lib/components/settings/SavedBackups.svelte b/app/src/renderer/src/lib/components/settings/SavedBackups.svelte new file mode 100644 index 0000000..9d72f48 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SavedBackups.svelte @@ -0,0 +1,83 @@ + + +{#each backups.list as backup (backup.id)} +
+ + {backupDate(backup)} + + {REASONS[backup.reason] ?? backup.reason} + {#if backup.tracks != null}· {backup.tracks.toLocaleString()} tracks{/if} + · {formatSize(backup.size)} + {#if backup.otherLibrary} · other library{/if} + {#if !backup.verified} · unchecked{/if} + + + + +
+{:else} +

No backups yet.

+{/each} + +{#if confirming} + {@const { kind, backup } = confirming} + {#if kind === 'restore'} + void backups.restore(backup)} + onclose={() => (confirming = null)} + /> + {:else} + void backups.remove(backup)} + onclose={() => (confirming = null)} + /> + {/if} +{/if} diff --git a/app/src/renderer/src/lib/components/settings/SettingChoice.svelte b/app/src/renderer/src/lib/components/settings/SettingChoice.svelte new file mode 100644 index 0000000..0ffd8ca --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SettingChoice.svelte @@ -0,0 +1,32 @@ + + +
+ +
+ {#each options as o (o.id)} + + {/each} +
+
diff --git a/app/src/renderer/src/lib/components/settings/SettingColor.svelte b/app/src/renderer/src/lib/components/settings/SettingColor.svelte new file mode 100644 index 0000000..091e984 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SettingColor.svelte @@ -0,0 +1,40 @@ + + +
+ + onchange(e.currentTarget.value)} + onchange={(e) => onchange(e.currentTarget.value)} + /> + {color} + +
diff --git a/app/src/renderer/src/lib/components/settings/SettingMenu.svelte b/app/src/renderer/src/lib/components/settings/SettingMenu.svelte new file mode 100644 index 0000000..7f1247f --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SettingMenu.svelte @@ -0,0 +1,26 @@ + + + diff --git a/app/src/renderer/src/lib/components/settings/SettingSlider.svelte b/app/src/renderer/src/lib/components/settings/SettingSlider.svelte new file mode 100644 index 0000000..5fb4be7 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SettingSlider.svelte @@ -0,0 +1,39 @@ + + +
+ + onchange(Number(e.currentTarget.value))} + /> + {valueText} +
diff --git a/app/src/renderer/src/lib/components/settings/SettingText.svelte b/app/src/renderer/src/lib/components/settings/SettingText.svelte new file mode 100644 index 0000000..cfec4f9 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SettingText.svelte @@ -0,0 +1,8 @@ + + + + {label} + {description} + diff --git a/app/src/renderer/src/lib/components/settings/SettingToggle.svelte b/app/src/renderer/src/lib/components/settings/SettingToggle.svelte new file mode 100644 index 0000000..dfde8e3 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SettingToggle.svelte @@ -0,0 +1,26 @@ + + + diff --git a/app/src/renderer/src/lib/components/settings/SettingsDialog.svelte b/app/src/renderer/src/lib/components/settings/SettingsDialog.svelte new file mode 100644 index 0000000..a2044dc --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SettingsDialog.svelte @@ -0,0 +1,53 @@ + + + onclose()} + onclick={(e) => { + if (e.target === e.currentTarget) e.currentTarget.close() + }} +> +
+ + + +
+
+ +{#snippet navItem(s: SectionInfo)} + +{/snippet} diff --git a/app/src/renderer/src/lib/components/settings/SettingsGroup.svelte b/app/src/renderer/src/lib/components/settings/SettingsGroup.svelte new file mode 100644 index 0000000..427bb9b --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SettingsGroup.svelte @@ -0,0 +1,10 @@ + + +
+

{title}

+ {@render children()} +
diff --git a/app/src/renderer/src/lib/components/settings/SettingsPage.svelte b/app/src/renderer/src/lib/components/settings/SettingsPage.svelte new file mode 100644 index 0000000..302ad98 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/SettingsPage.svelte @@ -0,0 +1,51 @@ + + +
+
+

{title}

+ {@render headerTools?.()} + +
+ +
+ {@render children()} +
+ + {#if defaults} +
+ {@render footerHint?.()} + +
+ {/if} +
diff --git a/app/src/renderer/src/lib/components/settings/ShortcutRow.svelte b/app/src/renderer/src/lib/components/settings/ShortcutRow.svelte new file mode 100644 index 0000000..93a40c7 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/ShortcutRow.svelte @@ -0,0 +1,83 @@ + + +
+ + {spec.label} + {#if 'hold' in spec && spec.hold} + hold + {/if} + +
+ {#each chords as chord, i (chord)} + {#if i > 0} + or + {/if} + + {#each chordKeys(chord) as key, k (k)} + {#if k > 0} + + {/if} + + {key} + + {/each} + + + {/each} + {#if recording} + + Press keys… + + {:else if !chords.length} + Not set + {/if} +
+
+ + +
+
diff --git a/app/src/renderer/src/lib/components/settings/ThemeSettings.svelte b/app/src/renderer/src/lib/components/settings/ThemeSettings.svelte new file mode 100644 index 0000000..f3ae835 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/ThemeSettings.svelte @@ -0,0 +1,72 @@ + + + + accent.set(color)} + /> + + waveform.setCustomRgbColors(!waveform.customRgbColors)} + /> + {#if waveform.customRgbColors} + {#each BAND_NAMES_LOW_TO_HIGH as name, band (name)} + rgbGradient.setStop(band, color)} + /> + {/each} + {/if} + + + {#each BAND_NAMES_LOW_TO_HIGH as name, band (name)} + bandStyle.setColor(band, color)} + /> + {/each} + + diff --git a/app/src/renderer/src/lib/components/settings/choice.ts b/app/src/renderer/src/lib/components/settings/choice.ts new file mode 100644 index 0000000..072297e --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/choice.ts @@ -0,0 +1 @@ +export type Choice = { id: Id; label: string } diff --git a/app/src/renderer/src/lib/components/settings/resettable.ts b/app/src/renderer/src/lib/components/settings/resettable.ts new file mode 100644 index 0000000..7aebaf0 --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/resettable.ts @@ -0,0 +1,12 @@ +export type Resettable = { isDefault: () => boolean; reset: () => void } + +export function setting(current: () => T, initial: T, set: (value: T) => void): Resettable { + return { isDefault: () => current() === initial, reset: () => set(initial) } +} + +export function allOf(...settings: Resettable[]): Resettable { + return { + isDefault: () => settings.every((s) => s.isDefault()), + reset: () => settings.forEach((s) => s.reset()) + } +} diff --git a/app/src/renderer/src/lib/components/settings/sections.ts b/app/src/renderer/src/lib/components/settings/sections.ts new file mode 100644 index 0000000..93005bf --- /dev/null +++ b/app/src/renderer/src/lib/components/settings/sections.ts @@ -0,0 +1,47 @@ +import type { Component } from 'svelte' +import { + AudioWaveform, + Blend, + Database, + FileAudio, + Info, + Keyboard, + Monitor, + Palette, + SlidersHorizontal, + Wrench +} from '@lucide/svelte' +import AboutSettings from './AboutSettings.svelte' +import AnalysisSettings from './AnalysisSettings.svelte' +import ConvertSettings from './ConvertSettings.svelte' +import DatabaseSettings from './DatabaseSettings.svelte' +import DeveloperSettings from './DeveloperSettings.svelte' +import DisplaySettings from './DisplaySettings.svelte' +import GeneralSettings from './GeneralSettings.svelte' +import KeyboardSettings from './KeyboardSettings.svelte' +import MixSettings from './MixSettings.svelte' +import ThemeSettings from './ThemeSettings.svelte' + +export type SettingsSection = + 'general' | 'display' | 'theme' | 'mix' | 'analysis' | 'convert' | 'database' | 'keyboard' | 'developer' | 'about' + +export type SectionInfo = { + id: SettingsSection + label: string + icon: typeof Keyboard + page: Component<{ title: string }> +} + +export const SECTIONS: SectionInfo[] = [ + { id: 'general', label: 'General', icon: SlidersHorizontal, page: GeneralSettings }, + { id: 'display', label: 'Display', icon: Monitor, page: DisplaySettings }, + { id: 'theme', label: 'Theme & colors', icon: Palette, page: ThemeSettings }, + { id: 'mix', label: 'Mix', icon: Blend, page: MixSettings }, + { id: 'analysis', label: 'Import & analysis', icon: AudioWaveform, page: AnalysisSettings }, + { id: 'convert', label: 'Convert formats', icon: FileAudio, page: ConvertSettings }, + { id: 'database', label: 'Database & files', icon: Database, page: DatabaseSettings }, + { id: 'keyboard', label: 'Keyboard shortcuts', icon: Keyboard, page: KeyboardSettings }, + { id: 'developer', label: 'Developer options', icon: Wrench, page: DeveloperSettings } +] + +export const ABOUT: SectionInfo = { id: 'about', label: 'About', icon: Info, page: AboutSettings } diff --git a/app/src/renderer/src/lib/deck/display.svelte.ts b/app/src/renderer/src/lib/deck/display.svelte.ts index 5938d25..37a0d57 100644 --- a/app/src/renderer/src/lib/deck/display.svelte.ts +++ b/app/src/renderer/src/lib/deck/display.svelte.ts @@ -11,11 +11,15 @@ type WaveformChange = { kind: 'mode' } | { kind: 'rgbColorSource' } | { kind: 'quality'; mode: WaveformMode; wasOff: boolean } const QUALITY_KEYS: Record = { rgb: 'waveQualityRgb', '3band': 'waveQuality3band' } -const DEFAULT_QUALITY = storedPercent('waveQuality', 30) export const MAX_QUALITY: Record = { rgb: 100, '3band': 25 } +export const DEFAULT_QUALITIES: Record = { rgb: 30, '3band': 25 } +export const DEFAULT_FALL_BLOCKS_PER_QUALITY = 1.3 +export const DEFAULT_CUSTOM_RGB_COLORS = true +const LEGACY_QUALITY_KEY = 'waveQuality' function storedQuality(mode: WaveformMode): Percent { - return Math.min(MAX_QUALITY[mode], storedPercent(QUALITY_KEYS[mode], DEFAULT_QUALITY)) + const fallback = storedPercent(LEGACY_QUALITY_KEY, DEFAULT_QUALITIES[mode]) + return Math.min(MAX_QUALITY[mode], storedPercent(QUALITY_KEYS[mode], fallback)) } export const MAX_FALL_BLOCKS_PER_QUALITY = 10 @@ -44,8 +48,10 @@ function snapToStep(value: number, min: number, max: number, step: number): numb export class WaveformPrefs { mode = $state((getSetting('waveformMode') as WaveformMode) || '3band') qualities = $state>({ rgb: storedQuality('rgb'), '3band': storedQuality('3band') }) - fallBlocksPerQuality = $state(storedNumber('fallBlocksPerQuality', 1.3, 0, MAX_FALL_BLOCKS_PER_QUALITY)) - customRgbColors = $state(storedFlag('waveformCustomRgbColors', true)) + fallBlocksPerQuality = $state( + storedNumber('fallBlocksPerQuality', DEFAULT_FALL_BLOCKS_PER_QUALITY, 0, MAX_FALL_BLOCKS_PER_QUALITY) + ) + customRgbColors = $state(storedFlag('waveformCustomRgbColors', DEFAULT_CUSTOM_RGB_COLORS)) heightScales = $state>({ detail: storedHeightScale('detail'), overview: storedHeightScale('overview') @@ -126,6 +132,15 @@ export class WaveformPrefs { setSetting(HEIGHT_SCALE_KEYS[view], String(snapped)) } + get heightScalesAreDefault(): boolean { + return Object.values(this.heightScales).every((scale) => scale === DEFAULT_HEIGHT_SCALE) + } + + resetHeightScales(): void { + this.setHeightScale('detail', DEFAULT_HEIGHT_SCALE) + this.setHeightScale('overview', DEFAULT_HEIGHT_SCALE) + } + setZoom(zoom: number): void { this.zoom = Math.max(0.25, Math.min(64, zoom)) setSetting('waveformZoom', String(this.zoom)) diff --git a/app/src/renderer/src/lib/deck/prefs.svelte.ts b/app/src/renderer/src/lib/deck/prefs.svelte.ts index 635dcdc..e2989e9 100644 --- a/app/src/renderer/src/lib/deck/prefs.svelte.ts +++ b/app/src/renderer/src/lib/deck/prefs.svelte.ts @@ -5,12 +5,24 @@ import { storedFlag, storeFlag } from './stored' export type CuePlacement = 'playhead' | 'cuePoint' export type TempoReadout = 'absolute' | 'relative' +export const DEFAULT_DECK_PREFS = { + quantise: true, + cuePlacement: 'cuePoint' as CuePlacement, + tempoReadout: 'absolute' as TempoReadout +} + +function stored(key: string, ids: readonly T[], fallback: T): T { + const raw = getSetting(key) + return ids.find((id) => id === raw) ?? fallback +} + /** Deck preferences that hold for every deck at once, kept between sessions. */ export class DeckPrefs { - quantise = $state(storedFlag('quantise', true)) - cuePlacement = $state(getSetting('cuePlacement') === 'playhead' ? 'playhead' : 'cuePoint') + quantise = $state(storedFlag('quantise', DEFAULT_DECK_PREFS.quantise)) + cuePlacement = $state(stored('cuePlacement', ['playhead', 'cuePoint'], DEFAULT_DECK_PREFS.cuePlacement)) snapPlayhead = $state(storedFlag('snapPlayhead')) - tempoReadout = $state(getSetting('tempoReadout') === 'relative' ? 'relative' : 'absolute') + unlimitedMemoryCues = $state(storedFlag('unlimitedMemoryCues')) + tempoReadout = $state(stored('tempoReadout', ['absolute', 'relative'], DEFAULT_DECK_PREFS.tempoReadout)) readonly waveform = new WaveformPrefs() setQuantise(on: boolean): void { @@ -23,6 +35,21 @@ export class DeckPrefs { storeFlag('snapPlayhead', on) } + setUnlimitedMemoryCues(on: boolean): void { + this.unlimitedMemoryCues = on + storeFlag('unlimitedMemoryCues', on) + } + + get developerOptionsAreDefault(): boolean { + return !this.snapPlayhead && !this.unlimitedMemoryCues && this.waveform.heightScalesAreDefault + } + + resetDeveloperOptions(): void { + this.setSnapPlayhead(false) + this.setUnlimitedMemoryCues(false) + this.waveform.resetHeightScales() + } + setCuePlacement(placement: CuePlacement): void { this.cuePlacement = placement setSetting('cuePlacement', placement) diff --git a/app/src/renderer/src/lib/devOptions.svelte.ts b/app/src/renderer/src/lib/devOptions.svelte.ts new file mode 100644 index 0000000..bd57da8 --- /dev/null +++ b/app/src/renderer/src/lib/devOptions.svelte.ts @@ -0,0 +1,14 @@ +import { storedFlag, storeFlag } from './deck/stored' + +const KEY = 'devOptions' + +class DevOptions { + enabled = $state(storedFlag(KEY)) + + setEnabled(on: boolean): void { + this.enabled = on + storeFlag(KEY, on) + } +} + +export const devOptions = new DevOptions() diff --git a/app/src/renderer/src/lib/library.svelte.ts b/app/src/renderer/src/lib/library.svelte.ts index 67ddc13..7a29e6c 100644 --- a/app/src/renderer/src/lib/library.svelte.ts +++ b/app/src/renderer/src/lib/library.svelte.ts @@ -1,5 +1,5 @@ import { api } from './api' -import { BROWSER_ROW_HEIGHT_KEY, clampRowHeight, readRowHeight } from './browserColumns' +import { BROWSER_ROW_HEIGHT_KEY, clampRowHeight, readRowHeight, ROW_HEIGHT_DEFAULT } from './browserColumns' import { browserPreviews } from './browserPreview.svelte' import { clearDetailCache } from './deck/session.svelte' import { clearWaveCache } from './deck/waves.svelte' @@ -11,6 +11,7 @@ import { DEFAULT_GREEN, playerById } from './hardware/players' import { isRekordboxPresetTrack } from './library/factory' import { loadViews, looseTrack, matchesTrack, pathKey, saveViews, type FileEntry } from './library/views' import { Listeners } from './listeners' +import { storedFlag, storeFlag } from './deck/stored' import { getSetting, setSetting } from './settings' import type { ConvertMode, @@ -40,15 +41,22 @@ export const PLAYLIST_TREE_DRAG_TYPE = 'application/x-exportstudio-playlist-tree */ export type BrowserTrackStyle = 'standard' | 'alternative' +export const DEFAULT_LIBRARY_PREFS = { + writeFileTags: false, + importArtwork: true, + hideStreaming: true, + hidePresetTracks: true, + doubleClickToLoad: true, + browserTrackStyle: 'standard' as BrowserTrackStyle, + browserRowHeight: ROW_HEIGHT_DEFAULT, + hardwareGreen: DEFAULT_GREEN +} + function storedTrackStyle(): BrowserTrackStyle { const stored = getSetting('browserTrackStyle') if (stored === 'standard' || stored === 'alternative') return stored // Earlier versions only had an artwork setting; whole artwork maps to the alternative style. - return getSetting('browserArtwork') === 'whole' ? 'alternative' : 'standard' -} - -function storedHidePresetTracks(): boolean { - return getSetting('hidePresetTracks') !== '0' + return getSetting('browserArtwork') === 'whole' ? 'alternative' : DEFAULT_LIBRARY_PREFS.browserTrackStyle } function storedPlayer(key: string, fallback: string): string { @@ -123,14 +131,14 @@ export class Library { editError = $state(null) editLevel = $state('info') - writeFileTags = $state(getSetting('writeFileTags') === '1') - importArtwork = $state(getSetting('importArtwork') !== '0') - hideStreaming = $state(getSetting('hideStreaming') !== '0') - hidePresetTracks = $state(storedHidePresetTracks()) - doubleClickToLoad = $state(getSetting('doubleClickToLoad') !== '0') + writeFileTags = $state(storedFlag('writeFileTags', DEFAULT_LIBRARY_PREFS.writeFileTags)) + importArtwork = $state(storedFlag('importArtwork', DEFAULT_LIBRARY_PREFS.importArtwork)) + hideStreaming = $state(storedFlag('hideStreaming', DEFAULT_LIBRARY_PREFS.hideStreaming)) + hidePresetTracks = $state(storedFlag('hidePresetTracks', DEFAULT_LIBRARY_PREFS.hidePresetTracks)) + doubleClickToLoad = $state(storedFlag('doubleClickToLoad', DEFAULT_LIBRARY_PREFS.doubleClickToLoad)) browserTrackStyle = $state(storedTrackStyle()) browserRowHeight = $state(readRowHeight(getSetting(BROWSER_ROW_HEIGHT_KEY))) - hardwareGreen = $state(storedPlayer('hardwareGreen', DEFAULT_GREEN)) + hardwareGreen = $state(storedPlayer('hardwareGreen', DEFAULT_LIBRARY_PREFS.hardwareGreen)) importing = $state(false) converting = $state(false) convertProgress = $state.raw<{ done: number; total: number } | null>(null) @@ -414,27 +422,27 @@ export class Library { setWriteFileTags(on: boolean): void { this.writeFileTags = on - setSetting('writeFileTags', on ? '1' : '0') + storeFlag('writeFileTags', on) } setImportArtwork(on: boolean): void { this.importArtwork = on - setSetting('importArtwork', on ? '1' : '0') + storeFlag('importArtwork', on) } setHideStreaming(on: boolean): void { this.hideStreaming = on - setSetting('hideStreaming', on ? '1' : '0') + storeFlag('hideStreaming', on) } setHidePresetTracks(on: boolean): void { this.hidePresetTracks = on - setSetting('hidePresetTracks', on ? '1' : '0') + storeFlag('hidePresetTracks', on) } setDoubleClickToLoad(on: boolean): void { this.doubleClickToLoad = on - setSetting('doubleClickToLoad', on ? '1' : '0') + storeFlag('doubleClickToLoad', on) } /** A row the browser lists. File-list rows that are not in the collection always stay. */ diff --git a/app/src/renderer/src/lib/mix/prefs.svelte.ts b/app/src/renderer/src/lib/mix/prefs.svelte.ts index 9124f8e..1acbf46 100644 --- a/app/src/renderer/src/lib/mix/prefs.svelte.ts +++ b/app/src/renderer/src/lib/mix/prefs.svelte.ts @@ -34,6 +34,7 @@ export const KEY_HIGHLIGHTS: { id: KeyHighlightId; label: string }[] = [ export const DEFAULT_CURVE_ID: CurveId = 'normal' export const DEFAULT_SWAP_TIMING_ID: SwapTimingId = 'centre' export const DEFAULT_KEY_HIGHLIGHT_ID: KeyHighlightId = 'adjacent' +export const DEFAULT_AUTO_MASTER = true function stored(key: string, ids: { id: T }[], fallback: T): T { const raw = getSetting(key) @@ -45,7 +46,7 @@ export class MixPrefs { curveId = $state(stored('mix.curve', CURVES, DEFAULT_CURVE_ID)) swapTimingId = $state(stored('mix.swapTiming', SWAP_TIMINGS, DEFAULT_SWAP_TIMING_ID)) /** The deck that alone has sound becomes the master. See `Mixer.makeSoleAudibleDeckMaster`. */ - autoMaster = $state(getSetting('mix.autoMaster') !== 'false') + autoMaster = $state((getSetting('mix.autoMaster') ?? String(DEFAULT_AUTO_MASTER)) !== 'false') keyHighlightId = $state(stored('mix.keyHighlight', KEY_HIGHLIGHTS, DEFAULT_KEY_HIGHLIGHT_ID)) get curve(): number { diff --git a/app/src/renderer/src/lib/player.svelte.ts b/app/src/renderer/src/lib/player.svelte.ts index 0ae4a62..4d7d10f 100644 --- a/app/src/renderer/src/lib/player.svelte.ts +++ b/app/src/renderer/src/lib/player.svelte.ts @@ -29,6 +29,7 @@ import type { Waveform } from './waveform/data' const CUE_HIT_TOLERANCE: Seconds = 0.05 const END_MARGIN: Seconds = 0.001 const SAME_LOOP_LENGTH_TOLERANCE: Seconds = 0.002 +export const MEMORY_CUE_LIMIT = 10 /** Rekordbox's detail rate, so zoom covers the same time for a file that has no analysis. */ const LOOSE_WAVE_RATE = 150 /** Matches the error the audio bridge sends when a track's file can't be resolved. */ @@ -497,7 +498,16 @@ export class Player { return this.cues.setHotCue(kind, ...this.newCueMs()) } + private get memoryCueLimitReached(): boolean { + if (this.prefs.unlimitedMemoryCues) return false + return this.displayCues.filter((c) => c.kind === 'memory').length >= MEMORY_CUE_LIMIT + } + addMemoryCue(): Promise { + if (this.memoryCueLimitReached) { + this.session.editError = `A track can have up to ${MEMORY_CUE_LIMIT} memory cues` + return Promise.resolve() + } return this.cues.addMemoryCue(...this.newCueMs()) } diff --git a/app/src/renderer/src/lib/waveform/analysis.ts b/app/src/renderer/src/lib/waveform/analysis.ts index 168fdf6..1a91b5a 100644 --- a/app/src/renderer/src/lib/waveform/analysis.ts +++ b/app/src/renderer/src/lib/waveform/analysis.ts @@ -57,7 +57,7 @@ export function drawRgbDetail( * Column levels, scaled to the loudest column, are raised to this power. A whole track squeezed * into a few hundred pixels is otherwise loud nearly everywhere; this lets the quieter passages sink. */ -const OVERVIEW_DYNAMICS_EXPONENT = 2 +const OVERVIEW_DYNAMICS_EXPONENT = 1 /** * The first `entries` of an RGB waveform from the bottom across `pw` pixels. Each column is the diff --git a/app/src/renderer/src/lib/waveform/bandStyle.svelte.ts b/app/src/renderer/src/lib/waveform/bandStyle.svelte.ts index 72b2f90..8536d83 100644 --- a/app/src/renderer/src/lib/waveform/bandStyle.svelte.ts +++ b/app/src/renderer/src/lib/waveform/bandStyle.svelte.ts @@ -34,6 +34,10 @@ export function isBandList(value: unknown, valid: (item: unknown) => item is return Array.isArray(value) && value.length === BAND_NAMES_LOW_TO_HIGH.length && value.every(valid) } +export function sameBands(a: readonly T[], b: readonly T[]): boolean { + return a.every((item, band) => item === b[band]) +} + export const isHex = (value: unknown): value is string => typeof value === 'string' && HEX.test(value) const isOpacity = (value: unknown): value is number => typeof value === 'number' && value >= 0 && value <= 1 const isBlend = (value: unknown): value is GlobalCompositeOperation => BLEND_MODES.some((mode) => mode.id === value) @@ -71,10 +75,6 @@ class BandStyle { return this.values.blend } - get isDefault(): boolean { - return JSON.stringify(this.values) === JSON.stringify(DEFAULT_BAND_STYLE) - } - setColor(band: number, color: string): void { const hex = color.toLowerCase() if (!isHex(hex)) return @@ -93,8 +93,25 @@ class BandStyle { this.save() } - reset(): void { - this.values = structuredClone(DEFAULT_BAND_STYLE) + get colorsAreDefault(): boolean { + return sameBands(this.values.colors, DEFAULT_BAND_STYLE.colors) + } + + resetColors(): void { + this.values.colors = [...DEFAULT_BAND_STYLE.colors] + this.save() + } + + get layeringIsDefault(): boolean { + return ( + this.values.blend === DEFAULT_BAND_STYLE.blend && + sameBands(this.values.layeredOpacities, DEFAULT_BAND_STYLE.layeredOpacities) + ) + } + + resetLayering(): void { + this.values.layeredOpacities = [...DEFAULT_BAND_STYLE.layeredOpacities] + this.values.blend = DEFAULT_BAND_STYLE.blend this.save() } diff --git a/app/src/renderer/src/lib/waveform/rgbGradient.svelte.ts b/app/src/renderer/src/lib/waveform/rgbGradient.svelte.ts index 5e8694f..6af0704 100644 --- a/app/src/renderer/src/lib/waveform/rgbGradient.svelte.ts +++ b/app/src/renderer/src/lib/waveform/rgbGradient.svelte.ts @@ -1,5 +1,5 @@ import { getSetting, setSetting } from '../settings' -import { isBandList, isHex } from './bandStyle.svelte' +import { isBandList, isHex, sameBands } from './bandStyle.svelte' export const DEFAULT_RGB_GRADIENT: readonly string[] = ['#0054ff', '#02cff7', '#ffffff'] @@ -27,6 +27,19 @@ class RgbGradient { const hex = color.toLowerCase() if (!isHex(hex)) return this.stops[band] = hex + this.save() + } + + get isDefault(): boolean { + return sameBands(this.stops, DEFAULT_RGB_GRADIENT) + } + + reset(): void { + this.stops = [...DEFAULT_RGB_GRADIENT] + this.save() + } + + private save(): void { setSetting(KEY, JSON.stringify(this.stops)) } }