add initial support for more integrations in the now playing panel

This commit is contained in:
Boof2015
2026-04-11 22:05:00 -04:00
parent 85eddbbad5
commit 71fd59c710
30 changed files with 2479 additions and 404 deletions
+1
View File
@@ -13,6 +13,7 @@
"test:audio-store": "node scripts/run-audio-store-tests.mjs",
"test:astra": "node scripts/run-astra-integration-tests.mjs",
"test:capture-support": "node scripts/run-capture-support-tests.mjs",
"test:now-playing": "node scripts/run-now-playing-manager-tests.mjs",
"test:profiles": "node scripts/run-profile-library-tests.mjs",
"test:themes": "node scripts/run-theme-library-tests.mjs",
"test:window-state": "node scripts/run-window-state-tests.mjs",
+44
View File
@@ -0,0 +1,44 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { build } from 'esbuild'
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
const tempDir = await mkdtemp(join(tmpdir(), 'prism-now-playing-manager-tests-'))
const bundledTestPath = join(tempDir, 'now-playing-manager.test.mjs')
const entryPoint = join(rootDir, 'test', 'now-playing-manager.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)
+193 -32
View File
@@ -1,11 +1,7 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, 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 { NowPlayingControlCommand, NowPlayingState } from '../types/nowPlaying'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
@@ -15,7 +11,7 @@ import type {
} from '../types/popout'
import type { ProfileMenuRequest } from '../types/profileMenu'
import type { LegacyProfileMigrationPayload, Profile } from '../types/profile'
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
import { SCOPE_KINDS, SCOPE_LABELS, type ScopeKind } from '../types/scope'
import type {
LegacyThemeMigrationPayload,
ThemeLibrarySnapshot,
@@ -31,7 +27,7 @@ import {
} from '../shared/windowGeometry'
import { calculateResizedWindowBounds } from '../shared/windowResize'
import { FileBackedProfileLibrary } from './profileLibrary'
import { AstraIntegrationService } from './services/astraIntegration'
import { NowPlayingManager } from './services/nowPlayingManager'
import { FileBackedThemeLibrary } from './themeLibrary'
import { FileBackedWindowStateStore } from './windowStateStore'
@@ -55,6 +51,8 @@ const scopePopoutWindows = new Map<ScopeKind, BrowserWindow>()
const scopePopoutCloseAllowed = new Set<ScopeKind>()
const popoutBoundsTimers = new Map<ScopeKind, ReturnType<typeof setTimeout>>()
const suppressNextPopoutBoundsEvents = new Set<ScopeKind>()
let nowPlayingConfigWindow: BrowserWindow | null = null
let nowPlayingConfigBoundsTimer: ReturnType<typeof setTimeout> | null = null
const windowSettingsHeights = new Map<number, number>()
const windowSettingsBottomAnchors = new Map<number, number>()
const pendingProfileOpenPaths: string[] = []
@@ -62,7 +60,7 @@ const pendingThemeOpenPaths: string[] = []
let profileLibrary: FileBackedProfileLibrary | null = null
let themeLibrary: FileBackedThemeLibrary | null = null
let astraIntegrationService: AstraIntegrationService | null = null
let nowPlayingManager: NowPlayingManager | null = null
let windowStateStore: FileBackedWindowStateStore | null = null
const WINDOW_DEFAULTS = {
@@ -79,6 +77,13 @@ const POPOUT_DEFAULTS = {
minHeight: 160,
}
const NOW_PLAYING_CONFIG_DEFAULTS = {
width: 620,
height: 640,
minWidth: 520,
minHeight: 520,
}
const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180
const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64
@@ -114,24 +119,32 @@ function getWindowStateStore(): FileBackedWindowStateStore {
return windowStateStore
}
function broadcastAstraState(state: AstraIntegrationState): void {
function broadcastNowPlayingState(state: NowPlayingState): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed() || window.webContents.isDestroyed()) continue
window.webContents.send('astra:state-changed', state)
window.webContents.send('now-playing:state-changed', state)
}
}
function getAstraIntegrationService(): AstraIntegrationService {
if (!astraIntegrationService) {
astraIntegrationService = new AstraIntegrationService({
configPath: join(app.getPath('userData'), 'astra-integration.json'),
function getNowPlayingManager(): NowPlayingManager {
if (!nowPlayingManager) {
nowPlayingManager = new NowPlayingManager({
astraConfigPath: join(app.getPath('userData'), 'astra-integration.json'),
localStatePath: join(app.getPath('userData'), 'now-playing-state.json'),
})
astraIntegrationService.subscribe((state) => {
broadcastAstraState(state)
nowPlayingManager.subscribe((state) => {
broadcastNowPlayingState(state)
})
}
return astraIntegrationService
return nowPlayingManager
}
function broadcastThemeSnapshot(snapshot: ThemeLibrarySnapshot): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed() || window.webContents.isDestroyed()) continue
window.webContents.send('themes:external-activated', snapshot)
}
}
function queueProfileOpenPath(filePath: string): void {
@@ -245,7 +258,7 @@ async function processPendingThemeOpenPaths(): Promise<void> {
applyNativeThemeSnapshot(latestSnapshot)
focusMainWindow()
mainWindow.webContents.send('themes:external-activated', latestSnapshot)
broadcastThemeSnapshot(latestSnapshot)
}
function applyNativeThemeSnapshot(snapshot: ThemeLibrarySnapshot): void {
@@ -791,6 +804,9 @@ function createMainWindow(): void {
for (const kind of SCOPE_KINDS) {
destroyScopePopoutWindow(kind)
}
if (nowPlayingConfigWindow && !nowPlayingConfigWindow.isDestroyed()) {
nowPlayingConfigWindow.close()
}
})
mainWindow.on('move', () => {
@@ -891,7 +907,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
minimizable: true,
skipTaskbar: true,
autoHideMenuBar: true,
title: `Prism ${kind}`,
title: `Prism ${SCOPE_LABELS[kind]}`,
alwaysOnTop: getWindowStateStore().getPopoutAlwaysOnTop(kind),
show: false,
webPreferences: {
@@ -972,6 +988,132 @@ function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void {
}
}
function normalizeNowPlayingConfigBounds(raw: unknown, fallback: WindowBounds): WindowBounds {
if (typeof raw !== 'object' || raw === null) {
return fallback
}
const candidate = raw as Partial<WindowBounds>
if (
typeof candidate.x !== 'number'
|| typeof candidate.y !== 'number'
|| typeof candidate.width !== 'number'
|| typeof candidate.height !== 'number'
) {
return fallback
}
return {
x: Math.round(candidate.x),
y: Math.round(candidate.y),
width: Math.max(NOW_PLAYING_CONFIG_DEFAULTS.minWidth, Math.round(candidate.width)),
height: Math.max(NOW_PLAYING_CONFIG_DEFAULTS.minHeight, Math.round(candidate.height)),
}
}
function scheduleNowPlayingConfigBoundsSave(window: BrowserWindow): void {
if (nowPlayingConfigBoundsTimer) {
clearTimeout(nowPlayingConfigBoundsTimer)
}
nowPlayingConfigBoundsTimer = setTimeout(() => {
nowPlayingConfigBoundsTimer = null
if (window.isDestroyed()) return
void getWindowStateStore().setNowPlayingConfigWindowBounds(window.getBounds()).catch((error) => {
console.warn('Could not persist now-playing config window bounds:', error)
})
}, 80)
}
function createNowPlayingConfigWindow(): BrowserWindow {
if (nowPlayingConfigWindow && !nowPlayingConfigWindow.isDestroyed()) {
return nowPlayingConfigWindow
}
const anchorBounds = mainWindow?.getBounds()
const fallbackBounds: WindowBounds = anchorBounds
? {
x: anchorBounds.x + 40,
y: anchorBounds.y + 40,
width: NOW_PLAYING_CONFIG_DEFAULTS.width,
height: NOW_PLAYING_CONFIG_DEFAULTS.height,
}
: {
x: Math.round(screen.getPrimaryDisplay().workArea.x + 48),
y: Math.round(screen.getPrimaryDisplay().workArea.y + 48),
width: NOW_PLAYING_CONFIG_DEFAULTS.width,
height: NOW_PLAYING_CONFIG_DEFAULTS.height,
}
const bounds = normalizeNowPlayingConfigBounds(
getWindowStateStore().getNowPlayingConfigWindowBounds(),
fallbackBounds,
)
nowPlayingConfigWindow = new BrowserWindow({
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
minWidth: NOW_PLAYING_CONFIG_DEFAULTS.minWidth,
minHeight: NOW_PLAYING_CONFIG_DEFAULTS.minHeight,
...getFramelessWindowChromeOptions(),
resizable: true,
fullscreenable: false,
maximizable: false,
minimizable: true,
autoHideMenuBar: true,
title: 'Prism Now Playing',
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false,
},
})
const configWindow = nowPlayingConfigWindow
configWindow.once('ready-to-show', () => {
if (!configWindow.isDestroyed()) {
configWindow.show()
configWindow.focus()
}
})
configWindow.on('move', () => {
scheduleNowPlayingConfigBoundsSave(configWindow)
})
configWindow.on('resize', () => {
scheduleNowPlayingConfigBoundsSave(configWindow)
})
configWindow.on('closed', () => {
if (resizeWindow === configWindow) {
stopWindowResizeController()
}
if (nowPlayingConfigBoundsTimer) {
clearTimeout(nowPlayingConfigBoundsTimer)
nowPlayingConfigBoundsTimer = null
}
windowSettingsHeights.delete(configWindow.id)
windowSettingsBottomAnchors.delete(configWindow.id)
nowPlayingConfigWindow = null
})
loadRendererTarget(configWindow, { window: 'now-playing-config' })
return configWindow
}
function openNowPlayingConfigWindow(): void {
const configWindow = createNowPlayingConfigWindow()
if (configWindow.isMinimized()) {
configWindow.restore()
}
configWindow.show()
configWindow.focus()
}
function setupPermissions(): void {
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === 'media') {
@@ -1095,25 +1237,38 @@ function setupIPC(): void {
return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? false
})
ipcMain.handle('astra:get-config', async () => {
return getAstraIntegrationService().getConfig()
ipcMain.handle('now-playing:get-state', async () => {
return getNowPlayingManager().getState()
})
ipcMain.handle('astra:save-config', async (_event, rawConfig: AstraIntegrationConfig) => {
return getAstraIntegrationService().saveConfig(rawConfig)
ipcMain.handle('now-playing:set-active', async (event, active: boolean) => {
return getNowPlayingManager().setConsumerActive(event.sender.id, Boolean(active))
})
ipcMain.handle('astra:get-state', async () => {
return getAstraIntegrationService().getState()
ipcMain.handle('now-playing:save-provider-config', async (_event, providerId: string, rawConfig: unknown) => {
if (providerId !== 'astra' && providerId !== 'spotify' && providerId !== 'tidal') {
throw new Error('Invalid now-playing provider.')
}
return getNowPlayingManager().saveProviderConfig(providerId, rawConfig)
})
ipcMain.handle('astra:set-active', async (event, active: boolean) => {
return getAstraIntegrationService().setConsumerActive(event.sender.id, Boolean(active))
ipcMain.handle('now-playing:set-provider-priority', async (_event, providerPriority: string[]) => {
return getNowPlayingManager().setProviderPriority(providerPriority)
})
ipcMain.handle('astra:send-control', async (_event, command: AstraControlCommand) => {
await getAstraIntegrationService().sendControl(command)
return getAstraIntegrationService().getState()
ipcMain.handle('now-playing:retry-provider', async (_event, providerId: string) => {
if (providerId !== 'astra' && providerId !== 'spotify' && providerId !== 'tidal') {
throw new Error('Invalid now-playing provider.')
}
return getNowPlayingManager().retryProvider(providerId)
})
ipcMain.handle('now-playing:send-control', async (_event, command: NowPlayingControlCommand) => {
return getNowPlayingManager().sendControl(command)
})
ipcMain.handle('now-playing:open-config-window', async () => {
openNowPlayingConfigWindow()
})
ipcMain.handle('profiles:get-snapshot', async () => {
@@ -1209,24 +1364,28 @@ function setupIPC(): void {
ipcMain.handle('themes:load', async (_event, id: string) => {
const snapshot = await getThemeLibrary().loadTheme(id)
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:rename', async (_event, id: string, name: string) => {
const snapshot = await getThemeLibrary().renameTheme(id, name)
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:delete', async (_event, id: string) => {
const snapshot = await getThemeLibrary().deleteTheme(id)
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:reload', async () => {
const snapshot = await getThemeLibrary().reloadThemes()
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
@@ -1251,6 +1410,7 @@ function setupIPC(): void {
const snapshot = await getThemeLibrary().importThemeFromPath(result.filePaths[0])
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
@@ -1265,6 +1425,7 @@ function setupIPC(): void {
ipcMain.handle('themes:migrate-legacy', async (_event, payload: LegacyThemeMigrationPayload) => {
const migration = await getThemeLibrary().migrateLegacyTheme(payload)
applyNativeThemeSnapshot(migration.snapshot)
broadcastThemeSnapshot(migration.snapshot)
return migration
})
@@ -1425,7 +1586,7 @@ if (!hasSingleInstanceLock) {
} else {
app.whenReady().then(async () => {
setupPermissions()
void getAstraIntegrationService().initialize()
void getNowPlayingManager().initialize()
setupIPC()
await getWindowStateStore().initialize()
await syncNativeThemeAppearance()
@@ -1458,6 +1619,6 @@ if (!hasSingleInstanceLock) {
}
app.on('window-all-closed', () => {
void astraIntegrationService?.dispose()
void nowPlayingManager?.dispose()
app.quit()
})
+338
View File
@@ -0,0 +1,338 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import type { AstraIntegrationConfig, AstraIntegrationState } from '../../types/astra'
import {
NOW_PLAYING_PROVIDER_DEFINITIONS,
NOW_PLAYING_PROVIDER_IDS,
type NowPlayingControlCommand,
type NowPlayingProviderConfigMap,
type NowPlayingProviderId,
type NowPlayingProviderState,
type NowPlayingProviderStateMap,
type NowPlayingSnapshot,
type NowPlayingState,
} from '../../types/nowPlaying'
import { AstraIntegrationService } from './astraIntegration'
interface NowPlayingManagerOptions {
astraConfigPath: string
localStatePath: string
astraService?: AstraServiceLike
}
interface NowPlayingLocalState {
providerPriority: NowPlayingProviderId[]
}
interface AstraServiceLike {
initialize(): Promise<void>
dispose(): Promise<void>
subscribe(listener: () => void): () => void
getState(): AstraIntegrationState
getConfig(): AstraIntegrationConfig
setConsumerActive(consumerId: number, active: boolean): Promise<unknown>
saveConfig(rawConfig: unknown): Promise<unknown>
sendControl(command: NowPlayingControlCommand): Promise<unknown>
}
function cloneSnapshot(snapshot: NowPlayingSnapshot | null): NowPlayingSnapshot | null {
if (!snapshot) return null
return {
...snapshot,
currentTrack: snapshot.currentTrack
? { ...snapshot.currentTrack }
: null,
}
}
function cloneProviderState(state: NowPlayingProviderState): NowPlayingProviderState {
return {
...state,
snapshot: cloneSnapshot(state.snapshot),
}
}
function cloneNowPlayingState(state: NowPlayingState): NowPlayingState {
return {
definitions: { ...state.definitions },
configs: {
astra: { ...state.configs.astra },
spotify: {},
tidal: {},
},
providers: NOW_PLAYING_PROVIDER_IDS.reduce((acc, providerId) => {
acc[providerId] = cloneProviderState(state.providers[providerId])
return acc
}, {} as NowPlayingProviderStateMap),
providerPriority: [...state.providerPriority],
activeProviderId: state.activeProviderId,
hasConfiguredProvider: state.hasConfiguredProvider,
onboardingRequired: state.onboardingRequired,
}
}
function normalizeProviderPriority(raw: unknown): NowPlayingProviderId[] {
const parsed = Array.isArray(raw) ? raw : []
const seen = new Set<NowPlayingProviderId>()
const normalized: NowPlayingProviderId[] = []
for (const value of parsed) {
if (typeof value !== 'string') continue
if (!NOW_PLAYING_PROVIDER_IDS.includes(value as NowPlayingProviderId)) continue
const providerId = value as NowPlayingProviderId
if (seen.has(providerId)) continue
seen.add(providerId)
normalized.push(providerId)
}
for (const providerId of NOW_PLAYING_PROVIDER_IDS) {
if (!seen.has(providerId)) {
normalized.push(providerId)
}
}
return normalized
}
function normalizeLocalState(raw: unknown): NowPlayingLocalState {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<NowPlayingLocalState>
: {}
return {
providerPriority: normalizeProviderPriority(parsed.providerPriority),
}
}
function isConfiguredAstraProvider(config: AstraIntegrationConfig): boolean {
return typeof config.token === 'string' && config.token.trim().length > 0
}
function toAstraProviderState(state: AstraIntegrationState): NowPlayingProviderState {
return {
providerId: 'astra',
connectionState: state.connectionState,
lastError: state.lastError,
lastControlError: state.lastControlError,
snapshot: state.snapshot ? {
...state.snapshot,
currentTrack: state.snapshot.currentTrack
? { ...state.snapshot.currentTrack }
: null,
} : null,
isConfigured: isConfiguredAstraProvider(state.config),
available: true,
supportsTransportControls: true,
}
}
function createUnavailableProviderState(providerId: Exclude<NowPlayingProviderId, 'astra'>): NowPlayingProviderState {
const definition = NOW_PLAYING_PROVIDER_DEFINITIONS[providerId]
return {
providerId,
connectionState: 'unavailable',
lastError: null,
lastControlError: null,
snapshot: null,
isConfigured: false,
available: definition.available,
supportsTransportControls: definition.supportsTransportControls,
}
}
export class NowPlayingManager {
private readonly astraService: AstraServiceLike
private readonly localStatePath: string
private readonly listeners = new Set<(state: NowPlayingState) => void>()
private providerPriority = [...NOW_PLAYING_PROVIDER_IDS]
private initialized = false
constructor(options: NowPlayingManagerOptions) {
this.astraService = options.astraService ?? new AstraIntegrationService({
configPath: options.astraConfigPath,
})
this.localStatePath = options.localStatePath
this.astraService.subscribe(() => {
if (!this.initialized) {
return
}
this.emitState()
})
}
async initialize(): Promise<void> {
if (this.initialized) return
const localState = await this.readLocalState()
this.providerPriority = localState.providerPriority
await this.astraService.initialize()
this.initialized = true
this.emitState()
}
async dispose(): Promise<void> {
await this.astraService.dispose()
}
subscribe(listener: (state: NowPlayingState) => void): () => void {
this.listeners.add(listener)
listener(this.getState())
return () => {
this.listeners.delete(listener)
}
}
getState(): NowPlayingState {
return cloneNowPlayingState(this.buildState())
}
async setConsumerActive(consumerId: number, active: boolean): Promise<NowPlayingState> {
await this.ensureInitialized()
await this.astraService.setConsumerActive(consumerId, active)
return this.getState()
}
async saveProviderConfig(
providerId: NowPlayingProviderId,
rawConfig: unknown,
): Promise<NowPlayingState> {
await this.ensureInitialized()
switch (providerId) {
case 'astra':
await this.astraService.saveConfig(rawConfig)
break
default:
throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not configurable yet.`)
}
return this.getState()
}
async retryProvider(providerId: NowPlayingProviderId): Promise<NowPlayingState> {
await this.ensureInitialized()
switch (providerId) {
case 'astra':
await this.astraService.saveConfig(this.astraService.getConfig())
break
default:
throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not available yet.`)
}
return this.getState()
}
async setProviderPriority(rawPriority: unknown): Promise<NowPlayingState> {
await this.ensureInitialized()
this.providerPriority = normalizeProviderPriority(rawPriority)
await this.writeLocalState({ providerPriority: this.providerPriority })
this.emitState()
return this.getState()
}
async sendControl(command: NowPlayingControlCommand): Promise<NowPlayingState> {
await this.ensureInitialized()
const activeProviderId = this.buildState().activeProviderId
switch (activeProviderId) {
case 'astra':
await this.astraService.sendControl(command)
break
case null:
throw new Error('No active now-playing provider is available.')
default:
throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[activeProviderId].label} controls are not available yet.`)
}
return this.getState()
}
private buildState(): NowPlayingState {
const astraState = this.astraService.getState()
const configs: NowPlayingProviderConfigMap = {
astra: { ...astraState.config },
spotify: {},
tidal: {},
}
const providers: NowPlayingProviderStateMap = {
astra: toAstraProviderState(astraState),
spotify: createUnavailableProviderState('spotify'),
tidal: createUnavailableProviderState('tidal'),
}
const hasConfiguredProvider = this.providerPriority.some((providerId) => {
const provider = providers[providerId]
return provider.available && provider.isConfigured
})
return {
definitions: NOW_PLAYING_PROVIDER_DEFINITIONS,
configs,
providers,
providerPriority: [...this.providerPriority],
activeProviderId: this.resolveActiveProviderId(providers),
hasConfiguredProvider,
onboardingRequired: !hasConfiguredProvider,
}
}
private resolveActiveProviderId(providers: NowPlayingProviderStateMap): NowPlayingProviderId | null {
const playingProvider = this.providerPriority.find((providerId) => {
const provider = providers[providerId]
return provider.available
&& provider.isConfigured
&& provider.snapshot?.playbackState === 'playing'
})
if (playingProvider) {
return playingProvider
}
const connectedProviders = this.providerPriority
.map((providerId) => providers[providerId])
.filter((provider) => {
return provider.available
&& provider.isConfigured
&& provider.connectionState === 'connected'
&& provider.snapshot !== null
})
.sort((left, right) => {
const leftPriority = this.providerPriority.indexOf(left.providerId)
const rightPriority = this.providerPriority.indexOf(right.providerId)
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority
}
const rightUpdatedAt = right.snapshot?.updatedAt ?? 0
const leftUpdatedAt = left.snapshot?.updatedAt ?? 0
return rightUpdatedAt - leftUpdatedAt
})
return connectedProviders[0]?.providerId ?? null
}
private emitState(): void {
const snapshot = this.getState()
for (const listener of this.listeners) {
listener(snapshot)
}
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize()
}
}
private async readLocalState(): Promise<NowPlayingLocalState> {
try {
const raw = await readFile(this.localStatePath, 'utf8')
return normalizeLocalState(JSON.parse(raw) as unknown)
} catch {
return normalizeLocalState(null)
}
}
private async writeLocalState(state: NowPlayingLocalState): Promise<void> {
await mkdir(dirname(this.localStatePath), { recursive: true })
await writeFile(this.localStatePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
}
}
+16
View File
@@ -1,6 +1,7 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { createEmptyWindowLocalState, normalizeWindowLocalState } from '../shared/windowState'
import type { WindowBounds } from '../types/popout'
import type { ScopeKind } from '../types/scope'
import type { PrismWindowLocalStateV1 } from '../types/windowState'
@@ -25,6 +26,12 @@ export class FileBackedWindowStateStore {
return this.state.popoutAlwaysOnTop[kind] === true
}
getNowPlayingConfigWindowBounds(): WindowBounds | undefined {
return this.state.nowPlayingConfigWindowBounds
? { ...this.state.nowPlayingConfigWindowBounds }
: undefined
}
async setMainAlwaysOnTop(alwaysOnTop: boolean): Promise<void> {
await this.ensureInitialized()
this.state = normalizeWindowLocalState({
@@ -52,6 +59,15 @@ export class FileBackedWindowStateStore {
await this.persistState()
}
async setNowPlayingConfigWindowBounds(bounds?: WindowBounds): Promise<void> {
await this.ensureInitialized()
this.state = normalizeWindowLocalState({
...this.state,
nowPlayingConfigWindowBounds: bounds,
})
await this.persistState()
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize()
+25 -14
View File
@@ -1,11 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron'
import type {
AstraControlCommand,
AstraIntegrationConfig,
AstraIntegrationState,
} from '../types/astra'
import type { CaptureBackendSupport } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type {
NowPlayingControlCommand,
NowPlayingProviderConfigMap,
NowPlayingProviderId,
NowPlayingState,
} from '../types/nowPlaying'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
@@ -48,11 +49,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as 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>,
getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise<NowPlayingState>,
setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise<NowPlayingState>,
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMap[K]) => {
return ipcRenderer.invoke('now-playing:save-provider-config', providerId, config) as Promise<NowPlayingState>
},
setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => {
return ipcRenderer.invoke('now-playing:set-provider-priority', providerPriority) as Promise<NowPlayingState>
},
retryNowPlayingProvider: (providerId: NowPlayingProviderId) => {
return ipcRenderer.invoke('now-playing:retry-provider', providerId) as Promise<NowPlayingState>
},
sendNowPlayingControl: (command: NowPlayingControlCommand) => {
return ipcRenderer.invoke('now-playing:send-control', command) as Promise<NowPlayingState>
},
openNowPlayingConfigWindow: () => ipcRenderer.invoke('now-playing:open-config-window') as Promise<void>,
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>,
@@ -98,10 +109,10 @@ 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)
onNowPlayingStateChanged: (callback: (state: NowPlayingState) => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: NowPlayingState): void => callback(state)
ipcRenderer.on('now-playing:state-changed', handler)
return () => ipcRenderer.removeListener('now-playing:state-changed', handler)
},
onMainCloseRequested: (callback: () => void) => {
const handler = (): void => callback()
+4 -4
View File
@@ -8,8 +8,8 @@ import WindowResizeOverlay from './components/WindowResizeOverlay'
import AppBanner from './components/AppBanner'
import { resolveMainWindowSettingsHeight } from './mainWindowSettings'
import { useSettingsStore } from './stores/settingsStore'
import { useAstraStore } from './stores/astraStore'
import { useAudioStore } from './stores/audioStore'
import { useNowPlayingStore } from './stores/nowPlayingStore'
import { useThemeStore } from './stores/themeStore'
import { useUiStore } from './stores/uiStore'
@@ -28,7 +28,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)
const initializeNowPlaying = useNowPlayingStore((s) => s.initialize)
const settingsOpen = useUiStore((s) => s.settingsOpen)
const toggleSettings = useUiStore((s) => s.toggleSettings)
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
@@ -48,7 +48,7 @@ export default function App(): JSX.Element {
void (async () => {
await initializeThemes()
await initializeProfiles()
await initializeAstra()
await initializeNowPlaying()
if (!isDisposed) {
window.electronAPI.notifyRendererReady()
}
@@ -129,7 +129,7 @@ export default function App(): JSX.Element {
importProfileFromPath,
initializeProfiles,
initializeThemes,
initializeAstra,
initializeNowPlaying,
showBanner,
showProfilesFolder,
updateMainWindowBounds,
+104 -34
View File
@@ -1,13 +1,14 @@
import { useEffect, useMemo, useState, type CSSProperties, type JSX } from 'react'
import type { NowPlayingProviderId } from '../../types/nowPlaying'
import type { ScopeSettings } from '../../types/settings'
import type { ResolvedAstraTheme } from '../../types/theme'
import { useAstraStore } from '../stores/astraStore'
import { useUiStore } from '../stores/uiStore'
import type { ResolvedNowPlayingTheme } from '../../types/theme'
import { useNowPlayingStore } from '../stores/nowPlayingStore'
import { formatAstraTime, getAstraPlaybackProgress } from '../utils/astra'
import { useUiStore } from '../stores/uiStore'
interface AstraScopeModuleProps {
theme: ResolvedAstraTheme
settings: ScopeSettings['astra']
theme: ResolvedNowPlayingTheme
settings: ScopeSettings['nowPlaying']
}
function getErrorMessage(error: unknown, fallback: string): string {
@@ -16,7 +17,7 @@ function getErrorMessage(error: unknown, fallback: string): string {
: fallback
}
function hasVisibleFields(settings: ScopeSettings['astra']): boolean {
function hasVisibleFields(settings: ScopeSettings['nowPlaying']): boolean {
return settings.showCoverArt
|| settings.showTitle
|| settings.showArtist
@@ -25,7 +26,23 @@ function hasVisibleFields(settings: ScopeSettings['astra']): boolean {
|| settings.showControls
}
function getFallbackTitle(connectionState: ReturnType<typeof useAstraStore.getState>['integrationState']['connectionState']): string {
function getConfiguredProviderId(
state: ReturnType<typeof useNowPlayingStore.getState>['nowPlayingState'],
): NowPlayingProviderId | null {
return state.providerPriority.find((providerId) => {
const provider = state.providers[providerId]
return provider.available && provider.isConfigured
}) ?? null
}
function getFallbackTitle(
providerId: NowPlayingProviderId | null,
connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null,
): string {
if (providerId !== 'astra' || connectionState === null) {
return 'Nothing playing'
}
switch (connectionState) {
case 'disabled':
return 'Astra is off'
@@ -35,19 +52,30 @@ function getFallbackTitle(connectionState: ReturnType<typeof useAstraStore.getSt
return 'Astra connection failed'
case 'connected':
return 'Nothing playing'
case 'unavailable':
return 'Provider unavailable'
}
}
function getFallbackDetail(connectionState: ReturnType<typeof useAstraStore.getState>['integrationState']['connectionState']): string {
function getFallbackDetail(
providerId: NowPlayingProviderId | null,
connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null,
): string {
if (providerId !== 'astra' || connectionState === null) {
return ''
}
switch (connectionState) {
case 'disabled':
return 'Open the Astra scope to connect.'
return 'Open the Now Playing configuration to connect Astra.'
case 'connecting':
return 'Waiting for the Astra API.'
case 'error':
return 'Check the Astra base URL and token.'
case 'connected':
return ''
case 'unavailable':
return 'This provider is not available yet.'
}
}
@@ -55,26 +83,38 @@ 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 saveConfig = useAstraStore((s) => s.saveConfig)
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
const initialize = useNowPlayingStore((s) => s.initialize)
const setConsumerActive = useNowPlayingStore((s) => s.setConsumerActive)
const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState)
const isSendingControl = useNowPlayingStore((s) => s.isSendingControl)
const sendControl = useNowPlayingStore((s) => s.sendControl)
const retryProvider = useNowPlayingStore((s) => s.retryProvider)
const openConfigWindow = useNowPlayingStore((s) => s.openConfigWindow)
const showBanner = useUiStore((s) => s.showBanner)
const [nowMs, setNowMs] = useState(() => Date.now())
useEffect(() => {
void initialize()
void setScopeActive(true)
void setConsumerActive(true)
return () => {
void setScopeActive(false)
void setConsumerActive(false)
}
}, [initialize, setScopeActive])
}, [initialize, setConsumerActive])
const configuredProviderId = useMemo(
() => getConfiguredProviderId(nowPlayingState),
[nowPlayingState],
)
const displayProviderId = nowPlayingState.activeProviderId ?? configuredProviderId
const providerState = displayProviderId
? nowPlayingState.providers[displayProviderId]
: null
const providerDefinition = displayProviderId
? nowPlayingState.definitions[displayProviderId]
: null
useEffect(() => {
if (integrationState.snapshot?.playbackState !== 'playing') {
if (providerState?.snapshot?.playbackState !== 'playing') {
return
}
@@ -85,17 +125,19 @@ export default function AstraScopeModule({
return () => {
window.clearInterval(timer)
}
}, [integrationState.snapshot?.playbackState])
}, [providerState?.snapshot?.playbackState])
const snapshot = integrationState.snapshot
const snapshot = providerState?.snapshot ?? null
const currentTrack = snapshot?.currentTrack ?? null
const liveProgress = useMemo(
() => getAstraPlaybackProgress(snapshot, nowMs),
[nowMs, snapshot],
)
const errorMessage = integrationState.lastError ?? integrationState.lastControlError
const errorMessage = providerState?.lastError ?? providerState?.lastControlError ?? null
const detailMessage = currentTrack?.artist
?? (integrationState.connectionState === 'connected' ? null : getFallbackDetail(integrationState.connectionState))
?? (providerState?.connectionState === 'connected'
? null
: getFallbackDetail(displayProviderId, providerState?.connectionState ?? null))
const style = {
'--astra-accent': theme.accent,
'--astra-bg': theme.background,
@@ -118,7 +160,29 @@ export default function AstraScopeModule({
return (
<div className="astra-scope astra-scope--empty" style={style}>
<div className="astra-scope__placeholder">
All Astra elements are hidden.
All Now Playing elements are hidden.
</div>
</div>
)
}
if (nowPlayingState.onboardingRequired) {
return (
<div className="astra-scope astra-scope--empty" style={style}>
<div className="astra-scope__placeholder">
<div className="astra-scope__empty-title">Not configured</div>
<div className="astra-scope__empty-detail">
Set up a provider to show track metadata and playback controls here.
</div>
<button
type="button"
className="astra-scope__control astra-scope__control--primary"
onClick={() => {
void openConfigWindow()
}}
>
Set Up Now Playing
</button>
</div>
</div>
)
@@ -153,8 +217,8 @@ export default function AstraScopeModule({
{(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 className="astra-scope__title" title={currentTrack?.title ?? getFallbackTitle(displayProviderId, providerState?.connectionState ?? null)}>
{currentTrack?.title ?? getFallbackTitle(displayProviderId, providerState?.connectionState ?? null)}
</div>
)}
{settings.showArtist && detailMessage && (
@@ -190,7 +254,7 @@ export default function AstraScopeModule({
<button
type="button"
className="astra-scope__control"
disabled={!currentTrack || isSendingControl}
disabled={!currentTrack || isSendingControl || !providerDefinition?.supportsTransportControls}
onClick={() => {
void sendControl('previous')
}}
@@ -202,7 +266,7 @@ export default function AstraScopeModule({
<button
type="button"
className="astra-scope__control astra-scope__control--primary"
disabled={!currentTrack || isSendingControl}
disabled={!currentTrack || isSendingControl || !providerDefinition?.supportsTransportControls}
onClick={() => {
void sendControl(toggleCommand)
}}
@@ -214,7 +278,7 @@ export default function AstraScopeModule({
<button
type="button"
className="astra-scope__control"
disabled={!currentTrack || isSendingControl}
disabled={!currentTrack || isSendingControl || !providerDefinition?.supportsTransportControls}
onClick={() => {
void sendControl('next')
}}
@@ -238,10 +302,14 @@ export default function AstraScopeModule({
type="button"
className="astra-scope__control"
onClick={() => {
void saveConfig(integrationState.config).catch((error) => {
if (!displayProviderId) {
return
}
void retryProvider(displayProviderId).catch((error) => {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not reconnect to Astra.'),
message: getErrorMessage(error, 'Could not reconnect to the provider.'),
actions: [],
})
})
@@ -252,9 +320,11 @@ export default function AstraScopeModule({
<button
type="button"
className="astra-scope__control"
onClick={() => setSettingsOpen(true)}
onClick={() => {
void openConfigWindow()
}}
>
Open Settings
Configure
</button>
</div>
</>
+83 -87
View File
@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX, type WheelEvent } from 'react'
import { useAstraStore } from '../stores/astraStore'
import { useNowPlayingStore } from '../stores/nowPlayingStore'
import { useAudioStore } from '../stores/audioStore'
import { usePerformanceStore } from '../stores/performanceStore'
import { useSettingsStore } from '../stores/settingsStore'
@@ -9,7 +9,6 @@ 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> = {
@@ -20,7 +19,7 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
astra: 'Astra',
nowPlaying: 'Now Playing',
}
interface BottomBarProps {
@@ -84,8 +83,6 @@ export function resolveThemeCreditDetails(theme: ThemeCreditSource): {
export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('')
const [astraTokenInput, setAstraTokenInput] = useState('')
const [isRefreshingThemes, setIsRefreshingThemes] = useState(false)
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
@@ -102,8 +99,9 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
reloadThemes,
showThemesFolder,
} = useThemeStore()
const astraState = useAstraStore((s) => s.integrationState)
const saveAstraConfig = useAstraStore((s) => s.saveConfig)
const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState)
const retryNowPlayingProvider = useNowPlayingStore((s) => s.retryProvider)
const openNowPlayingConfigWindow = useNowPlayingStore((s) => s.openConfigWindow)
const {
systemSources,
@@ -126,7 +124,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
setInputGain,
} = useAudioStore()
const showBanner = useUiStore((s) => s.showBanner)
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
useEffect(() => {
void refreshBackendSupport()
@@ -134,11 +131,6 @@ 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
@@ -207,22 +199,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
await loadTheme(value)
}
const handleSaveAstraConfig = async (): Promise<void> => {
const nextConfig: AstraIntegrationConfig = {
baseUrl: astraBaseUrlInput,
token: astraTokenInput,
}
try {
await saveAstraConfig(nextConfig)
} catch (error) {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not save the Astra settings.'),
actions: [],
})
}
}
const handleRetryCapture = async (): Promise<void> => {
clearCaptureNotice()
await startCapture()
@@ -238,8 +214,27 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
await startCapture()
}
const handleRetryAstra = async (): Promise<void> => {
await handleSaveAstraConfig()
const handleRetryNowPlaying = async (): Promise<void> => {
const providerId = nowPlayingState.activeProviderId
?? nowPlayingState.providerPriority.find((candidate) => {
const provider = nowPlayingState.providers[candidate]
return provider.available && provider.isConfigured
})
if (!providerId) {
await openNowPlayingConfigWindow()
return
}
try {
await retryNowPlayingProvider(providerId)
} catch (error) {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not reconnect the provider.'),
actions: [],
})
}
}
const handleShowThemesFolder = async (): Promise<void> => {
@@ -307,15 +302,36 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
event.preventDefault()
}
const astraStatusLabel = astraState.connectionState === 'connected'
? 'Connected'
: astraState.connectionState === 'connecting'
? 'Connecting'
: astraState.connectionState === 'error'
? 'Error'
: 'Off'
const currentNowPlayingProviderId = nowPlayingState.activeProviderId
?? nowPlayingState.providerPriority.find((providerId) => {
const provider = nowPlayingState.providers[providerId]
return provider.available && provider.isConfigured
})
?? null
const currentNowPlayingProvider = currentNowPlayingProviderId
? nowPlayingState.providers[currentNowPlayingProviderId]
: null
const nowPlayingStatusLabel = nowPlayingState.onboardingRequired
? 'Not Set Up'
: currentNowPlayingProvider?.connectionState === 'connected'
? 'Connected'
: currentNowPlayingProvider?.connectionState === 'connecting'
? 'Connecting'
: currentNowPlayingProvider?.connectionState === 'error'
? 'Error'
: 'Idle'
const nowPlayingSummary = nowPlayingState.onboardingRequired
? 'Set up Astra to enable live track data.'
: `Priority: ${nowPlayingState.providerPriority
.map((providerId) => nowPlayingState.definitions[providerId].label)
.join(' → ')}`
const captureMessage = captureError ?? captureNotice
const astraErrorMessage = astraState.lastError ?? astraState.lastControlError
const nowPlayingErrorMessage = currentNowPlayingProvider?.lastError ?? currentNowPlayingProvider?.lastControlError ?? null
const nowPlayingDetail = nowPlayingErrorMessage
? `${currentNowPlayingProviderId ? `${nowPlayingState.definitions[currentNowPlayingProviderId].label} · ` : ''}${nowPlayingErrorMessage}`
: currentNowPlayingProviderId
? `${nowPlayingState.definitions[currentNowPlayingProviderId].label} · ${nowPlayingSummary}`
: nowPlayingSummary
const canUseDefaultSource = captureMode === 'system'
? selectedSystemSourceId !== defaultSystemSourceId
: selectedDeviceId !== null
@@ -408,65 +424,45 @@ 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>
<section className="bottom-bar__section bottom-bar__section--now-playing">
<div className="bottom-bar__section-header bottom-bar__section-header--now-playing">
<div className="bottom-bar__section-title">Now Playing</div>
<div
className={`${nowPlayingErrorMessage ? 'bottom-bar__now-playing-summary is-error' : 'bottom-bar__now-playing-summary'}`.trim()}
title={nowPlayingDetail}
>
{nowPlayingDetail}
</div>
</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)}
/>
<div className="bottom-bar__inline bottom-bar__inline--now-playing">
<div className={`settings-status-pill ${currentNowPlayingProvider?.connectionState === 'disabled' || !currentNowPlayingProvider ? '' : `is-${currentNowPlayingProvider.connectionState}`}`.trim()}>
<span className="settings-status-pill__dot" />
<span>{nowPlayingStatusLabel}</span>
</div>
<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)}
/>
{nowPlayingErrorMessage ? (
<button
type="button"
className="settings-chip"
onClick={() => {
void handleRetryNowPlaying()
}}
>
Retry
</button>
) : null}
<button
type="button"
className="settings-chip"
onClick={() => {
void handleSaveAstraConfig()
void openNowPlayingConfigWindow()
}}
>
Save
Configure...
</button>
<div className={`settings-status-pill ${astraState.connectionState === 'disabled' ? '' : `is-${astraState.connectionState}`}`.trim()}>
<span className="settings-status-pill__dot" />
<span>{astraStatusLabel}</span>
</div>
</div>
{astraErrorMessage ? (
<>
<div className="settings-error-text bottom-bar__error-text">{astraErrorMessage}</div>
<div className="settings-inline-actions">
<button
type="button"
className="settings-chip"
onClick={() => {
void handleRetryAstra()
}}
>
Retry
</button>
<button
type="button"
className="settings-chip"
onClick={() => setSettingsOpen(true)}
>
Open Settings
</button>
</div>
</>
) : null}
</div>
</section>
@@ -0,0 +1,534 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type CSSProperties,
type DragEvent as ReactDragEvent,
type JSX,
type PointerEvent as ReactPointerEvent,
} from 'react'
import type { NowPlayingProviderDefinition, NowPlayingProviderId, NowPlayingProviderState } from '../../types/nowPlaying'
import AppBanner from './AppBanner'
import WindowResizeOverlay from './WindowResizeOverlay'
import { useNowPlayingStore } from '../stores/nowPlayingStore'
import { useThemeStore } from '../stores/themeStore'
import { useUiStore } from '../stores/uiStore'
function GripIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="5.5" cy="4" r="1.1" fill="currentColor" />
<circle cx="10.5" cy="4" r="1.1" fill="currentColor" />
<circle cx="5.5" cy="8" r="1.1" fill="currentColor" />
<circle cx="10.5" cy="8" r="1.1" fill="currentColor" />
<circle cx="5.5" cy="12" r="1.1" fill="currentColor" />
<circle cx="10.5" cy="12" r="1.1" fill="currentColor" />
</svg>
)
}
function CloseIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.5 3.5 12.5 12.5M12.5 3.5 3.5 12.5" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
)
}
function MinimizeIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.5 8h9" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
)
}
function ChevronIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.25 6.5 8 10.25 11.75 6.5" fill="none" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function AstraLogoIcon(): JSX.Element {
return (
<svg viewBox="0 0 1024 1024" aria-hidden="true">
<g transform="matrix(1.726813 0 0 1.726813 -660.505902 -397.11951)" fill="currentColor">
<path d="M526.083 500.65C529.86 496.662 535.112 494.402 540.605 494.402C553.071 494.402 576.056 494.402 588.831 494.402C594.652 494.402 600.185 496.939 603.984 501.35C610.054 508.396 619.61 519.49 627.207 528.31C633.905 536.085 633.631 547.668 626.573 555.117C603.295 579.689 553.937 631.788 536.916 649.755C533.139 653.742 527.889 656 522.397 656L452 656C440.954 656 432 647.046 432 636C432 626.32 432 615.247 432 607.967C432 602.851 433.96 597.93 437.478 594.215C454.783 575.942 508.184 519.551 526.083 500.65Z" />
<path d="M580 389.237C580 378.578 588.641 369.937 599.3 369.937C625.097 369.937 669.782 369.937 688.899 369.937C694.682 369.937 700.183 372.436 703.987 376.792C736.676 414.222 893.163 593.401 921.571 625.929C924.427 629.198 926 633.392 926 637.733C926 637.733 926 637.734 926 637.734C926 648.379 917.371 657.008 906.726 657.008L817.1 657.008C811.318 657.008 805.817 654.51 802.013 650.155C769.332 612.742 612.909 433.673 584.448 401.092C581.58 397.809 580 393.598 580 389.239C580 389.238 580 389.237 580 389.237Z" />
</g>
</svg>
)
}
function SpotifyIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.1 6.2c2.8-.9 5.7-.8 8.1.4" fill="none" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
<path d="M4.8 8.5c2.1-.6 4.4-.5 6.3.3" fill="none" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
<path d="M5.5 10.7c1.5-.4 3.1-.4 4.4.2" fill="none" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
</svg>
)
}
function TidalIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 2.2 10.6 4.8 8 7.4 5.4 4.8Z" fill="currentColor" />
<path d="M5 5.2 7.6 7.8 5 10.4 2.4 7.8Z" fill="currentColor" opacity="0.88" />
<path d="M11 5.2 13.6 7.8 11 10.4 8.4 7.8Z" fill="currentColor" opacity="0.88" />
<path d="M8 8.2 10.6 10.8 8 13.4 5.4 10.8Z" fill="currentColor" />
</svg>
)
}
function isToolbarInteractiveTarget(target: EventTarget | null): boolean {
return target instanceof Element
&& target.closest('button') !== null
}
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message
? error.message
: fallback
}
function getProviderStatusLabel(
definition: NowPlayingProviderDefinition,
provider: NowPlayingProviderState,
): string {
if (definition.comingSoon) {
return 'Coming Soon'
}
if (!provider.isConfigured) {
return 'Not Set Up'
}
switch (provider.connectionState) {
case 'disabled':
return 'Idle'
case 'connecting':
return 'Connecting'
case 'connected':
return provider.snapshot?.playbackState === 'playing' ? 'Live' : 'Connected'
case 'error':
return 'Error'
case 'unavailable':
return 'Coming Soon'
}
}
function getProviderMetaText(
definition: NowPlayingProviderDefinition,
provider: NowPlayingProviderState,
): string {
if (definition.comingSoon) {
return 'OAuth login coming later'
}
if (!provider.isConfigured) {
return 'Local API · Paste base URL and token'
}
switch (provider.connectionState) {
case 'disabled':
return 'Local API · Waiting for use'
case 'connecting':
return 'Local API · Connecting'
case 'connected':
return provider.snapshot?.playbackState === 'playing'
? 'Local API · Playing now'
: 'Local API · Connected'
case 'error':
return 'Local API · Needs attention'
case 'unavailable':
return 'Coming soon'
}
}
function moveProviderToTarget(
providerPriority: NowPlayingProviderId[],
sourceId: NowPlayingProviderId,
targetId: NowPlayingProviderId,
): NowPlayingProviderId[] {
const sourceIndex = providerPriority.indexOf(sourceId)
const targetIndex = providerPriority.indexOf(targetId)
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
return providerPriority
}
const nextPriority = [...providerPriority]
const [moved] = nextPriority.splice(sourceIndex, 1)
nextPriority.splice(targetIndex, 0, moved)
return nextPriority
}
function getProviderBrandColor(providerId: NowPlayingProviderId): string {
switch (providerId) {
case 'astra':
return '#0097ff'
case 'spotify':
return '#1ed760'
case 'tidal':
return '#f4f6f8'
}
}
function ProviderIcon({ providerId }: { providerId: NowPlayingProviderId }): JSX.Element {
switch (providerId) {
case 'astra':
return <AstraLogoIcon />
case 'spotify':
return <SpotifyIcon />
case 'tidal':
return <TidalIcon />
}
}
export default function NowPlayingConfigWindow(): JSX.Element {
const initializeThemes = useThemeStore((s) => s.initializeThemes)
const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot)
const initializeNowPlaying = useNowPlayingStore((s) => s.initialize)
const setConsumerActive = useNowPlayingStore((s) => s.setConsumerActive)
const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState)
const saveProviderConfig = useNowPlayingStore((s) => s.saveProviderConfig)
const setProviderPriority = useNowPlayingStore((s) => s.setProviderPriority)
const retryProvider = useNowPlayingStore((s) => s.retryProvider)
const showBanner = useUiStore((s) => s.showBanner)
const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('')
const [astraTokenInput, setAstraTokenInput] = useState('')
const [expandedProviderId, setExpandedProviderId] = useState<NowPlayingProviderId | null>('astra')
const [draggedProviderId, setDraggedProviderId] = useState<NowPlayingProviderId | null>(null)
const [dropTargetProviderId, setDropTargetProviderId] = useState<NowPlayingProviderId | null>(null)
useEffect(() => {
let disposed = false
void (async () => {
await initializeThemes()
await initializeNowPlaying()
await setConsumerActive(true)
})().catch((error) => {
if (disposed) return
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not initialize Now Playing.'),
actions: [],
})
})
const unsubscribeTheme = window.electronAPI.onExternalThemeActivated((snapshot) => {
applyExternalThemeSnapshot(snapshot)
})
return () => {
disposed = true
unsubscribeTheme()
void setConsumerActive(false)
window.electronAPI.stopWindowMove()
}
}, [
applyExternalThemeSnapshot,
initializeNowPlaying,
initializeThemes,
setConsumerActive,
showBanner,
])
useEffect(() => {
setAstraBaseUrlInput(nowPlayingState.configs.astra.baseUrl)
setAstraTokenInput(nowPlayingState.configs.astra.token)
}, [nowPlayingState.configs.astra.baseUrl, nowPlayingState.configs.astra.token])
const handleToolbarDragStart = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
window.electronAPI.startWindowMove()
}, [])
const handleToolbarDragEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
window.electronAPI.stopWindowMove()
}, [])
const handleSaveAstraConfig = useCallback(async (): Promise<void> => {
try {
await saveProviderConfig('astra', {
baseUrl: astraBaseUrlInput,
token: astraTokenInput,
})
} catch (error) {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not save the Astra settings.'),
actions: [],
})
}
}, [astraBaseUrlInput, astraTokenInput, saveProviderConfig, showBanner])
const orderedProviders = useMemo(
() => nowPlayingState.providerPriority.map((providerId) => ({
providerId,
definition: nowPlayingState.definitions[providerId],
provider: nowPlayingState.providers[providerId],
})),
[nowPlayingState],
)
const handleProviderDragStart = useCallback((providerId: NowPlayingProviderId, event: ReactDragEvent<HTMLDivElement>): void => {
setDraggedProviderId(providerId)
setDropTargetProviderId(null)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', providerId)
}, [])
const handleProviderDragEnd = useCallback((): void => {
setDraggedProviderId(null)
setDropTargetProviderId(null)
}, [])
const handleProviderDrop = useCallback(async (targetId: NowPlayingProviderId): Promise<void> => {
if (!draggedProviderId || draggedProviderId === targetId) {
setDropTargetProviderId(null)
return
}
const nextPriority = moveProviderToTarget(nowPlayingState.providerPriority, draggedProviderId, targetId)
try {
await setProviderPriority(nextPriority)
} catch (error) {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not reorder providers.'),
actions: [],
})
} finally {
setDraggedProviderId(null)
setDropTargetProviderId(null)
}
}, [draggedProviderId, nowPlayingState.providerPriority, setProviderPriority, showBanner])
return (
<div className="now-playing-config">
<div className="now-playing-config__shell">
<header
className="toolbar now-playing-config__toolbar"
onPointerDown={handleToolbarDragStart}
onPointerUp={handleToolbarDragEnd}
onPointerCancel={handleToolbarDragEnd}
onLostPointerCapture={handleToolbarDragEnd}
>
<div className="now-playing-config__toolbar-copy">
<div className="now-playing-config__toolbar-title">Now Playing</div>
<div className="now-playing-config__toolbar-subtitle">
Drag providers to set priority. Expand a row to configure keys or logins.
</div>
</div>
<div className="toolbar__actions">
<button
type="button"
className="toolbar__icon-button"
onClick={() => window.electronAPI.minimize()}
aria-label="Minimize window"
title="Minimize"
>
<MinimizeIcon />
</button>
<button
type="button"
className="toolbar__icon-button toolbar__icon-button--danger"
onClick={() => window.electronAPI.close()}
aria-label="Close window"
title="Close"
>
<CloseIcon />
</button>
</div>
</header>
<main className="now-playing-config__content">
<div className="now-playing-config__stack">
<div className="now-playing-config__intro">
{nowPlayingState.onboardingRequired
? 'Start with Astra. Spotify and TIDAL stay visible here for ordering, but their login flows are still coming later.'
: 'The highest configured provider that starts playing takes over immediately.'}
</div>
<div className="now-playing-config__list">
{orderedProviders.map(({ providerId, definition, provider }) => {
const isExpanded = expandedProviderId === providerId
const isDropTarget = draggedProviderId !== null
&& draggedProviderId !== providerId
&& dropTargetProviderId === providerId
const providerStyle = {
'--provider-brand': getProviderBrandColor(providerId),
} as CSSProperties
const statusLabel = getProviderStatusLabel(definition, provider)
const statusClass = provider.connectionState === 'disabled' || provider.connectionState === 'unavailable'
? 'settings-status-pill'
: `settings-status-pill is-${provider.connectionState}`
return (
<section
key={providerId}
className={[
'now-playing-config__provider',
isExpanded ? 'is-expanded' : '',
providerId === nowPlayingState.activeProviderId ? 'is-live' : '',
isDropTarget ? 'is-drop-target' : '',
].join(' ').trim()}
onDragOver={(event) => {
if (!draggedProviderId || draggedProviderId === providerId) return
event.preventDefault()
if (dropTargetProviderId !== providerId) {
setDropTargetProviderId(providerId)
}
}}
onDrop={(event) => {
event.preventDefault()
void handleProviderDrop(providerId)
}}
>
<div className="now-playing-config__provider-header">
<div
className={`now-playing-config__provider-handle ${draggedProviderId === providerId ? 'is-dragging' : ''}`.trim()}
draggable
onDragStart={(event) => handleProviderDragStart(providerId, event)}
onDragEnd={handleProviderDragEnd}
aria-label={`Drag to reorder ${definition.label}`}
title={`Drag to reorder ${definition.label}`}
>
<GripIcon />
</div>
<button
type="button"
className="now-playing-config__provider-summary"
onClick={() => {
setExpandedProviderId((current) => current === providerId ? null : providerId)
}}
>
<span className="now-playing-config__provider-icon-shell" style={providerStyle}>
<ProviderIcon providerId={providerId} />
</span>
<span className="now-playing-config__provider-copy">
<span className="now-playing-config__provider-name-row">
<span className="now-playing-config__provider-name">{definition.label}</span>
{providerId === nowPlayingState.activeProviderId ? (
<span className="now-playing-config__provider-live">Live</span>
) : null}
</span>
<span className="now-playing-config__provider-meta">
{getProviderMetaText(definition, provider)}
</span>
</span>
<span className={`${statusClass} now-playing-config__provider-status`.trim()}>
<span className="settings-status-pill__dot" />
<span>{statusLabel}</span>
</span>
<span className={`now-playing-config__provider-chevron ${isExpanded ? 'is-expanded' : ''}`.trim()} aria-hidden="true">
<ChevronIcon />
</span>
</button>
</div>
{isExpanded ? (
providerId === 'astra' ? (
<div className="now-playing-config__provider-body">
<div className="now-playing-config__provider-body-copy">
{definition.description}
</div>
<div className="now-playing-config__form-grid">
<label className="now-playing-config__field">
<span className="now-playing-config__field-label">Base URL</span>
<input
className="bottom-bar__text-input now-playing-config__input"
type="text"
value={astraBaseUrlInput}
placeholder="http://127.0.0.1:38401"
onChange={(event) => setAstraBaseUrlInput(event.target.value)}
/>
</label>
<label className="now-playing-config__field">
<span className="now-playing-config__field-label">API Token</span>
<input
className="bottom-bar__text-input now-playing-config__input"
type="password"
value={astraTokenInput}
placeholder="Astra API Token"
onChange={(event) => setAstraTokenInput(event.target.value)}
/>
</label>
</div>
<div className="settings-inline-actions now-playing-config__provider-actions">
<button
type="button"
className="settings-chip"
onClick={() => {
void handleSaveAstraConfig()
}}
>
Save
</button>
<button
type="button"
className="settings-chip"
disabled={!provider.isConfigured}
onClick={() => {
void retryProvider('astra').catch((error) => {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not reconnect to Astra.'),
actions: [],
})
})
}}
>
Retry
</button>
</div>
{provider.lastError || provider.lastControlError ? (
<div className="settings-error-text now-playing-config__provider-error">
{provider.lastError ?? provider.lastControlError}
</div>
) : null}
</div>
) : (
<div className="now-playing-config__provider-body">
<div className="now-playing-config__coming-soon">
{definition.description}
</div>
<div className="settings-info-text">
Login and connection flow land here later. For now, this row exists so you can set future priority.
</div>
</div>
)
) : null}
</section>
)
})}
</div>
</div>
</main>
</div>
<AppBanner />
<WindowResizeOverlay />
</div>
)
}
+4 -4
View File
@@ -300,7 +300,7 @@ export function scopeSettingsToOptions(
multiband: s.multiband,
}
}
case 'astra':
case 'nowPlaying':
return {}
default:
return {}
@@ -356,7 +356,7 @@ function createVisualizer(
...opts,
...(dataSource ? { dataSource: dataSource as WaveformDataSource } : {}),
})
case 'astra':
case 'nowPlaying':
return null
default:
return null
@@ -438,11 +438,11 @@ export default function ScopeModule({
}
}, [scopeKind, spectrumPeakMode, spectrumPeakInfo])
if (scopeKind === 'astra') {
if (scopeKind === 'nowPlaying') {
return (
<AstraScopeModule
theme={myTheme as ResolvedAstraTheme}
settings={mySettings as ScopeSettings['astra']}
settings={mySettings as ScopeSettings['nowPlaying']}
/>
)
}
@@ -19,7 +19,7 @@ function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): strin
}
}
function astraVisibleLabels(settings: ScopeSettings['astra']): string[] {
function nowPlayingVisibleLabels(settings: ScopeSettings['nowPlaying']): string[] {
const labels: string[] = []
if (settings.showCoverArt) labels.push('Cover')
if (settings.showTitle) labels.push('Title')
@@ -78,8 +78,8 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
}
return summary.join(' · ')
}
case 'astra': {
const visible = astraVisibleLabels(settings as ScopeSettings['astra'])
case 'nowPlaying': {
const visible = nowPlayingVisibleLabels(settings as ScopeSettings['nowPlaying'])
return visible.length > 0 ? visible.join(' · ') : 'Hidden'
}
}
@@ -548,39 +548,39 @@ export default function ScopeSettingsSection({
)
})()}
{kind === 'astra' && (() => {
const current = settings as ScopeSettings['astra']
{kind === 'nowPlaying' && (() => {
const current = settings as ScopeSettings['nowPlaying']
return (
<ToggleGroup label="Visible Elements">
<ToggleChip
label="Cover"
active={current.showCoverArt}
onClick={() => onUpdate('astra', { showCoverArt: !current.showCoverArt })}
onClick={() => onUpdate('nowPlaying', { showCoverArt: !current.showCoverArt })}
/>
<ToggleChip
label="Title"
active={current.showTitle}
onClick={() => onUpdate('astra', { showTitle: !current.showTitle })}
onClick={() => onUpdate('nowPlaying', { showTitle: !current.showTitle })}
/>
<ToggleChip
label="Artist"
active={current.showArtist}
onClick={() => onUpdate('astra', { showArtist: !current.showArtist })}
onClick={() => onUpdate('nowPlaying', { showArtist: !current.showArtist })}
/>
<ToggleChip
label="Bar"
active={current.showProgress}
onClick={() => onUpdate('astra', { showProgress: !current.showProgress })}
onClick={() => onUpdate('nowPlaying', { showProgress: !current.showProgress })}
/>
<ToggleChip
label="Time"
active={current.showTime}
onClick={() => onUpdate('astra', { showTime: !current.showTime })}
onClick={() => onUpdate('nowPlaying', { showTime: !current.showTime })}
/>
<ToggleChip
label="Controls"
active={current.showControls}
onClick={() => onUpdate('astra', { showControls: !current.showControls })}
onClick={() => onUpdate('nowPlaying', { showControls: !current.showControls })}
/>
</ToggleGroup>
)
+14 -11
View File
@@ -1,13 +1,14 @@
/// <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'
import type {
NowPlayingControlCommand,
NowPlayingProviderConfigMap,
NowPlayingProviderId,
NowPlayingState,
} from '../types/nowPlaying'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
@@ -49,11 +50,13 @@ declare global {
toggleAlwaysOnTop: () => void
isAlwaysOnTop: () => Promise<boolean>
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
getAstraConfig: () => Promise<AstraIntegrationConfig>
saveAstraConfig: (config: AstraIntegrationConfig) => Promise<AstraIntegrationConfig>
getAstraState: () => Promise<AstraIntegrationState>
setAstraActive: (active: boolean) => Promise<AstraIntegrationState>
sendAstraControl: (command: AstraControlCommand) => Promise<AstraIntegrationState>
getNowPlayingState: () => Promise<NowPlayingState>
setNowPlayingConsumerActive: (active: boolean) => Promise<NowPlayingState>
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMap[K]) => Promise<NowPlayingState>
setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise<NowPlayingState>
retryNowPlayingProvider: (providerId: NowPlayingProviderId) => Promise<NowPlayingState>
sendNowPlayingControl: (command: NowPlayingControlCommand) => Promise<NowPlayingState>
openNowPlayingConfigWindow: () => Promise<void>
getProfileSnapshot: () => Promise<ProfileLibrarySnapshot>
saveNewProfile: (name: string, profile: Profile) => Promise<ProfileLibrarySnapshot>
overwriteProfile: (id: string, profile: Profile) => Promise<ProfileLibrarySnapshot>
@@ -89,7 +92,7 @@ declare global {
sendScopePopoutSettingsUpdate: (kind: ScopeKind, partial: unknown) => void
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => () => void
onAstraStateChanged: (callback: (state: AstraIntegrationState) => void) => () => void
onNowPlayingStateChanged: (callback: (state: NowPlayingState) => void) => () => void
onMainCloseRequested: (callback: () => void) => () => void
onProfileMenuClosed: (callback: () => void) => () => void
onProfileMenuLoad: (callback: (id: string) => void) => () => void
+3
View File
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import App from './App'
import ScopePopoutWindow from './popouts/ScopePopoutWindow'
import DialogApp from './components/DialogApp'
import NowPlayingConfigWindow from './components/NowPlayingConfigWindow'
import './styles/globals.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
@@ -22,6 +23,8 @@ const scopeKind = params.get('scope')
let root: React.ReactElement
if (windowMode === 'dialog') {
root = <DialogApp />
} else if (windowRole === 'now-playing-config') {
root = <NowPlayingConfigWindow />
} else if (windowRole === 'scope-popout') {
root = isScopeKind(scopeKind)
? <ScopePopoutWindow scopeKind={scopeKind} />
-116
View File
@@ -1,116 +0,0 @@
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()
+164
View File
@@ -0,0 +1,164 @@
import { create } from 'zustand'
import {
NOW_PLAYING_PROVIDER_DEFINITIONS,
NOW_PLAYING_PROVIDER_IDS,
type NowPlayingControlCommand,
type NowPlayingProviderConfigMap,
type NowPlayingProviderId,
type NowPlayingProviderStateMap,
type NowPlayingState,
} from '../../types/nowPlaying'
interface NowPlayingStoreState {
initialized: boolean
nowPlayingState: NowPlayingState
isSendingControl: boolean
initialize: () => Promise<void>
setConsumerActive: (active: boolean) => Promise<void>
saveProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMap[K]) => Promise<void>
setProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise<void>
retryProvider: (providerId: NowPlayingProviderId) => Promise<void>
sendControl: (command: NowPlayingControlCommand) => Promise<void>
openConfigWindow: () => Promise<void>
applyExternalState: (state: NowPlayingState) => void
}
function createDefaultProviderStates(): NowPlayingProviderStateMap {
return NOW_PLAYING_PROVIDER_IDS.reduce((acc, providerId) => {
const definition = NOW_PLAYING_PROVIDER_DEFINITIONS[providerId]
acc[providerId] = {
providerId,
connectionState: definition.available ? 'disabled' : 'unavailable',
lastError: null,
lastControlError: null,
snapshot: null,
isConfigured: false,
available: definition.available,
supportsTransportControls: definition.supportsTransportControls,
}
return acc
}, {} as NowPlayingProviderStateMap)
}
function createDefaultNowPlayingState(): NowPlayingState {
return {
definitions: NOW_PLAYING_PROVIDER_DEFINITIONS,
configs: {
astra: {
baseUrl: 'http://127.0.0.1:38401',
token: '',
},
spotify: {},
tidal: {},
},
providers: createDefaultProviderStates(),
providerPriority: [...NOW_PLAYING_PROVIDER_IDS],
activeProviderId: null,
hasConfiguredProvider: false,
onboardingRequired: true,
}
}
let initializePromise: Promise<void> | null = null
let syncBound = false
export const useNowPlayingStore = create<NowPlayingStoreState>((set) => ({
initialized: false,
nowPlayingState: createDefaultNowPlayingState(),
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.getNowPlayingState()
set({
initialized: true,
nowPlayingState: state,
})
})()
try {
await initializePromise
} finally {
initializePromise = null
}
},
setConsumerActive: async (active) => {
const state = await window.electronAPI.setNowPlayingConsumerActive(active)
set({
initialized: true,
nowPlayingState: state,
})
},
saveProviderConfig: async (providerId, config) => {
const state = await window.electronAPI.saveNowPlayingProviderConfig(providerId, config)
set({
initialized: true,
nowPlayingState: state,
})
},
setProviderPriority: async (providerPriority) => {
const state = await window.electronAPI.setNowPlayingProviderPriority(providerPriority)
set({
initialized: true,
nowPlayingState: state,
})
},
retryProvider: async (providerId) => {
const state = await window.electronAPI.retryNowPlayingProvider(providerId)
set({
initialized: true,
nowPlayingState: state,
})
},
sendControl: async (command) => {
set({ isSendingControl: true })
try {
const state = await window.electronAPI.sendNowPlayingControl(command)
set({
initialized: true,
nowPlayingState: state,
})
} finally {
set({ isSendingControl: false })
}
},
openConfigWindow: async () => {
await window.electronAPI.openNowPlayingConfigWindow()
},
applyExternalState: (state) => {
set({
initialized: true,
nowPlayingState: state,
})
},
}))
function bindNowPlayingStateSync(): void {
if (syncBound || typeof window === 'undefined' || typeof window.electronAPI === 'undefined') {
return
}
syncBound = true
window.electronAPI.onNowPlayingStateChanged((state) => {
useNowPlayingStore.getState().applyExternalState(state)
})
}
bindNowPlayingStateSync()
+377 -2
View File
@@ -909,6 +909,30 @@ select {
text-transform: uppercase;
}
.astra-scope__placeholder {
flex-direction: column;
align-items: flex-start;
gap: 8px;
max-width: min(100%, 360px);
padding: 12px;
white-space: normal;
text-transform: none;
letter-spacing: 0;
font-family: 'Inter', sans-serif;
line-height: 1.45;
}
.astra-scope__empty-title {
font-size: 13px;
font-weight: 600;
color: var(--astra-text);
}
.astra-scope__empty-detail {
font-size: 11px;
color: var(--astra-subtext);
}
.astra-scope__status.is-ok {
color: var(--astra-status-ok);
}
@@ -1514,8 +1538,9 @@ select {
min-width: 420px;
}
.bottom-bar__section--astra {
min-width: 520px;
.bottom-bar__section--astra,
.bottom-bar__section--now-playing {
min-width: 420px;
}
.bottom-bar__section--source {
@@ -1575,6 +1600,10 @@ select {
gap: 6px;
}
.bottom-bar__inline--now-playing {
gap: 6px;
}
.bottom-bar__theme-credit {
color: var(--text-tertiary);
font-size: 10px;
@@ -1679,6 +1708,26 @@ select {
max-width: 360px;
}
.bottom-bar__section-header--now-playing {
align-items: center;
}
.bottom-bar__now-playing-summary {
flex: 1;
min-width: 0;
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.2;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bottom-bar__now-playing-summary.is-error {
color: var(--danger);
}
.bottom-bar__actions {
display: flex;
align-items: center;
@@ -1888,6 +1937,332 @@ select {
position: relative;
}
.now-playing-config {
width: 100vw;
height: 100vh;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
}
.now-playing-config__shell {
height: 100%;
display: flex;
flex-direction: column;
}
.now-playing-config__toolbar {
cursor: grab;
padding-right: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--divider);
}
.now-playing-config__toolbar:active {
cursor: grabbing;
}
.now-playing-config__toolbar-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.now-playing-config__toolbar-title {
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 10px;
color: var(--text-primary);
}
.now-playing-config__toolbar-subtitle {
font-size: 10px;
color: var(--text-tertiary);
}
.now-playing-config__content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 16px 16px;
}
.now-playing-config__stack {
width: min(820px, 100%);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.now-playing-config__intro,
.now-playing-config__provider-body-copy,
.now-playing-config__coming-soon {
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
}
.now-playing-config__intro {
padding: 0 2px;
}
.now-playing-config__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.now-playing-config__provider {
border-radius: 14px;
border: 1px solid var(--panel-outline);
background: linear-gradient(180deg, var(--panel-surface), var(--panel-surface-soft));
box-shadow: inset 0 1px 0 var(--glass-highlight);
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.now-playing-config__provider.is-live {
border-color: color-mix(in srgb, var(--accent) 28%, var(--panel-outline));
}
.now-playing-config__provider.is-drop-target {
border-color: var(--control-border-active);
box-shadow:
inset 0 1px 0 var(--glass-highlight),
inset 0 0 0 1px rgba(var(--accent-rgb), 0.16);
}
.now-playing-config__provider-header {
display: flex;
align-items: stretch;
gap: 6px;
padding: 6px;
}
.now-playing-config__provider-handle {
width: 28px;
flex: 0 0 28px;
display: inline-flex;
align-items: center;
justify-content: center;
align-self: center;
min-height: 28px;
border-radius: 999px;
color: var(--text-muted);
cursor: grab;
transition: background-color 140ms ease, color 140ms ease, opacity 140ms ease;
}
.now-playing-config__provider-handle:hover {
background: var(--control-bg-hover);
color: var(--text-secondary);
}
.now-playing-config__provider-handle:active {
cursor: grabbing;
}
.now-playing-config__provider-handle.is-dragging {
opacity: 0.42;
}
.now-playing-config__provider-handle svg {
width: 14px;
height: 14px;
}
.now-playing-config__provider-summary {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 8px 8px;
border: 0;
border-radius: 10px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 140ms ease;
}
.now-playing-config__provider-summary:hover {
background: rgba(255, 255, 255, 0.025);
}
.now-playing-config__provider-icon-shell {
width: 34px;
height: 34px;
flex: 0 0 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--provider-brand) 28%, var(--control-border));
background: color-mix(in srgb, var(--provider-brand) 10%, var(--control-bg));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: var(--provider-brand);
}
.now-playing-config__provider-icon-shell svg {
width: 18px;
height: 18px;
}
.now-playing-config__provider-icon-shell svg[viewBox="0 0 1024 1024"] {
width: 20px;
height: 20px;
}
.now-playing-config__provider-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.now-playing-config__provider-name-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.now-playing-config__provider-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.now-playing-config__provider-live {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 7px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--control-border));
color: var(--accent);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.now-playing-config__provider-meta {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.now-playing-config__provider-status {
flex-shrink: 0;
}
.now-playing-config__provider-chevron {
width: 16px;
height: 16px;
flex: 0 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
transition: transform 140ms ease, color 140ms ease;
}
.now-playing-config__provider-chevron.is-expanded {
color: var(--text-primary);
transform: rotate(180deg);
}
.now-playing-config__provider-chevron svg {
width: 16px;
height: 16px;
}
.now-playing-config__provider-body {
padding: 0 16px 14px 50px;
border-top: 1px solid var(--divider);
display: flex;
flex-direction: column;
gap: 10px;
}
.now-playing-config__provider-body-copy,
.now-playing-config__coming-soon {
padding-top: 12px;
}
.now-playing-config__form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.now-playing-config__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.now-playing-config__field-label {
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.now-playing-config__input {
width: 100%;
box-sizing: border-box;
}
.now-playing-config__provider-actions {
margin-top: 0;
}
.now-playing-config__provider-error {
margin-top: -2px;
}
@media (max-width: 760px) {
.now-playing-config__toolbar {
padding-right: 10px;
}
.now-playing-config__content {
padding: 12px;
}
.now-playing-config__toolbar-subtitle {
display: none;
}
.now-playing-config__provider-summary {
gap: 8px;
padding-right: 8px;
}
.now-playing-config__provider-status {
display: none;
}
.now-playing-config__provider-body {
padding: 0 12px 12px;
}
.now-playing-config__form-grid {
grid-template-columns: 1fr;
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
+33 -11
View File
@@ -12,7 +12,7 @@ import {
type PrismProfileFileV2,
type PrismProfileLocalStateV1,
} from '../types/profile'
import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, type ScopeKind } from '../types/scope'
import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } from '../types/scope'
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
import { normalizeSpectrumPeakInfoMode } from '../types/spectrum'
@@ -27,11 +27,11 @@ export const DEFAULT_SCOPE_WIDTH_WEIGHTS: Record<ScopeKind, number> = {
vumeter: 0.5,
lufsmeter: 0.5,
waveform: 1,
astra: 1,
nowPlaying: 1,
}
export function isScopeKind(value: unknown): value is ScopeKind {
return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind)
return normalizeScopeKind(value) !== null
}
export function cloneScopeSettings(settings: ScopeSettings): ScopeSettings {
@@ -73,11 +73,12 @@ export function normalizeWindowBounds(
export function normalizeScopeOrder(raw: unknown): ScopeKind[] {
if (!Array.isArray(raw)) return [...DEFAULT_SCOPE_ORDER]
const valid = raw.filter(isScopeKind)
const seen = new Set<ScopeKind>()
const normalized: ScopeKind[] = []
for (const kind of valid) {
for (const rawKind of raw) {
const kind = normalizeScopeKind(rawKind)
if (!kind) continue
if (seen.has(kind)) continue
seen.add(kind)
normalized.push(kind)
@@ -97,16 +98,25 @@ export function normalizeHiddenScopes(raw: unknown): ScopeKind[] {
return SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind))
}
return raw.filter(isScopeKind)
const seen = new Set<ScopeKind>()
const normalized: ScopeKind[] = []
for (const rawKind of raw) {
const kind = normalizeScopeKind(rawKind)
if (!kind || seen.has(kind)) continue
seen.add(kind)
normalized.push(kind)
}
return normalized
}
export function normalizeWidthWeights(raw: unknown): Record<ScopeKind, number> {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<Record<ScopeKind, unknown>>
: {}
const legacyParsed = parsed as Partial<Record<ScopeKind | 'astra', unknown>>
return SCOPE_KINDS.reduce((acc, kind) => {
const value = parsed[kind]
const value = legacyParsed[kind] ?? (kind === 'nowPlaying' ? legacyParsed.astra : undefined)
acc[kind] = typeof value === 'number' && Number.isFinite(value)
? Math.max(0.1, value)
: DEFAULT_SCOPE_WIDTH_WEIGHTS[kind]
@@ -118,9 +128,15 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<ScopeSettings>
: {}
const legacyParsed = parsed as Partial<ScopeSettings> & {
astra?: Partial<ScopeSettings['nowPlaying']>
}
const rawSpectrum: Partial<ScopeSettings['spectrum']> = typeof parsed.spectrum === 'object' && parsed.spectrum !== null
? parsed.spectrum
: {}
const rawNowPlaying: Partial<ScopeSettings['nowPlaying']> = typeof legacyParsed.nowPlaying === 'object' && legacyParsed.nowPlaying !== null
? legacyParsed.nowPlaying
: (typeof legacyParsed.astra === 'object' && legacyParsed.astra !== null ? legacyParsed.astra : {})
return {
spectrum: {
@@ -134,7 +150,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 ?? {}) },
nowPlaying: { ...DEFAULT_SCOPE_SETTINGS.nowPlaying, ...rawNowPlaying },
}
}
@@ -143,9 +159,10 @@ export function normalizeScopePopouts(raw: unknown): ScopePopoutStateMap {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<Record<ScopeKind, Partial<ScopePopoutStateMap[ScopeKind]>>>
: {}
const legacyParsed = parsed as Partial<Record<ScopeKind | 'astra', Partial<ScopePopoutStateMap[ScopeKind]>>>
for (const kind of SCOPE_KINDS) {
const value = parsed[kind]
const value = legacyParsed[kind] ?? (kind === 'nowPlaying' ? legacyParsed.astra : undefined)
defaults[kind] = {
poppedOut: Boolean(value?.poppedOut),
windowBounds: normalizeWindowBounds(value?.windowBounds),
@@ -192,9 +209,11 @@ export function normalizeProfileFileScopePopouts(raw: unknown): PrismProfileFile
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<Record<ScopeKind, { poppedOut?: unknown }>>
: {}
const legacyParsed = parsed as Partial<Record<ScopeKind | 'astra', { poppedOut?: unknown }>>
return SCOPE_KINDS.reduce((acc, kind) => {
acc[kind] = { poppedOut: Boolean(parsed[kind]?.poppedOut) }
const value = legacyParsed[kind] ?? (kind === 'nowPlaying' ? legacyParsed.astra : undefined)
acc[kind] = { poppedOut: Boolean(value?.poppedOut) }
return acc
}, {} as PrismProfileFileScopePopoutMap)
}
@@ -253,7 +272,10 @@ export function normalizeProfileLocalMetadata(raw: unknown): ProfileLocalMetadat
const scopePopoutBounds = typeof parsed.scopePopoutBounds === 'object' && parsed.scopePopoutBounds !== null
? SCOPE_KINDS.reduce((acc, kind) => {
const bounds = normalizeWindowBounds(parsed.scopePopoutBounds?.[kind])
const legacyBounds = parsed.scopePopoutBounds as Partial<Record<ScopeKind | 'astra', unknown>> | undefined
const bounds = normalizeWindowBounds(
legacyBounds?.[kind] ?? (kind === 'nowPlaying' ? legacyBounds?.astra : undefined),
)
if (bounds) {
acc[kind] = bounds
}
+38 -29
View File
@@ -9,7 +9,7 @@ import {
type PrismResolvedTheme,
type PrismTheme,
type PrismThemeLocalStateV1,
type ResolvedAstraTheme,
type ResolvedNowPlayingTheme,
type ResolvedInterfaceTheme,
type ResolvedLUFSMeterTheme,
type ResolvedOscilloscopeTheme,
@@ -29,7 +29,7 @@ import {
type ThemeVectorscopeTokens,
type ThemeVUMeterTokens,
type ThemeWaveformTokens,
type ThemeAstraTokens,
type ThemeNowPlayingTokens,
} from '../types/theme'
const DEFAULT_ACCENT = '#38bdf8'
@@ -158,7 +158,7 @@ const WAVEFORM_SCHEMA = {
guides: 'guides',
} as const satisfies SectionSchema<ThemeWaveformTokens>
const ASTRA_SCHEMA = {
const NOW_PLAYING_SCHEMA = {
accent: 'accent',
background: 'background',
surface: 'surface',
@@ -170,7 +170,7 @@ const ASTRA_SCHEMA = {
button_border: 'buttonBorder',
status_ok: 'statusOk',
status_error: 'statusError',
} as const satisfies SectionSchema<ThemeAstraTokens>
} as const satisfies SectionSchema<ThemeNowPlayingTokens>
const SECTION_KEY_MAP: Record<string, ThemeSectionName> = {
app: 'app',
@@ -183,7 +183,8 @@ const SECTION_KEY_MAP: Record<string, ThemeSectionName> = {
vumeter: 'vumeter',
lufsmeter: 'lufsmeter',
waveform: 'waveform',
astra: 'astra',
now_playing: 'nowPlaying',
astra: 'nowPlaying',
}
const SECTION_LABEL_MAP: Record<ThemeSectionName, string> = {
@@ -197,7 +198,7 @@ const SECTION_LABEL_MAP: Record<ThemeSectionName, string> = {
vumeter: 'VUMeter',
lufsmeter: 'LUFSMeter',
waveform: 'Waveform',
astra: 'Astra',
nowPlaying: 'Now Playing',
}
const SECTION_SCHEMAS = {
@@ -211,7 +212,7 @@ const SECTION_SCHEMAS = {
vumeter: VUMETER_SCHEMA,
lufsmeter: LUFSMETER_SCHEMA,
waveform: WAVEFORM_SCHEMA,
astra: ASTRA_SCHEMA,
nowPlaying: NOW_PLAYING_SCHEMA,
} as const
interface RgbaColor {
@@ -431,7 +432,7 @@ function createEmptyTheme(): PrismTheme {
vumeter: {},
lufsmeter: {},
waveform: {},
astra: {},
nowPlaying: {},
}
}
@@ -547,7 +548,7 @@ export function createDefaultTheme(): PrismTheme {
bandMid: DEFAULT_BAND_MID,
bandHigh: DEFAULT_BAND_HIGH,
},
astra: {
nowPlaying: {
accent: DEFAULT_ACCENT,
background: 'rgba(4, 8, 12, 0.9)',
surface: 'rgba(10, 16, 24, 0.92)',
@@ -586,8 +587,8 @@ function createPresetTheme(name: string, accent: string): PrismTheme {
base.lufsmeter.track = withAlpha(accent, 0.08)
base.lufsmeter.target = withAlpha(accent, 0.25)
base.waveform.line = accent
base.astra.accent = accent
base.astra.progressFill = accent
base.nowPlaying.accent = accent
base.nowPlaying.progressFill = accent
return normalizeTheme(base, name)
}
@@ -635,7 +636,11 @@ export function normalizeTheme(
normalized.vumeter = normalizeSectionTokens(parsed.vumeter, VUMETER_SCHEMA)
normalized.lufsmeter = normalizeSectionTokens(parsed.lufsmeter, LUFSMETER_SCHEMA)
normalized.waveform = normalizeSectionTokens(parsed.waveform, WAVEFORM_SCHEMA)
normalized.astra = normalizeSectionTokens(parsed.astra, ASTRA_SCHEMA)
normalized.nowPlaying = normalizeSectionTokens(
(parsed as { nowPlaying?: unknown; astra?: unknown }).nowPlaying
?? (parsed as { astra?: unknown }).astra,
NOW_PLAYING_SCHEMA,
)
return normalized
}
@@ -933,7 +938,11 @@ export function createTemplateThemeFile(): string {
guides: resolved.waveform.guides,
}, WAVEFORM_SCHEMA as SectionSchema<Record<string, string | undefined>>)
const astraSection = serializeSection('Astra', { ...base.astra }, ASTRA_SCHEMA as SectionSchema<Record<string, string | undefined>>)
const nowPlayingSection = serializeSection(
'Now Playing',
{ ...base.nowPlaying },
NOW_PLAYING_SCHEMA as SectionSchema<Record<string, string | undefined>>,
)
return `# Prism theme template
#
@@ -963,7 +972,7 @@ ${[
vumeterSection.join('\n'),
lufsmeterSection.join('\n'),
waveformSection.join('\n'),
astraSection.join('\n'),
nowPlayingSection.join('\n'),
].join('\n\n')}
`
}
@@ -1278,26 +1287,26 @@ function resolveAstraTheme(
app: ResolvedAppTokens,
controls: ResolvedControlsTokens,
scopes: ResolvedScopesTokens,
): ResolvedAstraTheme {
const accent = theme.astra.accent ?? app.accent
const text = theme.astra.text ?? app.text
const buttonBg = theme.astra.buttonSurface ?? controls.surface
): ResolvedNowPlayingTheme {
const accent = theme.nowPlaying.accent ?? app.accent
const text = theme.nowPlaying.text ?? app.text
const buttonBg = theme.nowPlaying.buttonSurface ?? controls.surface
return {
accent,
text,
subtext: withAlpha(text, 0.7),
background: theme.astra.background ?? scopes.background,
surface: theme.astra.surface ?? 'rgba(10, 16, 24, 0.92)',
border: theme.astra.border ?? withAlpha(app.border, 0.9),
progressTrack: theme.astra.progressTrack ?? withAlpha(text, 0.18),
progressFill: theme.astra.progressFill ?? accent,
background: theme.nowPlaying.background ?? scopes.background,
surface: theme.nowPlaying.surface ?? 'rgba(10, 16, 24, 0.92)',
border: theme.nowPlaying.border ?? withAlpha(app.border, 0.9),
progressTrack: theme.nowPlaying.progressTrack ?? withAlpha(text, 0.18),
progressFill: theme.nowPlaying.progressFill ?? accent,
buttonBg,
buttonBgHover: withAlpha(mixColors(buttonBg, accent, 0.14), 0.94),
buttonBgActive: withAlpha(accent, 0.16),
buttonBorder: theme.astra.buttonBorder ?? controls.border,
buttonBorder: theme.nowPlaying.buttonBorder ?? controls.border,
buttonText: text,
statusOk: theme.astra.statusOk ?? app.success,
statusError: theme.astra.statusError ?? app.danger,
statusOk: theme.nowPlaying.statusOk ?? app.success,
statusError: theme.nowPlaying.statusError ?? app.danger,
}
}
@@ -1320,7 +1329,7 @@ export function resolveTheme(theme: PrismTheme): PrismResolvedTheme {
vumeter: resolveVUMeterTheme(normalized, app, scopes),
lufsmeter: resolveLUFSMeterTheme(normalized, app, scopes),
waveform: resolveWaveformTheme(normalized, app, scopes),
astra: resolveAstraTheme(normalized, app, controls, scopes),
nowPlaying: resolveAstraTheme(normalized, app, controls, scopes),
}
}
@@ -1404,8 +1413,8 @@ export function createMigratedAccentTheme(accent: string): PrismTheme | null {
base.lufsmeter.track = withAlpha(base.app.accent, 0.08)
base.lufsmeter.target = withAlpha(base.app.accent, 0.25)
base.waveform.line = base.app.accent
base.astra.accent = base.app.accent
base.astra.progressFill = base.app.accent
base.nowPlaying.accent = base.app.accent
base.nowPlaying.progressFill = base.app.accent
return normalizeTheme(base, base.name)
}
+7 -3
View File
@@ -1,4 +1,5 @@
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
import { normalizeScopeKind, type ScopeKind } from '../types/scope'
import { normalizeWindowBounds } from './profileState'
import {
WINDOW_LOCAL_STATE_FORMAT,
WINDOW_LOCAL_STATE_VERSION,
@@ -11,6 +12,7 @@ export function createEmptyWindowLocalState(): PrismWindowLocalStateV1 {
version: WINDOW_LOCAL_STATE_VERSION,
mainAlwaysOnTop: false,
popoutAlwaysOnTop: {},
nowPlayingConfigWindowBounds: undefined,
}
}
@@ -20,8 +22,9 @@ export function normalizeWindowLocalState(raw: unknown): PrismWindowLocalStateV1
: {}
const popoutAlwaysOnTop = typeof parsed.popoutAlwaysOnTop === 'object' && parsed.popoutAlwaysOnTop !== null
? SCOPE_KINDS.reduce((acc, kind) => {
if ((parsed.popoutAlwaysOnTop as Partial<Record<ScopeKind, unknown>>)[kind] === true) {
? Object.entries(parsed.popoutAlwaysOnTop).reduce((acc, [rawKind, value]) => {
const kind = normalizeScopeKind(rawKind)
if (kind && value === true) {
acc[kind] = true
}
return acc
@@ -33,5 +36,6 @@ export function normalizeWindowLocalState(raw: unknown): PrismWindowLocalStateV1
version: WINDOW_LOCAL_STATE_VERSION,
mainAlwaysOnTop: parsed.mainAlwaysOnTop === true,
popoutAlwaysOnTop,
nowPlayingConfigWindowBounds: normalizeWindowBounds(parsed.nowPlayingConfigWindowBounds),
}
}
+102
View File
@@ -0,0 +1,102 @@
import type { AstraIntegrationConfig } from './astra'
export type NowPlayingProviderId = 'astra' | 'spotify' | 'tidal'
export type NowPlayingPlaybackState = 'stopped' | 'playing' | 'paused' | 'loading'
export type NowPlayingProviderConnectionState = 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable'
export type NowPlayingControlCommand = 'play' | 'pause' | 'next' | 'previous'
export type NowPlayingProviderAuthMode = 'token' | 'oauth' | 'none'
export interface NowPlayingTrackSnapshot {
id: string
title: string
artist: string
album: string
isFavorite: boolean
artworkDataUrl: string | null
}
export interface NowPlayingSnapshot {
playbackState: NowPlayingPlaybackState
currentTime: number
duration: number
queueLength: number
outputDeviceLabel: string | null
visualizerLineColor: string
currentTrack: NowPlayingTrackSnapshot | null
updatedAt: number
}
export interface NowPlayingProviderDefinition {
id: NowPlayingProviderId
label: string
description: string
authMode: NowPlayingProviderAuthMode
available: boolean
comingSoon: boolean
supportsTransportControls: boolean
}
export interface UnsupportedNowPlayingProviderConfig {
}
export interface NowPlayingProviderConfigMap {
astra: AstraIntegrationConfig
spotify: UnsupportedNowPlayingProviderConfig
tidal: UnsupportedNowPlayingProviderConfig
}
export interface NowPlayingProviderState {
providerId: NowPlayingProviderId
connectionState: NowPlayingProviderConnectionState
lastError: string | null
lastControlError: string | null
snapshot: NowPlayingSnapshot | null
isConfigured: boolean
available: boolean
supportsTransportControls: boolean
}
export type NowPlayingProviderStateMap = Record<NowPlayingProviderId, NowPlayingProviderState>
export type NowPlayingProviderDefinitionMap = Record<NowPlayingProviderId, NowPlayingProviderDefinition>
export interface NowPlayingState {
definitions: NowPlayingProviderDefinitionMap
configs: NowPlayingProviderConfigMap
providers: NowPlayingProviderStateMap
providerPriority: NowPlayingProviderId[]
activeProviderId: NowPlayingProviderId | null
hasConfiguredProvider: boolean
onboardingRequired: boolean
}
export const NOW_PLAYING_PROVIDER_IDS: NowPlayingProviderId[] = ['astra', 'spotify', 'tidal']
export const NOW_PLAYING_PROVIDER_DEFINITIONS: NowPlayingProviderDefinitionMap = {
astra: {
id: 'astra',
label: 'Astra',
description: 'Connect Prism to the Astra local API for live track data and transport controls.',
authMode: 'token',
available: true,
comingSoon: false,
supportsTransportControls: true,
},
spotify: {
id: 'spotify',
label: 'Spotify',
description: 'Spotify integration is planned but not implemented yet.',
authMode: 'oauth',
available: false,
comingSoon: true,
supportsTransportControls: false,
},
tidal: {
id: 'tidal',
label: 'TIDAL',
description: 'TIDAL integration is planned but not implemented yet.',
authMode: 'oauth',
available: false,
comingSoon: true,
supportsTransportControls: false,
},
}
+14 -4
View File
@@ -6,9 +6,9 @@ export type ScopeKind =
| 'vumeter'
| 'lufsmeter'
| 'waveform'
| 'astra'
| 'nowPlaying'
export type AudioScopeKind = Exclude<ScopeKind, 'astra'>
export type AudioScopeKind = Exclude<ScopeKind, 'nowPlaying'>
export const SCOPE_KINDS: ScopeKind[] = [
'spectrum',
@@ -18,7 +18,7 @@ export const SCOPE_KINDS: ScopeKind[] = [
'vumeter',
'lufsmeter',
'waveform',
'astra',
'nowPlaying',
]
export const AUDIO_SCOPE_KINDS: AudioScopeKind[] = [
@@ -35,6 +35,16 @@ export function isAudioScopeKind(value: unknown): value is AudioScopeKind {
return typeof value === 'string' && AUDIO_SCOPE_KINDS.includes(value as AudioScopeKind)
}
export function normalizeScopeKind(value: unknown): ScopeKind | null {
if (value === 'astra') {
return 'nowPlaying'
}
return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind)
? value as ScopeKind
: null
}
export const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
@@ -43,5 +53,5 @@ export const SCOPE_LABELS: Record<ScopeKind, string> = {
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
astra: 'Astra',
nowPlaying: 'Now Playing',
}
+2 -2
View File
@@ -51,7 +51,7 @@ export interface ScopeSettings {
gainDb: number
multiband: boolean
}
astra: {
nowPlaying: {
showCoverArt: boolean
showTitle: boolean
showArtist: boolean
@@ -69,7 +69,7 @@ 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: {
nowPlaying: {
showCoverArt: true,
showTitle: true,
showArtist: true,
+8 -4
View File
@@ -120,7 +120,7 @@ export interface ThemeWaveformTokens {
guides?: string
}
export interface ThemeAstraTokens {
export interface ThemeNowPlayingTokens {
accent?: string
background?: string
surface?: string
@@ -134,6 +134,8 @@ export interface ThemeAstraTokens {
statusError?: string
}
export type ThemeAstraTokens = ThemeNowPlayingTokens
export interface PrismTheme {
name: string
credit?: string
@@ -149,7 +151,7 @@ export interface PrismTheme {
vumeter: ThemeVUMeterTokens
lufsmeter: ThemeLUFSMeterTokens
waveform: ThemeWaveformTokens
astra: ThemeAstraTokens
nowPlaying: ThemeNowPlayingTokens
}
export interface PrismThemeLocalStateV1 {
@@ -305,7 +307,7 @@ export interface ResolvedWaveformTheme {
bandHigh: string
}
export interface ResolvedAstraTheme {
export interface ResolvedNowPlayingTheme {
accent: string
text: string
subtext: string
@@ -323,6 +325,8 @@ export interface ResolvedAstraTheme {
statusError: string
}
export type ResolvedAstraTheme = ResolvedNowPlayingTheme
export interface PrismResolvedTheme {
name: string
credit?: string
@@ -336,5 +340,5 @@ export interface PrismResolvedTheme {
vumeter: ResolvedVUMeterTheme
lufsmeter: ResolvedLUFSMeterTheme
waveform: ResolvedWaveformTheme
astra: ResolvedAstraTheme
nowPlaying: ResolvedNowPlayingTheme
}
+2
View File
@@ -1,4 +1,5 @@
import type { ScopeKind } from './scope'
import type { WindowBounds } from './popout'
export const WINDOW_LOCAL_STATE_FORMAT = 'prism-window-local'
export const WINDOW_LOCAL_STATE_VERSION = 1
@@ -8,4 +9,5 @@ export interface PrismWindowLocalStateV1 {
version: typeof WINDOW_LOCAL_STATE_VERSION
mainAlwaysOnTop: boolean
popoutAlwaysOnTop: Partial<Record<ScopeKind, boolean>>
nowPlayingConfigWindowBounds?: WindowBounds
}
+264
View File
@@ -0,0 +1,264 @@
import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { NowPlayingManager } from '../src/main/services/nowPlayingManager'
import {
DEFAULT_ASTRA_BASE_URL,
type AstraIntegrationConfig,
type AstraIntegrationState,
} from '../src/types/astra'
function createAstraState(overrides: Partial<AstraIntegrationState> = {}): AstraIntegrationState {
const config = overrides.config ?? {
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: '',
}
return {
connectionState: 'disabled',
lastError: null,
lastControlError: null,
snapshot: null,
...overrides,
config,
}
}
class StubAstraService {
state: AstraIntegrationState
consumerCalls: Array<{ consumerId: number, active: boolean }> = []
saveConfigCalls: unknown[] = []
controlCalls: string[] = []
initializeCalls = 0
disposeCalls = 0
private readonly listeners = new Set<() => void>()
constructor(state: AstraIntegrationState) {
this.state = state
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
emit(): void {
for (const listener of this.listeners) {
listener()
}
}
async initialize(): Promise<void> {
this.initializeCalls += 1
}
async dispose(): Promise<void> {
this.disposeCalls += 1
}
getState(): AstraIntegrationState {
return this.state
}
getConfig(): AstraIntegrationConfig {
return { ...this.state.config }
}
async setConsumerActive(consumerId: number, active: boolean): Promise<void> {
this.consumerCalls.push({ consumerId, active })
}
async saveConfig(rawConfig: unknown): Promise<void> {
this.saveConfigCalls.push(rawConfig)
const patch = typeof rawConfig === 'object' && rawConfig !== null
? rawConfig as Partial<AstraIntegrationConfig>
: {}
this.state = {
...this.state,
config: {
...this.state.config,
...patch,
},
}
this.emit()
}
async sendControl(command: 'play' | 'pause' | 'next' | 'previous'): Promise<void> {
this.controlCalls.push(command)
}
}
async function createHarness(state: AstraIntegrationState): Promise<{
cleanup: () => Promise<void>
manager: NowPlayingManager
stub: StubAstraService
}> {
const rootDir = await mkdtemp(join(tmpdir(), 'prism-now-playing-manager-'))
const stub = new StubAstraService(state)
return {
cleanup: () => rm(rootDir, { recursive: true, force: true }),
manager: new NowPlayingManager({
astraConfigPath: join(rootDir, 'astra-integration.json'),
localStatePath: join(rootDir, 'now-playing-state.json'),
astraService: stub,
}),
stub,
}
}
test('manager starts in onboarding mode until a supported provider is configured', async () => {
const harness = await createHarness(createAstraState())
try {
await harness.manager.initialize()
const state = harness.manager.getState()
assert.deepEqual(state.providerPriority, ['astra', 'spotify', 'tidal'])
assert.equal(state.hasConfiguredProvider, false)
assert.equal(state.onboardingRequired, true)
assert.equal(state.activeProviderId, null)
assert.equal(state.providers.astra.connectionState, 'disabled')
assert.equal(state.providers.spotify.connectionState, 'unavailable')
assert.equal(state.providers.tidal.connectionState, 'unavailable')
} finally {
await harness.cleanup()
}
})
test('coming-soon providers never outrank a configured Astra provider in arbitration', async () => {
const harness = await createHarness(createAstraState({
config: {
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token',
},
connectionState: 'connected',
snapshot: {
playbackState: 'paused',
currentTime: 12,
duration: 120,
queueLength: 4,
outputDeviceLabel: 'Studio',
visualizerLineColor: '#38bdf8',
currentTrack: {
id: 'track-1',
title: 'Track',
artist: 'Artist',
album: 'Album',
isFavorite: false,
artworkDataUrl: null,
},
updatedAt: 1000,
},
}))
try {
await harness.manager.initialize()
await harness.manager.setProviderPriority(['spotify', 'tidal', 'astra'])
const state = harness.manager.getState()
assert.deepEqual(state.providerPriority, ['spotify', 'tidal', 'astra'])
assert.equal(state.hasConfiguredProvider, true)
assert.equal(state.onboardingRequired, false)
assert.equal(state.activeProviderId, 'astra')
} finally {
await harness.cleanup()
}
})
test('manager forwards consumer activity for multiple now-playing surfaces', async () => {
const harness = await createHarness(createAstraState())
try {
await harness.manager.initialize()
await harness.manager.setConsumerActive(101, true)
await harness.manager.setConsumerActive(202, true)
await harness.manager.setConsumerActive(101, false)
assert.deepEqual(harness.stub.consumerCalls, [
{ consumerId: 101, active: true },
{ consumerId: 202, active: true },
{ consumerId: 101, active: false },
])
} finally {
await harness.cleanup()
}
})
test('manager routes config retry, controls, and service updates through Astra', async () => {
const harness = await createHarness(createAstraState({
config: {
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token',
},
connectionState: 'connected',
snapshot: {
playbackState: 'playing',
currentTime: 24,
duration: 180,
queueLength: 8,
outputDeviceLabel: 'Main Out',
visualizerLineColor: '#38bdf8',
currentTrack: {
id: 'track-2',
title: 'Playing Track',
artist: 'Artist',
album: 'Album',
isFavorite: true,
artworkDataUrl: null,
},
updatedAt: 2000,
},
}))
try {
await harness.manager.initialize()
const snapshots: Array<string | null> = []
const unsubscribe = harness.manager.subscribe((state) => {
snapshots.push(state.activeProviderId)
})
await harness.manager.retryProvider('astra')
await harness.manager.sendControl('next')
harness.stub.state = createAstraState({
config: {
baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token',
},
connectionState: 'connected',
snapshot: {
playbackState: 'paused',
currentTime: 48,
duration: 180,
queueLength: 8,
outputDeviceLabel: 'Main Out',
visualizerLineColor: '#38bdf8',
currentTrack: {
id: 'track-3',
title: 'Paused Track',
artist: 'Artist',
album: 'Album',
isFavorite: false,
artworkDataUrl: null,
},
updatedAt: 3000,
},
})
harness.stub.emit()
unsubscribe()
assert.deepEqual(harness.stub.saveConfigCalls, [
{ baseUrl: DEFAULT_ASTRA_BASE_URL, token: 'secret-token' },
])
assert.deepEqual(harness.stub.controlCalls, ['next'])
assert.deepEqual(snapshots, ['astra', 'astra', 'astra'])
} finally {
await harness.cleanup()
}
})
+12 -8
View File
@@ -64,9 +64,9 @@ test('profile file serialization excludes geometry and round-trips with local me
assert.equal(JSON.stringify(file).includes('inputGainDb'), false)
assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true })
assert.equal(file.scopeSettings.spectrum.heatmapSmoothing, 0.67)
assert.equal(file.scopeOrder.includes('astra'), false)
assert.equal(file.hiddenScopes.includes('astra'), true)
assert.equal(file.widthWeights.astra, 1)
assert.equal(file.scopeOrder.includes('nowPlaying'), false)
assert.equal(file.hiddenScopes.includes('nowPlaying'), true)
assert.equal(file.widthWeights.nowPlaying, 1)
const restored = profileFileToProfile(file, extractLocalProfileMetadata(profile))
assert.deepEqual(restored.windowBounds, profile.windowBounds)
@@ -74,7 +74,7 @@ test('profile file serialization excludes geometry and round-trips with local me
assert.equal(restored.scopeSettings.spectrum.showSideLine, true)
assert.equal(restored.scopeSettings.spectrum.heatmapSmoothing, 0.67)
assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono')
assert.equal(restored.scopeSettings.astra.showControls, true)
assert.equal(restored.scopeSettings.nowPlaying.showControls, true)
})
test('library saves, renames, deletes, and resolves filename collisions', async () => {
@@ -111,12 +111,13 @@ test('library saves, renames, deletes, and resolves filename collisions', async
}
})
test('astra stays opt-in for profile scope order normalization', () => {
test('now playing 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)
assert.equal(profile.scopeOrder.includes('nowPlaying'), false)
assert.equal(normalizeScopeOrder(undefined).includes('nowPlaying'), false)
assert.equal(normalizeScopeOrder(['spectrum', 'nowPlaying']).includes('nowPlaying'), true)
assert.equal(normalizeScopeOrder(['spectrum', 'astra']).includes('nowPlaying'), true)
})
test('default profile omits theme metadata from runtime state and saved files', async () => {
@@ -262,6 +263,9 @@ test('legacy profile files with themeId import successfully and ignore embedded
assert.ok(importedProfile)
assert.equal(importedProfile.name, 'Legacy Theme')
assert.equal('themeId' in importedProfile, false)
assert.equal(importedProfile.hiddenScopes.includes('nowPlaying'), true)
assert.equal(importedProfile.widthWeights.nowPlaying, 1)
assert.equal(importedProfile.scopePopouts.nowPlaying.poppedOut, false)
} finally {
await harness.cleanup()
}
+13 -13
View File
@@ -995,7 +995,7 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked
'vumeter',
'lufsmeter',
'waveform',
'astra',
'nowPlaying',
])
})
@@ -1682,12 +1682,12 @@ test('scopeSummary includes spectrum peak mode when enabled', () => {
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak Follow')
})
test('scopeSummary summarizes astra field visibility', () => {
test('scopeSummary summarizes now playing field visibility', () => {
const profile = createDefaultProfile('Default')
profile.scopeSettings.astra.showArtist = false
profile.scopeSettings.astra.showControls = false
profile.scopeSettings.nowPlaying.showArtist = false
profile.scopeSettings.nowPlaying.showControls = false
assert.equal(scopeSummary('astra', profile.scopeSettings.astra), 'Cover · Title · Bar · Time')
assert.equal(scopeSummary('nowPlaying', profile.scopeSettings.nowPlaying), 'Cover · Title · Bar · Time')
})
test('ScopePopoutDataSource switches waveform batches between mono and stereo queues', () => {
@@ -1897,7 +1897,7 @@ test('BottomBar theme section renders compact credit metadata and opens valid li
assert.match(stylesSource, /\.bottom-bar__theme-credit--link \{/)
})
test('toggleScope appends astra to the scope order when it is enabled from an opt-in profile', () => {
test('toggleScope appends now playing to the scope order when it is enabled from an opt-in profile', () => {
const previousSettingsState = useSettingsStore.getState()
const fakeWindow = installFakeElectronWindow()
@@ -1905,12 +1905,12 @@ test('toggleScope appends astra to the scope order when it is enabled from an op
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
seedProfileDraftState(profile)
assert.equal(useSettingsStore.getState().scopeOrder.includes('astra'), false)
assert.equal(useSettingsStore.getState().scopeOrder.includes('nowPlaying'), false)
useSettingsStore.getState().toggleScope('astra')
useSettingsStore.getState().toggleScope('nowPlaying')
assert.equal(useSettingsStore.getState().scopeOrder.at(-1), 'astra')
assert.equal(useSettingsStore.getState().hiddenScopes.has('astra'), false)
assert.equal(useSettingsStore.getState().scopeOrder.at(-1), 'nowPlaying')
assert.equal(useSettingsStore.getState().hiddenScopes.has('nowPlaying'), false)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
@@ -2337,7 +2337,7 @@ test('moveDockedScopeOrder is a no-op at the docked boundaries', () => {
initialOrder,
)
assert.equal(
moveDockedScopeOrder(initialOrder, new Set<ScopeKind>(), createScopePopouts(), 'astra', 'right'),
moveDockedScopeOrder(initialOrder, new Set<ScopeKind>(), createScopePopouts(), 'nowPlaying', 'right'),
initialOrder,
)
})
@@ -2359,7 +2359,7 @@ test('moveDockedScopeOrder preserves hidden scope positions in the full order',
'vumeter',
'lufsmeter',
'waveform',
'astra',
'nowPlaying',
])
})
@@ -2380,7 +2380,7 @@ test('moveDockedScopeOrder preserves popped-out scope positions in the full orde
'vumeter',
'lufsmeter',
'waveform',
'astra',
'nowPlaying',
])
})
+30 -15
View File
@@ -66,7 +66,7 @@ test('theme files round-trip and keep grouped sections intact', () => {
assert.equal(parsed.scopes.background, 'rgb(3, 7, 18)')
assert.equal(parsed.spectrum.heatMid, 'rgb(200, 50, 120)')
assert.equal(parsed.vumeter.track, 'rgb(17, 24, 39)')
assert.equal(parsed.astra.background, theme.astra.background)
assert.equal(parsed.nowPlaying.background, theme.nowPlaying.background)
})
test('theme files preserve optional credit and website metadata', () => {
@@ -83,6 +83,21 @@ test('theme files preserve optional credit and website metadata', () => {
assert.equal(parsed.website, 'https://themes.example/night-shift')
})
test('parseThemeFileContent maps legacy Astra sections into now playing tokens', () => {
const parsed = parseThemeFileContent(`
[Theme]
format = prism-theme
version = 2
[Astra]
background = 12, 18, 24, 255
button_surface = 30, 40, 50, 230
`, 'Legacy Astra')
assert.equal(parsed.nowPlaying.background, 'rgb(12, 18, 24)')
assert.equal(parsed.nowPlaying.buttonSurface, 'rgba(30, 40, 50, 0.902)')
})
test('parseThemeFileContent preserves passthrough controls tokens from .iro files', () => {
const parsed = parseThemeFileContent(`
[Theme]
@@ -139,7 +154,7 @@ test('resolveTheme maps grouped app, controls, and scopes tokens into UI and sco
theme.scopes.resizeHandle = 'rgb(38, 39, 40)'
theme.vumeter.track = 'rgb(41, 42, 43)'
theme.lufsmeter.track = 'rgb(44, 45, 46)'
theme.astra = {}
theme.nowPlaying = {}
const resolved = resolveTheme(theme)
assert.equal(resolved.interface.accent, 'rgb(255, 159, 67)')
@@ -161,9 +176,9 @@ test('resolveTheme maps grouped app, controls, and scopes tokens into UI and sco
assert.equal(resolved.waveform.guides, 'rgba(100, 110, 120, 0.2)')
assert.equal(resolved.vumeter.track, 'rgb(41, 42, 43)')
assert.equal(resolved.lufsmeter.track, 'rgb(44, 45, 46)')
assert.equal(resolved.astra.accent, 'rgb(255, 159, 67)')
assert.equal(resolved.astra.background, 'rgb(29, 30, 31)')
assert.equal(resolved.astra.text, 'rgb(240, 244, 248)')
assert.equal(resolved.nowPlaying.accent, 'rgb(255, 159, 67)')
assert.equal(resolved.nowPlaying.background, 'rgb(29, 30, 31)')
assert.equal(resolved.nowPlaying.text, 'rgb(240, 244, 248)')
})
test('themeToCssVariables exposes grouped UI, control, and scope variables', () => {
@@ -208,20 +223,20 @@ test('createTemplateThemeFile presents a simplified recommended theme layout', (
assert.equal(parsed.controls.flatControls, 'false')
})
test('resolveTheme exposes Astra-specific button tokens and derived button states', () => {
test('resolveTheme exposes now playing button tokens and derived button states', () => {
const theme = createDefaultTheme()
theme.astra.accent = 'rgb(100, 150, 200)'
theme.astra.text = 'rgb(240, 241, 242)'
theme.astra.buttonSurface = 'rgba(20, 30, 40, 0.9)'
theme.astra.buttonBorder = 'rgb(50, 60, 70)'
theme.nowPlaying.accent = 'rgb(100, 150, 200)'
theme.nowPlaying.text = 'rgb(240, 241, 242)'
theme.nowPlaying.buttonSurface = 'rgba(20, 30, 40, 0.9)'
theme.nowPlaying.buttonBorder = 'rgb(50, 60, 70)'
const resolved = resolveTheme(theme)
assert.equal(resolved.astra.buttonBg, 'rgba(20, 30, 40, 0.902)')
assert.equal(resolved.astra.buttonBgHover, 'rgba(31, 47, 62, 0.94)')
assert.equal(resolved.astra.buttonBgActive, 'rgba(100, 150, 200, 0.16)')
assert.equal(resolved.astra.buttonBorder, 'rgb(50, 60, 70)')
assert.equal(resolved.astra.buttonText, 'rgb(240, 241, 242)')
assert.equal(resolved.nowPlaying.buttonBg, 'rgba(20, 30, 40, 0.902)')
assert.equal(resolved.nowPlaying.buttonBgHover, 'rgba(31, 47, 62, 0.94)')
assert.equal(resolved.nowPlaying.buttonBgActive, 'rgba(100, 150, 200, 0.16)')
assert.equal(resolved.nowPlaying.buttonBorder, 'rgb(50, 60, 70)')
assert.equal(resolved.nowPlaying.buttonText, 'rgb(240, 241, 242)')
})
test('Astra renderer consumes Astra-specific button theme vars', async () => {
+39
View File
@@ -83,6 +83,31 @@ test('popout always-on-top persists independently per scope kind', async () => {
}
})
test('now playing config window bounds persist immediately and survive reload', async () => {
const harness = await createHarness()
try {
await harness.store.initialize()
await harness.store.setNowPlayingConfigWindowBounds({ x: 120, y: 80, width: 720, height: 560 })
const saved = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as {
nowPlayingConfigWindowBounds?: {
x: number
y: number
width: number
height: number
}
}
assert.deepEqual(saved.nowPlayingConfigWindowBounds, { x: 120, y: 80, width: 720, height: 560 })
const reloaded = new FileBackedWindowStateStore(harness.localStatePath)
await reloaded.initialize()
assert.deepEqual(reloaded.getNowPlayingConfigWindowBounds(), { x: 120, y: 80, width: 720, height: 560 })
} finally {
await harness.cleanup()
}
})
test('turning a popout pin back off clears its saved local state without affecting others', async () => {
const harness = await createHarness()
@@ -118,17 +143,31 @@ test('invalid saved window state normalizes to supported keys and boolean values
mainAlwaysOnTop: true,
popoutAlwaysOnTop: {
spectrum: true,
astra: true,
waveform: 'yes',
invalid_scope: true,
},
nowPlayingConfigWindowBounds: {
x: 12.4,
y: 18.7,
width: 640.2,
height: 420.9,
},
}, null, 2)}\n`, 'utf8')
await harness.store.initialize()
assert.equal(harness.store.getMainAlwaysOnTop(), true)
assert.equal(harness.store.getPopoutAlwaysOnTop('spectrum'), true)
assert.equal(harness.store.getPopoutAlwaysOnTop('nowPlaying'), true)
assert.equal(harness.store.getPopoutAlwaysOnTop('waveform'), false)
assert.equal(harness.store.getPopoutAlwaysOnTop('oscilloscope'), false)
assert.deepEqual(harness.store.getNowPlayingConfigWindowBounds(), {
x: 12,
y: 19,
width: 640,
height: 421,
})
} finally {
await harness.cleanup()
}