From 71fd59c710381ea431a2c902f282c1ef034d6f27 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:05:00 -0400 Subject: [PATCH] add initial support for more integrations in the now playing panel --- package.json | 1 + scripts/run-now-playing-manager-tests.mjs | 44 ++ src/main/index.ts | 225 ++++++-- src/main/services/nowPlayingManager.ts | 338 +++++++++++ src/main/windowStateStore.ts | 16 + src/preload/index.ts | 39 +- src/renderer/App.tsx | 8 +- src/renderer/components/AstraScopeModule.tsx | 138 +++-- src/renderer/components/BottomBar.tsx | 170 +++--- .../components/NowPlayingConfigWindow.tsx | 534 ++++++++++++++++++ src/renderer/components/ScopeModule.tsx | 8 +- .../components/ScopeSettingsSection.tsx | 22 +- src/renderer/env.d.ts | 25 +- src/renderer/main.tsx | 3 + src/renderer/stores/astraStore.ts | 116 ---- src/renderer/stores/nowPlayingStore.ts | 164 ++++++ src/renderer/styles/globals.css | 379 ++++++++++++- src/shared/profileState.ts | 44 +- src/shared/themeState.ts | 67 ++- src/shared/windowState.ts | 10 +- src/types/nowPlaying.ts | 102 ++++ src/types/scope.ts | 18 +- src/types/settings.ts | 4 +- src/types/theme.ts | 12 +- src/types/windowState.ts | 2 + test/now-playing-manager.test.ts | 264 +++++++++ test/profile-library.test.ts | 20 +- test/renderer-helpers.test.ts | 26 +- test/theme-library.test.ts | 45 +- test/window-state-store.test.ts | 39 ++ 30 files changed, 2479 insertions(+), 404 deletions(-) create mode 100644 scripts/run-now-playing-manager-tests.mjs create mode 100644 src/main/services/nowPlayingManager.ts create mode 100644 src/renderer/components/NowPlayingConfigWindow.tsx delete mode 100644 src/renderer/stores/astraStore.ts create mode 100644 src/renderer/stores/nowPlayingStore.ts create mode 100644 src/types/nowPlaying.ts create mode 100644 test/now-playing-manager.test.ts diff --git a/package.json b/package.json index a390ea5..c2e9338 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/run-now-playing-manager-tests.mjs b/scripts/run-now-playing-manager-tests.mjs new file mode 100644 index 0000000..6db711b --- /dev/null +++ b/scripts/run-now-playing-manager-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-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) diff --git a/src/main/index.ts b/src/main/index.ts index 6fa0dd7..9b7ac5b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() const scopePopoutCloseAllowed = new Set() const popoutBoundsTimers = new Map>() const suppressNextPopoutBoundsEvents = new Set() +let nowPlayingConfigWindow: BrowserWindow | null = null +let nowPlayingConfigBoundsTimer: ReturnType | null = null const windowSettingsHeights = new Map() const windowSettingsBottomAnchors = new Map() 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 { 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 + 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() }) diff --git a/src/main/services/nowPlayingManager.ts b/src/main/services/nowPlayingManager.ts new file mode 100644 index 0000000..af61b7d --- /dev/null +++ b/src/main/services/nowPlayingManager.ts @@ -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 + dispose(): Promise + subscribe(listener: () => void): () => void + getState(): AstraIntegrationState + getConfig(): AstraIntegrationConfig + setConsumerActive(consumerId: number, active: boolean): Promise + saveConfig(rawConfig: unknown): Promise + sendControl(command: NowPlayingControlCommand): Promise +} + +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() + 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 + : {} + + 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): 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 { + 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 { + 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 { + await this.ensureInitialized() + await this.astraService.setConsumerActive(consumerId, active) + return this.getState() + } + + async saveProviderConfig( + providerId: NowPlayingProviderId, + rawConfig: unknown, + ): Promise { + 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 { + 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 { + await this.ensureInitialized() + this.providerPriority = normalizeProviderPriority(rawPriority) + await this.writeLocalState({ providerPriority: this.providerPriority }) + this.emitState() + return this.getState() + } + + async sendControl(command: NowPlayingControlCommand): Promise { + 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 { + if (!this.initialized) { + await this.initialize() + } + } + + private async readLocalState(): Promise { + 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 { + await mkdir(dirname(this.localStatePath), { recursive: true }) + await writeFile(this.localStatePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8') + } +} diff --git a/src/main/windowStateStore.ts b/src/main/windowStateStore.ts index c1ebb48..39598f5 100644 --- a/src/main/windowStateStore.ts +++ b/src/main/windowStateStore.ts @@ -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 { await this.ensureInitialized() this.state = normalizeWindowLocalState({ @@ -52,6 +59,15 @@ export class FileBackedWindowStateStore { await this.persistState() } + async setNowPlayingConfigWindowBounds(bounds?: WindowBounds): Promise { + await this.ensureInitialized() + this.state = normalizeWindowLocalState({ + ...this.state, + nowPlayingConfigWindowBounds: bounds, + }) + await this.persistState() + } + private async ensureInitialized(): Promise { if (!this.initialized) { await this.initialize() diff --git a/src/preload/index.ts b/src/preload/index.ts index 711e50e..21f099c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, - saveAstraConfig: (config: AstraIntegrationConfig) => ipcRenderer.invoke('astra:save-config', config) as Promise, - getAstraState: () => ipcRenderer.invoke('astra:get-state') as Promise, - setAstraActive: (active: boolean) => ipcRenderer.invoke('astra:set-active', active) as Promise, - sendAstraControl: (command: AstraControlCommand) => ipcRenderer.invoke('astra:send-control', command) as Promise, + getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise, + setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise, + saveNowPlayingProviderConfig: (providerId: K, config: NowPlayingProviderConfigMap[K]) => { + return ipcRenderer.invoke('now-playing:save-provider-config', providerId, config) as Promise + }, + setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => { + return ipcRenderer.invoke('now-playing:set-provider-priority', providerPriority) as Promise + }, + retryNowPlayingProvider: (providerId: NowPlayingProviderId) => { + return ipcRenderer.invoke('now-playing:retry-provider', providerId) as Promise + }, + sendNowPlayingControl: (command: NowPlayingControlCommand) => { + return ipcRenderer.invoke('now-playing:send-control', command) as Promise + }, + openNowPlayingConfigWindow: () => ipcRenderer.invoke('now-playing:open-config-window') as Promise, getProfileSnapshot: () => ipcRenderer.invoke('profiles:get-snapshot') as Promise, saveNewProfile: (name: string, profile: Profile) => ipcRenderer.invoke('profiles:save-new', name, profile) as Promise, overwriteProfile: (id: string, profile: Profile) => ipcRenderer.invoke('profiles:overwrite', id, profile) as Promise, @@ -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() diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a93764b..178008a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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, diff --git a/src/renderer/components/AstraScopeModule.tsx b/src/renderer/components/AstraScopeModule.tsx index 68d6f02..a0c53c7 100644 --- a/src/renderer/components/AstraScopeModule.tsx +++ b/src/renderer/components/AstraScopeModule.tsx @@ -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['integrationState']['connectionState']): string { +function getConfiguredProviderId( + state: ReturnType['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['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 (
- All Astra elements are hidden. + All Now Playing elements are hidden. +
+
+ ) + } + + if (nowPlayingState.onboardingRequired) { + return ( +
+
+
Not configured
+
+ Set up a provider to show track metadata and playback controls here. +
+
) @@ -153,8 +217,8 @@ export default function AstraScopeModule({ {(settings.showTitle || settings.showArtist) && (
{settings.showTitle && ( -
- {currentTrack?.title ?? getFallbackTitle(integrationState.connectionState)} +
+ {currentTrack?.title ?? getFallbackTitle(displayProviderId, providerState?.connectionState ?? null)}
)} {settings.showArtist && detailMessage && ( @@ -190,7 +254,7 @@ export default function AstraScopeModule({
diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index bbbe073..4ccaf75 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -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 = { @@ -20,7 +19,7 @@ const SCOPE_LABELS: Record = { 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(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 => { - 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 => { clearCaptureNotice() await startCapture() @@ -238,8 +214,27 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): await startCapture() } - const handleRetryAstra = async (): Promise => { - await handleSaveAstraConfig() + const handleRetryNowPlaying = async (): Promise => { + 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 => { @@ -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):
-
-
Astra
+
+
+
Now Playing
+
+ {nowPlayingDetail} +
+
-
- setAstraBaseUrlInput(event.target.value)} - /> +
+
+ + {nowPlayingStatusLabel} +
- setAstraTokenInput(event.target.value)} - /> + {nowPlayingErrorMessage ? ( + + ) : null} - -
- - {astraStatusLabel} -
- - {astraErrorMessage ? ( - <> -
{astraErrorMessage}
-
- - -
- - ) : null}
diff --git a/src/renderer/components/NowPlayingConfigWindow.tsx b/src/renderer/components/NowPlayingConfigWindow.tsx new file mode 100644 index 0000000..6114ef9 --- /dev/null +++ b/src/renderer/components/NowPlayingConfigWindow.tsx @@ -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 ( + + ) +} + +function CloseIcon(): JSX.Element { + return ( + + ) +} + +function MinimizeIcon(): JSX.Element { + return ( + + ) +} + +function ChevronIcon(): JSX.Element { + return ( + + ) +} + +function AstraLogoIcon(): JSX.Element { + return ( + + ) +} + +function SpotifyIcon(): JSX.Element { + return ( + + ) +} + +function TidalIcon(): JSX.Element { + return ( + + ) +} + +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 + case 'spotify': + return + case 'tidal': + return + } +} + +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('astra') + const [draggedProviderId, setDraggedProviderId] = useState(null) + const [dropTargetProviderId, setDropTargetProviderId] = useState(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): void => { + if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + window.electronAPI.startWindowMove() + }, []) + + const handleToolbarDragEnd = useCallback((event: ReactPointerEvent): void => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + window.electronAPI.stopWindowMove() + }, []) + + const handleSaveAstraConfig = useCallback(async (): Promise => { + 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): 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 => { + 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 ( +
+
+
+
+
Now Playing
+
+ Drag providers to set priority. Expand a row to configure keys or logins. +
+
+ +
+ + +
+
+ +
+
+
+ {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.'} +
+ +
+ {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 ( +
{ + if (!draggedProviderId || draggedProviderId === providerId) return + event.preventDefault() + if (dropTargetProviderId !== providerId) { + setDropTargetProviderId(providerId) + } + }} + onDrop={(event) => { + event.preventDefault() + void handleProviderDrop(providerId) + }} + > +
+
handleProviderDragStart(providerId, event)} + onDragEnd={handleProviderDragEnd} + aria-label={`Drag to reorder ${definition.label}`} + title={`Drag to reorder ${definition.label}`} + > + +
+ + +
+ + {isExpanded ? ( + providerId === 'astra' ? ( +
+
+ {definition.description} +
+ +
+ + + +
+ +
+ + +
+ + {provider.lastError || provider.lastControlError ? ( +
+ {provider.lastError ?? provider.lastControlError} +
+ ) : null} +
+ ) : ( +
+
+ {definition.description} +
+
+ Login and connection flow land here later. For now, this row exists so you can set future priority. +
+
+ ) + ) : null} +
+ ) + })} +
+
+
+
+ + + +
+ ) +} diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 05149ec..9defa94 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -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 ( ) } diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index ff1de8b..4827fef 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -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 ( onUpdate('astra', { showCoverArt: !current.showCoverArt })} + onClick={() => onUpdate('nowPlaying', { showCoverArt: !current.showCoverArt })} /> onUpdate('astra', { showTitle: !current.showTitle })} + onClick={() => onUpdate('nowPlaying', { showTitle: !current.showTitle })} /> onUpdate('astra', { showArtist: !current.showArtist })} + onClick={() => onUpdate('nowPlaying', { showArtist: !current.showArtist })} /> onUpdate('astra', { showProgress: !current.showProgress })} + onClick={() => onUpdate('nowPlaying', { showProgress: !current.showProgress })} /> onUpdate('astra', { showTime: !current.showTime })} + onClick={() => onUpdate('nowPlaying', { showTime: !current.showTime })} /> onUpdate('astra', { showControls: !current.showControls })} + onClick={() => onUpdate('nowPlaying', { showControls: !current.showControls })} /> ) diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 8a1ee8b..d1efda4 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -1,13 +1,14 @@ /// -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 getCaptureBackendSupport: () => Promise - getAstraConfig: () => Promise - saveAstraConfig: (config: AstraIntegrationConfig) => Promise - getAstraState: () => Promise - setAstraActive: (active: boolean) => Promise - sendAstraControl: (command: AstraControlCommand) => Promise + getNowPlayingState: () => Promise + setNowPlayingConsumerActive: (active: boolean) => Promise + saveNowPlayingProviderConfig: (providerId: K, config: NowPlayingProviderConfigMap[K]) => Promise + setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise + retryNowPlayingProvider: (providerId: NowPlayingProviderId) => Promise + sendNowPlayingControl: (command: NowPlayingControlCommand) => Promise + openNowPlayingConfigWindow: () => Promise getProfileSnapshot: () => Promise saveNewProfile: (name: string, profile: Profile) => Promise overwriteProfile: (id: string, profile: Profile) => Promise @@ -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 diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 1307379..b5b7471 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -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 = +} else if (windowRole === 'now-playing-config') { + root = } else if (windowRole === 'scope-popout') { root = isScopeKind(scopeKind) ? diff --git a/src/renderer/stores/astraStore.ts b/src/renderer/stores/astraStore.ts deleted file mode 100644 index b146212..0000000 --- a/src/renderer/stores/astraStore.ts +++ /dev/null @@ -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 - saveConfig: (config: AstraIntegrationConfig) => Promise - setScopeActive: (active: boolean) => Promise - sendControl: (command: AstraControlCommand) => Promise - applyExternalState: (state: AstraIntegrationState) => void -} - -function createDefaultIntegrationState(): AstraIntegrationState { - return { - config: { - baseUrl: DEFAULT_ASTRA_BASE_URL, - token: '', - }, - connectionState: 'disabled', - lastError: null, - lastControlError: null, - snapshot: null, - } -} - -let initializePromise: Promise | null = null -let syncBound = false - -export const useAstraStore = create((set) => ({ - initialized: false, - integrationState: createDefaultIntegrationState(), - isSendingControl: false, - - initialize: async () => { - if (initializePromise) { - await initializePromise - return - } - - initializePromise = (async () => { - if (typeof window === 'undefined' || typeof window.electronAPI === 'undefined') { - set({ initialized: true }) - return - } - - const state = await window.electronAPI.getAstraState() - set({ - initialized: true, - integrationState: state, - }) - })() - - try { - await initializePromise - } finally { - initializePromise = null - } - }, - - saveConfig: async (config) => { - const savedConfig = await window.electronAPI.saveAstraConfig(config) - const nextState = await window.electronAPI.getAstraState() - set({ - integrationState: { - ...nextState, - config: savedConfig, - }, - }) - }, - - setScopeActive: async (active) => { - const nextState = await window.electronAPI.setAstraActive(active) - set({ - integrationState: nextState, - }) - }, - - sendControl: async (command) => { - set({ isSendingControl: true }) - try { - const nextState = await window.electronAPI.sendAstraControl(command) - set({ - integrationState: nextState, - }) - } finally { - set({ isSendingControl: false }) - } - }, - - applyExternalState: (state) => { - set({ - initialized: true, - integrationState: state, - }) - }, -})) - -function bindAstraStateSync(): void { - if (syncBound || typeof window === 'undefined' || typeof window.electronAPI === 'undefined') { - return - } - - syncBound = true - window.electronAPI.onAstraStateChanged((state) => { - useAstraStore.getState().applyExternalState(state) - }) -} - -bindAstraStateSync() diff --git a/src/renderer/stores/nowPlayingStore.ts b/src/renderer/stores/nowPlayingStore.ts new file mode 100644 index 0000000..03b5c98 --- /dev/null +++ b/src/renderer/stores/nowPlayingStore.ts @@ -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 + setConsumerActive: (active: boolean) => Promise + saveProviderConfig: (providerId: K, config: NowPlayingProviderConfigMap[K]) => Promise + setProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise + retryProvider: (providerId: NowPlayingProviderId) => Promise + sendControl: (command: NowPlayingControlCommand) => Promise + openConfigWindow: () => Promise + 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 | null = null +let syncBound = false + +export const useNowPlayingStore = create((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() diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index b919c3e..90b284e 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -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; diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 27c87a5..c49319b 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -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 = { 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() 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() + 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 { const parsed = typeof raw === 'object' && raw !== null ? raw as Partial> : {} + const legacyParsed = parsed as Partial> 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 : {} + const legacyParsed = parsed as Partial & { + astra?: Partial + } const rawSpectrum: Partial = typeof parsed.spectrum === 'object' && parsed.spectrum !== null ? parsed.spectrum : {} + const rawNowPlaying: Partial = 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>> : {} + const legacyParsed = parsed as Partial>> 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> : {} + const legacyParsed = parsed as Partial> 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> | undefined + const bounds = normalizeWindowBounds( + legacyBounds?.[kind] ?? (kind === 'nowPlaying' ? legacyBounds?.astra : undefined), + ) if (bounds) { acc[kind] = bounds } diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts index 5a1d01e..f332b07 100644 --- a/src/shared/themeState.ts +++ b/src/shared/themeState.ts @@ -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 -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 +} as const satisfies SectionSchema const SECTION_KEY_MAP: Record = { app: 'app', @@ -183,7 +183,8 @@ const SECTION_KEY_MAP: Record = { vumeter: 'vumeter', lufsmeter: 'lufsmeter', waveform: 'waveform', - astra: 'astra', + now_playing: 'nowPlaying', + astra: 'nowPlaying', } const SECTION_LABEL_MAP: Record = { @@ -197,7 +198,7 @@ const SECTION_LABEL_MAP: Record = { 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>) - const astraSection = serializeSection('Astra', { ...base.astra }, ASTRA_SCHEMA as SectionSchema>) + const nowPlayingSection = serializeSection( + 'Now Playing', + { ...base.nowPlaying }, + NOW_PLAYING_SCHEMA as SectionSchema>, + ) 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) } diff --git a/src/shared/windowState.ts b/src/shared/windowState.ts index ceac8a3..889a233 100644 --- a/src/shared/windowState.ts +++ b/src/shared/windowState.ts @@ -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>)[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), } } diff --git a/src/types/nowPlaying.ts b/src/types/nowPlaying.ts new file mode 100644 index 0000000..18397f1 --- /dev/null +++ b/src/types/nowPlaying.ts @@ -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 +export type NowPlayingProviderDefinitionMap = Record + +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, + }, +} diff --git a/src/types/scope.ts b/src/types/scope.ts index fd258a1..151237d 100644 --- a/src/types/scope.ts +++ b/src/types/scope.ts @@ -6,9 +6,9 @@ export type ScopeKind = | 'vumeter' | 'lufsmeter' | 'waveform' - | 'astra' + | 'nowPlaying' -export type AudioScopeKind = Exclude +export type AudioScopeKind = Exclude 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 = { spectrum: 'Spectrum', oscilloscope: 'Oscilloscope', @@ -43,5 +53,5 @@ export const SCOPE_LABELS: Record = { vumeter: 'VU Meter', lufsmeter: 'LUFS Meter', waveform: 'Waveform', - astra: 'Astra', + nowPlaying: 'Now Playing', } diff --git a/src/types/settings.ts b/src/types/settings.ts index e234025..ca645ad 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -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, diff --git a/src/types/theme.ts b/src/types/theme.ts index 1422440..e19e374 100644 --- a/src/types/theme.ts +++ b/src/types/theme.ts @@ -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 } diff --git a/src/types/windowState.ts b/src/types/windowState.ts index e8d83c3..b16f141 100644 --- a/src/types/windowState.ts +++ b/src/types/windowState.ts @@ -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> + nowPlayingConfigWindowBounds?: WindowBounds } diff --git a/test/now-playing-manager.test.ts b/test/now-playing-manager.test.ts new file mode 100644 index 0000000..38f3567 --- /dev/null +++ b/test/now-playing-manager.test.ts @@ -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 { + 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 { + this.initializeCalls += 1 + } + + async dispose(): Promise { + this.disposeCalls += 1 + } + + getState(): AstraIntegrationState { + return this.state + } + + getConfig(): AstraIntegrationConfig { + return { ...this.state.config } + } + + async setConsumerActive(consumerId: number, active: boolean): Promise { + this.consumerCalls.push({ consumerId, active }) + } + + async saveConfig(rawConfig: unknown): Promise { + this.saveConfigCalls.push(rawConfig) + const patch = typeof rawConfig === 'object' && rawConfig !== null + ? rawConfig as Partial + : {} + this.state = { + ...this.state, + config: { + ...this.state.config, + ...patch, + }, + } + this.emit() + } + + async sendControl(command: 'play' | 'pause' | 'next' | 'previous'): Promise { + this.controlCalls.push(command) + } +} + +async function createHarness(state: AstraIntegrationState): Promise<{ + cleanup: () => Promise + 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 = [] + 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() + } +}) diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 8fd0a99..265f1ac 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -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() } diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index ff62851..d7ddcfd 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -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(), createScopePopouts(), 'astra', 'right'), + moveDockedScopeOrder(initialOrder, new Set(), 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', ]) }) diff --git a/test/theme-library.test.ts b/test/theme-library.test.ts index 6ab82ff..7b1739d 100644 --- a/test/theme-library.test.ts +++ b/test/theme-library.test.ts @@ -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 () => { diff --git a/test/window-state-store.test.ts b/test/window-state-store.test.ts index c6b3a58..dc83e5e 100644 --- a/test/window-state-store.test.ts +++ b/test/window-state-store.test.ts @@ -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() }