mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
update session hooks on linux
This commit is contained in:
@@ -14,10 +14,12 @@ import type { NowPlayingProviderService } from './nowPlayingProvider'
|
||||
type FetchLike = typeof fetch
|
||||
type AccessLike = typeof access
|
||||
type AppleScriptRunner = (scriptLines: string[]) => Promise<string>
|
||||
type CommandRunner = (command: string, args: string[]) => Promise<string>
|
||||
|
||||
interface MacSpotifyProviderOptions {
|
||||
accessImpl?: AccessLike
|
||||
appPathCandidates?: string[]
|
||||
commandRunner?: CommandRunner
|
||||
fetchImpl?: FetchLike
|
||||
now?: () => number
|
||||
platform?: NodeJS.Platform
|
||||
@@ -41,11 +43,26 @@ 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'
|
||||
const SPOTIFY_DELIMITER = '\u001f'
|
||||
const SPOTIFY_APP_BUNDLE_ID = 'com.spotify.client'
|
||||
const MPRIS_PLAYER_INTERFACE = 'org.mpris.MediaPlayer2.Player'
|
||||
const MPRIS_PLAYER_OBJECT_PATH = '/org/mpris/MediaPlayer2'
|
||||
const SESSION_DBUS_INTERFACE = 'org.freedesktop.DBus'
|
||||
const SESSION_DBUS_OBJECT_PATH = '/org/freedesktop/DBus'
|
||||
const SPOTIFY_MPRIS_NAME_PATTERN = /^org\.mpris\.MediaPlayer2\.spotify(?:\..+)?$/i
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
function cloneSnapshot(snapshot: NowPlayingSnapshot | null): NowPlayingSnapshot | null {
|
||||
@@ -92,19 +109,46 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||
: fallback
|
||||
}
|
||||
|
||||
function normalizeSpotifyError(error: unknown, fallback: string): Error {
|
||||
function normalizeSpotifyError(
|
||||
error: unknown,
|
||||
fallback: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Error {
|
||||
const message = getErrorMessage(error, fallback)
|
||||
|
||||
if (message.includes('(-1743)') || /not authorized|not permitted|automation/i.test(message)) {
|
||||
return new Error('Prism needs macOS Automation permission to control Spotify.')
|
||||
if (platform === 'darwin') {
|
||||
if (message.includes('(-1743)') || /not authorized|not permitted|automation/i.test(message)) {
|
||||
return new Error('Prism needs macOS Automation permission to control Spotify.')
|
||||
}
|
||||
|
||||
if (message.includes('(-2700)') || message.includes('(-1728)') || /application can.?t be found/i.test(message)) {
|
||||
return new Error('Spotify.app is not installed.')
|
||||
}
|
||||
|
||||
if (message.includes('(-128)')) {
|
||||
return new Error('Spotify did not allow Prism to complete that request.')
|
||||
}
|
||||
}
|
||||
|
||||
if (message.includes('(-2700)') || message.includes('(-1728)') || /application can.?t be found/i.test(message)) {
|
||||
return new Error('Spotify.app is not installed.')
|
||||
if (platform === 'linux') {
|
||||
if (/spotify is not running/i.test(message)
|
||||
|| /ServiceUnknown|NameHasNoOwner|The name .* was not provided/i.test(message)) {
|
||||
return new Error('Spotify is not running.')
|
||||
}
|
||||
|
||||
if (/cannot autolaunch D-Bus|No such file or directory|NoServer|Cannot connect to session bus|Failed to connect to socket/i.test(message)) {
|
||||
return new Error('Prism could not access the Linux session media bus for Spotify.')
|
||||
}
|
||||
}
|
||||
|
||||
if (message.includes('(-128)')) {
|
||||
return new Error('Spotify did not allow Prism to complete that request.')
|
||||
if (platform === 'win32') {
|
||||
if (/spotify is not running/i.test(message) || /No current Spotify media session/i.test(message)) {
|
||||
return new Error('Spotify is not running.')
|
||||
}
|
||||
|
||||
if (/GlobalSystemMediaTransportControls|Windows\.Media\.Control|WinRT/i.test(message)) {
|
||||
return new Error('Prism could not access Windows media controls for Spotify.')
|
||||
}
|
||||
}
|
||||
|
||||
return new Error(message)
|
||||
@@ -114,8 +158,10 @@ function normalizeString(value: string | undefined): string {
|
||||
return (value ?? '').trim()
|
||||
}
|
||||
|
||||
function toSafeNumber(value: string | undefined): number {
|
||||
const numeric = Number.parseFloat((value ?? '').trim())
|
||||
function toSafeNumber(value: string | number | undefined): number {
|
||||
const numeric = typeof value === 'number'
|
||||
? value
|
||||
: Number.parseFloat((value ?? '').trim())
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0
|
||||
}
|
||||
@@ -123,10 +169,14 @@ function toSafeNumber(value: string | undefined): number {
|
||||
return Math.max(0, numeric)
|
||||
}
|
||||
|
||||
function millisecondsToSeconds(value: string | undefined): number {
|
||||
function millisecondsToSeconds(value: string | number | undefined): number {
|
||||
return toSafeNumber(value) / 1000
|
||||
}
|
||||
|
||||
function microsecondsToSeconds(value: string | number | undefined): number {
|
||||
return toSafeNumber(value) / 1_000_000
|
||||
}
|
||||
|
||||
function toOptionalUrl(value: string | undefined): string | null {
|
||||
const normalized = normalizeString(value)
|
||||
if (!normalized) {
|
||||
@@ -149,6 +199,76 @@ function createTrackId(title: string, artist: string, album: string): string {
|
||||
return `spotify-local:${title}\n${artist}\n${album}`
|
||||
}
|
||||
|
||||
function escapeRegexLiteral(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function decodeGVariantString(value: string): string {
|
||||
return value
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/\\'/g, '\'')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
}
|
||||
|
||||
function extractGdbusStringVariant(output: string, key: string): string {
|
||||
const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:@s )?'((?:\\\\.|[^'])*)'>`))
|
||||
return normalizeString(match?.[1] ? decodeGVariantString(match[1]) : '')
|
||||
}
|
||||
|
||||
function extractGdbusObjectPathVariant(output: string, key: string): string {
|
||||
const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': <objectpath '((?:\\\\.|[^'])*)'>`))
|
||||
return normalizeString(match?.[1] ? decodeGVariantString(match[1]) : '')
|
||||
}
|
||||
|
||||
function extractGdbusInt64Variant(output: string, key: string): number {
|
||||
const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:@x |@t |int64 |uint64 )?(-?\\d+)>`))
|
||||
if (!match) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const numeric = Number.parseInt(match[1], 10)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(0, numeric)
|
||||
}
|
||||
|
||||
function extractGdbusStringArrayVariant(output: string, key: string): string[] {
|
||||
const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:@as )?\\[([\\s\\S]*?)\\]>`))
|
||||
if (!match?.[1]) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.from(match[1].matchAll(/'((?:\\.|[^'])*)'/g), (entry) => {
|
||||
return normalizeString(decodeGVariantString(entry[1] ?? ''))
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
function parseLinuxPlaybackState(value: string): LocalSpotifySnapshot['playbackState'] {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'playing':
|
||||
return 'playing'
|
||||
case 'paused':
|
||||
return 'paused'
|
||||
default:
|
||||
return 'stopped'
|
||||
}
|
||||
}
|
||||
|
||||
function parseWindowsPlaybackState(value: string): LocalSpotifySnapshot['playbackState'] {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'playing':
|
||||
return 'playing'
|
||||
case 'paused':
|
||||
return 'paused'
|
||||
default:
|
||||
return 'stopped'
|
||||
}
|
||||
}
|
||||
|
||||
function parseSpotifyStatusOutput(output: string, now: () => number): LocalSpotifySnapshot | null {
|
||||
const trimmed = output.trim()
|
||||
if (!trimmed || trimmed === 'not_running') {
|
||||
@@ -192,6 +312,97 @@ function parseSpotifyStatusOutput(output: string, now: () => number): LocalSpoti
|
||||
}
|
||||
}
|
||||
|
||||
function parseLinuxSpotifyStatusOutput(output: string, now: () => number): LocalSpotifySnapshot | null {
|
||||
const trimmed = output.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const playbackState = parseLinuxPlaybackState(extractGdbusStringVariant(trimmed, 'PlaybackStatus'))
|
||||
const currentTime = microsecondsToSeconds(extractGdbusInt64Variant(trimmed, 'Position'))
|
||||
const duration = microsecondsToSeconds(extractGdbusInt64Variant(trimmed, 'mpris:length'))
|
||||
const title = extractGdbusStringVariant(trimmed, 'xesam:title')
|
||||
const artist = extractGdbusStringArrayVariant(trimmed, 'xesam:artist').join(', ')
|
||||
const album = extractGdbusStringVariant(trimmed, 'xesam:album')
|
||||
const artworkUrl = toOptionalUrl(extractGdbusStringVariant(trimmed, 'mpris:artUrl'))
|
||||
const trackId = extractGdbusStringVariant(trimmed, 'xesam:url')
|
||||
|| extractGdbusObjectPathVariant(trimmed, 'mpris:trackid')
|
||||
|
||||
const currentTrack = title && artist
|
||||
? {
|
||||
id: trackId || createTrackId(title, artist, album),
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
isFavorite: false,
|
||||
artworkUrl,
|
||||
} satisfies LocalSpotifyTrackSnapshot
|
||||
: null
|
||||
|
||||
return {
|
||||
playbackState,
|
||||
currentTime,
|
||||
duration,
|
||||
currentTrack,
|
||||
updatedAt: now(),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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 : '')
|
||||
const sourceAppUserModelId = normalizeString(typeof payload.sourceAppUserModelId === 'string' ? payload.sourceAppUserModelId : '')
|
||||
const playbackState = parseWindowsPlaybackState(typeof payload.playbackStatus === 'string' ? payload.playbackStatus : '')
|
||||
const currentTime = millisecondsToSeconds(
|
||||
typeof payload.positionMs === 'number' || typeof payload.positionMs === 'string'
|
||||
? payload.positionMs
|
||||
: 0,
|
||||
)
|
||||
const duration = millisecondsToSeconds(
|
||||
typeof payload.durationMs === 'number' || typeof payload.durationMs === 'string'
|
||||
? payload.durationMs
|
||||
: 0,
|
||||
)
|
||||
|
||||
const currentTrack = title && artist
|
||||
? {
|
||||
id: sourceAppUserModelId
|
||||
? `${sourceAppUserModelId}\n${title}\n${artist}\n${album}`
|
||||
: createTrackId(title, artist, album),
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
isFavorite: false,
|
||||
artworkUrl: null,
|
||||
} satisfies LocalSpotifyTrackSnapshot
|
||||
: null
|
||||
|
||||
return {
|
||||
playbackState,
|
||||
currentTime,
|
||||
duration,
|
||||
currentTrack,
|
||||
updatedAt: now(),
|
||||
}
|
||||
}
|
||||
|
||||
function parseLinuxBusNames(output: string): string[] {
|
||||
return Array.from(output.matchAll(/'((?:\\.|[^'])*)'/g), (entry) => {
|
||||
return normalizeString(decodeGVariantString(entry[1] ?? ''))
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
function getLinuxSpotifyBusName(output: string): string | null {
|
||||
return parseLinuxBusNames(output).find((name) => SPOTIFY_MPRIS_NAME_PATTERN.test(name)) ?? null
|
||||
}
|
||||
|
||||
function getArtworkKey(trackId: string | null, artworkUrl: string | null): string | null {
|
||||
if (!trackId || !artworkUrl) {
|
||||
return null
|
||||
@@ -319,11 +530,159 @@ async function defaultAppleScriptRunner(scriptLines: string[]): Promise<string>
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'> {
|
||||
async function defaultCommandRunner(command: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync(command, args)
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
function buildLinuxListNamesArgs(): string[] {
|
||||
return [
|
||||
'call',
|
||||
'--session',
|
||||
'--dest',
|
||||
SESSION_DBUS_INTERFACE,
|
||||
'--object-path',
|
||||
SESSION_DBUS_OBJECT_PATH,
|
||||
'--method',
|
||||
`${SESSION_DBUS_INTERFACE}.ListNames`,
|
||||
]
|
||||
}
|
||||
|
||||
function buildLinuxPropertiesArgs(busName: string): string[] {
|
||||
return [
|
||||
'call',
|
||||
'--session',
|
||||
'--dest',
|
||||
busName,
|
||||
'--object-path',
|
||||
MPRIS_PLAYER_OBJECT_PATH,
|
||||
'--method',
|
||||
'org.freedesktop.DBus.Properties.GetAll',
|
||||
MPRIS_PLAYER_INTERFACE,
|
||||
]
|
||||
}
|
||||
|
||||
function buildLinuxCommandArgs(busName: string, command: NowPlayingControlCommand): string[] {
|
||||
const methodName = (() => {
|
||||
switch (command) {
|
||||
case 'play':
|
||||
return 'Play'
|
||||
case 'pause':
|
||||
return 'Pause'
|
||||
case 'next':
|
||||
return 'Next'
|
||||
case 'previous':
|
||||
return 'Previous'
|
||||
}
|
||||
})()
|
||||
|
||||
return [
|
||||
'call',
|
||||
'--session',
|
||||
'--dest',
|
||||
busName,
|
||||
'--object-path',
|
||||
MPRIS_PLAYER_OBJECT_PATH,
|
||||
'--method',
|
||||
`${MPRIS_PLAYER_INTERFACE}.${methodName}`,
|
||||
]
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
private readonly accessImpl: AccessLike
|
||||
private readonly appPathCandidates: string[]
|
||||
private readonly commandRunner: CommandRunner
|
||||
private readonly fetchImpl: FetchLike
|
||||
private readonly now: () => number
|
||||
private readonly platform: NodeJS.Platform
|
||||
@@ -344,6 +703,7 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'>
|
||||
constructor(options: MacSpotifyProviderOptions = {}) {
|
||||
this.accessImpl = options.accessImpl ?? access
|
||||
this.appPathCandidates = options.appPathCandidates ?? getDefaultSpotifyAppCandidates()
|
||||
this.commandRunner = options.commandRunner ?? defaultCommandRunner
|
||||
this.fetchImpl = options.fetchImpl ?? fetch
|
||||
this.now = options.now ?? (() => Date.now())
|
||||
this.platform = options.platform ?? process.platform
|
||||
@@ -417,9 +777,7 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'>
|
||||
if (!this.state.available) {
|
||||
this.resetInactiveState()
|
||||
this.emitState()
|
||||
throw new Error(this.platform === 'darwin'
|
||||
? 'Install Spotify.app to enable the local Spotify integration.'
|
||||
: 'Local Spotify integration is only available on macOS.')
|
||||
throw new Error(this.getUnavailableMessage())
|
||||
}
|
||||
|
||||
if (!this.isScopeActive()) {
|
||||
@@ -433,13 +791,11 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'>
|
||||
|
||||
async sendControl(command: NowPlayingControlCommand): Promise<void> {
|
||||
if (!this.state.available) {
|
||||
throw new Error(this.platform === 'darwin'
|
||||
? 'Install Spotify.app to enable the local Spotify integration.'
|
||||
: 'Local Spotify integration is only available on macOS.')
|
||||
throw new Error(this.getUnavailableMessage())
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runner(buildSpotifyCommandScript(command))
|
||||
await this.sendPlatformControl(command)
|
||||
this.state = {
|
||||
...this.state,
|
||||
lastControlError: null,
|
||||
@@ -449,7 +805,7 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'>
|
||||
await this.queueRefresh()
|
||||
}
|
||||
} catch (error) {
|
||||
const normalizedError = normalizeSpotifyError(error, 'Prism could not control Spotify.')
|
||||
const normalizedError = normalizeSpotifyError(error, 'Prism could not control Spotify.', this.platform)
|
||||
this.state = {
|
||||
...this.state,
|
||||
lastControlError: normalizedError.message,
|
||||
@@ -469,7 +825,59 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'>
|
||||
return this.activeConsumers.size > 0
|
||||
}
|
||||
|
||||
private getUnavailableMessage(): string {
|
||||
if (this.platform === 'darwin') {
|
||||
return 'Install Spotify.app to enable the local Spotify integration.'
|
||||
}
|
||||
|
||||
if (this.platform === 'linux') {
|
||||
return this.state.lastError ?? 'Prism could not access Linux session media controls for Spotify.'
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
return this.state.lastError ?? 'Prism could not access Windows media controls for Spotify.'
|
||||
}
|
||||
|
||||
return 'Local Spotify integration is currently available on macOS, Linux, and Windows.'
|
||||
}
|
||||
|
||||
private async refreshAvailability(): Promise<void> {
|
||||
if (this.platform === 'linux') {
|
||||
try {
|
||||
await this.commandRunner('gdbus', buildLinuxListNamesArgs())
|
||||
this.state = {
|
||||
...createDefaultState(true),
|
||||
lastError: this.state.lastError,
|
||||
lastControlError: this.state.lastControlError,
|
||||
snapshot: cloneSnapshot(this.state.snapshot),
|
||||
}
|
||||
} catch (error) {
|
||||
this.state = {
|
||||
...createDefaultState(false),
|
||||
lastError: normalizeSpotifyError(error, 'Prism could not access Linux session media controls for Spotify.', this.platform).message,
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
try {
|
||||
await this.commandRunner('powershell.exe', buildWindowsPowerShellArgs(buildWindowsProbeScript()))
|
||||
this.state = {
|
||||
...createDefaultState(true),
|
||||
lastError: this.state.lastError,
|
||||
lastControlError: this.state.lastControlError,
|
||||
snapshot: cloneSnapshot(this.state.snapshot),
|
||||
}
|
||||
} catch (error) {
|
||||
this.state = {
|
||||
...createDefaultState(false),
|
||||
lastError: normalizeSpotifyError(error, 'Prism could not access Windows media controls for Spotify.', this.platform).message,
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.platform !== 'darwin') {
|
||||
this.state = createDefaultState(false)
|
||||
return
|
||||
@@ -498,12 +906,16 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'>
|
||||
this.currentArtworkKey = null
|
||||
this.currentArtworkDataUrl = null
|
||||
this.failedArtworkKey = null
|
||||
const preservedLastError = this.state.available ? null : this.state.lastError
|
||||
const preservedLastControlError = this.state.available ? null : this.state.lastControlError
|
||||
this.state = {
|
||||
...createDefaultState(this.state.available),
|
||||
available: this.state.available,
|
||||
isConfigured: this.state.available,
|
||||
supportsTransportControls: this.state.available,
|
||||
connectionState: this.state.available ? 'disabled' : 'unavailable',
|
||||
lastError: preservedLastError,
|
||||
lastControlError: preservedLastControlError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,7 +1023,7 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'>
|
||||
|
||||
await this.refreshArtwork(snapshot.currentTrack?.id ?? null, snapshot.currentTrack?.artworkUrl ?? null)
|
||||
} catch (error) {
|
||||
const normalizedError = normalizeSpotifyError(error, 'Prism could not read Spotify now-playing state.')
|
||||
const normalizedError = normalizeSpotifyError(error, 'Prism could not read Spotify now-playing state.', this.platform)
|
||||
this.state = {
|
||||
...this.state,
|
||||
connectionState: 'error',
|
||||
@@ -624,10 +1036,49 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'>
|
||||
}
|
||||
|
||||
private async readSpotifySnapshot(): Promise<LocalSpotifySnapshot | null> {
|
||||
if (this.platform === 'linux') {
|
||||
const busName = await this.resolveLinuxSpotifyBusName()
|
||||
if (!busName) {
|
||||
return null
|
||||
}
|
||||
|
||||
const output = await this.commandRunner('gdbus', buildLinuxPropertiesArgs(busName))
|
||||
return parseLinuxSpotifyStatusOutput(output, this.now)
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
const output = await this.commandRunner('powershell.exe', buildWindowsPowerShellArgs(buildWindowsStatusScript()))
|
||||
return parseWindowsSpotifyStatusOutput(output, this.now)
|
||||
}
|
||||
|
||||
const output = await this.runner(buildSpotifyStatusScript())
|
||||
return parseSpotifyStatusOutput(output, this.now)
|
||||
}
|
||||
|
||||
private async resolveLinuxSpotifyBusName(): Promise<string | null> {
|
||||
const output = await this.commandRunner('gdbus', buildLinuxListNamesArgs())
|
||||
return getLinuxSpotifyBusName(output)
|
||||
}
|
||||
|
||||
private async sendPlatformControl(command: NowPlayingControlCommand): Promise<void> {
|
||||
if (this.platform === 'linux') {
|
||||
const busName = await this.resolveLinuxSpotifyBusName()
|
||||
if (!busName) {
|
||||
throw new Error('Spotify is not running.')
|
||||
}
|
||||
|
||||
await this.commandRunner('gdbus', buildLinuxCommandArgs(busName, command))
|
||||
return
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
await this.commandRunner('powershell.exe', buildWindowsPowerShellArgs(buildWindowsControlScript(command)))
|
||||
return
|
||||
}
|
||||
|
||||
await this.runner(buildSpotifyCommandScript(command))
|
||||
}
|
||||
|
||||
private async refreshArtwork(trackId: string | null, artworkUrl: string | null): Promise<void> {
|
||||
const artworkKey = getArtworkKey(trackId, artworkUrl)
|
||||
const snapshotArtworkKey = getArtworkKey(
|
||||
@@ -677,3 +1128,5 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'>
|
||||
this.emitState()
|
||||
}
|
||||
}
|
||||
|
||||
export { SpotifyProvider as MacSpotifyProvider }
|
||||
|
||||
@@ -17,6 +17,18 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||
: fallback
|
||||
}
|
||||
|
||||
function isMacOSPlatform(platform: string): boolean {
|
||||
return platform === 'darwin'
|
||||
}
|
||||
|
||||
function isLinuxPlatform(platform: string): boolean {
|
||||
return platform === 'linux'
|
||||
}
|
||||
|
||||
function isWindowsPlatform(platform: string): boolean {
|
||||
return platform === 'win32'
|
||||
}
|
||||
|
||||
function hasVisibleFields(settings: ScopeSettings['nowPlaying']): boolean {
|
||||
return settings.showCoverArt
|
||||
|| settings.showTitle
|
||||
@@ -38,6 +50,7 @@ function getConfiguredProviderId(
|
||||
function getFallbackTitle(
|
||||
providerId: NowPlayingProviderId | null,
|
||||
connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null,
|
||||
platform: string,
|
||||
): string {
|
||||
if (connectionState === null) {
|
||||
return 'Nothing playing'
|
||||
@@ -54,6 +67,18 @@ function getFallbackTitle(
|
||||
case 'connected':
|
||||
return 'Nothing playing'
|
||||
case 'unavailable':
|
||||
if (isMacOSPlatform(platform)) {
|
||||
return 'Spotify unavailable'
|
||||
}
|
||||
|
||||
if (isLinuxPlatform(platform)) {
|
||||
return 'Spotify MPRIS unavailable'
|
||||
}
|
||||
|
||||
if (isWindowsPlatform(platform)) {
|
||||
return 'Spotify media session unavailable'
|
||||
}
|
||||
|
||||
return 'Spotify unavailable'
|
||||
}
|
||||
}
|
||||
@@ -79,6 +104,7 @@ function getFallbackTitle(
|
||||
function getFallbackDetail(
|
||||
providerId: NowPlayingProviderId | null,
|
||||
connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null,
|
||||
platform: string,
|
||||
): string {
|
||||
if (connectionState === null) {
|
||||
return ''
|
||||
@@ -87,15 +113,63 @@ function getFallbackDetail(
|
||||
if (providerId === 'spotify') {
|
||||
switch (connectionState) {
|
||||
case 'disabled':
|
||||
return 'Open Spotify on this Mac to show local playback here.'
|
||||
if (isMacOSPlatform(platform)) {
|
||||
return 'Open Spotify on this Mac to show local playback here.'
|
||||
}
|
||||
|
||||
if (isLinuxPlatform(platform)) {
|
||||
return 'Open Spotify on this Linux desktop to show local playback here.'
|
||||
}
|
||||
|
||||
if (isWindowsPlatform(platform)) {
|
||||
return 'Open Spotify on this PC to show local playback here.'
|
||||
}
|
||||
|
||||
return 'Open Spotify to show local playback here.'
|
||||
case 'connecting':
|
||||
return 'Waiting for the local Spotify app.'
|
||||
if (isMacOSPlatform(platform)) {
|
||||
return 'Waiting for the local Spotify app.'
|
||||
}
|
||||
|
||||
if (isLinuxPlatform(platform)) {
|
||||
return 'Waiting for the local Spotify MPRIS session.'
|
||||
}
|
||||
|
||||
if (isWindowsPlatform(platform)) {
|
||||
return 'Waiting for the local Windows media session.'
|
||||
}
|
||||
|
||||
return 'Waiting for the local Spotify integration.'
|
||||
case 'error':
|
||||
return 'Check Spotify access in System Settings > Privacy & Security > Automation.'
|
||||
if (isMacOSPlatform(platform)) {
|
||||
return 'Check Spotify access in System Settings > Privacy & Security > Automation.'
|
||||
}
|
||||
|
||||
if (isLinuxPlatform(platform)) {
|
||||
return 'Check that your Linux desktop session exposes Spotify over MPRIS.'
|
||||
}
|
||||
|
||||
if (isWindowsPlatform(platform)) {
|
||||
return 'Check that Windows media controls can see a Spotify session.'
|
||||
}
|
||||
|
||||
return 'Check that Spotify is available through the local system media controls.'
|
||||
case 'connected':
|
||||
return ''
|
||||
case 'unavailable':
|
||||
return 'Install Spotify.app to enable this provider.'
|
||||
if (isMacOSPlatform(platform)) {
|
||||
return 'Install Spotify.app to enable this provider.'
|
||||
}
|
||||
|
||||
if (isLinuxPlatform(platform)) {
|
||||
return 'Linux desktop media controls are unavailable for Spotify on this system.'
|
||||
}
|
||||
|
||||
if (isWindowsPlatform(platform)) {
|
||||
return 'Windows system media controls are unavailable for Spotify on this system.'
|
||||
}
|
||||
|
||||
return 'This local Spotify integration is currently available on macOS, Linux, and Windows.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +219,7 @@ export default function AstraScopeModule({
|
||||
const providerDefinition = displayProviderId
|
||||
? nowPlayingState.definitions[displayProviderId]
|
||||
: null
|
||||
const platform = window.electronAPI.platform
|
||||
|
||||
useEffect(() => {
|
||||
if (providerState?.snapshot?.playbackState !== 'playing') {
|
||||
@@ -170,7 +245,7 @@ export default function AstraScopeModule({
|
||||
const detailMessage = currentTrack?.artist
|
||||
?? (providerState?.connectionState === 'connected'
|
||||
? null
|
||||
: getFallbackDetail(displayProviderId, providerState?.connectionState ?? null))
|
||||
: getFallbackDetail(displayProviderId, providerState?.connectionState ?? null, platform))
|
||||
const style = {
|
||||
'--astra-accent': theme.accent,
|
||||
'--astra-bg': theme.background,
|
||||
@@ -250,8 +325,8 @@ export default function AstraScopeModule({
|
||||
{(settings.showTitle || settings.showArtist) && (
|
||||
<div className="astra-scope__meta">
|
||||
{settings.showTitle && (
|
||||
<div className="astra-scope__title" title={currentTrack?.title ?? getFallbackTitle(displayProviderId, providerState?.connectionState ?? null)}>
|
||||
{currentTrack?.title ?? getFallbackTitle(displayProviderId, providerState?.connectionState ?? null)}
|
||||
<div className="astra-scope__title" title={currentTrack?.title ?? getFallbackTitle(displayProviderId, providerState?.connectionState ?? null, platform)}>
|
||||
{currentTrack?.title ?? getFallbackTitle(displayProviderId, providerState?.connectionState ?? null, platform)}
|
||||
</div>
|
||||
)}
|
||||
{settings.showArtist && detailMessage && (
|
||||
|
||||
@@ -96,6 +96,82 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||
: fallback
|
||||
}
|
||||
|
||||
function isMacOSPlatform(platform: string): boolean {
|
||||
return platform === 'darwin'
|
||||
}
|
||||
|
||||
function isLinuxPlatform(platform: string): boolean {
|
||||
return platform === 'linux'
|
||||
}
|
||||
|
||||
function isWindowsPlatform(platform: string): boolean {
|
||||
return platform === 'win32'
|
||||
}
|
||||
|
||||
function getSpotifyIntegrationLabel(platform: string): string {
|
||||
if (isMacOSPlatform(platform)) {
|
||||
return 'Local macOS app'
|
||||
}
|
||||
|
||||
if (isLinuxPlatform(platform)) {
|
||||
return 'Local Linux MPRIS'
|
||||
}
|
||||
|
||||
if (isWindowsPlatform(platform)) {
|
||||
return 'Local Windows media session'
|
||||
}
|
||||
|
||||
return 'Local Spotify integration'
|
||||
}
|
||||
|
||||
function getSpotifyUnavailableMetaText(platform: string): string {
|
||||
if (isMacOSPlatform(platform)) {
|
||||
return 'Local macOS app unavailable'
|
||||
}
|
||||
|
||||
if (isLinuxPlatform(platform)) {
|
||||
return 'Local Linux MPRIS unavailable'
|
||||
}
|
||||
|
||||
if (isWindowsPlatform(platform)) {
|
||||
return 'Local Windows media session unavailable'
|
||||
}
|
||||
|
||||
return 'Local Spotify integration unavailable'
|
||||
}
|
||||
|
||||
function getSpotifyAvailabilityDetail(platform: string): string {
|
||||
if (isMacOSPlatform(platform)) {
|
||||
return 'Install Spotify.app in /Applications to enable this provider.'
|
||||
}
|
||||
|
||||
if (isLinuxPlatform(platform)) {
|
||||
return 'This provider needs a Linux desktop session with Spotify MPRIS access.'
|
||||
}
|
||||
|
||||
if (isWindowsPlatform(platform)) {
|
||||
return 'This provider needs Windows system media controls to expose a Spotify session.'
|
||||
}
|
||||
|
||||
return 'This provider is currently available on macOS, Linux, and Windows.'
|
||||
}
|
||||
|
||||
function getSpotifyProviderCopy(platform: string): string {
|
||||
if (isMacOSPlatform(platform)) {
|
||||
return 'No Spotify developer account or API setup is required. Prism reads the local Spotify macOS app directly.'
|
||||
}
|
||||
|
||||
if (isLinuxPlatform(platform)) {
|
||||
return 'No Spotify developer account or API setup is required. Prism reads Spotify through the local Linux MPRIS session.'
|
||||
}
|
||||
|
||||
if (isWindowsPlatform(platform)) {
|
||||
return 'No Spotify developer account or API setup is required. Prism reads Spotify through the local Windows media session.'
|
||||
}
|
||||
|
||||
return 'No Spotify developer account or API setup is required. On supported systems, Prism reads the local Spotify app directly.'
|
||||
}
|
||||
|
||||
function getProviderStatusLabel(
|
||||
definition: NowPlayingProviderDefinition,
|
||||
provider: NowPlayingProviderState,
|
||||
@@ -129,6 +205,7 @@ function getProviderStatusLabel(
|
||||
function getProviderMetaText(
|
||||
definition: NowPlayingProviderDefinition,
|
||||
provider: NowPlayingProviderState,
|
||||
platform: string,
|
||||
): string {
|
||||
if (definition.comingSoon) {
|
||||
return 'Local integration coming later'
|
||||
@@ -136,7 +213,7 @@ function getProviderMetaText(
|
||||
|
||||
if (!provider.available) {
|
||||
return provider.providerId === 'spotify'
|
||||
? 'Local macOS app unavailable'
|
||||
? getSpotifyUnavailableMetaText(platform)
|
||||
: 'Unavailable on this device'
|
||||
}
|
||||
|
||||
@@ -145,19 +222,20 @@ function getProviderMetaText(
|
||||
}
|
||||
|
||||
if (definition.authMode === 'local') {
|
||||
const integrationLabel = getSpotifyIntegrationLabel(platform)
|
||||
switch (provider.connectionState) {
|
||||
case 'disabled':
|
||||
return 'Local macOS app · Waiting for Spotify'
|
||||
return `${integrationLabel} · Waiting for Spotify`
|
||||
case 'connecting':
|
||||
return 'Local macOS app · Checking playback'
|
||||
return `${integrationLabel} · Checking playback`
|
||||
case 'connected':
|
||||
return provider.snapshot?.playbackState === 'playing'
|
||||
? 'Local macOS app · Playing now'
|
||||
: 'Local macOS app · Ready'
|
||||
? `${integrationLabel} · Playing now`
|
||||
: `${integrationLabel} · Ready`
|
||||
case 'error':
|
||||
return 'Local macOS app · Needs attention'
|
||||
return `${integrationLabel} · Needs attention`
|
||||
case 'unavailable':
|
||||
return 'Local macOS app unavailable'
|
||||
return getSpotifyUnavailableMetaText(platform)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +308,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
|
||||
const [draggedProviderId, setDraggedProviderId] = useState<NowPlayingProviderId | null>(null)
|
||||
const [dropTargetProviderId, setDropTargetProviderId] = useState<NowPlayingProviderId | null>(null)
|
||||
const useNativeDragRegions = getRendererWindowCapabilities().useNativeDragRegions
|
||||
const platform = window.electronAPI.platform
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -397,7 +476,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
|
||||
<div className="now-playing-config__stack">
|
||||
<div className="now-playing-config__intro">
|
||||
{nowPlayingState.onboardingRequired
|
||||
? 'Start with Astra or the local Spotify macOS app. TIDAL stays visible here for future priority.'
|
||||
? 'Start with Astra or the local Spotify integration. TIDAL stays visible here for future priority.'
|
||||
: 'The highest configured provider that starts playing takes over immediately.'}
|
||||
</div>
|
||||
|
||||
@@ -467,7 +546,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
|
||||
) : null}
|
||||
</span>
|
||||
<span className="now-playing-config__provider-meta">
|
||||
{getProviderMetaText(definition, provider)}
|
||||
{getProviderMetaText(definition, provider, platform)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -578,7 +657,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
|
||||
{definition.description}
|
||||
</div>
|
||||
<div className="settings-info-text">
|
||||
No Spotify developer account or API setup is required. Prism reads the local Spotify macOS app directly.
|
||||
{getSpotifyProviderCopy(platform)}
|
||||
</div>
|
||||
<div className="settings-inline-actions now-playing-config__provider-actions">
|
||||
<button
|
||||
@@ -600,9 +679,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
|
||||
</div>
|
||||
{!provider.available ? (
|
||||
<div className="settings-info-text">
|
||||
{window.electronAPI.platform === 'darwin'
|
||||
? 'Install Spotify.app in /Applications to enable this provider.'
|
||||
: 'This provider is currently available on macOS only.'}
|
||||
{getSpotifyAvailabilityDetail(platform)}
|
||||
</div>
|
||||
) : null}
|
||||
{provider.lastError || provider.lastControlError ? (
|
||||
|
||||
@@ -90,7 +90,7 @@ export const NOW_PLAYING_PROVIDER_DEFINITIONS: NowPlayingProviderDefinitionMap =
|
||||
spotify: {
|
||||
id: 'spotify',
|
||||
label: 'Spotify',
|
||||
description: 'Read track data and transport controls directly from the local Spotify macOS app.',
|
||||
description: 'Read track data and transport controls directly from the local Spotify app on supported desktop platforms.',
|
||||
authMode: 'local',
|
||||
available: true,
|
||||
comingSoon: false,
|
||||
|
||||
@@ -29,6 +29,45 @@ function createStatusPayload(options: {
|
||||
].join(DELIMITER)
|
||||
}
|
||||
|
||||
function createLinuxListNamesPayload(names: string[]): string {
|
||||
return `([${names.map((name) => `'${name}'`).join(', ')}],)`
|
||||
}
|
||||
|
||||
function createLinuxStatusPayload(options: {
|
||||
album?: string
|
||||
artist?: string
|
||||
artworkUrl?: string
|
||||
durationUs?: number
|
||||
playbackState: 'Playing' | 'Paused' | 'Stopped'
|
||||
positionUs?: number
|
||||
title?: string
|
||||
trackId?: string
|
||||
trackUrl?: string
|
||||
}): string {
|
||||
const artists = options.artist ? `['${options.artist}']` : '[]'
|
||||
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) {
|
||||
@@ -81,13 +120,26 @@ class StubSpotifyRunner {
|
||||
}
|
||||
}
|
||||
|
||||
test('provider stays unavailable when Spotify.app is not installed or not on macOS', async () => {
|
||||
class StubCommandRunner {
|
||||
calls: Array<{ command: string; args: string[] }> = []
|
||||
error: Error | null = null
|
||||
|
||||
constructor(
|
||||
private readonly handler: (command: string, args: string[]) => string,
|
||||
) {}
|
||||
|
||||
async run(command: string, args: string[]): Promise<string> {
|
||||
this.calls.push({ command, args: [...args] })
|
||||
if (this.error) {
|
||||
throw this.error
|
||||
}
|
||||
return this.handler(command, args)
|
||||
}
|
||||
}
|
||||
|
||||
test('provider stays unavailable on unsupported platforms', async () => {
|
||||
const provider = new MacSpotifyProvider({
|
||||
accessImpl: async () => {
|
||||
throw new Error('missing')
|
||||
},
|
||||
platform: 'linux',
|
||||
runner: async () => 'not_running',
|
||||
platform: 'freebsd',
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -99,7 +151,7 @@ test('provider stays unavailable when Spotify.app is not installed or not on mac
|
||||
}
|
||||
})
|
||||
|
||||
test('provider reads local Spotify playback and hydrates artwork while active', async () => {
|
||||
test('provider reads local macOS Spotify playback and hydrates artwork while active', async () => {
|
||||
const runner = new StubSpotifyRunner([
|
||||
createStatusPayload({
|
||||
playbackState: 'playing',
|
||||
@@ -144,7 +196,7 @@ test('provider reads local Spotify playback and hydrates artwork while active',
|
||||
}
|
||||
})
|
||||
|
||||
test('provider treats a non-running Spotify app as idle instead of erroring', async () => {
|
||||
test('provider treats a non-running macOS Spotify app as idle instead of erroring', async () => {
|
||||
const provider = new MacSpotifyProvider({
|
||||
accessImpl: async () => undefined,
|
||||
platform: 'darwin',
|
||||
@@ -180,7 +232,7 @@ test('provider surfaces macOS Automation permission failures during polling', as
|
||||
}
|
||||
})
|
||||
|
||||
test('provider routes transport controls through AppleScript and records control failures', async () => {
|
||||
test('provider routes macOS transport controls through AppleScript and records control failures', async () => {
|
||||
const runner = new StubSpotifyRunner([
|
||||
createStatusPayload({
|
||||
playbackState: 'paused',
|
||||
@@ -210,3 +262,140 @@ test('provider routes transport controls through AppleScript and records control
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('provider reads Linux Spotify playback through MPRIS and routes controls through gdbus', async () => {
|
||||
const runner = new StubCommandRunner((command, args) => {
|
||||
assert.equal(command, 'gdbus')
|
||||
const methodIndex = args.indexOf('--method')
|
||||
const method = methodIndex >= 0 ? args[methodIndex + 1] : ''
|
||||
|
||||
switch (method) {
|
||||
case 'org.freedesktop.DBus.ListNames':
|
||||
return createLinuxListNamesPayload([
|
||||
'org.freedesktop.DBus',
|
||||
'org.mpris.MediaPlayer2.spotify',
|
||||
])
|
||||
case 'org.freedesktop.DBus.Properties.GetAll':
|
||||
return createLinuxStatusPayload({
|
||||
playbackState: 'Playing',
|
||||
positionUs: 42000000,
|
||||
durationUs: 180000000,
|
||||
title: 'Song Linux',
|
||||
artist: 'Artist Linux',
|
||||
album: 'Album Linux',
|
||||
artworkUrl: 'https://i.scdn.co/image/linux-cover',
|
||||
trackUrl: 'spotify:track:linux123',
|
||||
})
|
||||
case 'org.mpris.MediaPlayer2.Player.Next':
|
||||
return '()'
|
||||
default:
|
||||
throw new Error(`Unhandled Linux method ${method}`)
|
||||
}
|
||||
})
|
||||
|
||||
const provider = new MacSpotifyProvider({
|
||||
commandRunner: (command, args) => runner.run(command, args),
|
||||
fetchImpl: async () => new Response(Buffer.from('linux-cover'), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'image/png',
|
||||
},
|
||||
}),
|
||||
now: () => 2000,
|
||||
platform: 'linux',
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.initialize()
|
||||
assert.equal(provider.getProviderState().available, true)
|
||||
await provider.setConsumerActive(1, true)
|
||||
await waitFor(() => provider.getProviderState().connectionState === 'connected', 'expected connected Linux Spotify state')
|
||||
|
||||
const state = provider.getProviderState()
|
||||
assert.equal(state.snapshot?.currentTrack?.title, 'Song Linux')
|
||||
assert.equal(state.snapshot?.currentTrack?.artist, 'Artist Linux')
|
||||
assert.equal(state.snapshot?.currentTrack?.isFavorite, false)
|
||||
assert.match(state.snapshot?.currentTrack?.artworkDataUrl ?? '', /^data:image\/png;base64,/)
|
||||
assert.equal(state.snapshot?.currentTime, 42)
|
||||
assert.equal(state.snapshot?.duration, 180)
|
||||
|
||||
await provider.sendControl('next')
|
||||
assert.ok(runner.calls.some((call) => call.args.includes('org.mpris.MediaPlayer2.Player.Next')))
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('provider treats a missing Linux Spotify MPRIS session as idle', async () => {
|
||||
const provider = new MacSpotifyProvider({
|
||||
commandRunner: async (_command, args) => {
|
||||
if (args.includes('org.freedesktop.DBus.ListNames')) {
|
||||
return createLinuxListNamesPayload(['org.freedesktop.DBus'])
|
||||
}
|
||||
|
||||
throw new Error('unexpected command')
|
||||
},
|
||||
platform: 'linux',
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.initialize()
|
||||
assert.equal(provider.getProviderState().available, true)
|
||||
await provider.setConsumerActive(1, true)
|
||||
await waitFor(() => provider.getProviderState().connectionState === 'disabled', 'expected disabled Linux Spotify state')
|
||||
assert.equal(provider.getProviderState().lastError, null)
|
||||
} 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 provider = new MacSpotifyProvider({
|
||||
commandRunner: (command, args) => runner.run(command, args),
|
||||
now: () => 3000,
|
||||
platform: 'win32',
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.initialize()
|
||||
assert.equal(provider.getProviderState().available, true)
|
||||
await provider.setConsumerActive(1, true)
|
||||
await waitFor(() => provider.getProviderState().connectionState === 'connected', 'expected connected Windows Spotify state')
|
||||
|
||||
const state = provider.getProviderState()
|
||||
assert.equal(state.snapshot?.currentTrack?.title, 'Song Windows')
|
||||
assert.equal(state.snapshot?.currentTrack?.artist, 'Artist Windows')
|
||||
assert.equal(state.snapshot?.currentTrack?.isFavorite, false)
|
||||
assert.equal(state.snapshot?.currentTime, 32)
|
||||
assert.equal(state.snapshot?.duration, 210)
|
||||
|
||||
await provider.sendControl('next')
|
||||
assert.ok(runner.calls.some((call) => (call.args.at(-1) ?? '').includes('TrySkipNextAsync')))
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user