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
+2 -1
View File
@@ -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"]
}
}
}],
+254
View File
@@ -10,11 +10,16 @@
#include <mmreg.h>
#include <propidl.h>
#include <avrt.h>
#include <roapi.h>
#include <wrl/client.h>
#include <windows.h>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Media.Control.h>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <condition_variable>
#include <cstdint>
@@ -22,7 +27,9 @@
#include <deque>
#include <limits>
#include <mutex>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
@@ -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<char>(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<GlobalSystemMediaTransportControlsSession> 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<int64_t>(
0,
std::chrono::duration_cast<std::chrono::milliseconds>(timeline.Position()).count());
const auto durationMs = std::max<int64_t>(
0,
std::chrono::duration_cast<std::chrono::milliseconds>(
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<double>(positionMs)));
payload.Set("durationMs", Napi::Number::New(env, static_cast<double>(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<Napi::String>().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)
+29
View File
@@ -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);
}
+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
}
+64 -44
View File
@@ -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': <objectpath '${options.trackId ?? '/com/spotify/track/123'}'>, 'mpris:length': <int64 ${options.durationUs ?? 0}>, 'mpris:artUrl': <'${options.artworkUrl ?? ''}'>, 'xesam:album': <'${options.album ?? ''}'>, 'xesam:artist': <${artists}>, 'xesam:title': <'${options.title ?? ''}'>, 'xesam:url': <'${options.trackUrl ?? ''}'>}>, 'Position': <int64 ${options.positionUs ?? 0}>},)`
}
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<void> {
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()
}