mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
astra integration module
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'prism-astra-integration-tests-'))
|
||||
const bundledTestPath = join(tempDir, 'astra-integration.test.mjs')
|
||||
const entryPoint = join(rootDir, 'test', 'astra-integration.test.ts')
|
||||
|
||||
let exitCode = 1
|
||||
|
||||
try {
|
||||
await build({
|
||||
entryPoints: [entryPoint],
|
||||
outfile: bundledTestPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node23',
|
||||
sourcemap: 'inline',
|
||||
})
|
||||
|
||||
exitCode = await new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, ['--test', bundledTestPath], {
|
||||
stdio: 'inherit',
|
||||
cwd: rootDir,
|
||||
})
|
||||
|
||||
child.on('exit', (code) => {
|
||||
resolve(code ?? 1)
|
||||
})
|
||||
|
||||
child.on('error', () => {
|
||||
resolve(1)
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
process.exit(exitCode)
|
||||
+51
-1
@@ -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()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import {
|
||||
DEFAULT_ASTRA_BASE_URL,
|
||||
type AstraControlCommand,
|
||||
type AstraIntegrationConfig,
|
||||
type AstraIntegrationState,
|
||||
type AstraNowPlayingSnapshot,
|
||||
type AstraPlaybackState,
|
||||
type AstraTrackSnapshot,
|
||||
} from '../../types/astra'
|
||||
|
||||
const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000]
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
type TimerHandle = ReturnType<typeof setTimeout>
|
||||
|
||||
interface RemoteTrackSnapshot {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
isFavorite: boolean
|
||||
artworkUrl: string | null
|
||||
}
|
||||
|
||||
interface RemoteNowPlayingSnapshot {
|
||||
playbackState: AstraPlaybackState
|
||||
currentTime: number
|
||||
duration: number
|
||||
queueLength: number
|
||||
outputDeviceLabel: string | null
|
||||
visualizerLineColor: string
|
||||
currentTrack: RemoteTrackSnapshot | null
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface AstraIntegrationServiceOptions {
|
||||
configPath: string
|
||||
fetchImpl?: FetchLike
|
||||
now?: () => number
|
||||
setTimeoutImpl?: typeof setTimeout
|
||||
clearTimeoutImpl?: typeof clearTimeout
|
||||
}
|
||||
|
||||
function cloneState(state: AstraIntegrationState): AstraIntegrationState {
|
||||
return JSON.parse(JSON.stringify(state)) as AstraIntegrationState
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message
|
||||
? error.message
|
||||
: fallback
|
||||
}
|
||||
|
||||
function isPlaybackState(value: unknown): value is AstraPlaybackState {
|
||||
return value === 'stopped' || value === 'playing' || value === 'paused' || value === 'loading'
|
||||
}
|
||||
|
||||
function toSafeNumber(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0
|
||||
return Math.max(0, value)
|
||||
}
|
||||
|
||||
function toSafeString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
function toOptionalString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.trim()
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
function bufferToDataUrl(bytes: Buffer, mimeType: string): string {
|
||||
return `data:${mimeType};base64,${bytes.toString('base64')}`
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: unknown): string {
|
||||
if (typeof value !== 'string') return DEFAULT_ASTRA_BASE_URL
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return DEFAULT_ASTRA_BASE_URL
|
||||
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
||||
}
|
||||
|
||||
export function normalizeAstraIntegrationConfig(raw: unknown): AstraIntegrationConfig {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<AstraIntegrationConfig>
|
||||
: {}
|
||||
|
||||
return {
|
||||
baseUrl: normalizeBaseUrl(parsed.baseUrl),
|
||||
token: typeof parsed.token === 'string' ? parsed.token.trim() : '',
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultState(): AstraIntegrationState {
|
||||
return {
|
||||
config: normalizeAstraIntegrationConfig(null),
|
||||
connectionState: 'disabled',
|
||||
lastError: null,
|
||||
lastControlError: null,
|
||||
snapshot: null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRemoteTrack(raw: unknown): RemoteTrackSnapshot | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null
|
||||
|
||||
const parsed = raw as Partial<RemoteTrackSnapshot>
|
||||
const id = toOptionalString(parsed.id)
|
||||
const title = toSafeString(parsed.title).trim()
|
||||
const artist = toSafeString(parsed.artist).trim()
|
||||
|
||||
if (!id || !title || !artist) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
artist,
|
||||
album: toSafeString(parsed.album).trim(),
|
||||
isFavorite: Boolean(parsed.isFavorite),
|
||||
artworkUrl: toOptionalString(parsed.artworkUrl),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRemoteSnapshot(raw: unknown, now: () => number): RemoteNowPlayingSnapshot {
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return {
|
||||
playbackState: 'stopped',
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
queueLength: 0,
|
||||
outputDeviceLabel: null,
|
||||
visualizerLineColor: '#38bdf8',
|
||||
currentTrack: null,
|
||||
updatedAt: now(),
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = raw as Partial<RemoteNowPlayingSnapshot>
|
||||
return {
|
||||
playbackState: isPlaybackState(parsed.playbackState) ? parsed.playbackState : 'stopped',
|
||||
currentTime: toSafeNumber(parsed.currentTime),
|
||||
duration: toSafeNumber(parsed.duration),
|
||||
queueLength: Math.max(0, Math.floor(toSafeNumber(parsed.queueLength))),
|
||||
outputDeviceLabel: toOptionalString(parsed.outputDeviceLabel),
|
||||
visualizerLineColor: toOptionalString(parsed.visualizerLineColor) ?? '#38bdf8',
|
||||
currentTrack: normalizeRemoteTrack(parsed.currentTrack),
|
||||
updatedAt: typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt)
|
||||
? parsed.updatedAt
|
||||
: now(),
|
||||
}
|
||||
}
|
||||
|
||||
function toRendererSnapshot(
|
||||
snapshot: RemoteNowPlayingSnapshot,
|
||||
artworkDataUrl: string | null,
|
||||
): AstraNowPlayingSnapshot {
|
||||
const currentTrack = snapshot.currentTrack
|
||||
return {
|
||||
playbackState: snapshot.playbackState,
|
||||
currentTime: snapshot.currentTime,
|
||||
duration: snapshot.duration,
|
||||
queueLength: snapshot.queueLength,
|
||||
outputDeviceLabel: snapshot.outputDeviceLabel,
|
||||
visualizerLineColor: snapshot.visualizerLineColor,
|
||||
currentTrack: currentTrack
|
||||
? {
|
||||
id: currentTrack.id,
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist,
|
||||
album: currentTrack.album,
|
||||
isFavorite: currentTrack.isFavorite,
|
||||
artworkDataUrl,
|
||||
} satisfies AstraTrackSnapshot
|
||||
: null,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponseError(response: Response): Promise<string> {
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) {
|
||||
const parsed = await response.json() as { error?: unknown }
|
||||
const errorMessage = toOptionalString(parsed.error)
|
||||
if (errorMessage) return errorMessage
|
||||
}
|
||||
|
||||
const text = (await response.text()).trim()
|
||||
if (text) return text
|
||||
} catch {
|
||||
// Ignore parse failures.
|
||||
}
|
||||
|
||||
return `Request failed with status ${response.status}.`
|
||||
}
|
||||
|
||||
function appendBasePath(baseUrl: string, path: string): string {
|
||||
const normalizedBase = baseUrl.endsWith('/') ? `${baseUrl}` : `${baseUrl}/`
|
||||
return new URL(path.replace(/^\//, ''), normalizedBase).toString()
|
||||
}
|
||||
|
||||
export class AstraIntegrationService {
|
||||
private readonly configPath: string
|
||||
private readonly fetchImpl: FetchLike
|
||||
private readonly now: () => number
|
||||
private readonly setTimeoutImpl: typeof setTimeout
|
||||
private readonly clearTimeoutImpl: typeof clearTimeout
|
||||
private readonly listeners = new Set<(state: AstraIntegrationState) => void>()
|
||||
private readonly activeConsumers = new Set<number>()
|
||||
|
||||
private state = createDefaultState()
|
||||
private remoteSnapshot: RemoteNowPlayingSnapshot | null = null
|
||||
private currentArtworkTrackId: string | null = null
|
||||
private currentArtworkDataUrl: string | null = null
|
||||
private streamAbortController: AbortController | null = null
|
||||
private reconnectTimer: TimerHandle | null = null
|
||||
private reconnectAttempt = 0
|
||||
private initialized = false
|
||||
private disposed = false
|
||||
|
||||
constructor(options: AstraIntegrationServiceOptions) {
|
||||
this.configPath = options.configPath
|
||||
this.fetchImpl = options.fetchImpl ?? fetch
|
||||
this.now = options.now ?? (() => Date.now())
|
||||
this.setTimeoutImpl = options.setTimeoutImpl ?? setTimeout
|
||||
this.clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const config = await this.loadConfigFile()
|
||||
this.initialized = true
|
||||
this.state = {
|
||||
...this.state,
|
||||
config,
|
||||
connectionState: 'disabled',
|
||||
}
|
||||
this.emitState()
|
||||
if (this.isScopeActive()) {
|
||||
await this.restartConnection()
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.cancelReconnect()
|
||||
this.abortStream()
|
||||
}
|
||||
|
||||
subscribe(listener: (state: AstraIntegrationState) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
listener(this.getState())
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
getState(): AstraIntegrationState {
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
getConfig(): AstraIntegrationConfig {
|
||||
return { ...this.state.config }
|
||||
}
|
||||
|
||||
async setConsumerActive(consumerId: number, active: boolean): Promise<AstraIntegrationState> {
|
||||
const wasActive = this.isScopeActive()
|
||||
if (active) {
|
||||
this.activeConsumers.add(consumerId)
|
||||
} else {
|
||||
this.activeConsumers.delete(consumerId)
|
||||
}
|
||||
|
||||
if (!this.initialized) {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
if (wasActive !== this.isScopeActive()) {
|
||||
await this.restartConnection()
|
||||
}
|
||||
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async saveConfig(rawConfig: unknown): Promise<AstraIntegrationConfig> {
|
||||
const config = normalizeAstraIntegrationConfig(rawConfig)
|
||||
this.state = {
|
||||
...this.state,
|
||||
config,
|
||||
connectionState: this.isScopeActive() ? 'connecting' : 'disabled',
|
||||
lastError: null,
|
||||
lastControlError: null,
|
||||
}
|
||||
this.emitState()
|
||||
await this.persistConfigFile(config)
|
||||
await this.restartConnection()
|
||||
return { ...config }
|
||||
}
|
||||
|
||||
async sendControl(command: AstraControlCommand): Promise<void> {
|
||||
if (!this.isScopeActive()) {
|
||||
const errorMessage = 'The Astra scope is not open.'
|
||||
this.state = {
|
||||
...this.state,
|
||||
lastControlError: errorMessage,
|
||||
}
|
||||
this.emitState()
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
if (!this.state.config.token) {
|
||||
const errorMessage = 'An Astra API token is required before sending controls.'
|
||||
this.state = {
|
||||
...this.state,
|
||||
lastControlError: errorMessage,
|
||||
}
|
||||
this.emitState()
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const response = await this.fetchImpl(this.buildEndpoint('/v1/control'), {
|
||||
method: 'POST',
|
||||
headers: this.buildAuthHeaders({
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
}),
|
||||
body: JSON.stringify({ command }),
|
||||
}).catch((error: unknown) => {
|
||||
throw new Error(getErrorMessage(error, 'Prism could not reach Astra.'))
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await readResponseError(response)
|
||||
this.state = {
|
||||
...this.state,
|
||||
lastControlError: errorMessage,
|
||||
}
|
||||
this.emitState()
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
lastControlError: null,
|
||||
}
|
||||
this.emitState()
|
||||
}
|
||||
|
||||
private emitState(): void {
|
||||
const snapshot = this.getState()
|
||||
for (const listener of this.listeners) {
|
||||
listener(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private async restartConnection(): Promise<void> {
|
||||
this.cancelReconnect()
|
||||
this.abortStream()
|
||||
|
||||
if (!this.isScopeActive() || this.disposed) {
|
||||
this.remoteSnapshot = null
|
||||
this.currentArtworkTrackId = null
|
||||
this.currentArtworkDataUrl = null
|
||||
this.state = {
|
||||
...this.state,
|
||||
connectionState: 'disabled',
|
||||
lastError: null,
|
||||
lastControlError: null,
|
||||
snapshot: null,
|
||||
}
|
||||
this.emitState()
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.state.config.token) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
connectionState: 'error',
|
||||
lastError: 'An Astra API token is required.',
|
||||
}
|
||||
this.emitState()
|
||||
return
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
connectionState: 'connecting',
|
||||
lastError: null,
|
||||
}
|
||||
this.emitState()
|
||||
|
||||
try {
|
||||
await this.fetchNowPlaying()
|
||||
} catch (error) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
connectionState: 'error',
|
||||
lastError: getErrorMessage(error, 'Prism could not read Astra now-playing state.'),
|
||||
}
|
||||
this.emitState()
|
||||
}
|
||||
|
||||
if (!this.isScopeActive() || this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
void this.openEventStream()
|
||||
}
|
||||
|
||||
private scheduleReconnect(message: string): void {
|
||||
if (!this.isScopeActive() || this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
connectionState: 'error',
|
||||
lastError: message,
|
||||
}
|
||||
this.emitState()
|
||||
|
||||
const delay = RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? 10000
|
||||
this.reconnectAttempt += 1
|
||||
this.cancelReconnect()
|
||||
this.reconnectTimer = this.setTimeoutImpl(() => {
|
||||
this.reconnectTimer = null
|
||||
void this.restartConnection()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private cancelReconnect(): void {
|
||||
if (this.reconnectTimer === null) return
|
||||
this.clearTimeoutImpl(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
private abortStream(): void {
|
||||
if (this.streamAbortController) {
|
||||
this.streamAbortController.abort()
|
||||
this.streamAbortController = null
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchNowPlaying(): Promise<void> {
|
||||
const response = await this.fetchImpl(this.buildEndpoint('/v1/now-playing'), {
|
||||
headers: this.buildAuthHeaders(),
|
||||
}).catch((error: unknown) => {
|
||||
throw new Error(getErrorMessage(error, 'Prism could not reach Astra.'))
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readResponseError(response))
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
const snapshot = normalizeRemoteSnapshot(payload, this.now)
|
||||
this.applyRemoteSnapshot(snapshot)
|
||||
await this.refreshArtwork(snapshot.currentTrack?.id ?? null, snapshot.currentTrack?.artworkUrl ?? null)
|
||||
}
|
||||
|
||||
private async openEventStream(): Promise<void> {
|
||||
this.abortStream()
|
||||
|
||||
const controller = new AbortController()
|
||||
this.streamAbortController = controller
|
||||
|
||||
try {
|
||||
const response = await this.fetchImpl(this.buildEndpoint('/v1/events'), {
|
||||
headers: this.buildAuthHeaders({
|
||||
Accept: 'text/event-stream',
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readResponseError(response))
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Astra did not return an event stream.')
|
||||
}
|
||||
|
||||
this.reconnectAttempt = 0
|
||||
this.state = {
|
||||
...this.state,
|
||||
connectionState: 'connected',
|
||||
lastError: null,
|
||||
}
|
||||
this.emitState()
|
||||
|
||||
await this.readEventStream(response.body, controller.signal)
|
||||
|
||||
if (!controller.signal.aborted && this.isScopeActive() && !this.disposed) {
|
||||
this.scheduleReconnect('The Astra event stream closed unexpectedly.')
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.scheduleReconnect(getErrorMessage(error, 'Prism could not connect to the Astra event stream.'))
|
||||
}
|
||||
}
|
||||
|
||||
private async readEventStream(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let eventName = 'message'
|
||||
let dataLines: string[] = []
|
||||
|
||||
const flushEvent = async (): Promise<void> => {
|
||||
if (dataLines.length === 0) {
|
||||
eventName = 'message'
|
||||
return
|
||||
}
|
||||
|
||||
const payload = dataLines.join('\n')
|
||||
const nextEvent = eventName
|
||||
eventName = 'message'
|
||||
dataLines = []
|
||||
|
||||
if (nextEvent === 'now-playing') {
|
||||
let parsed: unknown = null
|
||||
try {
|
||||
parsed = JSON.parse(payload)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = normalizeRemoteSnapshot(parsed, this.now)
|
||||
this.applyRemoteSnapshot(snapshot)
|
||||
await this.refreshArtwork(snapshot.currentTrack?.id ?? null, snapshot.currentTrack?.artworkUrl ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let newlineIndex = buffer.indexOf('\n')
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).replace(/\r$/, '')
|
||||
buffer = buffer.slice(newlineIndex + 1)
|
||||
|
||||
if (line === '') {
|
||||
await flushEvent()
|
||||
} else if (line.startsWith(':')) {
|
||||
// Ignore SSE comments.
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message'
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trimStart())
|
||||
}
|
||||
|
||||
newlineIndex = buffer.indexOf('\n')
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim().length > 0) {
|
||||
const line = buffer.replace(/\r$/, '')
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trimStart())
|
||||
}
|
||||
}
|
||||
|
||||
await flushEvent()
|
||||
await reader.cancel().catch(() => undefined)
|
||||
}
|
||||
|
||||
private applyRemoteSnapshot(snapshot: RemoteNowPlayingSnapshot): void {
|
||||
this.remoteSnapshot = snapshot
|
||||
const artworkDataUrl = snapshot.currentTrack?.id === this.currentArtworkTrackId
|
||||
? this.currentArtworkDataUrl
|
||||
: null
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
snapshot: toRendererSnapshot(snapshot, artworkDataUrl),
|
||||
}
|
||||
this.emitState()
|
||||
}
|
||||
|
||||
private async refreshArtwork(trackId: string | null, artworkUrl: string | null): Promise<void> {
|
||||
if (!trackId || !artworkUrl || !this.remoteSnapshot?.currentTrack || this.remoteSnapshot.currentTrack.id !== trackId) {
|
||||
this.currentArtworkTrackId = trackId
|
||||
this.currentArtworkDataUrl = null
|
||||
if (this.remoteSnapshot) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
snapshot: toRendererSnapshot(this.remoteSnapshot, null),
|
||||
}
|
||||
this.emitState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.currentArtworkTrackId === trackId && this.currentArtworkDataUrl) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
snapshot: toRendererSnapshot(this.remoteSnapshot, this.currentArtworkDataUrl),
|
||||
}
|
||||
this.emitState()
|
||||
return
|
||||
}
|
||||
|
||||
const response = await this.fetchImpl(artworkUrl, {
|
||||
headers: this.buildAuthHeaders(),
|
||||
}).catch(() => null)
|
||||
|
||||
if (!response || !response.ok) {
|
||||
this.currentArtworkTrackId = trackId
|
||||
this.currentArtworkDataUrl = null
|
||||
if (this.remoteSnapshot?.currentTrack?.id === trackId) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
snapshot: toRendererSnapshot(this.remoteSnapshot, null),
|
||||
}
|
||||
this.emitState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const mimeType = response.headers.get('content-type') ?? 'image/png'
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
const artworkDataUrl = bufferToDataUrl(bytes, mimeType)
|
||||
|
||||
if (this.remoteSnapshot?.currentTrack?.id !== trackId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.currentArtworkTrackId = trackId
|
||||
this.currentArtworkDataUrl = artworkDataUrl
|
||||
this.state = {
|
||||
...this.state,
|
||||
snapshot: toRendererSnapshot(this.remoteSnapshot, artworkDataUrl),
|
||||
}
|
||||
this.emitState()
|
||||
}
|
||||
|
||||
private buildAuthHeaders(extraHeaders?: Record<string, string>): HeadersInit {
|
||||
return {
|
||||
Authorization: `Bearer ${this.state.config.token}`,
|
||||
...extraHeaders,
|
||||
}
|
||||
}
|
||||
|
||||
private isScopeActive(): boolean {
|
||||
return this.activeConsumers.size > 0
|
||||
}
|
||||
|
||||
private buildEndpoint(path: string): string {
|
||||
return appendBasePath(this.state.config.baseUrl, path)
|
||||
}
|
||||
|
||||
private async loadConfigFile(): Promise<AstraIntegrationConfig> {
|
||||
try {
|
||||
const raw = await readFile(this.configPath, 'utf8')
|
||||
return normalizeAstraIntegrationConfig(JSON.parse(raw))
|
||||
} catch {
|
||||
return normalizeAstraIntegrationConfig(null)
|
||||
}
|
||||
}
|
||||
|
||||
private async persistConfigFile(config: AstraIntegrationConfig): Promise<void> {
|
||||
await mkdir(dirname(this.configPath), { recursive: true })
|
||||
await writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf8')
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type {
|
||||
AstraControlCommand,
|
||||
AstraIntegrationConfig,
|
||||
AstraIntegrationState,
|
||||
} from '../types/astra'
|
||||
import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
|
||||
import type { NativeCaptureAPI } from '../types/nativeCapture'
|
||||
import type {
|
||||
@@ -48,6 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
nativeBackend: resolveNativeCaptureSupport(support.nativeBackend),
|
||||
} satisfies CaptureBackendSupport
|
||||
},
|
||||
getAstraConfig: () => ipcRenderer.invoke('astra:get-config') as Promise<AstraIntegrationConfig>,
|
||||
saveAstraConfig: (config: AstraIntegrationConfig) => ipcRenderer.invoke('astra:save-config', config) as Promise<AstraIntegrationConfig>,
|
||||
getAstraState: () => ipcRenderer.invoke('astra:get-state') as Promise<AstraIntegrationState>,
|
||||
setAstraActive: (active: boolean) => ipcRenderer.invoke('astra:set-active', active) as Promise<AstraIntegrationState>,
|
||||
sendAstraControl: (command: AstraControlCommand) => ipcRenderer.invoke('astra:send-control', command) as Promise<AstraIntegrationState>,
|
||||
getProfileSnapshot: () => ipcRenderer.invoke('profiles:get-snapshot') as Promise<ProfileLibrarySnapshot>,
|
||||
saveNewProfile: (name: string, profile: Profile) => ipcRenderer.invoke('profiles:save-new', name, profile) as Promise<ProfileLibrarySnapshot>,
|
||||
overwriteProfile: (id: string, profile: Profile) => ipcRenderer.invoke('profiles:overwrite', id, profile) as Promise<ProfileLibrarySnapshot>,
|
||||
@@ -107,6 +117,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('window:bounds-changed', handler)
|
||||
return () => ipcRenderer.removeListener('window:bounds-changed', handler)
|
||||
},
|
||||
onAstraStateChanged: (callback: (state: AstraIntegrationState) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, state: AstraIntegrationState): void => callback(state)
|
||||
ipcRenderer.on('astra:state-changed', handler)
|
||||
return () => ipcRenderer.removeListener('astra:state-changed', handler)
|
||||
},
|
||||
onMainCloseRequested: (callback: () => void) => {
|
||||
const handler = (): void => callback()
|
||||
ipcRenderer.on('window:close-requested', handler)
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* do not accumulate backlog and queue overflow never reallocates.
|
||||
*/
|
||||
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../../types/scope'
|
||||
import { AUDIO_SCOPE_KINDS, type AudioScopeKind } from '../../types/scope'
|
||||
import type { CaptureBackendKind } from '../../types/capture'
|
||||
|
||||
const MAX_PENDING_CHUNKS = 20
|
||||
@@ -12,7 +12,7 @@ const MAX_PENDING_SPECTRUM_CHUNKS = 96
|
||||
const MAX_PENDING_VECTORSCOPE_CHUNKS = 20
|
||||
const LATENCY_SAMPLE_WINDOW = 240
|
||||
|
||||
const SCOPE_RING_CAPACITY: Record<ScopeKind, number> = {
|
||||
const SCOPE_RING_CAPACITY: Record<AudioScopeKind, number> = {
|
||||
spectrum: MAX_PENDING_SPECTRUM_CHUNKS,
|
||||
oscilloscope: MAX_PENDING_CHUNKS,
|
||||
vectorscope: MAX_PENDING_VECTORSCOPE_CHUNKS,
|
||||
@@ -57,7 +57,7 @@ export interface AudioRouterDiagnostics {
|
||||
notCapturingDrops: number
|
||||
staleSessionDrops: number
|
||||
undemandedChunks: number
|
||||
scopes: Record<ScopeKind, AudioRouterScopeDiagnostics>
|
||||
scopes: Record<AudioScopeKind, AudioRouterScopeDiagnostics>
|
||||
}
|
||||
|
||||
export type NormalizedVisualizerConsumerDemand = Required<VisualizerConsumerDemand>
|
||||
@@ -221,7 +221,7 @@ export class AudioRouter {
|
||||
waveform: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.waveform),
|
||||
}
|
||||
|
||||
private readonly scopeLatency: Record<ScopeKind, ScopeLatencyTracker> = {
|
||||
private readonly scopeLatency: Record<AudioScopeKind, ScopeLatencyTracker> = {
|
||||
spectrum: createScopeLatencyTracker(),
|
||||
oscilloscope: createScopeLatencyTracker(),
|
||||
vectorscope: createScopeLatencyTracker(),
|
||||
@@ -480,7 +480,7 @@ export class AudioRouter {
|
||||
let totalOverwriteCount = 0
|
||||
let overallP95CaptureToScopeMs: number | null = null
|
||||
|
||||
const scopes = SCOPE_KINDS.reduce<Record<ScopeKind, AudioRouterScopeDiagnostics>>((result, scope) => {
|
||||
const scopes = AUDIO_SCOPE_KINDS.reduce<Record<AudioScopeKind, AudioRouterScopeDiagnostics>>((result, scope) => {
|
||||
const scopeTracker = this.scopeLatency[scope]
|
||||
const overwriteCount = this.getRing(scope).getOverwriteCount()
|
||||
totalOverwriteCount += overwriteCount
|
||||
@@ -501,7 +501,7 @@ export class AudioRouter {
|
||||
lastSequence: scopeTracker.lastSequence,
|
||||
}
|
||||
return result
|
||||
}, {} as Record<ScopeKind, AudioRouterScopeDiagnostics>)
|
||||
}, {} as Record<AudioScopeKind, AudioRouterScopeDiagnostics>)
|
||||
|
||||
return {
|
||||
updatedAt: performance.now(),
|
||||
@@ -523,7 +523,7 @@ export class AudioRouter {
|
||||
private getActiveDemand(): NormalizedVisualizerConsumerDemand {
|
||||
const aggregated = createEmptyDemand()
|
||||
for (const demand of this.consumerDemand.values()) {
|
||||
for (const scope of SCOPE_KINDS) {
|
||||
for (const scope of AUDIO_SCOPE_KINDS) {
|
||||
if (demand[scope]) {
|
||||
aggregated[scope] = true
|
||||
}
|
||||
@@ -541,7 +541,7 @@ export class AudioRouter {
|
||||
|
||||
private pruneQueuesForDemand(): void {
|
||||
const activeDemand = this.getActiveDemand()
|
||||
for (const scope of SCOPE_KINDS) {
|
||||
for (const scope of AUDIO_SCOPE_KINDS) {
|
||||
if (!activeDemand[scope]) {
|
||||
this.getRing(scope).clear()
|
||||
}
|
||||
@@ -549,13 +549,13 @@ export class AudioRouter {
|
||||
}
|
||||
|
||||
private clearAllRings(): void {
|
||||
for (const scope of SCOPE_KINDS) {
|
||||
for (const scope of AUDIO_SCOPE_KINDS) {
|
||||
this.getRing(scope).clear()
|
||||
}
|
||||
}
|
||||
|
||||
private resetLatencyTrackers(): void {
|
||||
for (const scope of SCOPE_KINDS) {
|
||||
for (const scope of AUDIO_SCOPE_KINDS) {
|
||||
const tracker = this.scopeLatency[scope]
|
||||
tracker.lastCaptureToScopeMs = null
|
||||
tracker.drainedChunks = 0
|
||||
@@ -564,7 +564,7 @@ export class AudioRouter {
|
||||
}
|
||||
}
|
||||
|
||||
private recordScopeDrain(scope: ScopeKind, records: Array<MonoChunkRecord | StereoChunkRecord>): void {
|
||||
private recordScopeDrain(scope: AudioScopeKind, records: Array<MonoChunkRecord | StereoChunkRecord>): void {
|
||||
if (records.length === 0) return
|
||||
|
||||
const tracker = this.scopeLatency[scope]
|
||||
@@ -578,7 +578,7 @@ export class AudioRouter {
|
||||
tracker.drainedChunks += records.length
|
||||
}
|
||||
|
||||
private getRing(scope: ScopeKind): FixedChunkRing<MonoChunkRecord> | FixedChunkRing<StereoChunkRecord> {
|
||||
private getRing(scope: AudioScopeKind): FixedChunkRing<MonoChunkRecord> | FixedChunkRing<StereoChunkRecord> {
|
||||
return this.rings[scope]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type JSX } from 'react'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
import type { ResolvedAstraTheme } from '../../types/theme'
|
||||
import { useAstraStore } from '../stores/astraStore'
|
||||
import { formatAstraTime, getAstraPlaybackProgress } from '../utils/astra'
|
||||
|
||||
interface AstraScopeModuleProps {
|
||||
theme: ResolvedAstraTheme
|
||||
settings: ScopeSettings['astra']
|
||||
}
|
||||
|
||||
function hasVisibleFields(settings: ScopeSettings['astra']): boolean {
|
||||
return settings.showCoverArt
|
||||
|| settings.showTitle
|
||||
|| settings.showArtist
|
||||
|| settings.showProgress
|
||||
|| settings.showTime
|
||||
|| settings.showControls
|
||||
}
|
||||
|
||||
function getFallbackTitle(connectionState: ReturnType<typeof useAstraStore.getState>['integrationState']['connectionState']): string {
|
||||
switch (connectionState) {
|
||||
case 'disabled':
|
||||
return 'Astra is off'
|
||||
case 'connecting':
|
||||
return 'Connecting to Astra'
|
||||
case 'error':
|
||||
return 'Astra connection failed'
|
||||
case 'connected':
|
||||
return 'Nothing playing'
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackDetail(connectionState: ReturnType<typeof useAstraStore.getState>['integrationState']['connectionState']): string {
|
||||
switch (connectionState) {
|
||||
case 'disabled':
|
||||
return 'Open the Astra scope to connect.'
|
||||
case 'connecting':
|
||||
return 'Waiting for the Astra API.'
|
||||
case 'error':
|
||||
return 'Check the Astra base URL and token.'
|
||||
case 'connected':
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export default function AstraScopeModule({
|
||||
theme,
|
||||
settings,
|
||||
}: AstraScopeModuleProps): JSX.Element {
|
||||
const initialize = useAstraStore((s) => s.initialize)
|
||||
const setScopeActive = useAstraStore((s) => s.setScopeActive)
|
||||
const integrationState = useAstraStore((s) => s.integrationState)
|
||||
const isSendingControl = useAstraStore((s) => s.isSendingControl)
|
||||
const sendControl = useAstraStore((s) => s.sendControl)
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
void setScopeActive(true)
|
||||
return () => {
|
||||
void setScopeActive(false)
|
||||
}
|
||||
}, [initialize, setScopeActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (integrationState.snapshot?.playbackState !== 'playing') {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now())
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [integrationState.snapshot?.playbackState])
|
||||
|
||||
const snapshot = integrationState.snapshot
|
||||
const currentTrack = snapshot?.currentTrack ?? null
|
||||
const liveProgress = useMemo(
|
||||
() => getAstraPlaybackProgress(snapshot, nowMs),
|
||||
[nowMs, snapshot],
|
||||
)
|
||||
const errorMessage = integrationState.lastError ?? integrationState.lastControlError
|
||||
const detailMessage = currentTrack?.artist
|
||||
?? (integrationState.connectionState === 'connected' ? null : getFallbackDetail(integrationState.connectionState))
|
||||
const style = {
|
||||
'--astra-accent': theme.accent,
|
||||
'--astra-bg': theme.background,
|
||||
'--astra-surface': theme.surface,
|
||||
'--astra-border': theme.border,
|
||||
'--astra-text': theme.text,
|
||||
'--astra-subtext': theme.subtext,
|
||||
'--astra-progress-track': theme.progressTrack,
|
||||
'--astra-progress-fill': theme.progressFill,
|
||||
'--astra-status-ok': theme.statusOk,
|
||||
'--astra-status-error': theme.statusError,
|
||||
} as CSSProperties
|
||||
|
||||
if (!hasVisibleFields(settings)) {
|
||||
return (
|
||||
<div className="astra-scope astra-scope--empty" style={style}>
|
||||
<div className="astra-scope__placeholder">
|
||||
All Astra elements are hidden.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const toggleCommand = snapshot?.playbackState === 'playing' ? 'pause' : 'play'
|
||||
const toggleLabel = snapshot?.playbackState === 'playing' ? 'Pause' : 'Play'
|
||||
const cardClassName = settings.showCoverArt
|
||||
? 'astra-scope__card'
|
||||
: 'astra-scope__card astra-scope__card--no-cover'
|
||||
|
||||
return (
|
||||
<div className="astra-scope" style={style}>
|
||||
<div className={cardClassName}>
|
||||
{settings.showCoverArt && (
|
||||
<div className="astra-scope__cover-shell">
|
||||
{currentTrack?.artworkDataUrl ? (
|
||||
<img
|
||||
className="astra-scope__cover"
|
||||
src={currentTrack.artworkDataUrl}
|
||||
alt={currentTrack.title}
|
||||
/>
|
||||
) : (
|
||||
<div className="astra-scope__cover astra-scope__cover--fallback" aria-hidden="true">
|
||||
<span>A</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="astra-scope__body">
|
||||
{(settings.showTitle || settings.showArtist) && (
|
||||
<div className="astra-scope__meta">
|
||||
{settings.showTitle && (
|
||||
<div className="astra-scope__title" title={currentTrack?.title ?? getFallbackTitle(integrationState.connectionState)}>
|
||||
{currentTrack?.title ?? getFallbackTitle(integrationState.connectionState)}
|
||||
</div>
|
||||
)}
|
||||
{settings.showArtist && detailMessage && (
|
||||
<div className="astra-scope__artist" title={detailMessage}>
|
||||
{detailMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(settings.showProgress || settings.showTime) && (
|
||||
<div className="astra-scope__transport">
|
||||
{settings.showProgress && (
|
||||
<div className="astra-scope__progress" aria-hidden="true">
|
||||
<div
|
||||
className="astra-scope__progress-fill"
|
||||
style={{ width: `${Math.max(0, Math.min(100, liveProgress.progress * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{settings.showTime && (
|
||||
<div className="astra-scope__time">
|
||||
<span>{formatAstraTime(liveProgress.currentTime)}</span>
|
||||
<span>/</span>
|
||||
<span>{liveProgress.duration > 0 ? formatAstraTime(liveProgress.duration) : '--:--'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.showControls && (
|
||||
<div className="astra-scope__controls">
|
||||
<button
|
||||
type="button"
|
||||
className="astra-scope__control"
|
||||
disabled={!currentTrack || isSendingControl}
|
||||
onClick={() => {
|
||||
void sendControl('previous')
|
||||
}}
|
||||
aria-label="Previous track"
|
||||
title="Previous track"
|
||||
>
|
||||
⏮
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="astra-scope__control astra-scope__control--primary"
|
||||
disabled={!currentTrack || isSendingControl}
|
||||
onClick={() => {
|
||||
void sendControl(toggleCommand)
|
||||
}}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
>
|
||||
{toggleLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="astra-scope__control"
|
||||
disabled={!currentTrack || isSendingControl}
|
||||
onClick={() => {
|
||||
void sendControl('next')
|
||||
}}
|
||||
aria-label="Next track"
|
||||
title="Next track"
|
||||
>
|
||||
⏭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div
|
||||
className="astra-scope__status is-error"
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useRef, type CSSProperties, type JSX, type WheelEvent } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX, type WheelEvent } from 'react'
|
||||
import { useAstraStore } from '../stores/astraStore'
|
||||
import { useAudioStore } from '../stores/audioStore'
|
||||
import { usePerformanceStore } from '../stores/performanceStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
@@ -7,6 +8,7 @@ import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
|
||||
import { SCOPE_KINDS } from '../../types/scope'
|
||||
import type { AstraIntegrationConfig } from '../../types/astra'
|
||||
import ThemedSelect from './ThemedSelect'
|
||||
|
||||
const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
@@ -17,6 +19,7 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
vumeter: 'VU Meter',
|
||||
lufsmeter: 'LUFS Meter',
|
||||
waveform: 'Waveform',
|
||||
astra: 'Astra',
|
||||
}
|
||||
|
||||
interface BottomBarProps {
|
||||
@@ -35,8 +38,11 @@ const FRAME_TARGET_LABELS: Record<VisualizerFrameTarget, string> = {
|
||||
|
||||
export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('')
|
||||
const [astraTokenInput, setAstraTokenInput] = useState('')
|
||||
|
||||
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
|
||||
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
|
||||
const toggleScope = useSettingsStore((s) => s.toggleScope)
|
||||
const frameTarget = usePerformanceStore((s) => s.frameTarget)
|
||||
const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps)
|
||||
@@ -53,6 +59,8 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
importThemeFromDialog,
|
||||
showThemesFolder,
|
||||
} = useThemeStore()
|
||||
const astraState = useAstraStore((s) => s.integrationState)
|
||||
const saveAstraConfig = useAstraStore((s) => s.saveConfig)
|
||||
|
||||
const {
|
||||
systemSources,
|
||||
@@ -79,6 +87,11 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
void refreshDevices()
|
||||
}, [refreshBackendSupport, refreshSystemSources, refreshDevices])
|
||||
|
||||
useEffect(() => {
|
||||
setAstraBaseUrlInput(astraState.config.baseUrl)
|
||||
setAstraTokenInput(astraState.config.token)
|
||||
}, [astraState.config.baseUrl, astraState.config.token])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!onHeightChange || !rootRef.current) return
|
||||
|
||||
@@ -168,6 +181,14 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
setThemeId(useThemeStore.getState().activeThemeId)
|
||||
}
|
||||
|
||||
const handleSaveAstraConfig = async (): Promise<void> => {
|
||||
const nextConfig: AstraIntegrationConfig = {
|
||||
baseUrl: astraBaseUrlInput,
|
||||
token: astraTokenInput,
|
||||
}
|
||||
await saveAstraConfig(nextConfig)
|
||||
}
|
||||
|
||||
const handleRailWheel = (event: WheelEvent<HTMLDivElement>): void => {
|
||||
const railElement = event.currentTarget
|
||||
const target = event.target
|
||||
@@ -190,6 +211,14 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const astraStatusLabel = astraState.connectionState === 'connected'
|
||||
? 'Connected'
|
||||
: astraState.connectionState === 'connecting'
|
||||
? 'Connecting'
|
||||
: astraState.connectionState === 'error'
|
||||
? 'Error'
|
||||
: 'Off'
|
||||
|
||||
return (
|
||||
<div className="bottom-bar" ref={rootRef}>
|
||||
<div className="bottom-bar__rail" aria-label="Global settings" onWheel={handleRailWheel}>
|
||||
@@ -199,7 +228,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
<div className="bottom-bar__section-body">
|
||||
<div className="bottom-bar__inline bottom-bar__inline--chips">
|
||||
{SCOPE_KINDS.map((kind) => {
|
||||
const active = !hiddenScopes.has(kind)
|
||||
const active = scopeOrder.includes(kind) && !hiddenScopes.has(kind)
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
@@ -297,6 +326,50 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
|
||||
<div className="bottom-bar__divider" />
|
||||
|
||||
<section className="bottom-bar__section bottom-bar__section--astra">
|
||||
<div className="bottom-bar__section-title">Astra</div>
|
||||
<div className="bottom-bar__section-body">
|
||||
<div className="bottom-bar__inline bottom-bar__inline--theme">
|
||||
<input
|
||||
className="bottom-bar__text-input bottom-bar__text-input--url"
|
||||
type="text"
|
||||
value={astraBaseUrlInput}
|
||||
placeholder="Astra Base URL"
|
||||
onChange={(event) => setAstraBaseUrlInput(event.target.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="bottom-bar__text-input bottom-bar__text-input--token"
|
||||
type="password"
|
||||
value={astraTokenInput}
|
||||
placeholder="Astra API Token"
|
||||
onChange={(event) => setAstraTokenInput(event.target.value)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="settings-chip"
|
||||
onClick={() => {
|
||||
void handleSaveAstraConfig()
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
<div className={`settings-status-pill ${astraState.connectionState === 'disabled' ? '' : `is-${astraState.connectionState}`}`.trim()}>
|
||||
<span className="settings-status-pill__dot" />
|
||||
<span>{astraStatusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{astraState.lastError ? (
|
||||
<div className="settings-error-text bottom-bar__error-text">{astraState.lastError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="bottom-bar__divider" />
|
||||
|
||||
<section className="bottom-bar__section bottom-bar__section--source">
|
||||
<div className="bottom-bar__section-title">Audio Source</div>
|
||||
<div className="bottom-bar__section-body">
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ScopeKind } from '../../types/scope'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
import type {
|
||||
PrismResolvedTheme,
|
||||
ResolvedAstraTheme,
|
||||
ResolvedLUFSMeterTheme,
|
||||
ResolvedOscilloscopeTheme,
|
||||
ResolvedSpectrogramTheme,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
} from '../../types/theme'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import AstraScopeModule from './AstraScopeModule'
|
||||
import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer'
|
||||
import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope'
|
||||
import { Vectorscope, type VectorscopeDataSource } from '../visualizers/Vectorscope'
|
||||
@@ -30,6 +32,7 @@ type ScopeModuleTheme =
|
||||
| ResolvedVUMeterTheme
|
||||
| ResolvedLUFSMeterTheme
|
||||
| ResolvedWaveformTheme
|
||||
| ResolvedAstraTheme
|
||||
|
||||
interface ScopeModuleProps {
|
||||
scopeKind: ScopeKind
|
||||
@@ -172,6 +175,8 @@ export function scopeSettingsToOptions(
|
||||
multiband: s.multiband,
|
||||
}
|
||||
}
|
||||
case 'astra':
|
||||
return {}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
@@ -222,6 +227,8 @@ function createVisualizer(
|
||||
...opts,
|
||||
...(dataSource ? { dataSource: dataSource as WaveformDataSource } : {}),
|
||||
})
|
||||
case 'astra':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -244,6 +251,15 @@ export default function ScopeModule({
|
||||
const mySettings = settings ?? storeSettings
|
||||
const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind)
|
||||
|
||||
if (scopeKind === 'astra') {
|
||||
return (
|
||||
<AstraScopeModule
|
||||
theme={myTheme as ResolvedAstraTheme}
|
||||
settings={mySettings as ScopeSettings['astra']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
@@ -10,17 +10,24 @@ import type {
|
||||
ScopePopoutSnapshot,
|
||||
ScopePopoutSyncStateMap,
|
||||
} from '../../types/popout'
|
||||
import { SCOPE_KINDS, SCOPE_LABELS, type ScopeKind } from '../../types/scope'
|
||||
import {
|
||||
AUDIO_SCOPE_KINDS,
|
||||
SCOPE_KINDS,
|
||||
SCOPE_LABELS,
|
||||
isAudioScopeKind,
|
||||
type AudioScopeKind,
|
||||
type ScopeKind,
|
||||
} from '../../types/scope'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
|
||||
function buildConsumerDemand(kind: ScopeKind): Record<ScopeKind, boolean> {
|
||||
return SCOPE_KINDS.reduce((acc, currentKind) => {
|
||||
function buildConsumerDemand(kind: AudioScopeKind): Record<AudioScopeKind, boolean> {
|
||||
return AUDIO_SCOPE_KINDS.reduce((acc, currentKind) => {
|
||||
acc[currentKind] = currentKind === kind
|
||||
return acc
|
||||
}, {} as Record<ScopeKind, boolean>)
|
||||
}, {} as Record<AudioScopeKind, boolean>)
|
||||
}
|
||||
|
||||
function flushScopeAudioBatch(kind: ScopeKind, scopeSettings: ScopeSettings): ScopePopoutAudioBatch {
|
||||
function flushScopeAudioBatch(kind: AudioScopeKind, scopeSettings: ScopeSettings): ScopePopoutAudioBatch {
|
||||
switch (kind) {
|
||||
case 'spectrum':
|
||||
return scopeSettings.spectrum.showSideLine
|
||||
@@ -111,6 +118,7 @@ export default function ScopePopoutBridge(): null {
|
||||
useEffect(() => {
|
||||
const sessionState = toPopoutSessionState(audioRouter.getSessionState())
|
||||
for (const kind of activePopoutKinds) {
|
||||
if (!isAudioScopeKind(kind)) continue
|
||||
window.electronAPI.sendScopePopoutSession(kind, sessionState)
|
||||
}
|
||||
}, [activePopoutKinds])
|
||||
@@ -138,7 +146,9 @@ export default function ScopePopoutBridge(): null {
|
||||
scopeTheme: useThemeStore.getState().activeTheme[kind],
|
||||
settings: useSettingsStore.getState().scopeSettings[kind],
|
||||
})
|
||||
window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState()))
|
||||
if (isAudioScopeKind(kind)) {
|
||||
window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState()))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -150,7 +160,7 @@ export default function ScopePopoutBridge(): null {
|
||||
}, [popInScope, updatePopoutBounds, updateScopeSettings])
|
||||
|
||||
useEffect(() => {
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
for (const kind of AUDIO_SCOPE_KINDS) {
|
||||
const consumerId = `popout:${kind}`
|
||||
if (activePopoutKinds.includes(kind)) {
|
||||
audioRouter.setVisualizerConsumerDemand(consumerId, buildConsumerDemand(kind))
|
||||
@@ -160,7 +170,7 @@ export default function ScopePopoutBridge(): null {
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
for (const kind of AUDIO_SCOPE_KINDS) {
|
||||
audioRouter.clearVisualizerConsumerDemand(`popout:${kind}`)
|
||||
}
|
||||
}
|
||||
@@ -175,6 +185,7 @@ export default function ScopePopoutBridge(): null {
|
||||
}
|
||||
|
||||
for (const kind of activePopoutKindsRef.current) {
|
||||
if (!isAudioScopeKind(kind)) continue
|
||||
const batch = flushScopeAudioBatch(kind, useSettingsStore.getState().scopeSettings)
|
||||
if (batch.length > 0) {
|
||||
window.electronAPI.sendScopePopoutAudio(kind, batch)
|
||||
@@ -216,6 +227,7 @@ export default function ScopePopoutBridge(): null {
|
||||
return audioRouter.subscribeToSessionChanges((state) => {
|
||||
const nextSessionState = toPopoutSessionState(state)
|
||||
for (const kind of activePopoutKindsRef.current) {
|
||||
if (!isAudioScopeKind(kind)) continue
|
||||
window.electronAPI.sendScopePopoutSession(kind, nextSessionState)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,6 +19,17 @@ function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): strin
|
||||
}
|
||||
}
|
||||
|
||||
function astraVisibleLabels(settings: ScopeSettings['astra']): string[] {
|
||||
const labels: string[] = []
|
||||
if (settings.showCoverArt) labels.push('Cover')
|
||||
if (settings.showTitle) labels.push('Title')
|
||||
if (settings.showArtist) labels.push('Artist')
|
||||
if (settings.showProgress) labels.push('Bar')
|
||||
if (settings.showTime) labels.push('Time')
|
||||
if (settings.showControls) labels.push('Controls')
|
||||
return labels
|
||||
}
|
||||
|
||||
export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
|
||||
switch (kind) {
|
||||
case 'spectrum': {
|
||||
@@ -58,6 +69,10 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
|
||||
}
|
||||
return summary.join(' · ')
|
||||
}
|
||||
case 'astra': {
|
||||
const visible = astraVisibleLabels(settings as ScopeSettings['astra'])
|
||||
return visible.length > 0 ? visible.join(' · ') : 'Hidden'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +514,44 @@ export default function ScopeSettingsSection({
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{kind === 'astra' && (() => {
|
||||
const current = settings as ScopeSettings['astra']
|
||||
return (
|
||||
<ToggleGroup label="Visible Elements">
|
||||
<ToggleChip
|
||||
label="Cover"
|
||||
active={current.showCoverArt}
|
||||
onClick={() => onUpdate('astra', { showCoverArt: !current.showCoverArt })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Title"
|
||||
active={current.showTitle}
|
||||
onClick={() => onUpdate('astra', { showTitle: !current.showTitle })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Artist"
|
||||
active={current.showArtist}
|
||||
onClick={() => onUpdate('astra', { showArtist: !current.showArtist })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Bar"
|
||||
active={current.showProgress}
|
||||
onClick={() => onUpdate('astra', { showProgress: !current.showProgress })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Time"
|
||||
active={current.showTime}
|
||||
onClick={() => onUpdate('astra', { showTime: !current.showTime })}
|
||||
/>
|
||||
<ToggleChip
|
||||
label="Controls"
|
||||
active={current.showControls}
|
||||
onClick={() => onUpdate('astra', { showControls: !current.showControls })}
|
||||
/>
|
||||
</ToggleGroup>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Vendored
+11
@@ -1,5 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type {
|
||||
AstraControlCommand,
|
||||
AstraIntegrationConfig,
|
||||
AstraIntegrationState,
|
||||
} from '../types/astra'
|
||||
import type { VisualizerDSP } from './audio/native/visualizer-dsp'
|
||||
import type { CaptureBackendSupport } from '../types/capture'
|
||||
import type { NativeCaptureAPI } from '../types/nativeCapture'
|
||||
@@ -44,6 +49,11 @@ declare global {
|
||||
isAlwaysOnTop: () => Promise<boolean>
|
||||
getDesktopSources: () => Promise<{ id: string; name: string }[]>
|
||||
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
|
||||
getAstraConfig: () => Promise<AstraIntegrationConfig>
|
||||
saveAstraConfig: (config: AstraIntegrationConfig) => Promise<AstraIntegrationConfig>
|
||||
getAstraState: () => Promise<AstraIntegrationState>
|
||||
setAstraActive: (active: boolean) => Promise<AstraIntegrationState>
|
||||
sendAstraControl: (command: AstraControlCommand) => Promise<AstraIntegrationState>
|
||||
getProfileSnapshot: () => Promise<ProfileLibrarySnapshot>
|
||||
saveNewProfile: (name: string, profile: Profile) => Promise<ProfileLibrarySnapshot>
|
||||
overwriteProfile: (id: string, profile: Profile) => Promise<ProfileLibrarySnapshot>
|
||||
@@ -81,6 +91,7 @@ declare global {
|
||||
onToggleCapture: (callback: () => void) => () => void
|
||||
onToggleSettings: (callback: () => void) => () => void
|
||||
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => () => void
|
||||
onAstraStateChanged: (callback: (state: AstraIntegrationState) => void) => () => void
|
||||
onMainCloseRequested: (callback: () => void) => () => void
|
||||
onProfileMenuClosed: (callback: () => void) => () => void
|
||||
onProfileMenuLoad: (callback: (id: string) => void) => () => void
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
DEFAULT_ASTRA_BASE_URL,
|
||||
type AstraControlCommand,
|
||||
type AstraIntegrationConfig,
|
||||
type AstraIntegrationState,
|
||||
} from '../../types/astra'
|
||||
|
||||
interface AstraStoreState {
|
||||
initialized: boolean
|
||||
integrationState: AstraIntegrationState
|
||||
isSendingControl: boolean
|
||||
initialize: () => Promise<void>
|
||||
saveConfig: (config: AstraIntegrationConfig) => Promise<void>
|
||||
setScopeActive: (active: boolean) => Promise<void>
|
||||
sendControl: (command: AstraControlCommand) => Promise<void>
|
||||
applyExternalState: (state: AstraIntegrationState) => void
|
||||
}
|
||||
|
||||
function createDefaultIntegrationState(): AstraIntegrationState {
|
||||
return {
|
||||
config: {
|
||||
baseUrl: DEFAULT_ASTRA_BASE_URL,
|
||||
token: '',
|
||||
},
|
||||
connectionState: 'disabled',
|
||||
lastError: null,
|
||||
lastControlError: null,
|
||||
snapshot: null,
|
||||
}
|
||||
}
|
||||
|
||||
let initializePromise: Promise<void> | null = null
|
||||
let syncBound = false
|
||||
|
||||
export const useAstraStore = create<AstraStoreState>((set) => ({
|
||||
initialized: false,
|
||||
integrationState: createDefaultIntegrationState(),
|
||||
isSendingControl: false,
|
||||
|
||||
initialize: async () => {
|
||||
if (initializePromise) {
|
||||
await initializePromise
|
||||
return
|
||||
}
|
||||
|
||||
initializePromise = (async () => {
|
||||
if (typeof window === 'undefined' || typeof window.electronAPI === 'undefined') {
|
||||
set({ initialized: true })
|
||||
return
|
||||
}
|
||||
|
||||
const state = await window.electronAPI.getAstraState()
|
||||
set({
|
||||
initialized: true,
|
||||
integrationState: state,
|
||||
})
|
||||
})()
|
||||
|
||||
try {
|
||||
await initializePromise
|
||||
} finally {
|
||||
initializePromise = null
|
||||
}
|
||||
},
|
||||
|
||||
saveConfig: async (config) => {
|
||||
const savedConfig = await window.electronAPI.saveAstraConfig(config)
|
||||
const nextState = await window.electronAPI.getAstraState()
|
||||
set({
|
||||
integrationState: {
|
||||
...nextState,
|
||||
config: savedConfig,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
setScopeActive: async (active) => {
|
||||
const nextState = await window.electronAPI.setAstraActive(active)
|
||||
set({
|
||||
integrationState: nextState,
|
||||
})
|
||||
},
|
||||
|
||||
sendControl: async (command) => {
|
||||
set({ isSendingControl: true })
|
||||
try {
|
||||
const nextState = await window.electronAPI.sendAstraControl(command)
|
||||
set({
|
||||
integrationState: nextState,
|
||||
})
|
||||
} finally {
|
||||
set({ isSendingControl: false })
|
||||
}
|
||||
},
|
||||
|
||||
applyExternalState: (state) => {
|
||||
set({
|
||||
initialized: true,
|
||||
integrationState: state,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
function bindAstraStateSync(): void {
|
||||
if (syncBound || typeof window === 'undefined' || typeof window.electronAPI === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
syncBound = true
|
||||
window.electronAPI.onAstraStateChanged((state) => {
|
||||
useAstraStore.getState().applyExternalState(state)
|
||||
})
|
||||
}
|
||||
|
||||
bindAstraStateSync()
|
||||
@@ -510,6 +510,14 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
toggleScope: (kind: ScopeKind) => {
|
||||
set((state) => {
|
||||
const next = new Set(state.hiddenScopes)
|
||||
if (!state.scopeOrder.includes(kind)) {
|
||||
next.delete(kind)
|
||||
return commitWorkingState(state, {
|
||||
scopeOrder: [...state.scopeOrder, kind],
|
||||
hiddenScopes: next,
|
||||
})
|
||||
}
|
||||
|
||||
if (next.has(kind)) {
|
||||
next.delete(kind)
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
type PrismProfileFileV2,
|
||||
type PrismProfileLocalStateV1,
|
||||
} from '../types/profile'
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
|
||||
import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, type ScopeKind } from '../types/scope'
|
||||
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
|
||||
|
||||
export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
|
||||
export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS]
|
||||
|
||||
export const DEFAULT_SCOPE_WIDTH_WEIGHTS: Record<ScopeKind, number> = {
|
||||
spectrum: 1,
|
||||
@@ -25,6 +26,7 @@ export const DEFAULT_SCOPE_WIDTH_WEIGHTS: Record<ScopeKind, number> = {
|
||||
vumeter: 0.5,
|
||||
lufsmeter: 0.5,
|
||||
waveform: 1,
|
||||
astra: 1,
|
||||
}
|
||||
|
||||
export function isScopeKind(value: unknown): value is ScopeKind {
|
||||
@@ -68,7 +70,7 @@ export function normalizeWindowBounds(
|
||||
}
|
||||
|
||||
export function normalizeScopeOrder(raw: unknown): ScopeKind[] {
|
||||
if (!Array.isArray(raw)) return [...SCOPE_KINDS]
|
||||
if (!Array.isArray(raw)) return [...DEFAULT_SCOPE_ORDER]
|
||||
|
||||
const valid = raw.filter(isScopeKind)
|
||||
const seen = new Set<ScopeKind>()
|
||||
@@ -80,7 +82,7 @@ export function normalizeScopeOrder(raw: unknown): ScopeKind[] {
|
||||
normalized.push(kind)
|
||||
}
|
||||
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
for (const kind of DEFAULT_SCOPE_ORDER) {
|
||||
if (!seen.has(kind)) {
|
||||
normalized.push(kind)
|
||||
}
|
||||
@@ -124,6 +126,7 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings {
|
||||
vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, ...(parsed.vumeter ?? {}) },
|
||||
lufsmeter: { ...DEFAULT_SCOPE_SETTINGS.lufsmeter, ...(parsed.lufsmeter ?? {}) },
|
||||
waveform: { ...DEFAULT_SCOPE_SETTINGS.waveform, ...(parsed.waveform ?? {}) },
|
||||
astra: { ...DEFAULT_SCOPE_SETTINGS.astra, ...(parsed.astra ?? {}) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +157,7 @@ export function createDefaultProfile(name = DEFAULT_PROFILE_NAME): Profile {
|
||||
return {
|
||||
name,
|
||||
themeId: null,
|
||||
scopeOrder: [...SCOPE_KINDS],
|
||||
scopeOrder: [...DEFAULT_SCOPE_ORDER],
|
||||
hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)),
|
||||
widthWeights: { ...DEFAULT_SCOPE_WIDTH_WEIGHTS },
|
||||
scopeSettings: cloneScopeSettings(DEFAULT_SCOPE_SETTINGS),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type PrismResolvedTheme,
|
||||
type PrismTheme,
|
||||
type PrismThemeLocalStateV1,
|
||||
type ResolvedAstraTheme,
|
||||
type ResolvedInterfaceTheme,
|
||||
type ResolvedLUFSMeterTheme,
|
||||
type ResolvedOscilloscopeTheme,
|
||||
@@ -39,6 +40,7 @@ const MODULE_SECTION_ORDER: ThemeSectionName[] = [
|
||||
'vumeter',
|
||||
'lufsmeter',
|
||||
'waveform',
|
||||
'astra',
|
||||
]
|
||||
|
||||
const COLOR_KEY_ORDER: Array<keyof ThemeTokens> = [
|
||||
@@ -72,6 +74,7 @@ const SECTION_KEY_MAP: Record<string, ThemeSectionName> = {
|
||||
vumeter: 'vumeter',
|
||||
lufsmeter: 'lufsmeter',
|
||||
waveform: 'waveform',
|
||||
astra: 'astra',
|
||||
}
|
||||
|
||||
const TOKEN_KEY_MAP: Record<string, keyof ThemeTokens> = {
|
||||
@@ -328,6 +331,7 @@ function createEmptyTheme(): PrismTheme {
|
||||
vumeter: createEmptyThemeTokens(),
|
||||
lufsmeter: createEmptyThemeTokens(),
|
||||
waveform: createEmptyThemeTokens(),
|
||||
astra: createEmptyThemeTokens(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +472,7 @@ export function normalizeTheme(
|
||||
normalized.vumeter = normalizeTokens(parsed.vumeter)
|
||||
normalized.lufsmeter = normalizeTokens(parsed.lufsmeter)
|
||||
normalized.waveform = normalizeTokens(parsed.waveform)
|
||||
normalized.astra = normalizeTokens(parsed.astra)
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -635,6 +640,8 @@ export function serializeThemeFile(theme: PrismTheme): string {
|
||||
? 'VUMeter'
|
||||
: section === 'lufsmeter'
|
||||
? 'LUFSMeter'
|
||||
: section === 'astra'
|
||||
? 'Astra'
|
||||
: `${section.charAt(0).toUpperCase()}${section.slice(1)}`
|
||||
output.push(serializeSection(label, tokens).join('\n'))
|
||||
}
|
||||
@@ -643,7 +650,7 @@ export function serializeThemeFile(theme: PrismTheme): string {
|
||||
}
|
||||
|
||||
export function createTemplateThemeFile(): string {
|
||||
return `# Prism theme template\n#\n# Authoring rules:\n# - Colors use R, G, B or R, G, B, A (0-255)\n# - Omit sections or keys you do not want to override\n# - [All] sets the defaults for everything else\n# - [Interface] overrides the app window, controls, and menus\n# - Module sections only need the colors that should differ from [All]\n\n[Theme]\nformat = ${THEME_FILE_FORMAT}\nversion = ${THEME_FILE_VERSION}\nid = theme_template\nname = Template Theme\ncredit = Your Name\nwebsite = https://example.com\n\n[All]\nprimary = 56, 189, 248\nsecondary = 172, 192, 222\nguides = 255, 255, 255, 26\ntext = 255, 255, 255\nbackground = 0, 0, 0\nlow_band = 255, 68, 68\nmid_band = 68, 221, 68\nhigh_band = 68, 136, 255\nsuccess = 34, 197, 94\nwarning = 255, 191, 0\ndanger = 248, 113, 113\n\n[Interface]\nsecondary = 8, 11, 16, 235\nguides = 255, 255, 255, 23\nbackground = 0, 0, 0\n\n[Spectrum]\nsecondary = 56, 189, 248, 127\nheat_low = 15, 7, 33\nheat_mid = 163, 26, 121\nheat_high = 255, 241, 209\n\n[Oscilloscope]\nfill = 245, 248, 252, 46\n\n[VUMeter]\npeak = 255, 127, 0\nclip = 255, 120, 80, 230\n\n[LUFSMeter]\ntarget = 56, 189, 248, 64\n`
|
||||
return `# Prism theme template\n#\n# Authoring rules:\n# - Colors use R, G, B or R, G, B, A (0-255)\n# - Omit sections or keys you do not want to override\n# - [All] sets the defaults for everything else\n# - [Interface] overrides the app window, controls, and menus\n# - Module sections only need the colors that should differ from [All]\n\n[Theme]\nformat = ${THEME_FILE_FORMAT}\nversion = ${THEME_FILE_VERSION}\nid = theme_template\nname = Template Theme\ncredit = Your Name\nwebsite = https://example.com\n\n[All]\nprimary = 56, 189, 248\nsecondary = 172, 192, 222\nguides = 255, 255, 255, 26\ntext = 255, 255, 255\nbackground = 0, 0, 0\nlow_band = 255, 68, 68\nmid_band = 68, 221, 68\nhigh_band = 68, 136, 255\nsuccess = 34, 197, 94\nwarning = 255, 191, 0\ndanger = 248, 113, 113\n\n[Interface]\nsecondary = 8, 11, 16, 235\nguides = 255, 255, 255, 23\nbackground = 0, 0, 0\n\n[Spectrum]\nsecondary = 56, 189, 248, 127\nheat_low = 15, 7, 33\nheat_mid = 163, 26, 121\nheat_high = 255, 241, 209\n\n[Oscilloscope]\nfill = 245, 248, 252, 46\n\n[VUMeter]\npeak = 255, 127, 0\nclip = 255, 120, 80, 230\n\n[LUFSMeter]\ntarget = 56, 189, 248, 64\n\n[Astra]\nsecondary = 10, 16, 24, 235\nguides = 255, 255, 255, 31\nbackground = 4, 8, 12, 230\n`
|
||||
}
|
||||
|
||||
function getThemeFallbackSection(base: ThemeTokens): Required<ThemeTokens> {
|
||||
@@ -807,6 +814,31 @@ function resolveWaveformTheme(theme: PrismTheme, all: Required<ThemeTokens>): Re
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAstraTheme(theme: PrismTheme, all: Required<ThemeTokens>): ResolvedAstraTheme {
|
||||
const section = getThemeFallbackSection(mergeThemeTokens(all, theme.astra))
|
||||
const background = section.background
|
||||
const surface = theme.astra.secondary ?? withAlpha(section.secondary, 0.16)
|
||||
const guides = theme.astra.guides ?? all.guides
|
||||
|
||||
return {
|
||||
accent: section.primary,
|
||||
text: section.text,
|
||||
subtext: withAlpha(section.text, 0.7),
|
||||
background,
|
||||
surface,
|
||||
border: guides,
|
||||
progressTrack: withAlpha(guides, 0.3),
|
||||
progressFill: section.primary,
|
||||
buttonBg: surface,
|
||||
buttonBgHover: withAlpha(mixColors(surface, section.primary, 0.14), 0.94),
|
||||
buttonBgActive: withAlpha(section.primary, 0.16),
|
||||
buttonBorder: guides,
|
||||
buttonText: section.text,
|
||||
statusOk: section.success,
|
||||
statusError: section.danger,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: PrismTheme): PrismResolvedTheme {
|
||||
const normalized = normalizeTheme(theme, theme.id, theme.name)
|
||||
const baseAll = getThemeFallbackSection(mergeThemeTokens(createDefaultTheme().all, normalized.all))
|
||||
@@ -825,6 +857,7 @@ export function resolveTheme(theme: PrismTheme): PrismResolvedTheme {
|
||||
vumeter: resolveVUMeterTheme(normalized, baseAll),
|
||||
lufsmeter: resolveLUFSMeterTheme(normalized, baseAll),
|
||||
waveform: resolveWaveformTheme(normalized, baseAll),
|
||||
astra: resolveAstraTheme(normalized, baseAll),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export const DEFAULT_ASTRA_BASE_URL = 'http://127.0.0.1:38401'
|
||||
|
||||
export type AstraPlaybackState = 'stopped' | 'playing' | 'paused' | 'loading'
|
||||
export type AstraConnectionState = 'disabled' | 'connecting' | 'connected' | 'error'
|
||||
export type AstraControlCommand = 'play' | 'pause' | 'next' | 'previous'
|
||||
|
||||
export interface AstraTrackSnapshot {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
isFavorite: boolean
|
||||
artworkDataUrl: string | null
|
||||
}
|
||||
|
||||
export interface AstraNowPlayingSnapshot {
|
||||
playbackState: AstraPlaybackState
|
||||
currentTime: number
|
||||
duration: number
|
||||
queueLength: number
|
||||
outputDeviceLabel: string | null
|
||||
visualizerLineColor: string
|
||||
currentTrack: AstraTrackSnapshot | null
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AstraIntegrationConfig {
|
||||
baseUrl: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface AstraIntegrationState {
|
||||
config: AstraIntegrationConfig
|
||||
connectionState: AstraConnectionState
|
||||
lastError: string | null
|
||||
lastControlError: string | null
|
||||
snapshot: AstraNowPlayingSnapshot | null
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { CaptureBackendKind } from './capture'
|
||||
import type { ScopeKind } from './scope'
|
||||
import type { ScopeSettings } from './settings'
|
||||
import type {
|
||||
ResolvedAstraTheme,
|
||||
ResolvedInterfaceTheme,
|
||||
ResolvedLUFSMeterTheme,
|
||||
ResolvedOscilloscopeTheme,
|
||||
@@ -58,6 +59,7 @@ export type ScopePopoutResolvedScopeTheme =
|
||||
| ResolvedVUMeterTheme
|
||||
| ResolvedLUFSMeterTheme
|
||||
| ResolvedWaveformTheme
|
||||
| ResolvedAstraTheme
|
||||
|
||||
export interface ScopePopoutSnapshot<K extends ScopeKind = ScopeKind> {
|
||||
kind: K
|
||||
|
||||
+27
-1
@@ -1,4 +1,14 @@
|
||||
export type ScopeKind = 'spectrum' | 'oscilloscope' | 'vectorscope' | 'spectrogram' | 'vumeter' | 'lufsmeter' | 'waveform'
|
||||
export type ScopeKind =
|
||||
| 'spectrum'
|
||||
| 'oscilloscope'
|
||||
| 'vectorscope'
|
||||
| 'spectrogram'
|
||||
| 'vumeter'
|
||||
| 'lufsmeter'
|
||||
| 'waveform'
|
||||
| 'astra'
|
||||
|
||||
export type AudioScopeKind = Exclude<ScopeKind, 'astra'>
|
||||
|
||||
export const SCOPE_KINDS: ScopeKind[] = [
|
||||
'spectrum',
|
||||
@@ -8,8 +18,23 @@ export const SCOPE_KINDS: ScopeKind[] = [
|
||||
'vumeter',
|
||||
'lufsmeter',
|
||||
'waveform',
|
||||
'astra',
|
||||
]
|
||||
|
||||
export const AUDIO_SCOPE_KINDS: AudioScopeKind[] = [
|
||||
'spectrum',
|
||||
'oscilloscope',
|
||||
'vectorscope',
|
||||
'spectrogram',
|
||||
'vumeter',
|
||||
'lufsmeter',
|
||||
'waveform',
|
||||
]
|
||||
|
||||
export function isAudioScopeKind(value: unknown): value is AudioScopeKind {
|
||||
return typeof value === 'string' && AUDIO_SCOPE_KINDS.includes(value as AudioScopeKind)
|
||||
}
|
||||
|
||||
export const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
spectrum: 'Spectrum',
|
||||
oscilloscope: 'Oscilloscope',
|
||||
@@ -18,4 +43,5 @@ export const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
vumeter: 'VU Meter',
|
||||
lufsmeter: 'LUFS Meter',
|
||||
waveform: 'Waveform',
|
||||
astra: 'Astra',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { AstraIntegrationService, normalizeAstraIntegrationConfig } from '../src/main/services/astraIntegration'
|
||||
import { DEFAULT_ASTRA_BASE_URL, type AstraIntegrationConfig } from '../src/types/astra'
|
||||
|
||||
function createJsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createPngResponse(payload = 'artwork'): Response {
|
||||
return new Response(Buffer.from(payload), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'image/png',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createHeaders(init?: RequestInit): Headers {
|
||||
return new Headers(init?.headers)
|
||||
}
|
||||
|
||||
function createFakeTimers(): {
|
||||
clearTimeoutImpl: (handle: ReturnType<typeof setTimeout>) => void
|
||||
nextDelay: () => number | null
|
||||
pendingCount: () => number
|
||||
runNext: () => void
|
||||
setTimeoutImpl: typeof setTimeout
|
||||
} {
|
||||
let nextHandle = 1
|
||||
const timers = new Map<number, { callback: () => void; delay: number }>()
|
||||
|
||||
return {
|
||||
setTimeoutImpl(callback: TimerHandler, delay?: number): ReturnType<typeof setTimeout> {
|
||||
const handle = nextHandle
|
||||
nextHandle += 1
|
||||
timers.set(handle, {
|
||||
callback: typeof callback === 'function' ? callback as () => void : () => {},
|
||||
delay: typeof delay === 'number' ? delay : 0,
|
||||
})
|
||||
return handle as ReturnType<typeof setTimeout>
|
||||
},
|
||||
clearTimeoutImpl(handle: ReturnType<typeof setTimeout>): void {
|
||||
timers.delete(Number(handle))
|
||||
},
|
||||
nextDelay: () => {
|
||||
const next = timers.values().next().value as { delay: number } | undefined
|
||||
return next?.delay ?? null
|
||||
},
|
||||
pendingCount: () => timers.size,
|
||||
runNext: () => {
|
||||
const next = timers.entries().next().value as [number, { callback: () => void }] | undefined
|
||||
if (!next) return
|
||||
const [handle, timer] = next
|
||||
timers.delete(handle)
|
||||
timer.callback()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createSseStream(): {
|
||||
close: () => void
|
||||
pushEvent: (event: string, payload: unknown) => void
|
||||
response: Response
|
||||
} {
|
||||
const encoder = new TextEncoder()
|
||||
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null
|
||||
|
||||
const response = new Response(new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controllerRef = controller
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
response,
|
||||
pushEvent(event, payload) {
|
||||
controllerRef?.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`))
|
||||
},
|
||||
close() {
|
||||
controllerRef?.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 2000
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
assert.fail(message)
|
||||
}
|
||||
|
||||
async function createConfigFile(config: AstraIntegrationConfig): Promise<{
|
||||
cleanup: () => Promise<void>
|
||||
configPath: string
|
||||
}> {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'prism-astra-tests-'))
|
||||
const configPath = join(rootDir, 'userData', 'astra-integration.json')
|
||||
await mkdir(dirname(configPath), { recursive: true })
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')
|
||||
return {
|
||||
configPath,
|
||||
cleanup: () => rm(rootDir, { recursive: true, force: true }),
|
||||
}
|
||||
}
|
||||
|
||||
test('normalizeAstraIntegrationConfig applies defaults and trims input', () => {
|
||||
const config = normalizeAstraIntegrationConfig({
|
||||
baseUrl: ' http://127.0.0.1:38401/ ',
|
||||
token: ' secret ',
|
||||
})
|
||||
|
||||
assert.deepEqual(config, {
|
||||
baseUrl: DEFAULT_ASTRA_BASE_URL,
|
||||
token: 'secret',
|
||||
})
|
||||
})
|
||||
|
||||
test('service initializes from config, hydrates artwork, and applies SSE updates', async () => {
|
||||
const harness = await createConfigFile({
|
||||
baseUrl: DEFAULT_ASTRA_BASE_URL,
|
||||
token: 'secret-token',
|
||||
})
|
||||
const sse = createSseStream()
|
||||
const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-1`
|
||||
const calls: Array<{ init?: RequestInit; url: string }> = []
|
||||
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = String(input)
|
||||
calls.push({ url, init })
|
||||
const pathname = new URL(url).pathname
|
||||
const headers = createHeaders(init)
|
||||
assert.equal(headers.get('authorization'), 'Bearer secret-token')
|
||||
|
||||
if (pathname === '/v1/now-playing') {
|
||||
return createJsonResponse({
|
||||
playbackState: 'playing',
|
||||
currentTime: 15,
|
||||
duration: 180,
|
||||
queueLength: 2,
|
||||
outputDeviceLabel: 'Built-in Output',
|
||||
visualizerLineColor: '#4ade80',
|
||||
updatedAt: 1000,
|
||||
currentTrack: {
|
||||
id: 'track-1',
|
||||
title: 'Song One',
|
||||
artist: 'Artist One',
|
||||
album: 'Album One',
|
||||
isFavorite: false,
|
||||
artworkUrl,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === '/v1/artwork/current') {
|
||||
return createPngResponse('cover-one')
|
||||
}
|
||||
|
||||
if (pathname === '/v1/events') {
|
||||
assert.equal(headers.get('accept'), 'text/event-stream')
|
||||
return sse.response
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
}
|
||||
|
||||
const service = new AstraIntegrationService({
|
||||
configPath: harness.configPath,
|
||||
fetchImpl,
|
||||
now: () => 1000,
|
||||
})
|
||||
|
||||
try {
|
||||
await service.initialize()
|
||||
assert.equal(service.getState().connectionState, 'disabled')
|
||||
await service.setConsumerActive(1, true)
|
||||
await waitFor(() => service.getState().connectionState === 'connected', 'expected connected Astra state')
|
||||
|
||||
const initialState = service.getState()
|
||||
assert.equal(initialState.snapshot?.currentTrack?.title, 'Song One')
|
||||
assert.match(initialState.snapshot?.currentTrack?.artworkDataUrl ?? '', /^data:image\/png;base64,/)
|
||||
assert.equal(calls.some((call) => call.url.endsWith('/v1/artwork/current?trackId=track-1')), true)
|
||||
|
||||
sse.pushEvent('now-playing', {
|
||||
playbackState: 'paused',
|
||||
currentTime: 40,
|
||||
duration: 180,
|
||||
queueLength: 2,
|
||||
outputDeviceLabel: 'Built-in Output',
|
||||
visualizerLineColor: '#f97316',
|
||||
updatedAt: 2500,
|
||||
currentTrack: {
|
||||
id: 'track-2',
|
||||
title: 'Song Two',
|
||||
artist: 'Artist Two',
|
||||
album: 'Album Two',
|
||||
isFavorite: true,
|
||||
artworkUrl: null,
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => service.getState().snapshot?.currentTrack?.id === 'track-2', 'expected SSE track update')
|
||||
const nextState = service.getState()
|
||||
assert.equal(nextState.snapshot?.playbackState, 'paused')
|
||||
assert.equal(nextState.snapshot?.currentTrack?.artworkDataUrl, null)
|
||||
|
||||
await service.setConsumerActive(1, false)
|
||||
assert.equal(service.getState().connectionState, 'disabled')
|
||||
assert.equal(service.getState().snapshot, null)
|
||||
} finally {
|
||||
await service.dispose()
|
||||
await harness.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('service schedules reconnect when the SSE stream closes', async () => {
|
||||
const harness = await createConfigFile({
|
||||
baseUrl: DEFAULT_ASTRA_BASE_URL,
|
||||
token: 'secret-token',
|
||||
})
|
||||
const timers = createFakeTimers()
|
||||
const steadyStream = createSseStream()
|
||||
let eventStreamRequests = 0
|
||||
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
const url = String(input)
|
||||
const pathname = new URL(url).pathname
|
||||
|
||||
if (pathname === '/v1/now-playing') {
|
||||
return createJsonResponse({
|
||||
playbackState: 'stopped',
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
queueLength: 0,
|
||||
outputDeviceLabel: null,
|
||||
visualizerLineColor: '#38bdf8',
|
||||
updatedAt: 0,
|
||||
currentTrack: null,
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === '/v1/events') {
|
||||
eventStreamRequests += 1
|
||||
if (eventStreamRequests === 1) {
|
||||
return new Response(new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return steadyStream.response
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
}
|
||||
|
||||
const service = new AstraIntegrationService({
|
||||
configPath: harness.configPath,
|
||||
fetchImpl,
|
||||
setTimeoutImpl: timers.setTimeoutImpl,
|
||||
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||
})
|
||||
|
||||
try {
|
||||
await service.initialize()
|
||||
await service.setConsumerActive(1, true)
|
||||
await waitFor(() => service.getState().connectionState === 'error', 'expected reconnect error state')
|
||||
assert.equal(timers.nextDelay(), 1000)
|
||||
assert.equal(timers.pendingCount(), 1)
|
||||
|
||||
timers.runNext()
|
||||
|
||||
await waitFor(() => eventStreamRequests === 2, 'expected second SSE connection attempt')
|
||||
await waitFor(() => service.getState().connectionState === 'connected', 'expected connected state after reconnect')
|
||||
} finally {
|
||||
await service.dispose()
|
||||
await harness.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('service surfaces 401 and 403 control errors and clears them after success', async () => {
|
||||
const harness = await createConfigFile({
|
||||
baseUrl: DEFAULT_ASTRA_BASE_URL,
|
||||
token: 'secret-token',
|
||||
})
|
||||
const sse = createSseStream()
|
||||
let controlRequests = 0
|
||||
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = String(input)
|
||||
const pathname = new URL(url).pathname
|
||||
|
||||
if (pathname === '/v1/now-playing') {
|
||||
return createJsonResponse({
|
||||
playbackState: 'paused',
|
||||
currentTime: 20,
|
||||
duration: 180,
|
||||
queueLength: 1,
|
||||
outputDeviceLabel: 'Built-in Output',
|
||||
visualizerLineColor: '#38bdf8',
|
||||
updatedAt: 0,
|
||||
currentTrack: {
|
||||
id: 'track-1',
|
||||
title: 'Track',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
isFavorite: false,
|
||||
artworkUrl: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === '/v1/events') {
|
||||
return sse.response
|
||||
}
|
||||
|
||||
if (pathname === '/v1/control') {
|
||||
const headers = createHeaders(init)
|
||||
assert.equal(headers.get('authorization'), 'Bearer secret-token')
|
||||
controlRequests += 1
|
||||
if (controlRequests === 1) {
|
||||
return createJsonResponse({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
if (controlRequests === 2) {
|
||||
return createJsonResponse({ error: 'External playback controls are disabled.' }, 403)
|
||||
}
|
||||
return createJsonResponse({ ok: true }, 200)
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
}
|
||||
|
||||
const service = new AstraIntegrationService({
|
||||
configPath: harness.configPath,
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
try {
|
||||
await service.initialize()
|
||||
await service.setConsumerActive(1, true)
|
||||
await waitFor(() => service.getState().connectionState === 'connected', 'expected connected state before controls')
|
||||
|
||||
await assert.rejects(() => service.sendControl('next'), /Unauthorized/)
|
||||
assert.equal(service.getState().lastControlError, 'Unauthorized')
|
||||
|
||||
await assert.rejects(() => service.sendControl('previous'), /External playback controls are disabled\./)
|
||||
assert.equal(service.getState().lastControlError, 'External playback controls are disabled.')
|
||||
|
||||
await service.sendControl('play')
|
||||
assert.equal(service.getState().lastControlError, null)
|
||||
} finally {
|
||||
await service.dispose()
|
||||
await harness.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
getHorizontalWheelScrollResult,
|
||||
normalizeWheelDelta,
|
||||
} from '../src/renderer/utils/horizontalWheelScroll'
|
||||
import {
|
||||
formatAstraTime,
|
||||
getAstraPlaybackProgress,
|
||||
} from '../src/renderer/utils/astra'
|
||||
import {
|
||||
createDefaultProfile,
|
||||
} from '../src/shared/profileState'
|
||||
@@ -669,6 +673,7 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked
|
||||
'vumeter',
|
||||
'lufsmeter',
|
||||
'waveform',
|
||||
'astra',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -684,6 +689,30 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer
|
||||
assert.equal(options.lineColor, theme.spectrum.primary)
|
||||
})
|
||||
|
||||
test('astra playback progress advances from updatedAt while playing', () => {
|
||||
const progress = getAstraPlaybackProgress({
|
||||
playbackState: 'playing',
|
||||
currentTime: 12,
|
||||
duration: 120,
|
||||
queueLength: 4,
|
||||
outputDeviceLabel: 'Built-in Output',
|
||||
visualizerLineColor: '#38bdf8',
|
||||
currentTrack: {
|
||||
id: 'track-1',
|
||||
title: 'Track',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
isFavorite: false,
|
||||
artworkDataUrl: null,
|
||||
},
|
||||
updatedAt: 1000,
|
||||
}, 3500)
|
||||
|
||||
assertAlmostEqual(progress.currentTime, 14.5, 1e-6, 'current time advances')
|
||||
assertAlmostEqual(progress.progress, 14.5 / 120, 1e-6, 'progress ratio advances')
|
||||
assert.equal(formatAstraTime(progress.currentTime), '0:14')
|
||||
})
|
||||
|
||||
test('normalizeWheelDelta keeps pixel deltas unchanged', () => {
|
||||
assert.equal(normalizeWheelDelta(24, 0, 320), 24)
|
||||
})
|
||||
@@ -831,6 +860,14 @@ test('scopeSummary includes Stereo for waveform only when stereo mode is enabled
|
||||
assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB · Stereo · RGB')
|
||||
})
|
||||
|
||||
test('scopeSummary summarizes astra field visibility', () => {
|
||||
const profile = createDefaultProfile('Default')
|
||||
profile.scopeSettings.astra.showArtist = false
|
||||
profile.scopeSettings.astra.showControls = false
|
||||
|
||||
assert.equal(scopeSummary('astra', profile.scopeSettings.astra), 'Cover · Title · Bar · Time')
|
||||
})
|
||||
|
||||
test('ScopePopoutDataSource switches waveform batches between mono and stereo queues', () => {
|
||||
const dataSource = new ScopePopoutDataSource('waveform')
|
||||
const monoChunk = new Float32Array([0.1, 0.2, 0.3])
|
||||
@@ -950,6 +987,27 @@ test('buildProfileDraft preserves unlinked themes instead of coercing the active
|
||||
assert.equal(draft.themeId, null)
|
||||
})
|
||||
|
||||
test('toggleScope appends astra to the scope order when it is enabled from an opt-in profile', () => {
|
||||
const previousSettingsState = useSettingsStore.getState()
|
||||
const fakeWindow = installFakeElectronWindow()
|
||||
|
||||
try {
|
||||
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
|
||||
profile.themeId = 'theme_default'
|
||||
seedProfileDraftState(profile)
|
||||
|
||||
assert.equal(useSettingsStore.getState().scopeOrder.includes('astra'), false)
|
||||
|
||||
useSettingsStore.getState().toggleScope('astra')
|
||||
|
||||
assert.equal(useSettingsStore.getState().scopeOrder.at(-1), 'astra')
|
||||
assert.equal(useSettingsStore.getState().hiddenScopes.has('astra'), false)
|
||||
} finally {
|
||||
useSettingsStore.setState(previousSettingsState)
|
||||
fakeWindow.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('main-window bounds updates stay in memory in Electron mode until save', () => {
|
||||
const previousSettingsState = useSettingsStore.getState()
|
||||
const fakeStorage = installFakeLocalStorage()
|
||||
@@ -1204,7 +1262,7 @@ test('moveDockedScopeOrder is a no-op at the docked boundaries', () => {
|
||||
initialOrder,
|
||||
)
|
||||
assert.equal(
|
||||
moveDockedScopeOrder(initialOrder, new Set<ScopeKind>(), createScopePopouts(), 'waveform', 'right'),
|
||||
moveDockedScopeOrder(initialOrder, new Set<ScopeKind>(), createScopePopouts(), 'astra', 'right'),
|
||||
initialOrder,
|
||||
)
|
||||
})
|
||||
@@ -1226,6 +1284,7 @@ test('moveDockedScopeOrder preserves hidden scope positions in the full order',
|
||||
'vumeter',
|
||||
'lufsmeter',
|
||||
'waveform',
|
||||
'astra',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1246,6 +1305,7 @@ test('moveDockedScopeOrder preserves popped-out scope positions in the full orde
|
||||
'vumeter',
|
||||
'lufsmeter',
|
||||
'waveform',
|
||||
'astra',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user