update session hooks on linux

This commit is contained in:
Boof2015
2026-04-19 00:10:33 -04:00
parent 4324fdaf41
commit fa67547f01
5 changed files with 844 additions and 50 deletions
+473 -20
View File
@@ -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 }
+82 -7
View File
@@ -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 ? (
+1 -1
View File
@@ -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,
+198 -9
View File
@@ -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()
}
})