astra integration module

This commit is contained in:
Boof2015
2026-03-29 18:55:36 -04:00
parent 6cd30cac39
commit 6f73802252
30 changed files with 2314 additions and 39 deletions
@@ -0,0 +1,225 @@
import { useEffect, useMemo, useState, type CSSProperties, type JSX } from 'react'
import type { ScopeSettings } from '../../types/settings'
import type { ResolvedAstraTheme } from '../../types/theme'
import { useAstraStore } from '../stores/astraStore'
import { formatAstraTime, getAstraPlaybackProgress } from '../utils/astra'
interface AstraScopeModuleProps {
theme: ResolvedAstraTheme
settings: ScopeSettings['astra']
}
function hasVisibleFields(settings: ScopeSettings['astra']): boolean {
return settings.showCoverArt
|| settings.showTitle
|| settings.showArtist
|| settings.showProgress
|| settings.showTime
|| settings.showControls
}
function getFallbackTitle(connectionState: ReturnType<typeof useAstraStore.getState>['integrationState']['connectionState']): string {
switch (connectionState) {
case 'disabled':
return 'Astra is off'
case 'connecting':
return 'Connecting to Astra'
case 'error':
return 'Astra connection failed'
case 'connected':
return 'Nothing playing'
}
}
function getFallbackDetail(connectionState: ReturnType<typeof useAstraStore.getState>['integrationState']['connectionState']): string {
switch (connectionState) {
case 'disabled':
return 'Open the Astra scope to connect.'
case 'connecting':
return 'Waiting for the Astra API.'
case 'error':
return 'Check the Astra base URL and token.'
case 'connected':
return ''
}
}
export default function AstraScopeModule({
theme,
settings,
}: AstraScopeModuleProps): JSX.Element {
const initialize = useAstraStore((s) => s.initialize)
const setScopeActive = useAstraStore((s) => s.setScopeActive)
const integrationState = useAstraStore((s) => s.integrationState)
const isSendingControl = useAstraStore((s) => s.isSendingControl)
const sendControl = useAstraStore((s) => s.sendControl)
const [nowMs, setNowMs] = useState(() => Date.now())
useEffect(() => {
void initialize()
void setScopeActive(true)
return () => {
void setScopeActive(false)
}
}, [initialize, setScopeActive])
useEffect(() => {
if (integrationState.snapshot?.playbackState !== 'playing') {
return
}
const timer = window.setInterval(() => {
setNowMs(Date.now())
}, 250)
return () => {
window.clearInterval(timer)
}
}, [integrationState.snapshot?.playbackState])
const snapshot = integrationState.snapshot
const currentTrack = snapshot?.currentTrack ?? null
const liveProgress = useMemo(
() => getAstraPlaybackProgress(snapshot, nowMs),
[nowMs, snapshot],
)
const errorMessage = integrationState.lastError ?? integrationState.lastControlError
const detailMessage = currentTrack?.artist
?? (integrationState.connectionState === 'connected' ? null : getFallbackDetail(integrationState.connectionState))
const style = {
'--astra-accent': theme.accent,
'--astra-bg': theme.background,
'--astra-surface': theme.surface,
'--astra-border': theme.border,
'--astra-text': theme.text,
'--astra-subtext': theme.subtext,
'--astra-progress-track': theme.progressTrack,
'--astra-progress-fill': theme.progressFill,
'--astra-status-ok': theme.statusOk,
'--astra-status-error': theme.statusError,
} as CSSProperties
if (!hasVisibleFields(settings)) {
return (
<div className="astra-scope astra-scope--empty" style={style}>
<div className="astra-scope__placeholder">
All Astra elements are hidden.
</div>
</div>
)
}
const toggleCommand = snapshot?.playbackState === 'playing' ? 'pause' : 'play'
const toggleLabel = snapshot?.playbackState === 'playing' ? 'Pause' : 'Play'
const cardClassName = settings.showCoverArt
? 'astra-scope__card'
: 'astra-scope__card astra-scope__card--no-cover'
return (
<div className="astra-scope" style={style}>
<div className={cardClassName}>
{settings.showCoverArt && (
<div className="astra-scope__cover-shell">
{currentTrack?.artworkDataUrl ? (
<img
className="astra-scope__cover"
src={currentTrack.artworkDataUrl}
alt={currentTrack.title}
/>
) : (
<div className="astra-scope__cover astra-scope__cover--fallback" aria-hidden="true">
<span>A</span>
</div>
)}
</div>
)}
<div className="astra-scope__body">
{(settings.showTitle || settings.showArtist) && (
<div className="astra-scope__meta">
{settings.showTitle && (
<div className="astra-scope__title" title={currentTrack?.title ?? getFallbackTitle(integrationState.connectionState)}>
{currentTrack?.title ?? getFallbackTitle(integrationState.connectionState)}
</div>
)}
{settings.showArtist && detailMessage && (
<div className="astra-scope__artist" title={detailMessage}>
{detailMessage}
</div>
)}
</div>
)}
{(settings.showProgress || settings.showTime) && (
<div className="astra-scope__transport">
{settings.showProgress && (
<div className="astra-scope__progress" aria-hidden="true">
<div
className="astra-scope__progress-fill"
style={{ width: `${Math.max(0, Math.min(100, liveProgress.progress * 100))}%` }}
/>
</div>
)}
{settings.showTime && (
<div className="astra-scope__time">
<span>{formatAstraTime(liveProgress.currentTime)}</span>
<span>/</span>
<span>{liveProgress.duration > 0 ? formatAstraTime(liveProgress.duration) : '--:--'}</span>
</div>
)}
</div>
)}
{settings.showControls && (
<div className="astra-scope__controls">
<button
type="button"
className="astra-scope__control"
disabled={!currentTrack || isSendingControl}
onClick={() => {
void sendControl('previous')
}}
aria-label="Previous track"
title="Previous track"
>
&#9198;
</button>
<button
type="button"
className="astra-scope__control astra-scope__control--primary"
disabled={!currentTrack || isSendingControl}
onClick={() => {
void sendControl(toggleCommand)
}}
aria-label={toggleLabel}
title={toggleLabel}
>
{toggleLabel}
</button>
<button
type="button"
className="astra-scope__control"
disabled={!currentTrack || isSendingControl}
onClick={() => {
void sendControl('next')
}}
aria-label="Next track"
title="Next track"
>
&#9197;
</button>
</div>
)}
{errorMessage && (
<div
className="astra-scope__status is-error"
>
{errorMessage}
</div>
)}
</div>
</div>
</div>
)
}
+75 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useRef, type CSSProperties, type JSX, type WheelEvent } from 'react'
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX, type WheelEvent } from 'react'
import { useAstraStore } from '../stores/astraStore'
import { useAudioStore } from '../stores/audioStore'
import { usePerformanceStore } from '../stores/performanceStore'
import { useSettingsStore } from '../stores/settingsStore'
@@ -7,6 +8,7 @@ import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll'
import type { ScopeKind } from '../../types/scope'
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
import { SCOPE_KINDS } from '../../types/scope'
import type { AstraIntegrationConfig } from '../../types/astra'
import ThemedSelect from './ThemedSelect'
const SCOPE_LABELS: Record<ScopeKind, string> = {
@@ -17,6 +19,7 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
astra: 'Astra',
}
interface BottomBarProps {
@@ -35,8 +38,11 @@ const FRAME_TARGET_LABELS: Record<VisualizerFrameTarget, string> = {
export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('')
const [astraTokenInput, setAstraTokenInput] = useState('')
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
const toggleScope = useSettingsStore((s) => s.toggleScope)
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps)
@@ -53,6 +59,8 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
importThemeFromDialog,
showThemesFolder,
} = useThemeStore()
const astraState = useAstraStore((s) => s.integrationState)
const saveAstraConfig = useAstraStore((s) => s.saveConfig)
const {
systemSources,
@@ -79,6 +87,11 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
void refreshDevices()
}, [refreshBackendSupport, refreshSystemSources, refreshDevices])
useEffect(() => {
setAstraBaseUrlInput(astraState.config.baseUrl)
setAstraTokenInput(astraState.config.token)
}, [astraState.config.baseUrl, astraState.config.token])
useLayoutEffect(() => {
if (!onHeightChange || !rootRef.current) return
@@ -168,6 +181,14 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
setThemeId(useThemeStore.getState().activeThemeId)
}
const handleSaveAstraConfig = async (): Promise<void> => {
const nextConfig: AstraIntegrationConfig = {
baseUrl: astraBaseUrlInput,
token: astraTokenInput,
}
await saveAstraConfig(nextConfig)
}
const handleRailWheel = (event: WheelEvent<HTMLDivElement>): void => {
const railElement = event.currentTarget
const target = event.target
@@ -190,6 +211,14 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
event.preventDefault()
}
const astraStatusLabel = astraState.connectionState === 'connected'
? 'Connected'
: astraState.connectionState === 'connecting'
? 'Connecting'
: astraState.connectionState === 'error'
? 'Error'
: 'Off'
return (
<div className="bottom-bar" ref={rootRef}>
<div className="bottom-bar__rail" aria-label="Global settings" onWheel={handleRailWheel}>
@@ -199,7 +228,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--chips">
{SCOPE_KINDS.map((kind) => {
const active = !hiddenScopes.has(kind)
const active = scopeOrder.includes(kind) && !hiddenScopes.has(kind)
return (
<button
key={kind}
@@ -297,6 +326,50 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--astra">
<div className="bottom-bar__section-title">Astra</div>
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--theme">
<input
className="bottom-bar__text-input bottom-bar__text-input--url"
type="text"
value={astraBaseUrlInput}
placeholder="Astra Base URL"
onChange={(event) => setAstraBaseUrlInput(event.target.value)}
/>
<input
className="bottom-bar__text-input bottom-bar__text-input--token"
type="password"
value={astraTokenInput}
placeholder="Astra API Token"
onChange={(event) => setAstraTokenInput(event.target.value)}
/>
<button
type="button"
className="settings-chip"
onClick={() => {
void handleSaveAstraConfig()
}}
>
Save
</button>
<div className={`settings-status-pill ${astraState.connectionState === 'disabled' ? '' : `is-${astraState.connectionState}`}`.trim()}>
<span className="settings-status-pill__dot" />
<span>{astraStatusLabel}</span>
</div>
</div>
{astraState.lastError ? (
<div className="settings-error-text bottom-bar__error-text">{astraState.lastError}</div>
) : null}
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--source">
<div className="bottom-bar__section-title">Audio Source</div>
<div className="bottom-bar__section-body">
+16
View File
@@ -3,6 +3,7 @@ import type { ScopeKind } from '../../types/scope'
import type { ScopeSettings } from '../../types/settings'
import type {
PrismResolvedTheme,
ResolvedAstraTheme,
ResolvedLUFSMeterTheme,
ResolvedOscilloscopeTheme,
ResolvedSpectrogramTheme,
@@ -13,6 +14,7 @@ import type {
} from '../../types/theme'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import AstraScopeModule from './AstraScopeModule'
import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer'
import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope'
import { Vectorscope, type VectorscopeDataSource } from '../visualizers/Vectorscope'
@@ -30,6 +32,7 @@ type ScopeModuleTheme =
| ResolvedVUMeterTheme
| ResolvedLUFSMeterTheme
| ResolvedWaveformTheme
| ResolvedAstraTheme
interface ScopeModuleProps {
scopeKind: ScopeKind
@@ -172,6 +175,8 @@ export function scopeSettingsToOptions(
multiband: s.multiband,
}
}
case 'astra':
return {}
default:
return {}
}
@@ -222,6 +227,8 @@ function createVisualizer(
...opts,
...(dataSource ? { dataSource: dataSource as WaveformDataSource } : {}),
})
case 'astra':
return null
default:
return null
}
@@ -244,6 +251,15 @@ export default function ScopeModule({
const mySettings = settings ?? storeSettings
const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind)
if (scopeKind === 'astra') {
return (
<AstraScopeModule
theme={myTheme as ResolvedAstraTheme}
settings={mySettings as ScopeSettings['astra']}
/>
)
}
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
+20 -8
View File
@@ -10,17 +10,24 @@ import type {
ScopePopoutSnapshot,
ScopePopoutSyncStateMap,
} from '../../types/popout'
import { SCOPE_KINDS, SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import {
AUDIO_SCOPE_KINDS,
SCOPE_KINDS,
SCOPE_LABELS,
isAudioScopeKind,
type AudioScopeKind,
type ScopeKind,
} from '../../types/scope'
import type { ScopeSettings } from '../../types/settings'
function buildConsumerDemand(kind: ScopeKind): Record<ScopeKind, boolean> {
return SCOPE_KINDS.reduce((acc, currentKind) => {
function buildConsumerDemand(kind: AudioScopeKind): Record<AudioScopeKind, boolean> {
return AUDIO_SCOPE_KINDS.reduce((acc, currentKind) => {
acc[currentKind] = currentKind === kind
return acc
}, {} as Record<ScopeKind, boolean>)
}, {} as Record<AudioScopeKind, boolean>)
}
function flushScopeAudioBatch(kind: ScopeKind, scopeSettings: ScopeSettings): ScopePopoutAudioBatch {
function flushScopeAudioBatch(kind: AudioScopeKind, scopeSettings: ScopeSettings): ScopePopoutAudioBatch {
switch (kind) {
case 'spectrum':
return scopeSettings.spectrum.showSideLine
@@ -111,6 +118,7 @@ export default function ScopePopoutBridge(): null {
useEffect(() => {
const sessionState = toPopoutSessionState(audioRouter.getSessionState())
for (const kind of activePopoutKinds) {
if (!isAudioScopeKind(kind)) continue
window.electronAPI.sendScopePopoutSession(kind, sessionState)
}
}, [activePopoutKinds])
@@ -138,7 +146,9 @@ export default function ScopePopoutBridge(): null {
scopeTheme: useThemeStore.getState().activeTheme[kind],
settings: useSettingsStore.getState().scopeSettings[kind],
})
window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState()))
if (isAudioScopeKind(kind)) {
window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState()))
}
})
return () => {
@@ -150,7 +160,7 @@ export default function ScopePopoutBridge(): null {
}, [popInScope, updatePopoutBounds, updateScopeSettings])
useEffect(() => {
for (const kind of SCOPE_KINDS) {
for (const kind of AUDIO_SCOPE_KINDS) {
const consumerId = `popout:${kind}`
if (activePopoutKinds.includes(kind)) {
audioRouter.setVisualizerConsumerDemand(consumerId, buildConsumerDemand(kind))
@@ -160,7 +170,7 @@ export default function ScopePopoutBridge(): null {
}
return () => {
for (const kind of SCOPE_KINDS) {
for (const kind of AUDIO_SCOPE_KINDS) {
audioRouter.clearVisualizerConsumerDemand(`popout:${kind}`)
}
}
@@ -175,6 +185,7 @@ export default function ScopePopoutBridge(): null {
}
for (const kind of activePopoutKindsRef.current) {
if (!isAudioScopeKind(kind)) continue
const batch = flushScopeAudioBatch(kind, useSettingsStore.getState().scopeSettings)
if (batch.length > 0) {
window.electronAPI.sendScopePopoutAudio(kind, batch)
@@ -216,6 +227,7 @@ export default function ScopePopoutBridge(): null {
return audioRouter.subscribeToSessionChanges((state) => {
const nextSessionState = toPopoutSessionState(state)
for (const kind of activePopoutKindsRef.current) {
if (!isAudioScopeKind(kind)) continue
window.electronAPI.sendScopePopoutSession(kind, nextSessionState)
}
})
@@ -19,6 +19,17 @@ function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): strin
}
}
function astraVisibleLabels(settings: ScopeSettings['astra']): string[] {
const labels: string[] = []
if (settings.showCoverArt) labels.push('Cover')
if (settings.showTitle) labels.push('Title')
if (settings.showArtist) labels.push('Artist')
if (settings.showProgress) labels.push('Bar')
if (settings.showTime) labels.push('Time')
if (settings.showControls) labels.push('Controls')
return labels
}
export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
switch (kind) {
case 'spectrum': {
@@ -58,6 +69,10 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
}
return summary.join(' · ')
}
case 'astra': {
const visible = astraVisibleLabels(settings as ScopeSettings['astra'])
return visible.length > 0 ? visible.join(' · ') : 'Hidden'
}
}
}
@@ -499,6 +514,44 @@ export default function ScopeSettingsSection({
</>
)
})()}
{kind === 'astra' && (() => {
const current = settings as ScopeSettings['astra']
return (
<ToggleGroup label="Visible Elements">
<ToggleChip
label="Cover"
active={current.showCoverArt}
onClick={() => onUpdate('astra', { showCoverArt: !current.showCoverArt })}
/>
<ToggleChip
label="Title"
active={current.showTitle}
onClick={() => onUpdate('astra', { showTitle: !current.showTitle })}
/>
<ToggleChip
label="Artist"
active={current.showArtist}
onClick={() => onUpdate('astra', { showArtist: !current.showArtist })}
/>
<ToggleChip
label="Bar"
active={current.showProgress}
onClick={() => onUpdate('astra', { showProgress: !current.showProgress })}
/>
<ToggleChip
label="Time"
active={current.showTime}
onClick={() => onUpdate('astra', { showTime: !current.showTime })}
/>
<ToggleChip
label="Controls"
active={current.showControls}
onClick={() => onUpdate('astra', { showControls: !current.showControls })}
/>
</ToggleGroup>
)
})()}
</div>
</section>
)
+9 -9
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import { SCOPE_LABELS, isAudioScopeKind, type ScopeKind } from '../../types/scope'
import type { WindowBounds } from '../../types/popout'
import ScopeModule from './ScopeModule'
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
@@ -68,15 +68,15 @@ export default function Strip(): JSX.Element {
}, [dockedScopes])
useEffect(() => {
const visibleScopeSet = new Set(dockedScopes)
const visibleAudioScopeSet = new Set(dockedScopes.filter(isAudioScopeKind))
audioRouter.setVisualizerConsumerDemand('docked-strip', {
spectrum: visibleScopeSet.has('spectrum'),
oscilloscope: visibleScopeSet.has('oscilloscope'),
vectorscope: visibleScopeSet.has('vectorscope'),
spectrogram: visibleScopeSet.has('spectrogram'),
vumeter: visibleScopeSet.has('vumeter'),
lufsmeter: visibleScopeSet.has('lufsmeter'),
waveform: visibleScopeSet.has('waveform'),
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 () => {