mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
fix windows spotify native hooks
This commit is contained in:
+15
-1
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user