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
+1
View File
@@ -10,6 +10,7 @@
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"test:audio-router": "node scripts/run-audio-router-tests.mjs",
"test:astra": "node scripts/run-astra-integration-tests.mjs",
"test:profiles": "node scripts/run-profile-library-tests.mjs",
"test:themes": "node scripts/run-theme-library-tests.mjs",
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
+44
View File
@@ -0,0 +1,44 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { build } from 'esbuild'
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
const tempDir = await mkdtemp(join(tmpdir(), 'prism-astra-integration-tests-'))
const bundledTestPath = join(tempDir, 'astra-integration.test.mjs')
const entryPoint = join(rootDir, 'test', 'astra-integration.test.ts')
let exitCode = 1
try {
await build({
entryPoints: [entryPoint],
outfile: bundledTestPath,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node23',
sourcemap: 'inline',
})
exitCode = await new Promise((resolve) => {
const child = spawn(process.execPath, ['--test', bundledTestPath], {
stdio: 'inherit',
cwd: rootDir,
})
child.on('exit', (code) => {
resolve(code ?? 1)
})
child.on('error', () => {
resolve(1)
})
})
} finally {
await rm(tempDir, { recursive: true, force: true })
}
process.exit(exitCode)
+51 -1
View File
@@ -1,6 +1,11 @@
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, screen, session, shell } from 'electron'
import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron'
import { extname, join, resolve } from 'path'
import type {
AstraControlCommand,
AstraIntegrationConfig,
AstraIntegrationState,
} from '../types/astra'
import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
import type {
ScopePopoutAudioBatch,
@@ -20,6 +25,7 @@ import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
import { normalizeProfile } from '../shared/profileState'
import { calculateResizedWindowBounds } from '../shared/windowResize'
import { FileBackedProfileLibrary } from './profileLibrary'
import { AstraIntegrationService } from './services/astraIntegration'
import { FileBackedThemeLibrary } from './themeLibrary'
let mainWindow: BrowserWindow | null = null
@@ -48,6 +54,7 @@ const pendingThemeOpenPaths: string[] = []
let profileLibrary: FileBackedProfileLibrary | null = null
let themeLibrary: FileBackedThemeLibrary | null = null
let astraIntegrationService: AstraIntegrationService | null = null
const WINDOW_DEFAULTS = {
width: 900,
@@ -86,6 +93,26 @@ function getThemeLibrary(): FileBackedThemeLibrary {
return themeLibrary
}
function broadcastAstraState(state: AstraIntegrationState): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed() || window.webContents.isDestroyed()) continue
window.webContents.send('astra:state-changed', state)
}
}
function getAstraIntegrationService(): AstraIntegrationService {
if (!astraIntegrationService) {
astraIntegrationService = new AstraIntegrationService({
configPath: join(app.getPath('userData'), 'astra-integration.json'),
})
astraIntegrationService.subscribe((state) => {
broadcastAstraState(state)
})
}
return astraIntegrationService
}
function queueProfileOpenPath(filePath: string): void {
if (extname(filePath).toLowerCase() !== '.prsm') return
@@ -885,6 +912,27 @@ function setupIPC(): void {
return getCaptureBackendSupport()
})
ipcMain.handle('astra:get-config', async () => {
return getAstraIntegrationService().getConfig()
})
ipcMain.handle('astra:save-config', async (_event, rawConfig: AstraIntegrationConfig) => {
return getAstraIntegrationService().saveConfig(rawConfig)
})
ipcMain.handle('astra:get-state', async () => {
return getAstraIntegrationService().getState()
})
ipcMain.handle('astra:set-active', async (event, active: boolean) => {
return getAstraIntegrationService().setConsumerActive(event.sender.id, Boolean(active))
})
ipcMain.handle('astra:send-control', async (_event, command: AstraControlCommand) => {
await getAstraIntegrationService().sendControl(command)
return getAstraIntegrationService().getState()
})
ipcMain.handle('profiles:get-snapshot', async () => {
return getProfileLibrary().getSnapshot()
})
@@ -1171,7 +1219,7 @@ function setupIPC(): void {
function setupShortcuts(): void {
if (!mainWindow) return
const scopeKeys = ['1', '2', '3', '4', '5', '6', '7']
const scopeKeys = ['1', '2', '3', '4', '5', '6', '7', '8']
scopeKeys.forEach((key) => {
mainWindow!.webContents.on('before-input-event', (_event, input) => {
if (input.type === 'keyDown' && input.key === key && !input.alt && !input.control && !input.meta && !input.shift) {
@@ -1209,6 +1257,7 @@ if (!hasSingleInstanceLock) {
} else {
app.whenReady().then(() => {
setupPermissions()
void getAstraIntegrationService().initialize()
setupIPC()
createMainWindow()
setupShortcuts()
@@ -1240,5 +1289,6 @@ if (!hasSingleInstanceLock) {
}
app.on('window-all-closed', () => {
void astraIntegrationService?.dispose()
app.quit()
})
+676
View File
@@ -0,0 +1,676 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import {
DEFAULT_ASTRA_BASE_URL,
type AstraControlCommand,
type AstraIntegrationConfig,
type AstraIntegrationState,
type AstraNowPlayingSnapshot,
type AstraPlaybackState,
type AstraTrackSnapshot,
} from '../../types/astra'
const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000]
type FetchLike = typeof fetch
type TimerHandle = ReturnType<typeof setTimeout>
interface RemoteTrackSnapshot {
id: string
title: string
artist: string
album: string
isFavorite: boolean
artworkUrl: string | null
}
interface RemoteNowPlayingSnapshot {
playbackState: AstraPlaybackState
currentTime: number
duration: number
queueLength: number
outputDeviceLabel: string | null
visualizerLineColor: string
currentTrack: RemoteTrackSnapshot | null
updatedAt: number
}
interface AstraIntegrationServiceOptions {
configPath: string
fetchImpl?: FetchLike
now?: () => number
setTimeoutImpl?: typeof setTimeout
clearTimeoutImpl?: typeof clearTimeout
}
function cloneState(state: AstraIntegrationState): AstraIntegrationState {
return JSON.parse(JSON.stringify(state)) as AstraIntegrationState
}
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message
? error.message
: fallback
}
function isPlaybackState(value: unknown): value is AstraPlaybackState {
return value === 'stopped' || value === 'playing' || value === 'paused' || value === 'loading'
}
function toSafeNumber(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0
return Math.max(0, value)
}
function toSafeString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
function toOptionalString(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim()
return normalized.length > 0 ? normalized : null
}
function bufferToDataUrl(bytes: Buffer, mimeType: string): string {
return `data:${mimeType};base64,${bytes.toString('base64')}`
}
function normalizeBaseUrl(value: unknown): string {
if (typeof value !== 'string') return DEFAULT_ASTRA_BASE_URL
const trimmed = value.trim()
if (!trimmed) return DEFAULT_ASTRA_BASE_URL
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}
export function normalizeAstraIntegrationConfig(raw: unknown): AstraIntegrationConfig {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<AstraIntegrationConfig>
: {}
return {
baseUrl: normalizeBaseUrl(parsed.baseUrl),
token: typeof parsed.token === 'string' ? parsed.token.trim() : '',
}
}
function createDefaultState(): AstraIntegrationState {
return {
config: normalizeAstraIntegrationConfig(null),
connectionState: 'disabled',
lastError: null,
lastControlError: null,
snapshot: null,
}
}
function normalizeRemoteTrack(raw: unknown): RemoteTrackSnapshot | null {
if (typeof raw !== 'object' || raw === null) return null
const parsed = raw as Partial<RemoteTrackSnapshot>
const id = toOptionalString(parsed.id)
const title = toSafeString(parsed.title).trim()
const artist = toSafeString(parsed.artist).trim()
if (!id || !title || !artist) {
return null
}
return {
id,
title,
artist,
album: toSafeString(parsed.album).trim(),
isFavorite: Boolean(parsed.isFavorite),
artworkUrl: toOptionalString(parsed.artworkUrl),
}
}
function normalizeRemoteSnapshot(raw: unknown, now: () => number): RemoteNowPlayingSnapshot {
if (typeof raw !== 'object' || raw === null) {
return {
playbackState: 'stopped',
currentTime: 0,
duration: 0,
queueLength: 0,
outputDeviceLabel: null,
visualizerLineColor: '#38bdf8',
currentTrack: null,
updatedAt: now(),
}
}
const parsed = raw as Partial<RemoteNowPlayingSnapshot>
return {
playbackState: isPlaybackState(parsed.playbackState) ? parsed.playbackState : 'stopped',
currentTime: toSafeNumber(parsed.currentTime),
duration: toSafeNumber(parsed.duration),
queueLength: Math.max(0, Math.floor(toSafeNumber(parsed.queueLength))),
outputDeviceLabel: toOptionalString(parsed.outputDeviceLabel),
visualizerLineColor: toOptionalString(parsed.visualizerLineColor) ?? '#38bdf8',
currentTrack: normalizeRemoteTrack(parsed.currentTrack),
updatedAt: typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt)
? parsed.updatedAt
: now(),
}
}
function toRendererSnapshot(
snapshot: RemoteNowPlayingSnapshot,
artworkDataUrl: string | null,
): AstraNowPlayingSnapshot {
const currentTrack = snapshot.currentTrack
return {
playbackState: snapshot.playbackState,
currentTime: snapshot.currentTime,
duration: snapshot.duration,
queueLength: snapshot.queueLength,
outputDeviceLabel: snapshot.outputDeviceLabel,
visualizerLineColor: snapshot.visualizerLineColor,
currentTrack: currentTrack
? {
id: currentTrack.id,
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
isFavorite: currentTrack.isFavorite,
artworkDataUrl,
} satisfies AstraTrackSnapshot
: null,
updatedAt: snapshot.updatedAt,
}
}
async function readResponseError(response: Response): Promise<string> {
try {
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
const parsed = await response.json() as { error?: unknown }
const errorMessage = toOptionalString(parsed.error)
if (errorMessage) return errorMessage
}
const text = (await response.text()).trim()
if (text) return text
} catch {
// Ignore parse failures.
}
return `Request failed with status ${response.status}.`
}
function appendBasePath(baseUrl: string, path: string): string {
const normalizedBase = baseUrl.endsWith('/') ? `${baseUrl}` : `${baseUrl}/`
return new URL(path.replace(/^\//, ''), normalizedBase).toString()
}
export class AstraIntegrationService {
private readonly configPath: string
private readonly fetchImpl: FetchLike
private readonly now: () => number
private readonly setTimeoutImpl: typeof setTimeout
private readonly clearTimeoutImpl: typeof clearTimeout
private readonly listeners = new Set<(state: AstraIntegrationState) => void>()
private readonly activeConsumers = new Set<number>()
private state = createDefaultState()
private remoteSnapshot: RemoteNowPlayingSnapshot | null = null
private currentArtworkTrackId: string | null = null
private currentArtworkDataUrl: string | null = null
private streamAbortController: AbortController | null = null
private reconnectTimer: TimerHandle | null = null
private reconnectAttempt = 0
private initialized = false
private disposed = false
constructor(options: AstraIntegrationServiceOptions) {
this.configPath = options.configPath
this.fetchImpl = options.fetchImpl ?? fetch
this.now = options.now ?? (() => Date.now())
this.setTimeoutImpl = options.setTimeoutImpl ?? setTimeout
this.clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout
}
async initialize(): Promise<void> {
const config = await this.loadConfigFile()
this.initialized = true
this.state = {
...this.state,
config,
connectionState: 'disabled',
}
this.emitState()
if (this.isScopeActive()) {
await this.restartConnection()
}
}
async dispose(): Promise<void> {
this.disposed = true
this.cancelReconnect()
this.abortStream()
}
subscribe(listener: (state: AstraIntegrationState) => void): () => void {
this.listeners.add(listener)
listener(this.getState())
return () => {
this.listeners.delete(listener)
}
}
getState(): AstraIntegrationState {
return cloneState(this.state)
}
getConfig(): AstraIntegrationConfig {
return { ...this.state.config }
}
async setConsumerActive(consumerId: number, active: boolean): Promise<AstraIntegrationState> {
const wasActive = this.isScopeActive()
if (active) {
this.activeConsumers.add(consumerId)
} else {
this.activeConsumers.delete(consumerId)
}
if (!this.initialized) {
return this.getState()
}
if (wasActive !== this.isScopeActive()) {
await this.restartConnection()
}
return this.getState()
}
async saveConfig(rawConfig: unknown): Promise<AstraIntegrationConfig> {
const config = normalizeAstraIntegrationConfig(rawConfig)
this.state = {
...this.state,
config,
connectionState: this.isScopeActive() ? 'connecting' : 'disabled',
lastError: null,
lastControlError: null,
}
this.emitState()
await this.persistConfigFile(config)
await this.restartConnection()
return { ...config }
}
async sendControl(command: AstraControlCommand): Promise<void> {
if (!this.isScopeActive()) {
const errorMessage = 'The Astra scope is not open.'
this.state = {
...this.state,
lastControlError: errorMessage,
}
this.emitState()
throw new Error(errorMessage)
}
if (!this.state.config.token) {
const errorMessage = 'An Astra API token is required before sending controls.'
this.state = {
...this.state,
lastControlError: errorMessage,
}
this.emitState()
throw new Error(errorMessage)
}
const response = await this.fetchImpl(this.buildEndpoint('/v1/control'), {
method: 'POST',
headers: this.buildAuthHeaders({
'Content-Type': 'application/json; charset=utf-8',
}),
body: JSON.stringify({ command }),
}).catch((error: unknown) => {
throw new Error(getErrorMessage(error, 'Prism could not reach Astra.'))
})
if (!response.ok) {
const errorMessage = await readResponseError(response)
this.state = {
...this.state,
lastControlError: errorMessage,
}
this.emitState()
throw new Error(errorMessage)
}
this.state = {
...this.state,
lastControlError: null,
}
this.emitState()
}
private emitState(): void {
const snapshot = this.getState()
for (const listener of this.listeners) {
listener(snapshot)
}
}
private async restartConnection(): Promise<void> {
this.cancelReconnect()
this.abortStream()
if (!this.isScopeActive() || this.disposed) {
this.remoteSnapshot = null
this.currentArtworkTrackId = null
this.currentArtworkDataUrl = null
this.state = {
...this.state,
connectionState: 'disabled',
lastError: null,
lastControlError: null,
snapshot: null,
}
this.emitState()
return
}
if (!this.state.config.token) {
this.state = {
...this.state,
connectionState: 'error',
lastError: 'An Astra API token is required.',
}
this.emitState()
return
}
this.state = {
...this.state,
connectionState: 'connecting',
lastError: null,
}
this.emitState()
try {
await this.fetchNowPlaying()
} catch (error) {
this.state = {
...this.state,
connectionState: 'error',
lastError: getErrorMessage(error, 'Prism could not read Astra now-playing state.'),
}
this.emitState()
}
if (!this.isScopeActive() || this.disposed) {
return
}
void this.openEventStream()
}
private scheduleReconnect(message: string): void {
if (!this.isScopeActive() || this.disposed) {
return
}
this.state = {
...this.state,
connectionState: 'error',
lastError: message,
}
this.emitState()
const delay = RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? 10000
this.reconnectAttempt += 1
this.cancelReconnect()
this.reconnectTimer = this.setTimeoutImpl(() => {
this.reconnectTimer = null
void this.restartConnection()
}, delay)
}
private cancelReconnect(): void {
if (this.reconnectTimer === null) return
this.clearTimeoutImpl(this.reconnectTimer)
this.reconnectTimer = null
}
private abortStream(): void {
if (this.streamAbortController) {
this.streamAbortController.abort()
this.streamAbortController = null
}
}
private async fetchNowPlaying(): Promise<void> {
const response = await this.fetchImpl(this.buildEndpoint('/v1/now-playing'), {
headers: this.buildAuthHeaders(),
}).catch((error: unknown) => {
throw new Error(getErrorMessage(error, 'Prism could not reach Astra.'))
})
if (!response.ok) {
throw new Error(await readResponseError(response))
}
const payload = await response.json().catch(() => null)
const snapshot = normalizeRemoteSnapshot(payload, this.now)
this.applyRemoteSnapshot(snapshot)
await this.refreshArtwork(snapshot.currentTrack?.id ?? null, snapshot.currentTrack?.artworkUrl ?? null)
}
private async openEventStream(): Promise<void> {
this.abortStream()
const controller = new AbortController()
this.streamAbortController = controller
try {
const response = await this.fetchImpl(this.buildEndpoint('/v1/events'), {
headers: this.buildAuthHeaders({
Accept: 'text/event-stream',
}),
signal: controller.signal,
})
if (!response.ok) {
throw new Error(await readResponseError(response))
}
if (!response.body) {
throw new Error('Astra did not return an event stream.')
}
this.reconnectAttempt = 0
this.state = {
...this.state,
connectionState: 'connected',
lastError: null,
}
this.emitState()
await this.readEventStream(response.body, controller.signal)
if (!controller.signal.aborted && this.isScopeActive() && !this.disposed) {
this.scheduleReconnect('The Astra event stream closed unexpectedly.')
}
} catch (error) {
if (controller.signal.aborted || this.disposed) {
return
}
this.scheduleReconnect(getErrorMessage(error, 'Prism could not connect to the Astra event stream.'))
}
}
private async readEventStream(
stream: ReadableStream<Uint8Array>,
signal: AbortSignal,
): Promise<void> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let eventName = 'message'
let dataLines: string[] = []
const flushEvent = async (): Promise<void> => {
if (dataLines.length === 0) {
eventName = 'message'
return
}
const payload = dataLines.join('\n')
const nextEvent = eventName
eventName = 'message'
dataLines = []
if (nextEvent === 'now-playing') {
let parsed: unknown = null
try {
parsed = JSON.parse(payload)
} catch {
return
}
const snapshot = normalizeRemoteSnapshot(parsed, this.now)
this.applyRemoteSnapshot(snapshot)
await this.refreshArtwork(snapshot.currentTrack?.id ?? null, snapshot.currentTrack?.artworkUrl ?? null)
}
}
while (!signal.aborted) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, '')
buffer = buffer.slice(newlineIndex + 1)
if (line === '') {
await flushEvent()
} else if (line.startsWith(':')) {
// Ignore SSE comments.
} else if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || 'message'
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
newlineIndex = buffer.indexOf('\n')
}
}
if (buffer.trim().length > 0) {
const line = buffer.replace(/\r$/, '')
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
}
await flushEvent()
await reader.cancel().catch(() => undefined)
}
private applyRemoteSnapshot(snapshot: RemoteNowPlayingSnapshot): void {
this.remoteSnapshot = snapshot
const artworkDataUrl = snapshot.currentTrack?.id === this.currentArtworkTrackId
? this.currentArtworkDataUrl
: null
this.state = {
...this.state,
snapshot: toRendererSnapshot(snapshot, artworkDataUrl),
}
this.emitState()
}
private async refreshArtwork(trackId: string | null, artworkUrl: string | null): Promise<void> {
if (!trackId || !artworkUrl || !this.remoteSnapshot?.currentTrack || this.remoteSnapshot.currentTrack.id !== trackId) {
this.currentArtworkTrackId = trackId
this.currentArtworkDataUrl = null
if (this.remoteSnapshot) {
this.state = {
...this.state,
snapshot: toRendererSnapshot(this.remoteSnapshot, null),
}
this.emitState()
}
return
}
if (this.currentArtworkTrackId === trackId && this.currentArtworkDataUrl) {
this.state = {
...this.state,
snapshot: toRendererSnapshot(this.remoteSnapshot, this.currentArtworkDataUrl),
}
this.emitState()
return
}
const response = await this.fetchImpl(artworkUrl, {
headers: this.buildAuthHeaders(),
}).catch(() => null)
if (!response || !response.ok) {
this.currentArtworkTrackId = trackId
this.currentArtworkDataUrl = null
if (this.remoteSnapshot?.currentTrack?.id === trackId) {
this.state = {
...this.state,
snapshot: toRendererSnapshot(this.remoteSnapshot, null),
}
this.emitState()
}
return
}
const mimeType = response.headers.get('content-type') ?? 'image/png'
const bytes = Buffer.from(await response.arrayBuffer())
const artworkDataUrl = bufferToDataUrl(bytes, mimeType)
if (this.remoteSnapshot?.currentTrack?.id !== trackId) {
return
}
this.currentArtworkTrackId = trackId
this.currentArtworkDataUrl = artworkDataUrl
this.state = {
...this.state,
snapshot: toRendererSnapshot(this.remoteSnapshot, artworkDataUrl),
}
this.emitState()
}
private buildAuthHeaders(extraHeaders?: Record<string, string>): HeadersInit {
return {
Authorization: `Bearer ${this.state.config.token}`,
...extraHeaders,
}
}
private isScopeActive(): boolean {
return this.activeConsumers.size > 0
}
private buildEndpoint(path: string): string {
return appendBasePath(this.state.config.baseUrl, path)
}
private async loadConfigFile(): Promise<AstraIntegrationConfig> {
try {
const raw = await readFile(this.configPath, 'utf8')
return normalizeAstraIntegrationConfig(JSON.parse(raw))
} catch {
return normalizeAstraIntegrationConfig(null)
}
}
private async persistConfigFile(config: AstraIntegrationConfig): Promise<void> {
await mkdir(dirname(this.configPath), { recursive: true })
await writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf8')
}
}
+15
View File
@@ -1,4 +1,9 @@
import { contextBridge, ipcRenderer } from 'electron'
import type {
AstraControlCommand,
AstraIntegrationConfig,
AstraIntegrationState,
} from '../types/astra'
import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type {
@@ -48,6 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
nativeBackend: resolveNativeCaptureSupport(support.nativeBackend),
} satisfies CaptureBackendSupport
},
getAstraConfig: () => ipcRenderer.invoke('astra:get-config') as Promise<AstraIntegrationConfig>,
saveAstraConfig: (config: AstraIntegrationConfig) => ipcRenderer.invoke('astra:save-config', config) as Promise<AstraIntegrationConfig>,
getAstraState: () => ipcRenderer.invoke('astra:get-state') as Promise<AstraIntegrationState>,
setAstraActive: (active: boolean) => ipcRenderer.invoke('astra:set-active', active) as Promise<AstraIntegrationState>,
sendAstraControl: (command: AstraControlCommand) => ipcRenderer.invoke('astra:send-control', command) as Promise<AstraIntegrationState>,
getProfileSnapshot: () => ipcRenderer.invoke('profiles:get-snapshot') as Promise<ProfileLibrarySnapshot>,
saveNewProfile: (name: string, profile: Profile) => ipcRenderer.invoke('profiles:save-new', name, profile) as Promise<ProfileLibrarySnapshot>,
overwriteProfile: (id: string, profile: Profile) => ipcRenderer.invoke('profiles:overwrite', id, profile) as Promise<ProfileLibrarySnapshot>,
@@ -107,6 +117,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('window:bounds-changed', handler)
return () => ipcRenderer.removeListener('window:bounds-changed', handler)
},
onAstraStateChanged: (callback: (state: AstraIntegrationState) => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: AstraIntegrationState): void => callback(state)
ipcRenderer.on('astra:state-changed', handler)
return () => ipcRenderer.removeListener('astra:state-changed', handler)
},
onMainCloseRequested: (callback: () => void) => {
const handler = (): void => callback()
ipcRenderer.on('window:close-requested', handler)
+4
View File
@@ -6,6 +6,7 @@ import BottomBar from './components/BottomBar'
import ScopePopoutBridge from './components/ScopePopoutBridge'
import WindowResizeOverlay from './components/WindowResizeOverlay'
import { useSettingsStore } from './stores/settingsStore'
import { useAstraStore } from './stores/astraStore'
import { useAudioStore } from './stores/audioStore'
import { useThemeStore } from './stores/themeStore'
import { SCOPE_KINDS } from '../types/scope'
@@ -28,6 +29,7 @@ export default function App(): JSX.Element {
const updateMainWindowBounds = useSettingsStore((s) => s.updateMainWindowBounds)
const initializeThemes = useThemeStore((s) => s.initializeThemes)
const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot)
const initializeAstra = useAstraStore((s) => s.initialize)
// Auto-capture on launch
useEffect(() => {
@@ -43,6 +45,7 @@ export default function App(): JSX.Element {
void (async () => {
await initializeThemes()
await initializeProfiles()
await initializeAstra()
if (!isDisposed) {
window.electronAPI.notifyRendererReady()
}
@@ -95,6 +98,7 @@ export default function App(): JSX.Element {
importProfileFromPath,
initializeProfiles,
initializeThemes,
initializeAstra,
updateMainWindowBounds,
])
+12 -12
View File
@@ -4,7 +4,7 @@
* do not accumulate backlog and queue overflow never reallocates.
*/
import { SCOPE_KINDS, type ScopeKind } from '../../types/scope'
import { AUDIO_SCOPE_KINDS, type AudioScopeKind } from '../../types/scope'
import type { CaptureBackendKind } from '../../types/capture'
const MAX_PENDING_CHUNKS = 20
@@ -12,7 +12,7 @@ const MAX_PENDING_SPECTRUM_CHUNKS = 96
const MAX_PENDING_VECTORSCOPE_CHUNKS = 20
const LATENCY_SAMPLE_WINDOW = 240
const SCOPE_RING_CAPACITY: Record<ScopeKind, number> = {
const SCOPE_RING_CAPACITY: Record<AudioScopeKind, number> = {
spectrum: MAX_PENDING_SPECTRUM_CHUNKS,
oscilloscope: MAX_PENDING_CHUNKS,
vectorscope: MAX_PENDING_VECTORSCOPE_CHUNKS,
@@ -57,7 +57,7 @@ export interface AudioRouterDiagnostics {
notCapturingDrops: number
staleSessionDrops: number
undemandedChunks: number
scopes: Record<ScopeKind, AudioRouterScopeDiagnostics>
scopes: Record<AudioScopeKind, AudioRouterScopeDiagnostics>
}
export type NormalizedVisualizerConsumerDemand = Required<VisualizerConsumerDemand>
@@ -221,7 +221,7 @@ export class AudioRouter {
waveform: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.waveform),
}
private readonly scopeLatency: Record<ScopeKind, ScopeLatencyTracker> = {
private readonly scopeLatency: Record<AudioScopeKind, ScopeLatencyTracker> = {
spectrum: createScopeLatencyTracker(),
oscilloscope: createScopeLatencyTracker(),
vectorscope: createScopeLatencyTracker(),
@@ -480,7 +480,7 @@ export class AudioRouter {
let totalOverwriteCount = 0
let overallP95CaptureToScopeMs: number | null = null
const scopes = SCOPE_KINDS.reduce<Record<ScopeKind, AudioRouterScopeDiagnostics>>((result, scope) => {
const scopes = AUDIO_SCOPE_KINDS.reduce<Record<AudioScopeKind, AudioRouterScopeDiagnostics>>((result, scope) => {
const scopeTracker = this.scopeLatency[scope]
const overwriteCount = this.getRing(scope).getOverwriteCount()
totalOverwriteCount += overwriteCount
@@ -501,7 +501,7 @@ export class AudioRouter {
lastSequence: scopeTracker.lastSequence,
}
return result
}, {} as Record<ScopeKind, AudioRouterScopeDiagnostics>)
}, {} as Record<AudioScopeKind, AudioRouterScopeDiagnostics>)
return {
updatedAt: performance.now(),
@@ -523,7 +523,7 @@ export class AudioRouter {
private getActiveDemand(): NormalizedVisualizerConsumerDemand {
const aggregated = createEmptyDemand()
for (const demand of this.consumerDemand.values()) {
for (const scope of SCOPE_KINDS) {
for (const scope of AUDIO_SCOPE_KINDS) {
if (demand[scope]) {
aggregated[scope] = true
}
@@ -541,7 +541,7 @@ export class AudioRouter {
private pruneQueuesForDemand(): void {
const activeDemand = this.getActiveDemand()
for (const scope of SCOPE_KINDS) {
for (const scope of AUDIO_SCOPE_KINDS) {
if (!activeDemand[scope]) {
this.getRing(scope).clear()
}
@@ -549,13 +549,13 @@ export class AudioRouter {
}
private clearAllRings(): void {
for (const scope of SCOPE_KINDS) {
for (const scope of AUDIO_SCOPE_KINDS) {
this.getRing(scope).clear()
}
}
private resetLatencyTrackers(): void {
for (const scope of SCOPE_KINDS) {
for (const scope of AUDIO_SCOPE_KINDS) {
const tracker = this.scopeLatency[scope]
tracker.lastCaptureToScopeMs = null
tracker.drainedChunks = 0
@@ -564,7 +564,7 @@ export class AudioRouter {
}
}
private recordScopeDrain(scope: ScopeKind, records: Array<MonoChunkRecord | StereoChunkRecord>): void {
private recordScopeDrain(scope: AudioScopeKind, records: Array<MonoChunkRecord | StereoChunkRecord>): void {
if (records.length === 0) return
const tracker = this.scopeLatency[scope]
@@ -578,7 +578,7 @@ export class AudioRouter {
tracker.drainedChunks += records.length
}
private getRing(scope: ScopeKind): FixedChunkRing<MonoChunkRecord> | FixedChunkRing<StereoChunkRecord> {
private getRing(scope: AudioScopeKind): FixedChunkRing<MonoChunkRecord> | FixedChunkRing<StereoChunkRecord> {
return this.rings[scope]
}
}
@@ -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 () => {
+11
View File
@@ -1,5 +1,10 @@
/// <reference types="vite/client" />
import type {
AstraControlCommand,
AstraIntegrationConfig,
AstraIntegrationState,
} from '../types/astra'
import type { VisualizerDSP } from './audio/native/visualizer-dsp'
import type { CaptureBackendSupport } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
@@ -44,6 +49,11 @@ declare global {
isAlwaysOnTop: () => Promise<boolean>
getDesktopSources: () => Promise<{ id: string; name: string }[]>
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
getAstraConfig: () => Promise<AstraIntegrationConfig>
saveAstraConfig: (config: AstraIntegrationConfig) => Promise<AstraIntegrationConfig>
getAstraState: () => Promise<AstraIntegrationState>
setAstraActive: (active: boolean) => Promise<AstraIntegrationState>
sendAstraControl: (command: AstraControlCommand) => Promise<AstraIntegrationState>
getProfileSnapshot: () => Promise<ProfileLibrarySnapshot>
saveNewProfile: (name: string, profile: Profile) => Promise<ProfileLibrarySnapshot>
overwriteProfile: (id: string, profile: Profile) => Promise<ProfileLibrarySnapshot>
@@ -81,6 +91,7 @@ declare global {
onToggleCapture: (callback: () => void) => () => void
onToggleSettings: (callback: () => void) => () => void
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => () => void
onAstraStateChanged: (callback: (state: AstraIntegrationState) => void) => () => void
onMainCloseRequested: (callback: () => void) => () => void
onProfileMenuClosed: (callback: () => void) => () => void
onProfileMenuLoad: (callback: (id: string) => void) => () => void
+116
View File
@@ -0,0 +1,116 @@
import { create } from 'zustand'
import {
DEFAULT_ASTRA_BASE_URL,
type AstraControlCommand,
type AstraIntegrationConfig,
type AstraIntegrationState,
} from '../../types/astra'
interface AstraStoreState {
initialized: boolean
integrationState: AstraIntegrationState
isSendingControl: boolean
initialize: () => Promise<void>
saveConfig: (config: AstraIntegrationConfig) => Promise<void>
setScopeActive: (active: boolean) => Promise<void>
sendControl: (command: AstraControlCommand) => Promise<void>
applyExternalState: (state: AstraIntegrationState) => void
}
function createDefaultIntegrationState(): AstraIntegrationState {
return {
config: {
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: '',
},
connectionState: 'disabled',
lastError: null,
lastControlError: null,
snapshot: null,
}
}
let initializePromise: Promise<void> | null = null
let syncBound = false
export const useAstraStore = create<AstraStoreState>((set) => ({
initialized: false,
integrationState: createDefaultIntegrationState(),
isSendingControl: false,
initialize: async () => {
if (initializePromise) {
await initializePromise
return
}
initializePromise = (async () => {
if (typeof window === 'undefined' || typeof window.electronAPI === 'undefined') {
set({ initialized: true })
return
}
const state = await window.electronAPI.getAstraState()
set({
initialized: true,
integrationState: state,
})
})()
try {
await initializePromise
} finally {
initializePromise = null
}
},
saveConfig: async (config) => {
const savedConfig = await window.electronAPI.saveAstraConfig(config)
const nextState = await window.electronAPI.getAstraState()
set({
integrationState: {
...nextState,
config: savedConfig,
},
})
},
setScopeActive: async (active) => {
const nextState = await window.electronAPI.setAstraActive(active)
set({
integrationState: nextState,
})
},
sendControl: async (command) => {
set({ isSendingControl: true })
try {
const nextState = await window.electronAPI.sendAstraControl(command)
set({
integrationState: nextState,
})
} finally {
set({ isSendingControl: false })
}
},
applyExternalState: (state) => {
set({
initialized: true,
integrationState: state,
})
},
}))
function bindAstraStateSync(): void {
if (syncBound || typeof window === 'undefined' || typeof window.electronAPI === 'undefined') {
return
}
syncBound = true
window.electronAPI.onAstraStateChanged((state) => {
useAstraStore.getState().applyExternalState(state)
})
}
bindAstraStateSync()
+8
View File
@@ -510,6 +510,14 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
toggleScope: (kind: ScopeKind) => {
set((state) => {
const next = new Set(state.hiddenScopes)
if (!state.scopeOrder.includes(kind)) {
next.delete(kind)
return commitWorkingState(state, {
scopeOrder: [...state.scopeOrder, kind],
hiddenScopes: next,
})
}
if (next.has(kind)) {
next.delete(kind)
} else {
+300
View File
@@ -571,6 +571,264 @@ select {
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.12), transparent);
}
.astra-scope {
--astra-cover-size: clamp(56px, min(34cqi, 72cqb), 220px);
--astra-card-gap: clamp(8px, min(3cqi, 3cqb), 14px);
display: flex;
width: 100%;
height: 100%;
min-height: 0;
overflow: auto;
background: var(--astra-bg);
color: var(--astra-text);
container-type: size;
scrollbar-width: none;
}
.astra-scope::-webkit-scrollbar {
display: none;
}
.astra-scope--empty {
align-items: center;
justify-content: center;
padding: 12px;
}
.astra-scope__card {
flex: 1;
display: grid;
grid-template-columns: var(--astra-cover-size) minmax(0, 1fr);
align-items: center;
gap: var(--astra-card-gap);
width: 100%;
height: 100%;
padding: clamp(10px, 3cqi, 12px);
}
.astra-scope__card--no-cover {
grid-template-columns: minmax(0, 1fr);
}
.astra-scope__cover-shell {
display: flex;
align-items: center;
justify-content: flex-start;
align-self: center;
justify-self: start;
width: var(--astra-cover-size);
min-width: 0;
}
.astra-scope__cover {
width: 100%;
aspect-ratio: 1;
border-radius: 12px;
border: 1px solid var(--astra-border);
object-fit: cover;
background: var(--astra-surface);
}
.astra-scope__cover--fallback {
display: flex;
align-items: center;
justify-content: center;
color: var(--astra-accent);
font-family: 'JetBrains Mono', monospace;
font-size: clamp(20px, 4vw, 38px);
letter-spacing: 0.14em;
text-transform: uppercase;
background: var(--astra-surface);
}
.astra-scope__body {
width: 100%;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(8px, min(2.4cqi, 2.4cqb), 10px);
}
.astra-scope__meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.astra-scope__title {
min-width: 0;
color: var(--astra-text);
font-size: clamp(14px, 2vw, 20px);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.astra-scope__artist {
min-width: 0;
color: var(--astra-subtext);
font-size: 12px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.astra-scope__transport {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.astra-scope__progress {
width: 100%;
height: 7px;
overflow: hidden;
border-radius: 999px;
background: var(--astra-progress-track);
}
.astra-scope__progress-fill {
height: 100%;
border-radius: inherit;
background: var(--astra-progress-fill);
}
.astra-scope__time {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--astra-subtext);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.08em;
}
.astra-scope__controls {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: nowrap;
}
.astra-scope__control {
flex: 0 0 auto;
min-width: 36px;
height: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid var(--control-border);
background: var(--control-bg);
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.astra-scope__control--primary {
flex: 1 1 72px;
min-width: 72px;
}
.astra-scope__control:hover:not(:disabled),
.astra-scope__control:focus-visible {
background: var(--control-bg-hover);
border-color: var(--control-border-active);
color: var(--text-primary);
outline: none;
}
.astra-scope__control:active:not(:disabled) {
background: var(--control-bg-active);
}
.astra-scope__control:disabled {
opacity: 0.45;
cursor: default;
}
.astra-scope__status,
.astra-scope__placeholder {
display: inline-flex;
align-items: center;
align-self: flex-start;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--astra-border);
color: var(--astra-subtext);
background: var(--astra-surface);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.astra-scope__status.is-ok {
color: var(--astra-status-ok);
}
.astra-scope__status.is-error {
color: var(--astra-status-error);
border-color: var(--astra-status-error);
}
@container (max-width: 420px) {
.astra-scope {
--astra-cover-size: clamp(44px, min(28cqi, 60cqb), 132px);
--astra-card-gap: 8px;
}
.astra-scope__title {
font-size: 14px;
}
.astra-scope__artist,
.astra-scope__time,
.astra-scope__control {
font-size: 11px;
}
.astra-scope__control {
padding: 0 10px;
}
}
@container (max-width: 260px) {
.astra-scope {
--astra-cover-size: 40px;
--astra-card-gap: 8px;
}
.astra-scope__controls {
flex-wrap: wrap;
}
}
@container (max-height: 180px) {
.astra-scope {
--astra-cover-size: clamp(40px, min(22cqi, 46cqb), 88px);
--astra-card-gap: 8px;
}
.astra-scope__card {
padding: 8px;
}
.astra-scope__control {
height: 30px;
}
}
.settings-panel {
background: linear-gradient(180deg, rgba(7, 10, 15, 0.95), rgba(4, 6, 10, 0.98));
display: flex;
@@ -954,6 +1212,16 @@ select {
background: linear-gradient(180deg, rgba(34, 197, 94, 0.14), rgba(7, 10, 15, 0.96));
}
.settings-status-pill.is-connected .settings-status-pill__dot {
background: var(--success);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.34);
}
.settings-status-pill.is-connected {
border-color: rgba(34, 197, 94, 0.28);
background: linear-gradient(180deg, rgba(34, 197, 94, 0.12), rgba(7, 10, 15, 0.96));
}
.settings-status-pill.is-error .settings-status-pill__dot {
background: var(--danger);
box-shadow: 0 0 8px rgba(248, 113, 113, 0.36);
@@ -1089,6 +1357,10 @@ select {
min-width: 228px;
}
.bottom-bar__section--astra {
min-width: 520px;
}
.bottom-bar__section--source {
min-width: 360px;
}
@@ -1175,6 +1447,34 @@ select {
max-width: 280px;
}
.bottom-bar__text-input {
height: 32px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid var(--input-border);
background: var(--input-bg);
color: var(--text-primary);
font-size: 12px;
outline: none;
}
.bottom-bar__text-input::placeholder {
color: var(--text-muted);
}
.bottom-bar__text-input:focus {
border-color: var(--input-border-focus);
background: var(--input-bg-focus);
}
.bottom-bar__text-input--url {
width: 190px;
}
.bottom-bar__text-input--token {
width: 180px;
}
.bottom-bar__fps-pill {
min-width: 84px;
justify-content: center;
+51
View File
@@ -0,0 +1,51 @@
import type { AstraNowPlayingSnapshot } from '../../types/astra'
export interface AstraPlaybackProgress {
currentTime: number
duration: number
progress: number
}
export function getAstraPlaybackProgress(
snapshot: AstraNowPlayingSnapshot | null,
nowMs = Date.now(),
): AstraPlaybackProgress {
if (!snapshot) {
return {
currentTime: 0,
duration: 0,
progress: 0,
}
}
const duration = Math.max(0, snapshot.duration)
const baseCurrentTime = Math.max(0, snapshot.currentTime)
const elapsedSeconds = snapshot.playbackState === 'playing'
? Math.max(0, (nowMs - snapshot.updatedAt) / 1000)
: 0
const currentTime = duration > 0
? Math.min(duration, baseCurrentTime + elapsedSeconds)
: baseCurrentTime + elapsedSeconds
const progress = duration > 0
? Math.max(0, Math.min(1, currentTime / duration))
: 0
return {
currentTime,
duration,
progress,
}
}
export function formatAstraTime(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(totalSeconds))
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainder = seconds % 60
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}`
}
return `${minutes}:${remainder.toString().padStart(2, '0')}`
}
+7 -4
View File
@@ -12,10 +12,11 @@ import {
type PrismProfileFileV2,
type PrismProfileLocalStateV1,
} from '../types/profile'
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, type ScopeKind } from '../types/scope'
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS]
export const DEFAULT_SCOPE_WIDTH_WEIGHTS: Record<ScopeKind, number> = {
spectrum: 1,
@@ -25,6 +26,7 @@ export const DEFAULT_SCOPE_WIDTH_WEIGHTS: Record<ScopeKind, number> = {
vumeter: 0.5,
lufsmeter: 0.5,
waveform: 1,
astra: 1,
}
export function isScopeKind(value: unknown): value is ScopeKind {
@@ -68,7 +70,7 @@ export function normalizeWindowBounds(
}
export function normalizeScopeOrder(raw: unknown): ScopeKind[] {
if (!Array.isArray(raw)) return [...SCOPE_KINDS]
if (!Array.isArray(raw)) return [...DEFAULT_SCOPE_ORDER]
const valid = raw.filter(isScopeKind)
const seen = new Set<ScopeKind>()
@@ -80,7 +82,7 @@ export function normalizeScopeOrder(raw: unknown): ScopeKind[] {
normalized.push(kind)
}
for (const kind of SCOPE_KINDS) {
for (const kind of DEFAULT_SCOPE_ORDER) {
if (!seen.has(kind)) {
normalized.push(kind)
}
@@ -124,6 +126,7 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings {
vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, ...(parsed.vumeter ?? {}) },
lufsmeter: { ...DEFAULT_SCOPE_SETTINGS.lufsmeter, ...(parsed.lufsmeter ?? {}) },
waveform: { ...DEFAULT_SCOPE_SETTINGS.waveform, ...(parsed.waveform ?? {}) },
astra: { ...DEFAULT_SCOPE_SETTINGS.astra, ...(parsed.astra ?? {}) },
}
}
@@ -154,7 +157,7 @@ export function createDefaultProfile(name = DEFAULT_PROFILE_NAME): Profile {
return {
name,
themeId: null,
scopeOrder: [...SCOPE_KINDS],
scopeOrder: [...DEFAULT_SCOPE_ORDER],
hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)),
widthWeights: { ...DEFAULT_SCOPE_WIDTH_WEIGHTS },
scopeSettings: cloneScopeSettings(DEFAULT_SCOPE_SETTINGS),
+34 -1
View File
@@ -10,6 +10,7 @@ import {
type PrismResolvedTheme,
type PrismTheme,
type PrismThemeLocalStateV1,
type ResolvedAstraTheme,
type ResolvedInterfaceTheme,
type ResolvedLUFSMeterTheme,
type ResolvedOscilloscopeTheme,
@@ -39,6 +40,7 @@ const MODULE_SECTION_ORDER: ThemeSectionName[] = [
'vumeter',
'lufsmeter',
'waveform',
'astra',
]
const COLOR_KEY_ORDER: Array<keyof ThemeTokens> = [
@@ -72,6 +74,7 @@ const SECTION_KEY_MAP: Record<string, ThemeSectionName> = {
vumeter: 'vumeter',
lufsmeter: 'lufsmeter',
waveform: 'waveform',
astra: 'astra',
}
const TOKEN_KEY_MAP: Record<string, keyof ThemeTokens> = {
@@ -328,6 +331,7 @@ function createEmptyTheme(): PrismTheme {
vumeter: createEmptyThemeTokens(),
lufsmeter: createEmptyThemeTokens(),
waveform: createEmptyThemeTokens(),
astra: createEmptyThemeTokens(),
}
}
@@ -468,6 +472,7 @@ export function normalizeTheme(
normalized.vumeter = normalizeTokens(parsed.vumeter)
normalized.lufsmeter = normalizeTokens(parsed.lufsmeter)
normalized.waveform = normalizeTokens(parsed.waveform)
normalized.astra = normalizeTokens(parsed.astra)
return normalized
}
@@ -635,6 +640,8 @@ export function serializeThemeFile(theme: PrismTheme): string {
? 'VUMeter'
: section === 'lufsmeter'
? 'LUFSMeter'
: section === 'astra'
? 'Astra'
: `${section.charAt(0).toUpperCase()}${section.slice(1)}`
output.push(serializeSection(label, tokens).join('\n'))
}
@@ -643,7 +650,7 @@ export function serializeThemeFile(theme: PrismTheme): string {
}
export function createTemplateThemeFile(): string {
return `# Prism theme template\n#\n# Authoring rules:\n# - Colors use R, G, B or R, G, B, A (0-255)\n# - Omit sections or keys you do not want to override\n# - [All] sets the defaults for everything else\n# - [Interface] overrides the app window, controls, and menus\n# - Module sections only need the colors that should differ from [All]\n\n[Theme]\nformat = ${THEME_FILE_FORMAT}\nversion = ${THEME_FILE_VERSION}\nid = theme_template\nname = Template Theme\ncredit = Your Name\nwebsite = https://example.com\n\n[All]\nprimary = 56, 189, 248\nsecondary = 172, 192, 222\nguides = 255, 255, 255, 26\ntext = 255, 255, 255\nbackground = 0, 0, 0\nlow_band = 255, 68, 68\nmid_band = 68, 221, 68\nhigh_band = 68, 136, 255\nsuccess = 34, 197, 94\nwarning = 255, 191, 0\ndanger = 248, 113, 113\n\n[Interface]\nsecondary = 8, 11, 16, 235\nguides = 255, 255, 255, 23\nbackground = 0, 0, 0\n\n[Spectrum]\nsecondary = 56, 189, 248, 127\nheat_low = 15, 7, 33\nheat_mid = 163, 26, 121\nheat_high = 255, 241, 209\n\n[Oscilloscope]\nfill = 245, 248, 252, 46\n\n[VUMeter]\npeak = 255, 127, 0\nclip = 255, 120, 80, 230\n\n[LUFSMeter]\ntarget = 56, 189, 248, 64\n`
return `# Prism theme template\n#\n# Authoring rules:\n# - Colors use R, G, B or R, G, B, A (0-255)\n# - Omit sections or keys you do not want to override\n# - [All] sets the defaults for everything else\n# - [Interface] overrides the app window, controls, and menus\n# - Module sections only need the colors that should differ from [All]\n\n[Theme]\nformat = ${THEME_FILE_FORMAT}\nversion = ${THEME_FILE_VERSION}\nid = theme_template\nname = Template Theme\ncredit = Your Name\nwebsite = https://example.com\n\n[All]\nprimary = 56, 189, 248\nsecondary = 172, 192, 222\nguides = 255, 255, 255, 26\ntext = 255, 255, 255\nbackground = 0, 0, 0\nlow_band = 255, 68, 68\nmid_band = 68, 221, 68\nhigh_band = 68, 136, 255\nsuccess = 34, 197, 94\nwarning = 255, 191, 0\ndanger = 248, 113, 113\n\n[Interface]\nsecondary = 8, 11, 16, 235\nguides = 255, 255, 255, 23\nbackground = 0, 0, 0\n\n[Spectrum]\nsecondary = 56, 189, 248, 127\nheat_low = 15, 7, 33\nheat_mid = 163, 26, 121\nheat_high = 255, 241, 209\n\n[Oscilloscope]\nfill = 245, 248, 252, 46\n\n[VUMeter]\npeak = 255, 127, 0\nclip = 255, 120, 80, 230\n\n[LUFSMeter]\ntarget = 56, 189, 248, 64\n\n[Astra]\nsecondary = 10, 16, 24, 235\nguides = 255, 255, 255, 31\nbackground = 4, 8, 12, 230\n`
}
function getThemeFallbackSection(base: ThemeTokens): Required<ThemeTokens> {
@@ -807,6 +814,31 @@ function resolveWaveformTheme(theme: PrismTheme, all: Required<ThemeTokens>): Re
}
}
function resolveAstraTheme(theme: PrismTheme, all: Required<ThemeTokens>): ResolvedAstraTheme {
const section = getThemeFallbackSection(mergeThemeTokens(all, theme.astra))
const background = section.background
const surface = theme.astra.secondary ?? withAlpha(section.secondary, 0.16)
const guides = theme.astra.guides ?? all.guides
return {
accent: section.primary,
text: section.text,
subtext: withAlpha(section.text, 0.7),
background,
surface,
border: guides,
progressTrack: withAlpha(guides, 0.3),
progressFill: section.primary,
buttonBg: surface,
buttonBgHover: withAlpha(mixColors(surface, section.primary, 0.14), 0.94),
buttonBgActive: withAlpha(section.primary, 0.16),
buttonBorder: guides,
buttonText: section.text,
statusOk: section.success,
statusError: section.danger,
}
}
export function resolveTheme(theme: PrismTheme): PrismResolvedTheme {
const normalized = normalizeTheme(theme, theme.id, theme.name)
const baseAll = getThemeFallbackSection(mergeThemeTokens(createDefaultTheme().all, normalized.all))
@@ -825,6 +857,7 @@ export function resolveTheme(theme: PrismTheme): PrismResolvedTheme {
vumeter: resolveVUMeterTheme(normalized, baseAll),
lufsmeter: resolveLUFSMeterTheme(normalized, baseAll),
waveform: resolveWaveformTheme(normalized, baseAll),
astra: resolveAstraTheme(normalized, baseAll),
}
}
+38
View File
@@ -0,0 +1,38 @@
export const DEFAULT_ASTRA_BASE_URL = 'http://127.0.0.1:38401'
export type AstraPlaybackState = 'stopped' | 'playing' | 'paused' | 'loading'
export type AstraConnectionState = 'disabled' | 'connecting' | 'connected' | 'error'
export type AstraControlCommand = 'play' | 'pause' | 'next' | 'previous'
export interface AstraTrackSnapshot {
id: string
title: string
artist: string
album: string
isFavorite: boolean
artworkDataUrl: string | null
}
export interface AstraNowPlayingSnapshot {
playbackState: AstraPlaybackState
currentTime: number
duration: number
queueLength: number
outputDeviceLabel: string | null
visualizerLineColor: string
currentTrack: AstraTrackSnapshot | null
updatedAt: number
}
export interface AstraIntegrationConfig {
baseUrl: string
token: string
}
export interface AstraIntegrationState {
config: AstraIntegrationConfig
connectionState: AstraConnectionState
lastError: string | null
lastControlError: string | null
snapshot: AstraNowPlayingSnapshot | null
}
+2
View File
@@ -2,6 +2,7 @@ import type { CaptureBackendKind } from './capture'
import type { ScopeKind } from './scope'
import type { ScopeSettings } from './settings'
import type {
ResolvedAstraTheme,
ResolvedInterfaceTheme,
ResolvedLUFSMeterTheme,
ResolvedOscilloscopeTheme,
@@ -58,6 +59,7 @@ export type ScopePopoutResolvedScopeTheme =
| ResolvedVUMeterTheme
| ResolvedLUFSMeterTheme
| ResolvedWaveformTheme
| ResolvedAstraTheme
export interface ScopePopoutSnapshot<K extends ScopeKind = ScopeKind> {
kind: K
+27 -1
View File
@@ -1,4 +1,14 @@
export type ScopeKind = 'spectrum' | 'oscilloscope' | 'vectorscope' | 'spectrogram' | 'vumeter' | 'lufsmeter' | 'waveform'
export type ScopeKind =
| 'spectrum'
| 'oscilloscope'
| 'vectorscope'
| 'spectrogram'
| 'vumeter'
| 'lufsmeter'
| 'waveform'
| 'astra'
export type AudioScopeKind = Exclude<ScopeKind, 'astra'>
export const SCOPE_KINDS: ScopeKind[] = [
'spectrum',
@@ -8,8 +18,23 @@ export const SCOPE_KINDS: ScopeKind[] = [
'vumeter',
'lufsmeter',
'waveform',
'astra',
]
export const AUDIO_SCOPE_KINDS: AudioScopeKind[] = [
'spectrum',
'oscilloscope',
'vectorscope',
'spectrogram',
'vumeter',
'lufsmeter',
'waveform',
]
export function isAudioScopeKind(value: unknown): value is AudioScopeKind {
return typeof value === 'string' && AUDIO_SCOPE_KINDS.includes(value as AudioScopeKind)
}
export const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
@@ -18,4 +43,5 @@ export const SCOPE_LABELS: Record<ScopeKind, string> = {
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
astra: 'Astra',
}
+16
View File
@@ -48,6 +48,14 @@ export interface ScopeSettings {
gainDb: number
multiband: boolean
}
astra: {
showCoverArt: boolean
showTitle: boolean
showArtist: boolean
showProgress: boolean
showTime: boolean
showControls: boolean
}
}
export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
@@ -58,4 +66,12 @@ export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
vumeter: { mode: 'bar', orientation: 'horizontal' },
lufsmeter: { mode: 'bar' },
waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: 1, gainDb: 0, multiband: false },
astra: {
showCoverArt: true,
showTitle: true,
showArtist: true,
showProgress: true,
showTime: true,
showControls: true,
},
}
+20
View File
@@ -49,6 +49,7 @@ export interface PrismTheme {
vumeter: ThemeTokens
lufsmeter: ThemeTokens
waveform: ThemeTokens
astra: ThemeTokens
}
export interface PrismThemeLocalStateV1 {
@@ -182,6 +183,24 @@ export interface ResolvedWaveformTheme {
highBand: string
}
export interface ResolvedAstraTheme {
accent: string
text: string
subtext: string
background: string
surface: string
border: string
progressTrack: string
progressFill: string
buttonBg: string
buttonBgHover: string
buttonBgActive: string
buttonBorder: string
buttonText: string
statusOk: string
statusError: string
}
export interface PrismResolvedTheme {
id: string
name: string
@@ -196,4 +215,5 @@ export interface PrismResolvedTheme {
vumeter: ResolvedVUMeterTheme
lufsmeter: ResolvedLUFSMeterTheme
waveform: ResolvedWaveformTheme
astra: ResolvedAstraTheme
}
+380
View File
@@ -0,0 +1,380 @@
import assert from 'node:assert/strict'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { AstraIntegrationService, normalizeAstraIntegrationConfig } from '../src/main/services/astraIntegration'
import { DEFAULT_ASTRA_BASE_URL, type AstraIntegrationConfig } from '../src/types/astra'
function createJsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'content-type': 'application/json; charset=utf-8',
},
})
}
function createPngResponse(payload = 'artwork'): Response {
return new Response(Buffer.from(payload), {
status: 200,
headers: {
'content-type': 'image/png',
},
})
}
function createHeaders(init?: RequestInit): Headers {
return new Headers(init?.headers)
}
function createFakeTimers(): {
clearTimeoutImpl: (handle: ReturnType<typeof setTimeout>) => void
nextDelay: () => number | null
pendingCount: () => number
runNext: () => void
setTimeoutImpl: typeof setTimeout
} {
let nextHandle = 1
const timers = new Map<number, { callback: () => void; delay: number }>()
return {
setTimeoutImpl(callback: TimerHandler, delay?: number): ReturnType<typeof setTimeout> {
const handle = nextHandle
nextHandle += 1
timers.set(handle, {
callback: typeof callback === 'function' ? callback as () => void : () => {},
delay: typeof delay === 'number' ? delay : 0,
})
return handle as ReturnType<typeof setTimeout>
},
clearTimeoutImpl(handle: ReturnType<typeof setTimeout>): void {
timers.delete(Number(handle))
},
nextDelay: () => {
const next = timers.values().next().value as { delay: number } | undefined
return next?.delay ?? null
},
pendingCount: () => timers.size,
runNext: () => {
const next = timers.entries().next().value as [number, { callback: () => void }] | undefined
if (!next) return
const [handle, timer] = next
timers.delete(handle)
timer.callback()
},
}
}
function createSseStream(): {
close: () => void
pushEvent: (event: string, payload: unknown) => void
response: Response
} {
const encoder = new TextEncoder()
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null
const response = new Response(new ReadableStream<Uint8Array>({
start(controller) {
controllerRef = controller
},
}), {
status: 200,
headers: {
'content-type': 'text/event-stream; charset=utf-8',
},
})
return {
response,
pushEvent(event, payload) {
controllerRef?.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`))
},
close() {
controllerRef?.close()
},
}
}
async function waitFor(predicate: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 2000
while (Date.now() < deadline) {
if (predicate()) {
return
}
await new Promise((resolve) => setTimeout(resolve, 0))
}
assert.fail(message)
}
async function createConfigFile(config: AstraIntegrationConfig): Promise<{
cleanup: () => Promise<void>
configPath: string
}> {
const rootDir = await mkdtemp(join(tmpdir(), 'prism-astra-tests-'))
const configPath = join(rootDir, 'userData', 'astra-integration.json')
await mkdir(dirname(configPath), { recursive: true })
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')
return {
configPath,
cleanup: () => rm(rootDir, { recursive: true, force: true }),
}
}
test('normalizeAstraIntegrationConfig applies defaults and trims input', () => {
const config = normalizeAstraIntegrationConfig({
baseUrl: ' http://127.0.0.1:38401/ ',
token: ' secret ',
})
assert.deepEqual(config, {
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret',
})
})
test('service initializes from config, hydrates artwork, and applies SSE updates', async () => {
const harness = await createConfigFile({
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token',
})
const sse = createSseStream()
const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-1`
const calls: Array<{ init?: RequestInit; url: string }> = []
const fetchImpl: typeof fetch = async (input, init) => {
const url = String(input)
calls.push({ url, init })
const pathname = new URL(url).pathname
const headers = createHeaders(init)
assert.equal(headers.get('authorization'), 'Bearer secret-token')
if (pathname === '/v1/now-playing') {
return createJsonResponse({
playbackState: 'playing',
currentTime: 15,
duration: 180,
queueLength: 2,
outputDeviceLabel: 'Built-in Output',
visualizerLineColor: '#4ade80',
updatedAt: 1000,
currentTrack: {
id: 'track-1',
title: 'Song One',
artist: 'Artist One',
album: 'Album One',
isFavorite: false,
artworkUrl,
},
})
}
if (pathname === '/v1/artwork/current') {
return createPngResponse('cover-one')
}
if (pathname === '/v1/events') {
assert.equal(headers.get('accept'), 'text/event-stream')
return sse.response
}
throw new Error(`Unexpected request: ${url}`)
}
const service = new AstraIntegrationService({
configPath: harness.configPath,
fetchImpl,
now: () => 1000,
})
try {
await service.initialize()
assert.equal(service.getState().connectionState, 'disabled')
await service.setConsumerActive(1, true)
await waitFor(() => service.getState().connectionState === 'connected', 'expected connected Astra state')
const initialState = service.getState()
assert.equal(initialState.snapshot?.currentTrack?.title, 'Song One')
assert.match(initialState.snapshot?.currentTrack?.artworkDataUrl ?? '', /^data:image\/png;base64,/)
assert.equal(calls.some((call) => call.url.endsWith('/v1/artwork/current?trackId=track-1')), true)
sse.pushEvent('now-playing', {
playbackState: 'paused',
currentTime: 40,
duration: 180,
queueLength: 2,
outputDeviceLabel: 'Built-in Output',
visualizerLineColor: '#f97316',
updatedAt: 2500,
currentTrack: {
id: 'track-2',
title: 'Song Two',
artist: 'Artist Two',
album: 'Album Two',
isFavorite: true,
artworkUrl: null,
},
})
await waitFor(() => service.getState().snapshot?.currentTrack?.id === 'track-2', 'expected SSE track update')
const nextState = service.getState()
assert.equal(nextState.snapshot?.playbackState, 'paused')
assert.equal(nextState.snapshot?.currentTrack?.artworkDataUrl, null)
await service.setConsumerActive(1, false)
assert.equal(service.getState().connectionState, 'disabled')
assert.equal(service.getState().snapshot, null)
} finally {
await service.dispose()
await harness.cleanup()
}
})
test('service schedules reconnect when the SSE stream closes', async () => {
const harness = await createConfigFile({
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token',
})
const timers = createFakeTimers()
const steadyStream = createSseStream()
let eventStreamRequests = 0
const fetchImpl: typeof fetch = async (input) => {
const url = String(input)
const pathname = new URL(url).pathname
if (pathname === '/v1/now-playing') {
return createJsonResponse({
playbackState: 'stopped',
currentTime: 0,
duration: 0,
queueLength: 0,
outputDeviceLabel: null,
visualizerLineColor: '#38bdf8',
updatedAt: 0,
currentTrack: null,
})
}
if (pathname === '/v1/events') {
eventStreamRequests += 1
if (eventStreamRequests === 1) {
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.close()
},
}), {
status: 200,
headers: {
'content-type': 'text/event-stream; charset=utf-8',
},
})
}
return steadyStream.response
}
throw new Error(`Unexpected request: ${url}`)
}
const service = new AstraIntegrationService({
configPath: harness.configPath,
fetchImpl,
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
})
try {
await service.initialize()
await service.setConsumerActive(1, true)
await waitFor(() => service.getState().connectionState === 'error', 'expected reconnect error state')
assert.equal(timers.nextDelay(), 1000)
assert.equal(timers.pendingCount(), 1)
timers.runNext()
await waitFor(() => eventStreamRequests === 2, 'expected second SSE connection attempt')
await waitFor(() => service.getState().connectionState === 'connected', 'expected connected state after reconnect')
} finally {
await service.dispose()
await harness.cleanup()
}
})
test('service surfaces 401 and 403 control errors and clears them after success', async () => {
const harness = await createConfigFile({
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token',
})
const sse = createSseStream()
let controlRequests = 0
const fetchImpl: typeof fetch = async (input, init) => {
const url = String(input)
const pathname = new URL(url).pathname
if (pathname === '/v1/now-playing') {
return createJsonResponse({
playbackState: 'paused',
currentTime: 20,
duration: 180,
queueLength: 1,
outputDeviceLabel: 'Built-in Output',
visualizerLineColor: '#38bdf8',
updatedAt: 0,
currentTrack: {
id: 'track-1',
title: 'Track',
artist: 'Artist',
album: 'Album',
isFavorite: false,
artworkUrl: null,
},
})
}
if (pathname === '/v1/events') {
return sse.response
}
if (pathname === '/v1/control') {
const headers = createHeaders(init)
assert.equal(headers.get('authorization'), 'Bearer secret-token')
controlRequests += 1
if (controlRequests === 1) {
return createJsonResponse({ error: 'Unauthorized' }, 401)
}
if (controlRequests === 2) {
return createJsonResponse({ error: 'External playback controls are disabled.' }, 403)
}
return createJsonResponse({ ok: true }, 200)
}
throw new Error(`Unexpected request: ${url}`)
}
const service = new AstraIntegrationService({
configPath: harness.configPath,
fetchImpl,
})
try {
await service.initialize()
await service.setConsumerActive(1, true)
await waitFor(() => service.getState().connectionState === 'connected', 'expected connected state before controls')
await assert.rejects(() => service.sendControl('next'), /Unauthorized/)
assert.equal(service.getState().lastControlError, 'Unauthorized')
await assert.rejects(() => service.sendControl('previous'), /External playback controls are disabled\./)
assert.equal(service.getState().lastControlError, 'External playback controls are disabled.')
await service.sendControl('play')
assert.equal(service.getState().lastControlError, null)
} finally {
await service.dispose()
await harness.cleanup()
}
})
+6
View File
@@ -202,3 +202,9 @@ test('publishes aggregated visualizer demand changes for downstream transports',
waveform: false,
})
})
test('audio diagnostics stay scoped to audio-only visualizers', () => {
const router = new AudioRouter()
assert.equal('astra' in router.getDiagnosticsSnapshot().scopes, false)
})
+13
View File
@@ -7,6 +7,7 @@ import { FileBackedProfileLibrary } from '../src/main/profileLibrary'
import {
createDefaultProfile,
extractLocalProfileMetadata,
normalizeScopeOrder,
profileFileToProfile,
profileToFileData,
} from '../src/shared/profileState'
@@ -79,12 +80,16 @@ test('profile file serialization excludes geometry and round-trips with local me
assert.equal(JSON.stringify(file).includes('windowBounds'), false)
assert.equal(JSON.stringify(file).includes('frameTarget'), false)
assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true })
assert.equal(file.scopeOrder.includes('astra'), false)
assert.equal(file.hiddenScopes.includes('astra'), true)
assert.equal(file.widthWeights.astra, 1)
const restored = profileFileToProfile(file, extractLocalProfileMetadata(profile))
assert.deepEqual(restored.windowBounds, profile.windowBounds)
assert.deepEqual(restored.scopePopouts.spectrum.windowBounds, profile.scopePopouts.spectrum.windowBounds)
assert.equal(restored.scopeSettings.spectrum.showSideLine, true)
assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono')
assert.equal(restored.scopeSettings.astra.showControls, true)
})
test('library saves, renames, deletes, and resolves filename collisions', async () => {
@@ -121,6 +126,14 @@ test('library saves, renames, deletes, and resolves filename collisions', async
}
})
test('astra stays opt-in for profile scope order normalization', () => {
const profile = createDefaultProfile('Default')
assert.equal(profile.scopeOrder.includes('astra'), false)
assert.equal(normalizeScopeOrder(undefined).includes('astra'), false)
assert.equal(normalizeScopeOrder(['spectrum', 'astra']).includes('astra'), true)
})
test('default profile seeds with the current active theme when available', async () => {
const harness = await createHarnessWithOptions({ defaultThemeId: 'theme_midnight' })
+61 -1
View File
@@ -10,6 +10,10 @@ import {
getHorizontalWheelScrollResult,
normalizeWheelDelta,
} from '../src/renderer/utils/horizontalWheelScroll'
import {
formatAstraTime,
getAstraPlaybackProgress,
} from '../src/renderer/utils/astra'
import {
createDefaultProfile,
} from '../src/shared/profileState'
@@ -669,6 +673,7 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked
'vumeter',
'lufsmeter',
'waveform',
'astra',
])
})
@@ -684,6 +689,30 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer
assert.equal(options.lineColor, theme.spectrum.primary)
})
test('astra playback progress advances from updatedAt while playing', () => {
const progress = getAstraPlaybackProgress({
playbackState: 'playing',
currentTime: 12,
duration: 120,
queueLength: 4,
outputDeviceLabel: 'Built-in Output',
visualizerLineColor: '#38bdf8',
currentTrack: {
id: 'track-1',
title: 'Track',
artist: 'Artist',
album: 'Album',
isFavorite: false,
artworkDataUrl: null,
},
updatedAt: 1000,
}, 3500)
assertAlmostEqual(progress.currentTime, 14.5, 1e-6, 'current time advances')
assertAlmostEqual(progress.progress, 14.5 / 120, 1e-6, 'progress ratio advances')
assert.equal(formatAstraTime(progress.currentTime), '0:14')
})
test('normalizeWheelDelta keeps pixel deltas unchanged', () => {
assert.equal(normalizeWheelDelta(24, 0, 320), 24)
})
@@ -831,6 +860,14 @@ test('scopeSummary includes Stereo for waveform only when stereo mode is enabled
assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB · Stereo · RGB')
})
test('scopeSummary summarizes astra field visibility', () => {
const profile = createDefaultProfile('Default')
profile.scopeSettings.astra.showArtist = false
profile.scopeSettings.astra.showControls = false
assert.equal(scopeSummary('astra', profile.scopeSettings.astra), 'Cover · Title · Bar · Time')
})
test('ScopePopoutDataSource switches waveform batches between mono and stereo queues', () => {
const dataSource = new ScopePopoutDataSource('waveform')
const monoChunk = new Float32Array([0.1, 0.2, 0.3])
@@ -950,6 +987,27 @@ test('buildProfileDraft preserves unlinked themes instead of coercing the active
assert.equal(draft.themeId, null)
})
test('toggleScope appends astra to the scope order when it is enabled from an opt-in profile', () => {
const previousSettingsState = useSettingsStore.getState()
const fakeWindow = installFakeElectronWindow()
try {
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.themeId = 'theme_default'
seedProfileDraftState(profile)
assert.equal(useSettingsStore.getState().scopeOrder.includes('astra'), false)
useSettingsStore.getState().toggleScope('astra')
assert.equal(useSettingsStore.getState().scopeOrder.at(-1), 'astra')
assert.equal(useSettingsStore.getState().hiddenScopes.has('astra'), false)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
}
})
test('main-window bounds updates stay in memory in Electron mode until save', () => {
const previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage()
@@ -1204,7 +1262,7 @@ test('moveDockedScopeOrder is a no-op at the docked boundaries', () => {
initialOrder,
)
assert.equal(
moveDockedScopeOrder(initialOrder, new Set<ScopeKind>(), createScopePopouts(), 'waveform', 'right'),
moveDockedScopeOrder(initialOrder, new Set<ScopeKind>(), createScopePopouts(), 'astra', 'right'),
initialOrder,
)
})
@@ -1226,6 +1284,7 @@ test('moveDockedScopeOrder preserves hidden scope positions in the full order',
'vumeter',
'lufsmeter',
'waveform',
'astra',
])
})
@@ -1246,6 +1305,7 @@ test('moveDockedScopeOrder preserves popped-out scope positions in the full orde
'vumeter',
'lufsmeter',
'waveform',
'astra',
])
})
+23
View File
@@ -7,6 +7,7 @@ import { FileBackedThemeLibrary } from '../src/main/themeLibrary'
import {
createDefaultTheme,
parseThemeFileContent,
resolveTheme,
serializeThemeFile,
} from '../src/shared/themeState'
import {
@@ -45,6 +46,28 @@ test('theme files round-trip and keep grouped sections intact', () => {
assert.equal(parsed.name, DEFAULT_THEME_NAME)
assert.equal(parsed.spectrum.heatMid, 'rgb(200, 50, 120)')
assert.equal(parsed.interface.secondary, theme.interface.secondary)
assert.equal(parsed.astra.background, theme.astra.background)
})
test('astra theme falls back to [All] tokens until [Astra] overrides are provided', () => {
const theme = createDefaultTheme()
theme.all.primary = 'rgb(255, 159, 67)'
theme.all.background = 'rgb(5, 6, 7)'
theme.all.text = 'rgb(240, 244, 248)'
const fallbackResolved = resolveTheme(theme)
assert.equal(fallbackResolved.astra.accent, 'rgb(255, 159, 67)')
assert.equal(fallbackResolved.astra.background, 'rgb(5, 6, 7)')
assert.equal(fallbackResolved.astra.text, 'rgb(240, 244, 248)')
theme.astra.primary = 'rgb(129, 140, 248)'
theme.astra.background = 'rgba(12, 18, 32, 0.88)'
theme.astra.text = 'rgb(248, 250, 252)'
const overriddenResolved = resolveTheme(theme)
assert.equal(overriddenResolved.astra.accent, 'rgb(129, 140, 248)')
assert.match(overriddenResolved.astra.background, /^rgba\(12, 18, 32, 0\.87/)
assert.equal(overriddenResolved.astra.text, 'rgb(248, 250, 252)')
})
test('library seeds default themes and template file', async () => {