diff --git a/src/main/services/macSpotifyProvider.ts b/src/main/services/macSpotifyProvider.ts index c6101fb..3bb9828 100644 --- a/src/main/services/macSpotifyProvider.ts +++ b/src/main/services/macSpotifyProvider.ts @@ -14,10 +14,12 @@ import type { NowPlayingProviderService } from './nowPlayingProvider' type FetchLike = typeof fetch type AccessLike = typeof access type AppleScriptRunner = (scriptLines: string[]) => Promise +type CommandRunner = (command: string, args: string[]) => Promise 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)}': `)) + 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 return stdout.trim() } -export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'> { +async function defaultCommandRunner(command: string, args: string[]): Promise { + 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 { 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 { + 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 { + 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 { + const output = await this.commandRunner('gdbus', buildLinuxListNamesArgs()) + return getLinuxSpotifyBusName(output) + } + + private async sendPlatformControl(command: NowPlayingControlCommand): Promise { + 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 { const artworkKey = getArtworkKey(trackId, artworkUrl) const snapshotArtworkKey = getArtworkKey( @@ -677,3 +1128,5 @@ export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'> this.emitState() } } + +export { SpotifyProvider as MacSpotifyProvider } diff --git a/src/renderer/components/AstraScopeModule.tsx b/src/renderer/components/AstraScopeModule.tsx index 02cfa93..95cfb96 100644 --- a/src/renderer/components/AstraScopeModule.tsx +++ b/src/renderer/components/AstraScopeModule.tsx @@ -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) && (
{settings.showTitle && ( -
- {currentTrack?.title ?? getFallbackTitle(displayProviderId, providerState?.connectionState ?? null)} +
+ {currentTrack?.title ?? getFallbackTitle(displayProviderId, providerState?.connectionState ?? null, platform)}
)} {settings.showArtist && detailMessage && ( diff --git a/src/renderer/components/NowPlayingConfigWindow.tsx b/src/renderer/components/NowPlayingConfigWindow.tsx index 6f8728f..b231181 100644 --- a/src/renderer/components/NowPlayingConfigWindow.tsx +++ b/src/renderer/components/NowPlayingConfigWindow.tsx @@ -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(null) const [dropTargetProviderId, setDropTargetProviderId] = useState(null) const useNativeDragRegions = getRendererWindowCapabilities().useNativeDragRegions + const platform = window.electronAPI.platform useEffect(() => { let disposed = false @@ -397,7 +476,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
{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.'}
@@ -467,7 +546,7 @@ export default function NowPlayingConfigWindow(): JSX.Element { ) : null} - {getProviderMetaText(definition, provider)} + {getProviderMetaText(definition, provider, platform)} @@ -578,7 +657,7 @@ export default function NowPlayingConfigWindow(): JSX.Element { {definition.description}
- No Spotify developer account or API setup is required. Prism reads the local Spotify macOS app directly. + {getSpotifyProviderCopy(platform)}