diff --git a/src/renderer/analyzerLayout.ts b/src/renderer/analyzerLayout.ts new file mode 100644 index 0000000..1329b7b --- /dev/null +++ b/src/renderer/analyzerLayout.ts @@ -0,0 +1,34 @@ +import type { ScopeKind } from '../types/scope' + +const DEFAULT_COLLAPSED_SCOPE_WEIGHT = 1 + +function usesCollapsedDefaultWeight(scope: ScopeKind): boolean { + return scope === 'spectrogram' + || scope === 'vumeter' + || scope === 'lufsmeter' + || scope === 'waveform' +} + +export function buildAnalyzerGridTemplateColumns( + visibleScopes: ScopeKind[], + widthWeights: Partial> +): string { + if (visibleScopes.length === 0) return '' + + return visibleScopes.map((scope) => { + const weight = widthWeights[scope] ?? 1 + + if (scope === 'vectorscope') { + if (weight <= 0) { + return 'minmax(96px, clamp(96px, 18vw, calc(var(--analyzer-height, 240px) - 8px)))' + } + return `minmax(clamp(96px, 18vw, calc(var(--analyzer-height, 240px) - 8px)), ${weight}fr)` + } + + if (usesCollapsedDefaultWeight(scope) && weight <= 0) { + return `minmax(0, ${DEFAULT_COLLAPSED_SCOPE_WEIGHT}fr)` + } + + return `minmax(0, ${weight}fr)` + }).join(' ') +} diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index c080c8b..6b3d7b4 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -12,7 +12,6 @@ import { Waveform } from '../visualizers/Waveform' interface ScopeModuleProps { scopeKind: ScopeKind lineColor?: string - widthWeight?: number } interface Visualizer { @@ -98,7 +97,7 @@ function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, mySet } } -export default function ScopeModule({ scopeKind, lineColor = '#38bdf8', widthWeight = 1 }: ScopeModuleProps): JSX.Element { +export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeModuleProps): JSX.Element { const containerRef = useRef(null) const canvasRef = useRef(null) const visualizerRef = useRef(null) @@ -167,7 +166,6 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8', widthWei
= { spectrum: 'Spectrum', @@ -151,7 +152,7 @@ function RangeControl({ ) } -function ScopeSettingsCard({ +function ScopeSettingsSection({ kind, settings, onUpdate, @@ -163,13 +164,13 @@ function ScopeSettingsCard({ const scopeSettings = settings[kind] return ( -
-
-
{SCOPE_LABELS[kind]}
-
{scopeSummary(kind, scopeSettings)}
+
+
+
{SCOPE_LABELS[kind]}
+
{scopeSummary(kind, scopeSettings)}
-
+
{kind === 'spectrum' && (() => { const current = scopeSettings as ScopeSettings['spectrum'] return ( @@ -487,11 +488,16 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel setCaptureMode, startCapture, } = useAudioStore() - const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder } = useSettingsStore() + const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore() const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore() const panelRef = useRef(null) const visibleScopes = scopeOrder.filter((kind) => !hiddenScopes.has(kind)) + const scopeTrackStyle = useMemo(() => { + const gridTemplateColumns = buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights) + if (!gridTemplateColumns) return undefined + return { gridTemplateColumns } as CSSProperties + }, [visibleScopes, widthWeights]) useEffect(() => { void refreshDevices() @@ -542,8 +548,8 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel return (
-
-
+
+
Audio Source
-
+
Theme
@@ -616,11 +622,19 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
+ +
-
+
{visibleScopes.map((kind) => ( - ))}
- -
) } diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx index 61af358..41b95e7 100644 --- a/src/renderer/components/Strip.tsx +++ b/src/renderer/components/Strip.tsx @@ -1,30 +1,132 @@ -import { Fragment, useCallback, useRef, type JSX } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react' import { useSettingsStore } from '../stores/settingsStore' import { useThemeStore } from '../stores/themeStore' import type { ScopeKind } from '../../types/scope' import ScopeModule from './ScopeModule' +import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout' -function ResizeHandle({ leftKind, rightKind }: { leftKind: ScopeKind; rightKind: ScopeKind }): JSX.Element { +export default function Strip(): JSX.Element { + const scopeOrder = useSettingsStore((s) => s.scopeOrder) + const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) + const widthWeights = useSettingsStore((s) => s.widthWeights) const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight) - const handleRef = useRef(null) + const accent = useThemeStore((s) => s.accent) + const stripRef = useRef(null) + const gridRef = useRef(null) + const scopeRefs = useRef>>({}) + const [handleOffsets, setHandleOffsets] = useState([]) + const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k)) + const gridTemplateColumns = useMemo(() => { + return buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights) + }, [visibleScopes, widthWeights]) + const gridStyle = useMemo(() => { + if (!gridTemplateColumns) return undefined + return { gridTemplateColumns } as CSSProperties + }, [gridTemplateColumns]) - const onMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault() - const startX = e.clientX - const container = handleRef.current?.parentElement - if (!container) return + const updateHandleOffsets = useCallback((): void => { + if (visibleScopes.length < 2) { + setHandleOffsets([]) + return + } - const totalWidth = container.getBoundingClientRect().width - const { widthWeights } = useSettingsStore.getState() - const startLeftWeight = widthWeights[leftKind] ?? 1 - const startRightWeight = widthWeights[rightKind] ?? 1 + const nextOffsets: number[] = [] + for (let index = 0; index < visibleScopes.length - 1; index += 1) { + const leftElement = scopeRefs.current[visibleScopes[index]] + if (!leftElement) continue + nextOffsets.push(leftElement.offsetLeft + leftElement.offsetWidth) + } + + setHandleOffsets(nextOffsets) + }, [visibleScopes]) + + useEffect(() => { + const strip = stripRef.current + if (!strip) return + + const setAnalyzerHeight = (): void => { + const nextHeight = Math.max(0, Math.ceil(strip.getBoundingClientRect().height)) + document.documentElement.style.setProperty('--analyzer-height', `${nextHeight}px`) + } + + setAnalyzerHeight() + + const observer = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => setAnalyzerHeight()) + + observer?.observe(strip) + window.addEventListener('resize', setAnalyzerHeight) + + return () => { + observer?.disconnect() + window.removeEventListener('resize', setAnalyzerHeight) + } + }, []) + + useEffect(() => { + updateHandleOffsets() + + const observer = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => updateHandleOffsets()) + + if (gridRef.current) { + observer?.observe(gridRef.current) + } + + for (const scope of visibleScopes) { + const element = scopeRefs.current[scope] + if (element) { + observer?.observe(element) + } + } + + window.addEventListener('resize', updateHandleOffsets) + + return () => { + observer?.disconnect() + window.removeEventListener('resize', updateHandleOffsets) + } + }, [gridTemplateColumns, updateHandleOffsets, visibleScopes]) + + const startResizeDrag = useCallback((handleIndex: number, event: React.MouseEvent) => { + const leftKind = visibleScopes[handleIndex] + const rightKind = visibleScopes[handleIndex + 1] + if (!leftKind || !rightKind) return + + const leftElement = scopeRefs.current[leftKind] + const rightElement = scopeRefs.current[rightKind] + if (!leftElement || !rightElement) return + + event.preventDefault() + + const startX = event.clientX + const leftWidth = leftElement.getBoundingClientRect().width + const rightWidth = rightElement.getBoundingClientRect().width + const pairWidth = Math.max(1, leftWidth + rightWidth) + const { widthWeights: currentWeights } = useSettingsStore.getState() + const startLeftWeight = currentWeights[leftKind] ?? 1 + const startRightWeight = currentWeights[rightKind] ?? 1 const totalWeight = startLeftWeight + startRightWeight + const minWeight = 0.15 const onMouseMove = (ev: MouseEvent): void => { const delta = ev.clientX - startX - const ratio = delta / totalWidth * totalWeight * 2 - const newLeft = Math.max(0.15, startLeftWeight + ratio) - const newRight = Math.max(0.15, startRightWeight - ratio) + const weightDelta = (delta / pairWidth) * totalWeight + let newLeft = startLeftWeight + weightDelta + let newRight = startRightWeight - weightDelta + + if (newLeft < minWeight) { + newRight -= minWeight - newLeft + newLeft = minWeight + } + + if (newRight < minWeight) { + newLeft -= minWeight - newRight + newRight = minWeight + } + setScopeWidthWeight(leftKind, newLeft) setScopeWidthWeight(rightKind, newRight) } @@ -40,36 +142,38 @@ function ResizeHandle({ leftKind, rightKind }: { leftKind: ScopeKind; rightKind: document.body.style.userSelect = 'none' document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) - }, [leftKind, rightKind, setScopeWidthWeight]) + }, [setScopeWidthWeight, visibleScopes]) return ( -
-
-
- ) -} +
+
+ {visibleScopes.map((kind) => ( +
{ + scopeRefs.current[kind] = element + }} + className="scope-strip__cell" + > + +
+ ))} +
-export default function Strip(): JSX.Element { - const scopeOrder = useSettingsStore((s) => s.scopeOrder) - const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) - const widthWeights = useSettingsStore((s) => s.widthWeights) - const accent = useThemeStore((s) => s.accent) - - const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k)) - - return ( -
- {visibleScopes.map((kind, i) => ( - - {i > 0 && ( - - )} - - + {visibleScopes.length > 1 && handleOffsets.map((offset, index) => ( + ))}
) diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index a17bed0..a42668a 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -121,8 +121,8 @@ select { .toolbar__chip, .toolbar__icon-button, .settings-section-title, -.settings-card__title, -.settings-card__summary, +.settings-scope-section__title, +.settings-scope-section__summary, .settings-control__label, .settings-chip, .settings-status-pill, @@ -221,24 +221,42 @@ select { } .scope-strip { - display: flex; - flex-direction: row; + position: relative; width: 100%; height: 100%; background-color: var(--bg-primary); } -.scope-strip__handle { - width: 6px; - flex-shrink: 0; +.scope-strip__grid { + display: grid; + width: 100%; + height: 100%; +} + +.scope-strip__cell { + min-width: 0; + height: 100%; + position: relative; + overflow: hidden; +} + +.scope-strip__resize-handle { + position: absolute; + top: 0; + bottom: 0; + width: 12px; + transform: translateX(-50%); + padding: 0; + border: 0; + background: transparent; cursor: col-resize; display: flex; - align-items: center; + align-items: stretch; justify-content: center; z-index: 2; } -.scope-strip__handle-line { +.scope-strip__resize-handle-grip { width: 1px; height: 100%; background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.12), transparent); @@ -248,75 +266,79 @@ select { background: linear-gradient(180deg, rgba(6, 8, 11, 0.98), rgba(4, 5, 7, 0.98)); border-top: 1px solid rgba(255, 255, 255, 0.08); display: flex; - flex-direction: row; + flex-direction: column; + gap: 12px; overflow: hidden; flex-shrink: 0; position: relative; - padding-bottom: 12px; + padding: 12px 0 14px; } -.settings-panel__utility { - width: 224px; - padding: 12px; - border-right: 1px solid rgba(255, 255, 255, 0.06); +.settings-panel__utility-row { display: flex; - flex-direction: column; - gap: 12px; - flex-shrink: 0; + align-items: flex-start; + gap: 14px 18px; + flex-wrap: wrap; + padding: 0 14px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } -.settings-utility-card, -.settings-card { - border-radius: 16px; - border: 1px solid var(--panel-outline); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0.008)), - var(--panel-surface-soft); - box-shadow: var(--panel-shadow); +.settings-utility-section { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 14px; + min-width: 0; } -.settings-utility-card { - padding: 12px; +.settings-utility-section--source { + flex: 1 1 360px; +} + +.settings-utility-section--theme { + flex: 1 1 300px; +} + +.settings-panel__scope-track { + display: grid; + width: 100%; + min-width: 0; + gap: 0; + padding: 0 0 2px; + align-items: start; } .settings-section-title { + width: 100%; color: rgba(255, 255, 255, 0.72); font-size: 10px; } -.settings-panel__scopes { - flex: 1; +.settings-scope-section { min-width: 0; - display: grid; - grid-auto-flow: column; - grid-auto-columns: minmax(240px, 1fr); - gap: 12px; - padding: 12px 12px 36px; - overflow-x: auto; - overflow-y: visible; - align-items: start; -} - -.settings-card { - min-width: 240px; - padding: 12px; display: flex; flex-direction: column; gap: 10px; + padding: 0 16px; + border-left: 1px solid rgba(255, 255, 255, 0.06); } -.settings-card__header { +.settings-scope-section:first-child { + border-left: 0; +} + +.settings-scope-section__header { display: flex; flex-direction: column; gap: 4px; } -.settings-card__title { +.settings-scope-section__title { color: var(--accent); font-size: 10px; } -.settings-card__summary { +.settings-scope-section__summary { color: var(--text-tertiary); font-size: 9px; letter-spacing: 0.08em; @@ -325,10 +347,10 @@ select { text-overflow: ellipsis; } -.settings-card__controls { +.settings-scope-section__controls { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px 12px; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 10px 14px; align-content: start; } @@ -368,17 +390,19 @@ select { padding: 0 12px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.1); - background: var(--panel-surface); + background: rgba(8, 12, 18, 0.72); color: rgba(255, 255, 255, 0.86); font-size: 11px; outline: none; + backdrop-filter: blur(14px) saturate(1.04); + -webkit-backdrop-filter: blur(14px) saturate(1.04); transition: border-color 140ms ease, background-color 140ms ease; } .settings-control__select:hover, .settings-control__select:focus { border-color: rgba(255, 255, 255, 0.16); - background: rgba(10, 14, 20, 0.98); + background: rgba(12, 18, 26, 0.82); } .settings-control__range { @@ -432,9 +456,11 @@ select { padding: 0 11px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.03); + background: rgba(8, 12, 18, 0.72); color: var(--text-secondary); font-size: 10px; + backdrop-filter: blur(14px) saturate(1.04); + -webkit-backdrop-filter: blur(14px) saturate(1.04); cursor: pointer; transition: border-color 140ms ease, @@ -463,9 +489,11 @@ select { padding: 0 10px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.03); + background: rgba(8, 12, 18, 0.72); color: var(--text-secondary); font-size: 10px; + backdrop-filter: blur(14px) saturate(1.04); + -webkit-backdrop-filter: blur(14px) saturate(1.04); } .settings-status-pill__dot { @@ -496,7 +524,6 @@ select { display: flex; flex-wrap: wrap; gap: 6px; - margin-top: 10px; } .settings-swatch { @@ -520,31 +547,31 @@ select { .settings-accent-row { display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; } .settings-accent-input { - flex: 1; - min-width: 0; + width: 92px; height: 32px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.1); - background: var(--panel-surface); + background: rgba(8, 12, 18, 0.72); cursor: pointer; padding: 4px; } .settings-panel__close { - position: absolute; - right: 12px; - bottom: 10px; + margin-left: auto; min-height: 28px; padding: 0 12px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.03); + background: rgba(8, 12, 18, 0.72); color: var(--text-tertiary); font-size: 9px; + backdrop-filter: blur(14px) saturate(1.04); + -webkit-backdrop-filter: blur(14px) saturate(1.04); cursor: pointer; transition: border-color 140ms ease,