mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-18 19:44:16 +02:00
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react'
|
|
import { useSettingsStore } from '../stores/settingsStore'
|
|
import { SCOPE_LABELS, isAudioScopeKind, type ScopeKind } from '../../types/scope'
|
|
import type { WindowBounds } from '../../types/popout'
|
|
import ScopeModule from './ScopeModule'
|
|
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
|
|
import { audioRouter } from '../audio/AudioRouter'
|
|
import { usePerformanceStore } from '../stores/performanceStore'
|
|
import { FrameScheduler } from '../visualizers/frameScheduler'
|
|
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
|
import { getScopePopoutMinWidth } from '../../shared/scopeSizing'
|
|
|
|
interface StripProps {
|
|
onMeasurementActiveChange?: (active: boolean) => void
|
|
}
|
|
|
|
export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.Element {
|
|
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
|
|
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
|
|
const scopePopouts = useSettingsStore((s) => s.scopePopouts)
|
|
const widthWeights = useSettingsStore((s) => s.widthWeights)
|
|
const moveDockedScope = useSettingsStore((s) => s.moveDockedScope)
|
|
const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight)
|
|
const popOutScope = useSettingsStore((s) => s.popOutScope)
|
|
const frameTarget = usePerformanceStore((s) => s.frameTarget)
|
|
const setDockedRenderFps = usePerformanceStore((s) => s.setDockedRenderFps)
|
|
const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), [])
|
|
const stripRef = useRef<HTMLDivElement>(null)
|
|
const gridRef = useRef<HTMLDivElement>(null)
|
|
const scopeRefs = useRef<Partial<Record<ScopeKind, HTMLDivElement | null>>>({})
|
|
const [handleOffsets, setHandleOffsets] = useState<number[]>([])
|
|
const supportsGeometryPersistence = getRendererWindowCapabilities().supportsGeometryPersistence
|
|
const dockedScopes = useMemo(
|
|
() => scopeOrder.filter((k) => !hiddenScopes.has(k) && !scopePopouts[k]?.poppedOut),
|
|
[hiddenScopes, scopeOrder, scopePopouts],
|
|
)
|
|
const visibleScopeKey = useMemo(() => dockedScopes.join('|'), [dockedScopes])
|
|
const gridTemplateColumns = useMemo(() => {
|
|
return buildAnalyzerGridTemplateColumns(dockedScopes, widthWeights)
|
|
}, [dockedScopes, widthWeights])
|
|
const gridStyle = useMemo(() => {
|
|
if (!gridTemplateColumns) return undefined
|
|
return { gridTemplateColumns } as CSSProperties
|
|
}, [gridTemplateColumns])
|
|
|
|
useEffect(() => {
|
|
frameScheduler.setFrameTarget(frameTarget)
|
|
}, [frameScheduler, frameTarget])
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = frameScheduler.subscribeToActualFps((fps) => {
|
|
setDockedRenderFps(fps)
|
|
})
|
|
|
|
return () => {
|
|
unsubscribe()
|
|
setDockedRenderFps(0)
|
|
}
|
|
}, [frameScheduler, setDockedRenderFps])
|
|
|
|
const updateHandleOffsets = useCallback((): void => {
|
|
if (dockedScopes.length < 2) {
|
|
setHandleOffsets([])
|
|
return
|
|
}
|
|
|
|
const nextOffsets: number[] = []
|
|
for (let index = 0; index < dockedScopes.length - 1; index += 1) {
|
|
const leftElement = scopeRefs.current[dockedScopes[index]]
|
|
if (!leftElement) continue
|
|
nextOffsets.push(leftElement.offsetLeft + leftElement.offsetWidth)
|
|
}
|
|
|
|
setHandleOffsets(nextOffsets)
|
|
}, [dockedScopes])
|
|
|
|
useEffect(() => {
|
|
const visibleAudioScopeSet = new Set(dockedScopes.filter(isAudioScopeKind))
|
|
audioRouter.setVisualizerConsumerDemand('docked-strip', {
|
|
spectrum: visibleAudioScopeSet.has('spectrum'),
|
|
oscilloscope: visibleAudioScopeSet.has('oscilloscope'),
|
|
vectorscope: visibleAudioScopeSet.has('vectorscope'),
|
|
spectrogram: visibleAudioScopeSet.has('spectrogram'),
|
|
vumeter: visibleAudioScopeSet.has('vumeter'),
|
|
lufsmeter: visibleAudioScopeSet.has('lufsmeter'),
|
|
waveform: visibleAudioScopeSet.has('waveform'),
|
|
})
|
|
|
|
return () => {
|
|
audioRouter.clearVisualizerConsumerDemand('docked-strip')
|
|
}
|
|
}, [visibleScopeKey, dockedScopes])
|
|
|
|
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 dockedScopes) {
|
|
const element = scopeRefs.current[scope]
|
|
if (element) {
|
|
observer?.observe(element)
|
|
}
|
|
}
|
|
|
|
window.addEventListener('resize', updateHandleOffsets)
|
|
|
|
return () => {
|
|
observer?.disconnect()
|
|
window.removeEventListener('resize', updateHandleOffsets)
|
|
}
|
|
}, [dockedScopes, gridTemplateColumns, updateHandleOffsets])
|
|
|
|
const startResizeDrag = useCallback((handleIndex: number, event: React.MouseEvent<HTMLButtonElement>) => {
|
|
const leftKind = dockedScopes[handleIndex]
|
|
const rightKind = dockedScopes[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 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)
|
|
}
|
|
|
|
const onMouseUp = (): void => {
|
|
document.removeEventListener('mousemove', onMouseMove)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
document.body.style.cursor = ''
|
|
document.body.style.userSelect = ''
|
|
}
|
|
|
|
document.body.style.cursor = 'col-resize'
|
|
document.body.style.userSelect = 'none'
|
|
document.addEventListener('mousemove', onMouseMove)
|
|
document.addEventListener('mouseup', onMouseUp)
|
|
}, [dockedScopes, setScopeWidthWeight])
|
|
|
|
const handlePopoutScope = useCallback(async (kind: ScopeKind): Promise<void> => {
|
|
if (!supportsGeometryPersistence) {
|
|
popOutScope(kind)
|
|
return
|
|
}
|
|
|
|
const element = scopeRefs.current[kind]
|
|
const rect = element?.getBoundingClientRect()
|
|
const windowBounds = await window.electronAPI.getWindowBounds()
|
|
|
|
let nextBounds: WindowBounds | undefined
|
|
if (rect && windowBounds) {
|
|
nextBounds = {
|
|
x: Math.round(windowBounds.x + rect.left),
|
|
y: Math.round(windowBounds.y + rect.top),
|
|
width: Math.max(getScopePopoutMinWidth(kind), Math.round(rect.width)),
|
|
height: Math.max(160, Math.round(rect.height)),
|
|
}
|
|
}
|
|
|
|
popOutScope(kind, nextBounds)
|
|
}, [popOutScope, supportsGeometryPersistence])
|
|
|
|
return (
|
|
<div ref={stripRef} className="scope-strip">
|
|
<div ref={gridRef} className="scope-strip__grid" style={gridStyle}>
|
|
{dockedScopes.map((kind, index) => (
|
|
<div
|
|
key={kind}
|
|
ref={(element) => {
|
|
scopeRefs.current[kind] = element
|
|
}}
|
|
className="scope-strip__cell"
|
|
>
|
|
{dockedScopes.length > 1 && (
|
|
<div className="scope-strip__reorder-controls">
|
|
<button
|
|
type="button"
|
|
className="scope-strip__reorder-button"
|
|
onClick={() => moveDockedScope(kind, 'left')}
|
|
aria-label={`Move ${SCOPE_LABELS[kind]} left`}
|
|
title={`Move ${SCOPE_LABELS[kind]} left`}
|
|
disabled={index === 0}
|
|
>
|
|
<span className="scope-strip__reorder-icon" aria-hidden="true">
|
|
←
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="scope-strip__reorder-button"
|
|
onClick={() => moveDockedScope(kind, 'right')}
|
|
aria-label={`Move ${SCOPE_LABELS[kind]} right`}
|
|
title={`Move ${SCOPE_LABELS[kind]} right`}
|
|
disabled={index === dockedScopes.length - 1}
|
|
>
|
|
<span className="scope-strip__reorder-icon" aria-hidden="true">
|
|
→
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="scope-strip__popout-button"
|
|
onClick={() => {
|
|
void handlePopoutScope(kind)
|
|
}}
|
|
aria-label={`Pop out ${SCOPE_LABELS[kind]}`}
|
|
title={`Pop out ${SCOPE_LABELS[kind]}`}
|
|
>
|
|
<span className="scope-strip__popout-icon" aria-hidden="true">
|
|
↗
|
|
</span>
|
|
</button>
|
|
<ScopeModule
|
|
scopeKind={kind}
|
|
frameScheduler={frameScheduler}
|
|
onMeasurementActiveChange={onMeasurementActiveChange}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{dockedScopes.length > 1 && handleOffsets.map((offset, index) => (
|
|
<button
|
|
key={`${dockedScopes[index]}:${dockedScopes[index + 1]}`}
|
|
type="button"
|
|
className="scope-strip__resize-handle"
|
|
style={{ left: `${offset}px` }}
|
|
onMouseDown={(event) => startResizeDrag(index, event)}
|
|
aria-label={`Resize between ${dockedScopes[index]} and ${dockedScopes[index + 1]}`}
|
|
>
|
|
<span className="scope-strip__resize-handle-grip" aria-hidden="true" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|