fix windows spotify native hooks

This commit is contained in:
Boof2015
2026-04-19 18:43:54 -04:00
parent da520de981
commit 677c4aa331
8 changed files with 457 additions and 160 deletions
+15 -1
View File
@@ -28,12 +28,14 @@ import {
} from '../shared/windowGeometry'
import { calculateResizedWindowBounds } from '../shared/windowResize'
import { FileBackedProfileLibrary } from './profileLibrary'
import { loadNativeWindowsMediaApi } from './nativeWindowsMedia'
import { NowPlayingManager } from './services/nowPlayingManager'
import { AstraIntegrationService } from './services/astraIntegration'
import { MacSpotifyProvider } from './services/macSpotifyProvider'
import { SecretVault } from './services/secretVault'
import { FileBackedThemeLibrary } from './themeLibrary'
import { FileBackedWindowStateStore } from './windowStateStore'
import type { NativeWindowsMediaAPI } from '../types/nativeWindowsMedia'
let mainWindow: BrowserWindow | null = null
let moveInterval: ReturnType<typeof setInterval> | null = null
@@ -66,6 +68,7 @@ let themeLibrary: FileBackedThemeLibrary | null = null
let nowPlayingManager: NowPlayingManager | null = null
let windowStateStore: FileBackedWindowStateStore | null = null
let secretVault: SecretVault | null = null
let nativeWindowsMediaApi: NativeWindowsMediaAPI | null | undefined
const WINDOW_DEFAULTS = {
width: 900,
@@ -147,6 +150,15 @@ function getSecretVault(): SecretVault {
return secretVault
}
function getNativeWindowsMediaApi(): NativeWindowsMediaAPI | null {
if (nativeWindowsMediaApi !== undefined) {
return nativeWindowsMediaApi
}
nativeWindowsMediaApi = loadNativeWindowsMediaApi()
return nativeWindowsMediaApi
}
function getNowPlayingManager(): NowPlayingManager {
if (!nowPlayingManager) {
nowPlayingManager = new NowPlayingManager({
@@ -156,7 +168,9 @@ function getNowPlayingManager(): NowPlayingManager {
configPath: join(app.getPath('userData'), 'astra-integration.json'),
secretVault: getSecretVault(),
}),
new MacSpotifyProvider(),
new MacSpotifyProvider({
windowsMediaApi: getNativeWindowsMediaApi(),
}),
],
})
nowPlayingManager.subscribe((state) => {
+30
View File
@@ -0,0 +1,30 @@
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type { NativeWindowsMediaAPI } from '../types/nativeWindowsMedia'
type NativeAddonModule = VisualizerDSP & NativeCaptureAPI & {
windowsMedia?: NativeWindowsMediaAPI
}
const require = createRequire(import.meta.url)
const currentDir = dirname(fileURLToPath(import.meta.url))
export function loadNativeWindowsMediaApi(): NativeWindowsMediaAPI | null {
if (process.platform !== 'win32') {
return null
}
try {
const isDev = process.env.NODE_ENV === 'development'
const modulePath = isDev
? join(currentDir, '../../native/build/Release/visualizer_dsp.node')
: join(process.resourcesPath!, 'native/visualizer_dsp.node')
const nativeAddon = require(modulePath) as NativeAddonModule
return nativeAddon.windowsMedia ?? null
} catch {
return null
}
}
+43 -114
View File
@@ -9,6 +9,10 @@ import type {
NowPlayingProviderState,
NowPlayingSnapshot,
} from '../../types/nowPlaying'
import type {
NativeWindowsMediaAPI,
NativeWindowsSpotifyPlaybackState,
} from '../../types/nativeWindowsMedia'
import type { NowPlayingProviderService } from './nowPlayingProvider'
type FetchLike = typeof fetch
@@ -24,6 +28,7 @@ interface MacSpotifyProviderOptions {
now?: () => number
platform?: NodeJS.Platform
runner?: AppleScriptRunner
windowsMediaApi?: NativeWindowsMediaAPI | null
}
interface LocalSpotifyTrackSnapshot {
@@ -43,16 +48,6 @@ interface LocalSpotifySnapshot {
updatedAt: number
}
interface WindowsSpotifyStatusPayload {
album?: unknown
artist?: unknown
durationMs?: unknown
playbackStatus?: unknown
sourceAppUserModelId?: unknown
title?: unknown
positionMs?: unknown
}
const FAST_POLL_MS = 1500
const SLOW_POLL_MS = 5000
const SPOTIFY_ARTWORK_COLOR = '#1ed760'
@@ -147,7 +142,7 @@ function normalizeSpotifyError(
}
if (/GlobalSystemMediaTransportControls|Windows\.Media\.Control|WinRT/i.test(message)) {
return new Error('Prism could not access Windows media controls for Spotify.')
return new Error(`Prism could not access Windows media controls for Spotify. ${message}`)
}
}
@@ -348,13 +343,10 @@ function parseLinuxSpotifyStatusOutput(output: string, now: () => number): Local
}
}
function parseWindowsSpotifyStatusOutput(output: string, now: () => number): LocalSpotifySnapshot | null {
const trimmed = output.trim()
if (!trimmed || trimmed === 'null') {
return null
}
const payload = JSON.parse(trimmed) as WindowsSpotifyStatusPayload
function parseWindowsSpotifyStatusPayload(
payload: Partial<NativeWindowsSpotifyPlaybackState>,
now: () => number,
): LocalSpotifySnapshot | null {
const title = normalizeString(typeof payload.title === 'string' ? payload.title : '')
const artist = normalizeString(typeof payload.artist === 'string' ? payload.artist : '')
const album = normalizeString(typeof payload.album === 'string' ? payload.album : '')
@@ -588,95 +580,6 @@ function buildLinuxCommandArgs(busName: string, command: NowPlayingControlComman
]
}
function buildWindowsPowerShellArgs(script: string): string[] {
return [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
script,
]
}
function buildWindowsPowerShellPrelude(): string[] {
return [
'$ErrorActionPreference = "Stop"',
'Add-Type -AssemblyName System.Runtime.WindowsRuntime',
'[void][Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager, Windows.Media.Control, ContentType=WindowsRuntime]',
'[void][System.WindowsRuntimeSystemExtensions]',
'function Await-WinRT($operation) { return [System.WindowsRuntimeSystemExtensions]::AsTask($operation).GetAwaiter().GetResult() }',
'function Get-SpotifySession {',
' $manager = Await-WinRT ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync())',
' $currentSession = $manager.GetCurrentSession()',
' if ($currentSession -and $currentSession.SourceAppUserModelId -match "spotify") {',
' return $currentSession',
' }',
' return $manager.GetSessions() | Where-Object { $_.SourceAppUserModelId -match "spotify" } | Select-Object -First 1',
'}',
]
}
function buildWindowsProbeScript(): string {
return [
...buildWindowsPowerShellPrelude(),
'$null = Get-SpotifySession',
'Write-Output "ready"',
].join('\n')
}
function buildWindowsStatusScript(): string {
return [
...buildWindowsPowerShellPrelude(),
'$session = Get-SpotifySession',
'if (-not $session) {',
' Write-Output "null"',
' exit 0',
'}',
'$timeline = $session.GetTimelineProperties()',
'$playbackInfo = $session.GetPlaybackInfo()',
'$mediaProperties = Await-WinRT ($session.TryGetMediaPropertiesAsync())',
'$payload = [PSCustomObject]@{',
' playbackStatus = [string]$playbackInfo.PlaybackStatus',
' positionMs = [double]$timeline.Position.TotalMilliseconds',
' durationMs = [double](($timeline.EndTime - $timeline.StartTime).TotalMilliseconds)',
' title = [string]$mediaProperties.Title',
' artist = [string]$mediaProperties.Artist',
' album = [string]$mediaProperties.AlbumTitle',
' sourceAppUserModelId = [string]$session.SourceAppUserModelId',
'}',
'$payload | ConvertTo-Json -Compress',
].join('\n')
}
function buildWindowsControlScript(command: NowPlayingControlCommand): string {
const methodName = (() => {
switch (command) {
case 'play':
return 'TryPlayAsync'
case 'pause':
return 'TryPauseAsync'
case 'next':
return 'TrySkipNextAsync'
case 'previous':
return 'TrySkipPreviousAsync'
}
})()
return [
...buildWindowsPowerShellPrelude(),
'$session = Get-SpotifySession',
'if (-not $session) {',
' throw "Spotify is not running."',
'}',
`$result = Await-WinRT ($session.${methodName}())`,
'if (-not $result) {',
' throw "Spotify did not allow Prism to complete that request."',
'}',
'Write-Output "ok"',
].join('\n')
}
export class SpotifyProvider implements NowPlayingProviderService<'spotify'> {
readonly providerId = 'spotify'
@@ -687,6 +590,7 @@ export class SpotifyProvider implements NowPlayingProviderService<'spotify'> {
private readonly now: () => number
private readonly platform: NodeJS.Platform
private readonly runner: AppleScriptRunner
private readonly windowsMediaApi: NativeWindowsMediaAPI | null
private readonly listeners = new Set<() => void>()
private readonly activeConsumers = new Set<number>()
@@ -708,6 +612,7 @@ export class SpotifyProvider implements NowPlayingProviderService<'spotify'> {
this.now = options.now ?? (() => Date.now())
this.platform = options.platform ?? process.platform
this.runner = options.runner ?? defaultAppleScriptRunner
this.windowsMediaApi = options.windowsMediaApi ?? null
}
async initialize(): Promise<void> {
@@ -861,18 +766,30 @@ export class SpotifyProvider implements NowPlayingProviderService<'spotify'> {
}
if (this.platform === 'win32') {
try {
await this.commandRunner('powershell.exe', buildWindowsPowerShellArgs(buildWindowsProbeScript()))
if (!this.windowsMediaApi) {
this.state = {
...createDefaultState(false),
lastError: 'Prism native Windows media integration is not available in this build.',
}
return
}
const support = this.windowsMediaApi.getSupport()
if (support.available) {
this.state = {
...createDefaultState(true),
lastError: this.state.lastError,
lastControlError: this.state.lastControlError,
snapshot: cloneSnapshot(this.state.snapshot),
}
} catch (error) {
} else {
this.state = {
...createDefaultState(false),
lastError: normalizeSpotifyError(error, 'Prism could not access Windows media controls for Spotify.', this.platform).message,
lastError: normalizeSpotifyError(
support.reason,
'Prism could not access Windows media controls for Spotify.',
this.platform,
).message,
}
}
return
@@ -1047,8 +964,16 @@ export class SpotifyProvider implements NowPlayingProviderService<'spotify'> {
}
if (this.platform === 'win32') {
const output = await this.commandRunner('powershell.exe', buildWindowsPowerShellArgs(buildWindowsStatusScript()))
return parseWindowsSpotifyStatusOutput(output, this.now)
if (!this.windowsMediaApi) {
throw new Error('Prism native Windows media integration is not available in this build.')
}
const payload = this.windowsMediaApi.getSpotifyPlaybackState()
if (!payload) {
return null
}
return parseWindowsSpotifyStatusPayload(payload, this.now)
}
const output = await this.runner(buildSpotifyStatusScript())
@@ -1072,7 +997,11 @@ export class SpotifyProvider implements NowPlayingProviderService<'spotify'> {
}
if (this.platform === 'win32') {
await this.commandRunner('powershell.exe', buildWindowsPowerShellArgs(buildWindowsControlScript(command)))
if (!this.windowsMediaApi) {
throw new Error('Prism native Windows media integration is not available in this build.')
}
this.windowsMediaApi.sendSpotifyControl(command)
return
}
+20
View File
@@ -0,0 +1,20 @@
import type { NativeCaptureSupport } from './nativeCapture'
import type { NowPlayingControlCommand } from './nowPlaying'
export type NativeWindowsMediaSupport = NativeCaptureSupport
export interface NativeWindowsSpotifyPlaybackState {
album: string
artist: string
durationMs: number
playbackStatus: string
positionMs: number
sourceAppUserModelId: string
title: string
}
export interface NativeWindowsMediaAPI {
getSupport: () => NativeWindowsMediaSupport
getSpotifyPlaybackState: () => NativeWindowsSpotifyPlaybackState | null
sendSpotifyControl: (command: NowPlayingControlCommand) => boolean
}