From 6f7380225225a6ec2d2a4d86ebeb8a2ae63baa33 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:55:36 -0400 Subject: [PATCH] astra integration module --- package.json | 1 + scripts/run-astra-integration-tests.mjs | 44 ++ src/main/index.ts | 52 +- src/main/services/astraIntegration.ts | 676 ++++++++++++++++++ src/preload/index.ts | 15 + src/renderer/App.tsx | 4 + src/renderer/audio/AudioRouter.ts | 24 +- src/renderer/components/AstraScopeModule.tsx | 225 ++++++ src/renderer/components/BottomBar.tsx | 77 +- src/renderer/components/ScopeModule.tsx | 16 + src/renderer/components/ScopePopoutBridge.tsx | 28 +- .../components/ScopeSettingsSection.tsx | 53 ++ src/renderer/components/Strip.tsx | 18 +- src/renderer/env.d.ts | 11 + src/renderer/stores/astraStore.ts | 116 +++ src/renderer/stores/settingsStore.ts | 8 + src/renderer/styles/globals.css | 300 ++++++++ src/renderer/utils/astra.ts | 51 ++ src/shared/profileState.ts | 11 +- src/shared/themeState.ts | 35 +- src/types/astra.ts | 38 + src/types/popout.ts | 2 + src/types/scope.ts | 28 +- src/types/settings.ts | 16 + src/types/theme.ts | 20 + test/astra-integration.test.ts | 380 ++++++++++ test/audio-router.test.ts | 6 + test/profile-library.test.ts | 13 + test/renderer-helpers.test.ts | 62 +- test/theme-library.test.ts | 23 + 30 files changed, 2314 insertions(+), 39 deletions(-) create mode 100644 scripts/run-astra-integration-tests.mjs create mode 100644 src/main/services/astraIntegration.ts create mode 100644 src/renderer/components/AstraScopeModule.tsx create mode 100644 src/renderer/stores/astraStore.ts create mode 100644 src/renderer/utils/astra.ts create mode 100644 src/types/astra.ts create mode 100644 test/astra-integration.test.ts diff --git a/package.json b/package.json index 77ace5d..9c1c2ba 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/run-astra-integration-tests.mjs b/scripts/run-astra-integration-tests.mjs new file mode 100644 index 0000000..9577bee --- /dev/null +++ b/scripts/run-astra-integration-tests.mjs @@ -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) diff --git a/src/main/index.ts b/src/main/index.ts index 10ddbba..96648be 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() }) diff --git a/src/main/services/astraIntegration.ts b/src/main/services/astraIntegration.ts new file mode 100644 index 0000000..7038233 --- /dev/null +++ b/src/main/services/astraIntegration.ts @@ -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 + +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 + : {} + + 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 + 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 + 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 { + 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() + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + signal: AbortSignal, + ): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + let eventName = 'message' + let dataLines: string[] = [] + + const flushEvent = async (): Promise => { + 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 { + 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): 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 { + try { + const raw = await readFile(this.configPath, 'utf8') + return normalizeAstraIntegrationConfig(JSON.parse(raw)) + } catch { + return normalizeAstraIntegrationConfig(null) + } + } + + private async persistConfigFile(config: AstraIntegrationConfig): Promise { + await mkdir(dirname(this.configPath), { recursive: true }) + await writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf8') + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b11d9f5..a3d6620 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, + saveAstraConfig: (config: AstraIntegrationConfig) => ipcRenderer.invoke('astra:save-config', config) as Promise, + getAstraState: () => ipcRenderer.invoke('astra:get-state') as Promise, + setAstraActive: (active: boolean) => ipcRenderer.invoke('astra:set-active', active) as Promise, + sendAstraControl: (command: AstraControlCommand) => ipcRenderer.invoke('astra:send-control', command) as Promise, getProfileSnapshot: () => ipcRenderer.invoke('profiles:get-snapshot') as Promise, saveNewProfile: (name: string, profile: Profile) => ipcRenderer.invoke('profiles:save-new', name, profile) as Promise, overwriteProfile: (id: string, profile: Profile) => ipcRenderer.invoke('profiles:overwrite', id, profile) as Promise, @@ -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) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 53648f0..f0fdcd6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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, ]) diff --git a/src/renderer/audio/AudioRouter.ts b/src/renderer/audio/AudioRouter.ts index b50b33e..6601c19 100644 --- a/src/renderer/audio/AudioRouter.ts +++ b/src/renderer/audio/AudioRouter.ts @@ -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 = { +const SCOPE_RING_CAPACITY: Record = { 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 + scopes: Record } export type NormalizedVisualizerConsumerDemand = Required @@ -221,7 +221,7 @@ export class AudioRouter { waveform: new FixedChunkRing(SCOPE_RING_CAPACITY.waveform), } - private readonly scopeLatency: Record = { + private readonly scopeLatency: Record = { 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>((result, scope) => { + const scopes = AUDIO_SCOPE_KINDS.reduce>((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) + }, {} as Record) 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): void { + private recordScopeDrain(scope: AudioScopeKind, records: Array): 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 | FixedChunkRing { + private getRing(scope: AudioScopeKind): FixedChunkRing | FixedChunkRing { return this.rings[scope] } } diff --git a/src/renderer/components/AstraScopeModule.tsx b/src/renderer/components/AstraScopeModule.tsx new file mode 100644 index 0000000..dfb26fd --- /dev/null +++ b/src/renderer/components/AstraScopeModule.tsx @@ -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['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['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 ( +
+
+ All Astra elements are hidden. +
+
+ ) + } + + 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 ( +
+
+ {settings.showCoverArt && ( +
+ {currentTrack?.artworkDataUrl ? ( + {currentTrack.title} + ) : ( + + )} +
+ )} + +
+ {(settings.showTitle || settings.showArtist) && ( +
+ {settings.showTitle && ( +
+ {currentTrack?.title ?? getFallbackTitle(integrationState.connectionState)} +
+ )} + {settings.showArtist && detailMessage && ( +
+ {detailMessage} +
+ )} +
+ )} + + {(settings.showProgress || settings.showTime) && ( +
+ {settings.showProgress && ( + + )} + + {settings.showControls && ( +
+ + + +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+
+
+ ) +} diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 3a8840e..6892ea3 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -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 = { @@ -17,6 +19,7 @@ const SCOPE_LABELS: Record = { vumeter: 'VU Meter', lufsmeter: 'LUFS Meter', waveform: 'Waveform', + astra: 'Astra', } interface BottomBarProps { @@ -35,8 +38,11 @@ const FRAME_TARGET_LABELS: Record = { export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element { const rootRef = useRef(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 => { + const nextConfig: AstraIntegrationConfig = { + baseUrl: astraBaseUrlInput, + token: astraTokenInput, + } + await saveAstraConfig(nextConfig) + } + const handleRailWheel = (event: WheelEvent): 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 (
@@ -199,7 +228,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
{SCOPE_KINDS.map((kind) => { - const active = !hiddenScopes.has(kind) + const active = scopeOrder.includes(kind) && !hiddenScopes.has(kind) return ( + +
+ + {astraStatusLabel} +
+
+ + {astraState.lastError ? ( +
{astraState.lastError}
+ ) : null} +
+ + +
+
Audio Source
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 9286a6b..57f9fa3 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -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 ( + + ) + } + useEffect(() => { const canvas = canvasRef.current if (!canvas) return diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index 15db3e2..a7db3aa 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -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 { - return SCOPE_KINDS.reduce((acc, currentKind) => { +function buildConsumerDemand(kind: AudioScopeKind): Record { + return AUDIO_SCOPE_KINDS.reduce((acc, currentKind) => { acc[currentKind] = currentKind === kind return acc - }, {} as Record) + }, {} as Record) } -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) } }) diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index df8d932..fb9287d 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -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 ( + + onUpdate('astra', { showCoverArt: !current.showCoverArt })} + /> + onUpdate('astra', { showTitle: !current.showTitle })} + /> + onUpdate('astra', { showArtist: !current.showArtist })} + /> + onUpdate('astra', { showProgress: !current.showProgress })} + /> + onUpdate('astra', { showTime: !current.showTime })} + /> + onUpdate('astra', { showControls: !current.showControls })} + /> + + ) + })()}
) diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx index e99711c..8e06f9d 100644 --- a/src/renderer/components/Strip.tsx +++ b/src/renderer/components/Strip.tsx @@ -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 () => { diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 4b0352f..a1b9465 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -1,5 +1,10 @@ /// +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 getDesktopSources: () => Promise<{ id: string; name: string }[]> getCaptureBackendSupport: () => Promise + getAstraConfig: () => Promise + saveAstraConfig: (config: AstraIntegrationConfig) => Promise + getAstraState: () => Promise + setAstraActive: (active: boolean) => Promise + sendAstraControl: (command: AstraControlCommand) => Promise getProfileSnapshot: () => Promise saveNewProfile: (name: string, profile: Profile) => Promise overwriteProfile: (id: string, profile: Profile) => Promise @@ -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 diff --git a/src/renderer/stores/astraStore.ts b/src/renderer/stores/astraStore.ts new file mode 100644 index 0000000..b146212 --- /dev/null +++ b/src/renderer/stores/astraStore.ts @@ -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 + saveConfig: (config: AstraIntegrationConfig) => Promise + setScopeActive: (active: boolean) => Promise + sendControl: (command: AstraControlCommand) => Promise + 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 | null = null +let syncBound = false + +export const useAstraStore = create((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() diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 2244c4a..5932b08 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -510,6 +510,14 @@ export const useSettingsStore = create((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 { diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 2f21f54..59297cd 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -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; diff --git a/src/renderer/utils/astra.ts b/src/renderer/utils/astra.ts new file mode 100644 index 0000000..49f2f03 --- /dev/null +++ b/src/renderer/utils/astra.ts @@ -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')}` +} diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 234cd7e..c34d739 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -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 = { spectrum: 1, @@ -25,6 +26,7 @@ export const DEFAULT_SCOPE_WIDTH_WEIGHTS: Record = { 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() @@ -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), diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts index 818eaa0..b76d4ac 100644 --- a/src/shared/themeState.ts +++ b/src/shared/themeState.ts @@ -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 = [ @@ -72,6 +74,7 @@ const SECTION_KEY_MAP: Record = { vumeter: 'vumeter', lufsmeter: 'lufsmeter', waveform: 'waveform', + astra: 'astra', } const TOKEN_KEY_MAP: Record = { @@ -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 { @@ -807,6 +814,31 @@ function resolveWaveformTheme(theme: PrismTheme, all: Required): Re } } +function resolveAstraTheme(theme: PrismTheme, all: Required): 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), } } diff --git a/src/types/astra.ts b/src/types/astra.ts new file mode 100644 index 0000000..2dd10e3 --- /dev/null +++ b/src/types/astra.ts @@ -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 +} diff --git a/src/types/popout.ts b/src/types/popout.ts index 9814231..3980add 100644 --- a/src/types/popout.ts +++ b/src/types/popout.ts @@ -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 { kind: K diff --git a/src/types/scope.ts b/src/types/scope.ts index ff8d4be..fd258a1 100644 --- a/src/types/scope.ts +++ b/src/types/scope.ts @@ -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 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 = { spectrum: 'Spectrum', oscilloscope: 'Oscilloscope', @@ -18,4 +43,5 @@ export const SCOPE_LABELS: Record = { vumeter: 'VU Meter', lufsmeter: 'LUFS Meter', waveform: 'Waveform', + astra: 'Astra', } diff --git a/src/types/settings.ts b/src/types/settings.ts index e8af65c..1251dfa 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -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, + }, } diff --git a/src/types/theme.ts b/src/types/theme.ts index 1db887f..61118c2 100644 --- a/src/types/theme.ts +++ b/src/types/theme.ts @@ -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 } diff --git a/test/astra-integration.test.ts b/test/astra-integration.test.ts new file mode 100644 index 0000000..25ec60a --- /dev/null +++ b/test/astra-integration.test.ts @@ -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) => void + nextDelay: () => number | null + pendingCount: () => number + runNext: () => void + setTimeoutImpl: typeof setTimeout +} { + let nextHandle = 1 + const timers = new Map void; delay: number }>() + + return { + setTimeoutImpl(callback: TimerHandler, delay?: number): ReturnType { + 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 + }, + clearTimeoutImpl(handle: ReturnType): 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 | null = null + + const response = new Response(new ReadableStream({ + 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 { + 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 + 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({ + 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() + } +}) diff --git a/test/audio-router.test.ts b/test/audio-router.test.ts index 8690eab..99b78f3 100644 --- a/test/audio-router.test.ts +++ b/test/audio-router.test.ts @@ -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) +}) diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index ea24cb1..bf970b6 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -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' }) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 9a355b6..c897f1e 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -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(), createScopePopouts(), 'waveform', 'right'), + moveDockedScopeOrder(initialOrder, new Set(), 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', ]) }) diff --git a/test/theme-library.test.ts b/test/theme-library.test.ts index 2a77246..79ee9fc 100644 --- a/test/theme-library.test.ts +++ b/test/theme-library.test.ts @@ -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 () => {