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:
+2
-1
@@ -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"]
|
||||
}
|
||||
}
|
||||
}],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user