From 677c4aa33137a61bd55732d7669c8f4da980c1af Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:43:54 -0400 Subject: [PATCH] fix windows spotify native hooks --- native/binding.gyp | 3 +- native/src/windows_capture.cpp | 254 ++++++++++++++++++++++++ native/src/windows_capture_stub.cpp | 29 +++ src/main/index.ts | 16 +- src/main/nativeWindowsMedia.ts | 30 +++ src/main/services/macSpotifyProvider.ts | 157 ++++----------- src/types/nativeWindowsMedia.ts | 20 ++ test/mac-spotify-provider.test.ts | 108 ++++++---- 8 files changed, 457 insertions(+), 160 deletions(-) create mode 100644 src/main/nativeWindowsMedia.ts create mode 100644 src/types/nativeWindowsMedia.ts diff --git a/native/binding.gyp b/native/binding.gyp index cb57abb..60104eb 100644 --- a/native/binding.gyp +++ b/native/binding.gyp @@ -49,12 +49,13 @@ "libraries": [ "ole32.lib", "avrt.lib", + "runtimeobject.lib", "uuid.lib" ], "msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1, - "AdditionalOptions": ["/O2"] + "AdditionalOptions": ["/O2", "/std:c++17"] } } }], diff --git a/native/src/windows_capture.cpp b/native/src/windows_capture.cpp index b3ceda9..93a66aa 100644 --- a/native/src/windows_capture.cpp +++ b/native/src/windows_capture.cpp @@ -10,11 +10,16 @@ #include #include #include +#include #include #include +#include +#include +#include #include #include +#include #include #include #include @@ -22,7 +27,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -30,6 +37,9 @@ namespace { using Microsoft::WRL::ComPtr; +using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSession; +using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager; +using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus; constexpr size_t kMaxQueuedChunks = 256; constexpr size_t kDefaultDrainChunkLimit = 64; @@ -119,6 +129,41 @@ std::string hresultMessage(const char* operation, HRESULT hr) { return stream.str(); } +std::string winrtErrorMessage(const char* operation, const winrt::hresult_error& error) { + std::string message = hresultMessage(operation, error.code().value); + const std::wstring detailWide = error.message().c_str(); + const std::string detail = wideToUtf8(detailWide); + if (!detail.empty()) { + message += ": " + detail; + } + return message; +} + +std::string toLowerAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return value; +} + +std::string playbackStatusToString(GlobalSystemMediaTransportControlsSessionPlaybackStatus status) { + switch (status) { + case GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing: + return "Playing"; + case GlobalSystemMediaTransportControlsSessionPlaybackStatus::Paused: + return "Paused"; + case GlobalSystemMediaTransportControlsSessionPlaybackStatus::Stopped: + return "Stopped"; + case GlobalSystemMediaTransportControlsSessionPlaybackStatus::Opened: + return "Opened"; + case GlobalSystemMediaTransportControlsSessionPlaybackStatus::Changing: + return "Changing"; + case GlobalSystemMediaTransportControlsSessionPlaybackStatus::Closed: + default: + return "Closed"; + } +} + class ScopedCoInit { public: ScopedCoInit() @@ -144,6 +189,68 @@ private: bool usable_; }; +class ScopedRoInit { +public: + ScopedRoInit() + : hr_(RoInitialize(RO_INIT_MULTITHREADED)), + usable_(SUCCEEDED(hr_) || hr_ == RPC_E_CHANGED_MODE) {} + + ~ScopedRoInit() { + if (SUCCEEDED(hr_)) { + RoUninitialize(); + } + } + + bool usable() const { + return usable_; + } + + HRESULT result() const { + return hr_; + } + +private: + HRESULT hr_; + bool usable_; +}; + +bool isSpotifySession(const GlobalSystemMediaTransportControlsSession& session) { + if (!session) { + return false; + } + + const std::string sourceId = toLowerAscii(winrt::to_string(session.SourceAppUserModelId())); + return sourceId.find("spotify") != std::string::npos; +} + +std::optional findSpotifySession( + const GlobalSystemMediaTransportControlsSessionManager& manager) { + const auto currentSession = manager.GetCurrentSession(); + if (isSpotifySession(currentSession)) { + return currentSession; + } + + for (const auto& session : manager.GetSessions()) { + if (isSpotifySession(session)) { + return session; + } + } + + return std::nullopt; +} + +Napi::Object createWindowsMediaSupport( + Napi::Env env, bool available, const std::string& reason = std::string()) { + Napi::Object support = Napi::Object::New(env); + support.Set("available", Napi::Boolean::New(env, available)); + if (available || reason.empty()) { + support.Set("reason", env.Null()); + } else { + support.Set("reason", Napi::String::New(env, reason)); + } + return support; +} + std::string getDeviceId(IMMDevice* device) { if (device == nullptr) { return {}; @@ -856,6 +963,143 @@ Napi::Value WindowsNowMilliseconds(const Napi::CallbackInfo& info) { return Napi::Number::New(info.Env(), engine().NowMilliseconds()); } +Napi::Value WindowsMediaGetSupport(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + try { + ScopedRoInit init; + if (!init.usable()) { + throw std::runtime_error( + hresultMessage("RoInitialize(RO_INIT_MULTITHREADED)", init.result())); + } + + auto manager = GlobalSystemMediaTransportControlsSessionManager::RequestAsync().get(); + (void)manager; + return createWindowsMediaSupport(env, true); + } catch (const winrt::hresult_error& error) { + return createWindowsMediaSupport( + env, + false, + winrtErrorMessage( + "Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager::RequestAsync", + error)); + } catch (const std::exception& error) { + return createWindowsMediaSupport(env, false, error.what()); + } +} + +Napi::Value WindowsMediaGetSpotifyPlaybackState(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + try { + ScopedRoInit init; + if (!init.usable()) { + throw std::runtime_error( + hresultMessage("RoInitialize(RO_INIT_MULTITHREADED)", init.result())); + } + + const auto manager = GlobalSystemMediaTransportControlsSessionManager::RequestAsync().get(); + const auto session = findSpotifySession(manager); + if (!session.has_value()) { + return env.Null(); + } + + const auto playbackInfo = session->GetPlaybackInfo(); + const auto timeline = session->GetTimelineProperties(); + const auto mediaProperties = session->TryGetMediaPropertiesAsync().get(); + + const auto positionMs = std::max( + 0, + std::chrono::duration_cast(timeline.Position()).count()); + const auto durationMs = std::max( + 0, + std::chrono::duration_cast( + timeline.EndTime() - timeline.StartTime()) + .count()); + + Napi::Object payload = Napi::Object::New(env); + payload.Set( + "playbackStatus", + Napi::String::New( + env, playbackStatusToString(playbackInfo.PlaybackStatus()))); + payload.Set("positionMs", Napi::Number::New(env, static_cast(positionMs))); + payload.Set("durationMs", Napi::Number::New(env, static_cast(durationMs))); + payload.Set("title", Napi::String::New(env, winrt::to_string(mediaProperties.Title()))); + payload.Set("artist", Napi::String::New(env, winrt::to_string(mediaProperties.Artist()))); + payload.Set( + "album", Napi::String::New(env, winrt::to_string(mediaProperties.AlbumTitle()))); + payload.Set( + "sourceAppUserModelId", + Napi::String::New(env, winrt::to_string(session->SourceAppUserModelId()))); + return payload; + } catch (const winrt::hresult_error& error) { + Napi::Error::New( + env, + winrtErrorMessage( + "Windows.Media.Control.GlobalSystemMediaTransportControlsSession", + error)) + .ThrowAsJavaScriptException(); + return env.Null(); + } catch (const std::exception& error) { + Napi::Error::New(env, error.what()).ThrowAsJavaScriptException(); + return env.Null(); + } +} + +Napi::Value WindowsMediaSendSpotifyControl(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + Napi::TypeError::New(env, "Expected a Spotify control command.").ThrowAsJavaScriptException(); + return env.Null(); + } + + const std::string command = info[0].As().Utf8Value(); + + try { + ScopedRoInit init; + if (!init.usable()) { + throw std::runtime_error( + hresultMessage("RoInitialize(RO_INIT_MULTITHREADED)", init.result())); + } + + const auto manager = GlobalSystemMediaTransportControlsSessionManager::RequestAsync().get(); + const auto session = findSpotifySession(manager); + if (!session.has_value()) { + throw std::runtime_error("Spotify is not running."); + } + + bool accepted = false; + if (command == "play") { + accepted = session->TryPlayAsync().get(); + } else if (command == "pause") { + accepted = session->TryPauseAsync().get(); + } else if (command == "next") { + accepted = session->TrySkipNextAsync().get(); + } else if (command == "previous") { + accepted = session->TrySkipPreviousAsync().get(); + } else { + throw std::runtime_error("Unsupported Spotify control command."); + } + + if (!accepted) { + throw std::runtime_error("Spotify did not allow Prism to complete that request."); + } + + return Napi::Boolean::New(env, true); + } catch (const winrt::hresult_error& error) { + Napi::Error::New( + env, + winrtErrorMessage( + "Windows.Media.Control.GlobalSystemMediaTransportControlsSession", + error)) + .ThrowAsJavaScriptException(); + return env.Null(); + } catch (const std::exception& error) { + Napi::Error::New(env, error.what()).ThrowAsJavaScriptException(); + return env.Null(); + } +} + } // namespace void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) { @@ -868,6 +1112,16 @@ void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) { captureExports.Set("drain", Napi::Function::New(env, WindowsDrain)); captureExports.Set("nowMilliseconds", Napi::Function::New(env, WindowsNowMilliseconds)); exports.Set("windowsCapture", captureExports); + + Napi::Object mediaExports = Napi::Object::New(env); + mediaExports.Set("getSupport", Napi::Function::New(env, WindowsMediaGetSupport)); + mediaExports.Set( + "getSpotifyPlaybackState", + Napi::Function::New(env, WindowsMediaGetSpotifyPlaybackState)); + mediaExports.Set( + "sendSpotifyControl", + Napi::Function::New(env, WindowsMediaSendSpotifyControl)); + exports.Set("windowsMedia", mediaExports); } #endif // defined(_WIN32) diff --git a/native/src/windows_capture_stub.cpp b/native/src/windows_capture_stub.cpp index 7bc180f..bdd0405 100644 --- a/native/src/windows_capture_stub.cpp +++ b/native/src/windows_capture_stub.cpp @@ -39,6 +39,27 @@ Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) { return Napi::Number::New(info.Env(), 0); } +Napi::Value MediaGetSupport(const Napi::CallbackInfo& info) { + Napi::Object support = Napi::Object::New(info.Env()); + support.Set("available", Napi::Boolean::New(info.Env(), false)); + support.Set( + "reason", + Napi::String::New( + info.Env(), "Native Windows media-session integration is unavailable on this platform.")); + return support; +} + +Napi::Value GetSpotifyPlaybackState(const Napi::CallbackInfo& info) { + return info.Env().Null(); +} + +Napi::Value SendSpotifyControl(const Napi::CallbackInfo& info) { + Napi::Error::New( + info.Env(), "Native Windows media-session integration is unavailable on this platform.") + .ThrowAsJavaScriptException(); + return info.Env().Undefined(); +} + } // namespace void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) { @@ -51,4 +72,12 @@ void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) { captureExports.Set("drain", Napi::Function::New(env, Drain)); captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds)); exports.Set("windowsCapture", captureExports); + + Napi::Object mediaExports = Napi::Object::New(env); + mediaExports.Set("getSupport", Napi::Function::New(env, MediaGetSupport)); + mediaExports.Set( + "getSpotifyPlaybackState", + Napi::Function::New(env, GetSpotifyPlaybackState)); + mediaExports.Set("sendSpotifyControl", Napi::Function::New(env, SendSpotifyControl)); + exports.Set("windowsMedia", mediaExports); } diff --git a/src/main/index.ts b/src/main/index.ts index 117a923..a6a08fb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 | 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) => { diff --git a/src/main/nativeWindowsMedia.ts b/src/main/nativeWindowsMedia.ts new file mode 100644 index 0000000..9498f7b --- /dev/null +++ b/src/main/nativeWindowsMedia.ts @@ -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 + } +} diff --git a/src/main/services/macSpotifyProvider.ts b/src/main/services/macSpotifyProvider.ts index 3bb9828..53343ec 100644 --- a/src/main/services/macSpotifyProvider.ts +++ b/src/main/services/macSpotifyProvider.ts @@ -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, + 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() @@ -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 { @@ -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 } diff --git a/src/types/nativeWindowsMedia.ts b/src/types/nativeWindowsMedia.ts new file mode 100644 index 0000000..5c2adec --- /dev/null +++ b/src/types/nativeWindowsMedia.ts @@ -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 +} diff --git a/test/mac-spotify-provider.test.ts b/test/mac-spotify-provider.test.ts index 05f8735..68ab9ff 100644 --- a/test/mac-spotify-provider.test.ts +++ b/test/mac-spotify-provider.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict' import test from 'node:test' import { MacSpotifyProvider } from '../src/main/services/macSpotifyProvider' +import type { + NativeWindowsMediaAPI, + NativeWindowsSpotifyPlaybackState, +} from '../src/types/nativeWindowsMedia' const DELIMITER = '\u001f' @@ -48,26 +52,6 @@ function createLinuxStatusPayload(options: { return `({'PlaybackStatus': <'${options.playbackState}'>, 'Metadata': <{'mpris:trackid': , 'mpris:length': , 'mpris:artUrl': <'${options.artworkUrl ?? ''}'>, 'xesam:album': <'${options.album ?? ''}'>, 'xesam:artist': <${artists}>, 'xesam:title': <'${options.title ?? ''}'>, 'xesam:url': <'${options.trackUrl ?? ''}'>}>, 'Position': },)` } -function createWindowsStatusPayload(options: { - album?: string - artist?: string - durationMs?: number - playbackStatus: 'Playing' | 'Paused' | 'Stopped' - positionMs?: number - sourceAppUserModelId?: string - title?: string -}): string { - return JSON.stringify({ - album: options.album ?? '', - artist: options.artist ?? '', - durationMs: options.durationMs ?? 0, - playbackStatus: options.playbackStatus, - positionMs: options.positionMs ?? 0, - sourceAppUserModelId: options.sourceAppUserModelId ?? 'SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify', - title: options.title ?? '', - }) -} - async function waitFor(predicate: () => boolean, message: string): Promise { const deadline = Date.now() + 2000 while (Date.now() < deadline) { @@ -137,6 +121,42 @@ class StubCommandRunner { } } +class StubWindowsMediaApi implements NativeWindowsMediaAPI { + calls: Array<{ kind: 'status' | 'control'; command?: string }> = [] + controlError: Error | null = null + statusError: Error | null = null + support = { + available: true, + reason: null, + } as const + + constructor( + private readonly playbackState: NativeWindowsSpotifyPlaybackState | null, + ) {} + + getSupport() { + return this.support + } + + getSpotifyPlaybackState(): NativeWindowsSpotifyPlaybackState | null { + this.calls.push({ kind: 'status' }) + if (this.statusError) { + throw this.statusError + } + + return this.playbackState + } + + sendSpotifyControl(command: 'play' | 'pause' | 'next' | 'previous'): boolean { + this.calls.push({ kind: 'control', command }) + if (this.controlError) { + throw this.controlError + } + + return true + } +} + test('provider stays unavailable on unsupported platforms', async () => { const provider = new MacSpotifyProvider({ platform: 'freebsd', @@ -349,35 +369,35 @@ test('provider treats a missing Linux Spotify MPRIS session as idle', async () = } }) +test('provider stays unavailable on Windows when the native media API is not present', async () => { + const provider = new MacSpotifyProvider({ + platform: 'win32', + }) + + try { + await provider.initialize() + assert.equal(provider.getProviderState().available, false) + assert.match(provider.getProviderState().lastError ?? '', /native Windows media integration is not available/) + } finally { + await provider.dispose() + } +}) + test('provider reads Windows Spotify playback through system media controls and routes commands', async () => { - const runner = new StubCommandRunner((command, args) => { - assert.equal(command, 'powershell.exe') - const script = args.at(-1) ?? '' - - if (script.includes('Write-Output "ready"')) { - return 'ready' - } - if (script.includes('ConvertTo-Json')) { - return createWindowsStatusPayload({ - playbackStatus: 'Playing', - positionMs: 32000, - durationMs: 210000, - title: 'Song Windows', - artist: 'Artist Windows', - album: 'Album Windows', - }) - } - if (script.includes('TrySkipNextAsync')) { - return 'ok' - } - - throw new Error('unexpected powershell script') + const windowsMediaApi = new StubWindowsMediaApi({ + playbackStatus: 'Playing', + positionMs: 32000, + durationMs: 210000, + title: 'Song Windows', + artist: 'Artist Windows', + album: 'Album Windows', + sourceAppUserModelId: 'SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify', }) const provider = new MacSpotifyProvider({ - commandRunner: (command, args) => runner.run(command, args), now: () => 3000, platform: 'win32', + windowsMediaApi, }) try { @@ -394,7 +414,7 @@ test('provider reads Windows Spotify playback through system media controls and assert.equal(state.snapshot?.duration, 210) await provider.sendControl('next') - assert.ok(runner.calls.some((call) => (call.args.at(-1) ?? '').includes('TrySkipNextAsync'))) + assert.ok(windowsMediaApi.calls.some((call) => call.kind === 'control' && call.command === 'next')) } finally { await provider.dispose() }