upnext-adapter-nowplaying 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,13 +3,17 @@
3
3
  [![npm](https://img.shields.io/npm/v/upnext-adapter-nowplaying)](https://www.npmjs.com/package/upnext-adapter-nowplaying)
4
4
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/tothienbao6a0/upnext/blob/main/LICENSE)
5
5
 
6
- **Read and control whatever your Mac is playing — including a browser tab —
7
- without an extension.**
6
+ **Read and control whatever your machine is playing — including a browser tab —
7
+ without an extension.** macOS and Linux.
8
8
 
9
- macOS keeps a system-wide Now Playing register: the thing Control Center shows,
10
- and the thing your keyboard's play/pause key talks to. Anything that publishes
11
- to it is reachable — a YouTube tab in Chrome, a podcast in Safari, VLC, Doppler,
12
- Music, Spotify.
9
+ Both platforms keep a system-wide register of what is playing: on macOS the one
10
+ Control Center shows and your keyboard's play/pause key talks to, on Linux the
11
+ MPRIS interface players publish on D-Bus. Anything that publishes to it is
12
+ reachable — a YouTube tab in Chrome, a podcast in Safari or Firefox, VLC,
13
+ Doppler, Music, Spotify.
14
+
15
+ Same entry, same code, either OS. A host should not have to care which one it
16
+ is on, so this package answers that question itself.
13
17
 
14
18
  ```ts
15
19
  import { readNowPlaying, sendTransport } from 'upnext-adapter-nowplaying';
@@ -22,8 +26,17 @@ await readNowPlaying();
22
26
  await sendTransport('pause'); // pauses it, whatever it is
23
27
  ```
24
28
 
29
+ On Linux the same call answers from MPRIS instead, and the reading has the same
30
+ shape — `bundleId` is the player name (`firefox`, `vlc`, `spotify`) rather than
31
+ a bundle identifier.
32
+
25
33
  That works with no browser extension, no accessibility permission, and no
26
- per-site integration.
34
+ per-site integration. On Linux it needs `playerctl` installed; on macOS it needs
35
+ nothing at all.
36
+
37
+ If you specifically want one register rather than whichever this machine has,
38
+ `readMediaRemote` / `sendMediaRemote` and `readMpris` / `sendMpris` are exported
39
+ under their own names.
27
40
 
28
41
  ## As a queue entry
29
42
 
@@ -75,6 +88,35 @@ take somebody's podcast away from them.
75
88
 
76
89
  ## How it works, and the honest caveats
77
90
 
91
+ Two registers, one shape. `sourceFor()` picks by platform at `init()`, and
92
+ everything above it — the adapter, the URI, the reading — is identical.
93
+
94
+ ### Linux: MPRIS, through `playerctl`
95
+
96
+ MPRIS is a D-Bus interface that most Linux players implement: browsers, VLC,
97
+ mpv with a plugin, Spotify's Linux client. `playerctl` is its standard
98
+ command-line client, and this shells out to it for the same reason the macOS
99
+ side shells out to `osascript` — the protocol is somebody else's moving target,
100
+ and a process boundary is the right place to keep it.
101
+
102
+ One thing makes that safe rather than fragile: **`playerctl` takes a `--format`
103
+ template, so the output shape is ours.** Rather than parsing whatever a tool
104
+ decided to print, this asks for exactly the fields it wants, in exactly the
105
+ order it wants them, separated by ASCII 31 — because real track titles contain
106
+ every printable delimiter worth using.
107
+
108
+ That is also why it can be tested honestly. CI runs a real `playerctl` against a
109
+ real MPRIS player on a real session bus, so a template typo fails the build
110
+ rather than a user's machine. Without a player on the bus, `playerctl` exits on
111
+ "No players found" before it ever reads the template — which means a green test
112
+ run with no player proves nothing, and this one does not do that. See
113
+ [`scripts/mpris-ci.sh`](../../scripts/mpris-ci.sh).
114
+
115
+ Install it with `apt install playerctl`, `dnf install playerctl`, or your
116
+ distribution's equivalent. Without it, `init()` says so in a sentence.
117
+
118
+ ### macOS: MediaRemote
119
+
78
120
  It reaches `MediaRemote`, a **private Apple framework**, through JXA's
79
121
  Objective-C bridge. Private API is normally a bad trade, so:
80
122
 
@@ -92,15 +134,18 @@ Objective-C bridge. Private API is normally a bad trade, so:
92
134
  itself unavailable, the registry excludes it, and the rest of your queue
93
135
  carries on.
94
136
 
95
- **macOS only.** On any other platform `init()` fails cleanly and
96
- `getState().adapters` shows `available: false` with the reason.
137
+ **Windows is not implemented.** It has an equivalent — SMTC — and the adapter's
138
+ shape would carry over, but nobody has written it. On any unsupported platform
139
+ `init()` fails cleanly and `getState().adapters` shows `available: false` with
140
+ the reason, rather than the adapter pretending and returning nothing.
97
141
 
98
142
  ### It filters out things that aren't media
99
143
 
100
- macOS's now-playing client is *whatever last made a sound*. A received voice
144
+ The now-playing client is *whatever last made a sound*. A received voice
101
145
  message leaves Messages sitting there as the now-playing app, with no title and
102
146
  a seven-second "track". Requiring a title and a real duration is what separates
103
- *something is playing* from *something made a noise once*.
147
+ *something is playing* from *something made a noise once*. Both platforms get
148
+ the same filter, because both have the same problem.
104
149
 
105
150
  ## Compared with the other adapters
106
151
 
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/adapter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,OAAO,EACP,cAAc,EACd,OAAO,EACP,YAAY,EACZ,QAAQ,EACT,MAAM,aAAa,CAAC;AAErB,OAAO,EAAwB,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAE5E,mDAAmD;AACnD,eAAO,MAAM,eAAe,uBAAuB,CAAC;AAEpD,MAAM,WAAW,wBAAwB;IACvC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IAC/C,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACxD;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,iBAAkB,YAAW,OAAO;;IAC/C,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAEpB,QAAQ,CAAC,YAAY,EAAE,YAAY,CAcjC;gBAKU,OAAO,GAAE,wBAA6B;IAK5C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAS3B;;;;;OAKG;IACH,KAAK,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM;IAItB,OAAO,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAuBrD,mDAAmD;IAC7C,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAIrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B;;;;;;OAMG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAKrB,IAAI,IAAI,OAAO,CAAC,cAAc,CAAC;CAwBtC"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/adapter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,OAAO,EACP,cAAc,EACd,OAAO,EACP,YAAY,EACZ,QAAQ,EACT,MAAM,aAAa,CAAC;AAErB,OAAO,EAAwB,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAE5E,mDAAmD;AACnD,eAAO,MAAM,eAAe,uBAAuB,CAAC;AAEpD,MAAM,WAAW,wBAAwB;IACvC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IAC/C,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACxD;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,iBAAkB,YAAW,OAAO;;IAC/C,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAEpB,QAAQ,CAAC,YAAY,EAAE,YAAY,CAcjC;gBAKU,OAAO,GAAE,wBAA6B;IAK5C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAc3B;;;;;OAKG;IACH,KAAK,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM;IAItB,OAAO,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAuBrD,mDAAmD;IAC7C,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAIrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B;;;;;;OAMG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAKrB,IAAI,IAAI,OAAO,CAAC,cAAc,CAAC;CA0BtC"}
@@ -1,5 +1,5 @@
1
1
  import { defaultCapabilities } from 'upnext-core';
2
- import { isAvailable, readNowPlaying, sendTransport } from './mediaremote.js';
2
+ import { sourceFor, UNSUPPORTED } from './source.js';
3
3
  import { hasEnded, readingUri } from './reading.js';
4
4
  /** The one thing this adapter can be asked for. */
5
5
  export const NOW_PLAYING_URI = 'nowplaying:current';
@@ -51,8 +51,13 @@ export class NowPlayingAdapter {
51
51
  async init() {
52
52
  if (this.#options.read)
53
53
  return; // Injected: nothing to probe.
54
- if (!(await isAvailable())) {
55
- throw new Error('macOS Now Playing is unavailable here — this adapter needs macOS and the MediaRemote framework');
54
+ const source = sourceFor();
55
+ if (!source)
56
+ throw new Error(UNSUPPORTED);
57
+ if (!(await source.available())) {
58
+ throw new Error(source.platform === 'linux'
59
+ ? 'playerctl was not found — install it to reach MPRIS on Linux'
60
+ : 'the MediaRemote framework did not answer here');
56
61
  }
57
62
  }
58
63
  /**
@@ -122,10 +127,14 @@ export class NowPlayingAdapter {
122
127
  };
123
128
  }
124
129
  #read() {
125
- return (this.#options.read ?? readNowPlaying)();
130
+ if (this.#options.read)
131
+ return this.#options.read();
132
+ return sourceFor()?.read() ?? Promise.resolve(null);
126
133
  }
127
134
  #send(command) {
128
- return (this.#options.send ?? sendTransport)(command);
135
+ if (this.#options.send)
136
+ return this.#options.send(command);
137
+ return sourceFor()?.send(command) ?? Promise.resolve(false);
129
138
  }
130
139
  }
131
140
  //# sourceMappingURL=adapter.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../../src/adapter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../../src/adapter.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,13 @@
1
1
  export { NowPlayingAdapter, NOW_PLAYING_URI } from './adapter.js';
2
2
  export type { NowPlayingAdapterOptions } from './adapter.js';
3
- export { readNowPlaying, sendTransport, isAvailable, COMMANDS } from './mediaremote.js';
3
+ export { readNowPlaying, sendTransport, isAvailable } from './source.js';
4
+ export { COMMANDS } from './mediaremote.js';
4
5
  export type { TransportCommand } from './mediaremote.js';
6
+ export { readNowPlaying as readMediaRemote, sendTransport as sendMediaRemote, isAvailable as mediaRemoteAvailable, } from './mediaremote.js';
5
7
  export { parseReading, readingUri, hasEnded } from './reading.js';
6
8
  export type { NowPlayingReading } from './reading.js';
9
+ export { sourceFor } from './source.js';
10
+ export type { NowPlayingSource } from './source.js';
11
+ export { parseMpris, readMpris, sendMpris, mprisAvailable, FORMAT as MPRIS_FORMAT } from './mpris.js';
12
+ export type { MprisCommand, Runner as MprisRunner } from './mpris.js';
7
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAClE,YAAY,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAE7D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACxF,YAAY,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAClE,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAClE,YAAY,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAI7D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,YAAY,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAIzD,OAAO,EACL,cAAc,IAAI,eAAe,EACjC,aAAa,IAAI,eAAe,EAChC,WAAW,IAAI,oBAAoB,GACpC,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAClE,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,IAAI,YAAY,EAAE,MAAM,YAAY,CAAC;AACtG,YAAY,EAAE,YAAY,EAAE,MAAM,IAAI,WAAW,EAAE,MAAM,YAAY,CAAC"}
package/dist/src/index.js CHANGED
@@ -1,4 +1,12 @@
1
1
  export { NowPlayingAdapter, NOW_PLAYING_URI } from './adapter.js';
2
- export { readNowPlaying, sendTransport, isAvailable, COMMANDS } from './mediaremote.js';
2
+ // The platform-dispatching versions. On macOS these are exactly the
3
+ // MediaRemote ones; on Linux they reach MPRIS. A caller should not have to ask.
4
+ export { readNowPlaying, sendTransport, isAvailable } from './source.js';
5
+ export { COMMANDS } from './mediaremote.js';
6
+ // Still reachable by their platform-specific names, for anyone who genuinely
7
+ // wants one register rather than "whatever this machine has".
8
+ export { readNowPlaying as readMediaRemote, sendTransport as sendMediaRemote, isAvailable as mediaRemoteAvailable, } from './mediaremote.js';
3
9
  export { parseReading, readingUri, hasEnded } from './reading.js';
10
+ export { sourceFor } from './source.js';
11
+ export { parseMpris, readMpris, sendMpris, mprisAvailable, FORMAT as MPRIS_FORMAT } from './mpris.js';
4
12
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAGlE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAGxF,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAGlE,oEAAoE;AACpE,gFAAgF;AAChF,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAG5C,6EAA6E;AAC7E,8DAA8D;AAC9D,OAAO,EACL,cAAc,IAAI,eAAe,EACjC,aAAa,IAAI,eAAe,EAChC,WAAW,IAAI,oBAAoB,GACpC,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAGlE,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,IAAI,YAAY,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,24 @@
1
+ import type { NowPlayingReading } from './reading.js';
2
+ /**
3
+ * One record, in a shape this file chose.
4
+ *
5
+ * `default(…, 0)` guards the two numeric fields: a stream has no length, and a
6
+ * player that has not started has no position. Without it those come back empty
7
+ * and the record still parses — but a zero says the same thing more plainly.
8
+ */
9
+ export declare const FORMAT: string;
10
+ export type Runner = (args: string[]) => Promise<string>;
11
+ export declare const runPlayerctl: Runner;
12
+ /**
13
+ * Parse one record.
14
+ *
15
+ * MPRIS reports position and length in **microseconds**, which is the detail
16
+ * most likely to be got wrong and the one worth stating twice.
17
+ */
18
+ export declare function parseMpris(raw: string): NowPlayingReading | null;
19
+ export declare function readMpris(run?: Runner): Promise<NowPlayingReading | null>;
20
+ export type MprisCommand = 'play' | 'pause' | 'togglePlayPause' | 'next' | 'previous';
21
+ export declare function sendMpris(command: MprisCommand, run?: Runner): Promise<boolean>;
22
+ /** Whether `playerctl` is here at all. Nothing needs to be playing. */
23
+ export declare function mprisAvailable(run?: Runner): Promise<boolean>;
24
+ //# sourceMappingURL=mpris.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mpris.d.ts","sourceRoot":"","sources":["../../src/mpris.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AA+BtD;;;;;;GAMG;AACH,eAAO,MAAM,MAAM,QAQP,CAAC;AAEb,MAAM,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAEzD,eAAO,MAAM,YAAY,EAAE,MAKvB,CAAC;AAEL;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CA0BhE;AAED,wBAAsB,SAAS,CAAC,GAAG,GAAE,MAAqB,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAQ7F;AAiBD,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,OAAO,GAAG,iBAAiB,GAAG,MAAM,GAAG,UAAU,CAAC;AAEtF,wBAAsB,SAAS,CAC7B,OAAO,EAAE,YAAY,EACrB,GAAG,GAAE,MAAqB,GACzB,OAAO,CAAC,OAAO,CAAC,CASlB;AAED,uEAAuE;AACvE,wBAAsB,cAAc,CAAC,GAAG,GAAE,MAAqB,GAAG,OAAO,CAAC,OAAO,CAAC,CAQjF"}
@@ -0,0 +1,132 @@
1
+ import { execFile } from 'node:child_process';
2
+ /**
3
+ * Linux's answer to the same question: what is this machine playing?
4
+ *
5
+ * MPRIS is a D-Bus interface that media players implement — browsers, VLC,
6
+ * Spotify's Linux client, mpv with a plugin, most of them. `playerctl` is the
7
+ * standard command-line client for it, and this shells out to that for the same
8
+ * reason the macOS side shells out to `osascript`: the protocol is somebody
9
+ * else's moving target, and a process boundary is where it should stay.
10
+ *
11
+ * The important difference from the macOS path, and the reason this could be
12
+ * written safely at all: **the output shape is ours.** `playerctl` takes a
13
+ * `--format` template, so rather than parsing whatever a tool decided to print
14
+ * this asks for exactly the fields it wants, in exactly the order it wants
15
+ * them. There is no guessing at somebody else's serialisation.
16
+ */
17
+ const TIMEOUT_MS = 4000;
18
+ /**
19
+ * ASCII 31. Track titles contain every printable delimiter worth using.
20
+ *
21
+ * Spelled as an expression rather than written literally: an invisible byte in
22
+ * source survives a compiler but not always an editor, a copy, or a patch, and
23
+ * a delimiter that silently becomes the empty string takes every field with it
24
+ * -- silently, because every record then parses as one field and returns null,
25
+ * which is indistinguishable from nothing playing.
26
+ */
27
+ const UNIT = String.fromCharCode(31);
28
+ /**
29
+ * One record, in a shape this file chose.
30
+ *
31
+ * `default(…, 0)` guards the two numeric fields: a stream has no length, and a
32
+ * player that has not started has no position. Without it those come back empty
33
+ * and the record still parses — but a zero says the same thing more plainly.
34
+ */
35
+ export const FORMAT = [
36
+ '{{playerName}}',
37
+ '{{status}}',
38
+ '{{title}}',
39
+ '{{artist}}',
40
+ '{{album}}',
41
+ '{{default(position,0)}}',
42
+ '{{default(mpris:length,0)}}',
43
+ ].join(UNIT);
44
+ export const runPlayerctl = (args) => new Promise((resolve, reject) => {
45
+ execFile('playerctl', args, { timeout: TIMEOUT_MS }, (error, stdout, stderr) => error ? reject(new Error(stderr.trim() || error.message)) : resolve(stdout));
46
+ });
47
+ /**
48
+ * Parse one record.
49
+ *
50
+ * MPRIS reports position and length in **microseconds**, which is the detail
51
+ * most likely to be got wrong and the one worth stating twice.
52
+ */
53
+ export function parseMpris(raw) {
54
+ const trimmed = raw.trim();
55
+ if (!trimmed)
56
+ return null;
57
+ const parts = trimmed.split(UNIT);
58
+ if (parts.length < 7)
59
+ return null;
60
+ const [player = '', status = '', title = '', artist = '', album = '', position = '', length = ''] = parts;
61
+ // Same filter as the macOS side, for the same reason: something that made a
62
+ // noise is not necessarily something with a transport worth showing.
63
+ if (!title.trim())
64
+ return null;
65
+ const durationMs = micros(length);
66
+ if (durationMs <= 0)
67
+ return null;
68
+ return {
69
+ bundleId: player.trim(),
70
+ label: player.trim(),
71
+ playing: status.trim().toLowerCase() === 'playing',
72
+ title: title.trim(),
73
+ artist: artist.trim(),
74
+ ...(album.trim() ? { album: album.trim() } : {}),
75
+ elapsedMs: Math.max(0, micros(position)),
76
+ durationMs,
77
+ };
78
+ }
79
+ export async function readMpris(run = runPlayerctl) {
80
+ try {
81
+ return parseMpris(await run(['metadata', '--format', FORMAT]));
82
+ }
83
+ catch {
84
+ // `playerctl` exits non-zero with "No players found" when nothing is
85
+ // running, which is an ordinary state rather than a fault.
86
+ return null;
87
+ }
88
+ }
89
+ /**
90
+ * playerctl's names for the transport commands, where they differ from ours.
91
+ *
92
+ * Only `play-pause` actually differs, but going through a map means a command
93
+ * this file has not thought about cannot be passed through to the shell
94
+ * unchecked.
95
+ */
96
+ const PLAYERCTL = {
97
+ play: 'play',
98
+ pause: 'pause',
99
+ togglePlayPause: 'play-pause',
100
+ next: 'next',
101
+ previous: 'previous',
102
+ };
103
+ export async function sendMpris(command, run = runPlayerctl) {
104
+ const verb = PLAYERCTL[command];
105
+ if (!verb)
106
+ return false;
107
+ try {
108
+ await run([verb]);
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ /** Whether `playerctl` is here at all. Nothing needs to be playing. */
116
+ export async function mprisAvailable(run = runPlayerctl) {
117
+ if (process.platform !== 'linux')
118
+ return false;
119
+ try {
120
+ await run(['--version']);
121
+ return true;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ function micros(value) {
128
+ const n = Number.parseFloat(value);
129
+ // Microseconds. Getting this wrong by a thousand is the classic MPRIS bug.
130
+ return Number.isFinite(n) ? Math.round(n / 1000) : 0;
131
+ }
132
+ //# sourceMappingURL=mpris.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mpris.js","sourceRoot":"","sources":["../../src/mpris.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ import type { TransportCommand } from './mediaremote.js';
2
+ import type { NowPlayingReading } from './reading.js';
3
+ /**
4
+ * Which system register to ask, on this machine.
5
+ *
6
+ * macOS and Linux answer the same question through completely different
7
+ * plumbing — a private Apple framework reached through JXA, and a D-Bus
8
+ * interface reached through `playerctl`. A host should not have to care, and
9
+ * neither should the queue: `nowplaying:current` means the same thing on both.
10
+ *
11
+ * Windows has an equivalent in SMTC and is not implemented. It would slot in
12
+ * here, and nothing above this file would change.
13
+ */
14
+ export interface NowPlayingSource {
15
+ readonly platform: string;
16
+ available(): Promise<boolean>;
17
+ read(): Promise<NowPlayingReading | null>;
18
+ send(command: TransportCommand): Promise<boolean>;
19
+ }
20
+ /** Null on a platform with no implementation, which the adapter reports. */
21
+ export declare function sourceFor(platform?: string): NowPlayingSource | null;
22
+ export declare const UNSUPPORTED: string;
23
+ /**
24
+ * The package's public reads and commands, on whichever platform this is.
25
+ *
26
+ * These are what a host imports, and what the `upnext` CLI's `now`, `pause`
27
+ * and `next` are built on. They dispatch rather than reaching for MediaRemote
28
+ * directly — which they used to, quietly making the CLI macOS-only even after
29
+ * the Linux register worked.
30
+ */
31
+ export declare function readNowPlaying(): Promise<NowPlayingReading | null>;
32
+ export declare function sendTransport(command: TransportCommand): Promise<boolean>;
33
+ /** Whether this machine has a register we can reach at all. */
34
+ export declare function isAvailable(): Promise<boolean>;
35
+ //# sourceMappingURL=source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source.d.ts","sourceRoot":"","sources":["../../src/source.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAEtD;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9B,IAAI,IAAI,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IAC1C,IAAI,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnD;AAgBD,4EAA4E;AAC5E,wBAAgB,SAAS,CAAC,QAAQ,GAAE,MAAyB,GAAG,gBAAgB,GAAG,IAAI,CAItF;AAED,eAAO,MAAM,WAAW,QAEY,CAAC;AAErC;;;;;;;GAOG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAGxE;AAED,wBAAsB,aAAa,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAG/E;AAED,+DAA+D;AAC/D,wBAAsB,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC,CAGpD"}
@@ -0,0 +1,46 @@
1
+ import { isAvailable as macAvailable, readNowPlaying as macRead, sendTransport as macSend } from './mediaremote.js';
2
+ import { mprisAvailable, readMpris, sendMpris } from './mpris.js';
3
+ const MAC = {
4
+ platform: 'darwin',
5
+ available: () => macAvailable(),
6
+ read: () => macRead(),
7
+ send: (command) => macSend(command),
8
+ };
9
+ const LINUX = {
10
+ platform: 'linux',
11
+ available: () => mprisAvailable(),
12
+ read: () => readMpris(),
13
+ send: (command) => sendMpris(command),
14
+ };
15
+ /** Null on a platform with no implementation, which the adapter reports. */
16
+ export function sourceFor(platform = process.platform) {
17
+ if (platform === 'darwin')
18
+ return MAC;
19
+ if (platform === 'linux')
20
+ return LINUX;
21
+ return null;
22
+ }
23
+ export const UNSUPPORTED = 'no system Now Playing register here — this adapter supports macOS (MediaRemote) ' +
24
+ 'and Linux (MPRIS, via playerctl)';
25
+ /**
26
+ * The package's public reads and commands, on whichever platform this is.
27
+ *
28
+ * These are what a host imports, and what the `upnext` CLI's `now`, `pause`
29
+ * and `next` are built on. They dispatch rather than reaching for MediaRemote
30
+ * directly — which they used to, quietly making the CLI macOS-only even after
31
+ * the Linux register worked.
32
+ */
33
+ export async function readNowPlaying() {
34
+ const source = sourceFor();
35
+ return source ? source.read() : null;
36
+ }
37
+ export async function sendTransport(command) {
38
+ const source = sourceFor();
39
+ return source ? source.send(command) : false;
40
+ }
41
+ /** Whether this machine has a register we can reach at all. */
42
+ export async function isAvailable() {
43
+ const source = sourceFor();
44
+ return source ? source.available() : false;
45
+ }
46
+ //# sourceMappingURL=source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source.js","sourceRoot":"","sources":["../../src/source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,IAAI,YAAY,EAAE,cAAc,IAAI,OAAO,EAAE,aAAa,IAAI,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAEpH,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAqBlE,MAAM,GAAG,GAAqB;IAC5B,QAAQ,EAAE,QAAQ;IAClB,SAAS,EAAE,GAAG,EAAE,CAAC,YAAY,EAAE;IAC/B,IAAI,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE;IACrB,IAAI,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;CACpC,CAAC;AAEF,MAAM,KAAK,GAAqB;IAC9B,QAAQ,EAAE,OAAO;IACjB,SAAS,EAAE,GAAG,EAAE,CAAC,cAAc,EAAE;IACjC,IAAI,EAAE,GAAG,EAAE,CAAC,SAAS,EAAE;IACvB,IAAI,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC;CACtC,CAAC;AAEF,4EAA4E;AAC5E,MAAM,UAAU,SAAS,CAAC,WAAmB,OAAO,CAAC,QAAQ;IAC3D,IAAI,QAAQ,KAAK,QAAQ;QAAE,OAAO,GAAG,CAAC;IACtC,IAAI,QAAQ,KAAK,OAAO;QAAE,OAAO,KAAK,CAAC;IACvC,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,MAAM,WAAW,GACtB,kFAAkF;IAClF,kCAAkC,CAAC;AAErC;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc;IAClC,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;IAC3B,OAAO,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACvC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAyB;IAC3D,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;IAC3B,OAAO,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;AAC/C,CAAC;AAED,+DAA+D;AAC/D,MAAM,CAAC,KAAK,UAAU,WAAW;IAC/B,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;IAC3B,OAAO,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;AAC7C,CAAC"}
package/package.json CHANGED
@@ -1,20 +1,21 @@
1
1
  {
2
2
  "name": "upnext-adapter-nowplaying",
3
- "version": "0.2.0",
4
- "description": "Read and control whatever macOS is playing \u2014 a browser tab, VLC, Doppler, anything that publishes to Control Center.",
3
+ "version": "0.3.1",
4
+ "description": "Read and control whatever macOS is playing — a browser tab, VLC, Doppler, anything that publishes to Control Center.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "./dist/src/index.js",
8
8
  "types": "./dist/src/index.d.ts",
9
9
  "exports": {
10
- ".": "./dist/src/index.js"
10
+ ".": "./dist/src/index.js",
11
+ "./package.json": "./package.json"
11
12
  },
12
13
  "files": [
13
14
  "dist/src",
14
15
  "src"
15
16
  ],
16
17
  "dependencies": {
17
- "upnext-core": "0.2.0"
18
+ "upnext-core": "0.3.1"
18
19
  },
19
20
  "engines": {
20
21
  "node": ">=20"
package/src/adapter.ts CHANGED
@@ -6,7 +6,7 @@ import type {
6
6
  Capabilities,
7
7
  MediaRef,
8
8
  } from 'upnext-core';
9
- import { isAvailable, readNowPlaying, sendTransport } from './mediaremote.js';
9
+ import { sourceFor, UNSUPPORTED } from './source.js';
10
10
  import { hasEnded, readingUri, type NowPlayingReading } from './reading.js';
11
11
 
12
12
  /** The one thing this adapter can be asked for. */
@@ -70,9 +70,14 @@ export class NowPlayingAdapter implements Adapter {
70
70
 
71
71
  async init(): Promise<void> {
72
72
  if (this.#options.read) return; // Injected: nothing to probe.
73
- if (!(await isAvailable())) {
73
+
74
+ const source = sourceFor();
75
+ if (!source) throw new Error(UNSUPPORTED);
76
+ if (!(await source.available())) {
74
77
  throw new Error(
75
- 'macOS Now Playing is unavailable here — this adapter needs macOS and the MediaRemote framework',
78
+ source.platform === 'linux'
79
+ ? 'playerctl was not found — install it to reach MPRIS on Linux'
80
+ : 'the MediaRemote framework did not answer here',
76
81
  );
77
82
  }
78
83
  }
@@ -153,10 +158,12 @@ export class NowPlayingAdapter implements Adapter {
153
158
  }
154
159
 
155
160
  #read(): Promise<NowPlayingReading | null> {
156
- return (this.#options.read ?? readNowPlaying)();
161
+ if (this.#options.read) return this.#options.read();
162
+ return sourceFor()?.read() ?? Promise.resolve(null);
157
163
  }
158
164
 
159
165
  #send(command: 'play' | 'pause'): Promise<boolean> {
160
- return (this.#options.send ?? sendTransport)(command);
166
+ if (this.#options.send) return this.#options.send(command);
167
+ return sourceFor()?.send(command) ?? Promise.resolve(false);
161
168
  }
162
169
  }
package/src/index.ts CHANGED
@@ -1,8 +1,24 @@
1
1
  export { NowPlayingAdapter, NOW_PLAYING_URI } from './adapter.js';
2
2
  export type { NowPlayingAdapterOptions } from './adapter.js';
3
3
 
4
- export { readNowPlaying, sendTransport, isAvailable, COMMANDS } from './mediaremote.js';
4
+ // The platform-dispatching versions. On macOS these are exactly the
5
+ // MediaRemote ones; on Linux they reach MPRIS. A caller should not have to ask.
6
+ export { readNowPlaying, sendTransport, isAvailable } from './source.js';
7
+ export { COMMANDS } from './mediaremote.js';
5
8
  export type { TransportCommand } from './mediaremote.js';
6
9
 
10
+ // Still reachable by their platform-specific names, for anyone who genuinely
11
+ // wants one register rather than "whatever this machine has".
12
+ export {
13
+ readNowPlaying as readMediaRemote,
14
+ sendTransport as sendMediaRemote,
15
+ isAvailable as mediaRemoteAvailable,
16
+ } from './mediaremote.js';
17
+
7
18
  export { parseReading, readingUri, hasEnded } from './reading.js';
8
19
  export type { NowPlayingReading } from './reading.js';
20
+
21
+ export { sourceFor } from './source.js';
22
+ export type { NowPlayingSource } from './source.js';
23
+ export { parseMpris, readMpris, sendMpris, mprisAvailable, FORMAT as MPRIS_FORMAT } from './mpris.js';
24
+ export type { MprisCommand, Runner as MprisRunner } from './mpris.js';
package/src/mpris.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { execFile } from 'node:child_process';
2
+ import type { NowPlayingReading } from './reading.js';
3
+
4
+ /**
5
+ * Linux's answer to the same question: what is this machine playing?
6
+ *
7
+ * MPRIS is a D-Bus interface that media players implement — browsers, VLC,
8
+ * Spotify's Linux client, mpv with a plugin, most of them. `playerctl` is the
9
+ * standard command-line client for it, and this shells out to that for the same
10
+ * reason the macOS side shells out to `osascript`: the protocol is somebody
11
+ * else's moving target, and a process boundary is where it should stay.
12
+ *
13
+ * The important difference from the macOS path, and the reason this could be
14
+ * written safely at all: **the output shape is ours.** `playerctl` takes a
15
+ * `--format` template, so rather than parsing whatever a tool decided to print
16
+ * this asks for exactly the fields it wants, in exactly the order it wants
17
+ * them. There is no guessing at somebody else's serialisation.
18
+ */
19
+
20
+ const TIMEOUT_MS = 4000;
21
+
22
+ /**
23
+ * ASCII 31. Track titles contain every printable delimiter worth using.
24
+ *
25
+ * Spelled as an expression rather than written literally: an invisible byte in
26
+ * source survives a compiler but not always an editor, a copy, or a patch, and
27
+ * a delimiter that silently becomes the empty string takes every field with it
28
+ * -- silently, because every record then parses as one field and returns null,
29
+ * which is indistinguishable from nothing playing.
30
+ */
31
+ const UNIT = String.fromCharCode(31);
32
+
33
+ /**
34
+ * One record, in a shape this file chose.
35
+ *
36
+ * `default(…, 0)` guards the two numeric fields: a stream has no length, and a
37
+ * player that has not started has no position. Without it those come back empty
38
+ * and the record still parses — but a zero says the same thing more plainly.
39
+ */
40
+ export const FORMAT = [
41
+ '{{playerName}}',
42
+ '{{status}}',
43
+ '{{title}}',
44
+ '{{artist}}',
45
+ '{{album}}',
46
+ '{{default(position,0)}}',
47
+ '{{default(mpris:length,0)}}',
48
+ ].join(UNIT);
49
+
50
+ export type Runner = (args: string[]) => Promise<string>;
51
+
52
+ export const runPlayerctl: Runner = (args) =>
53
+ new Promise((resolve, reject) => {
54
+ execFile('playerctl', args, { timeout: TIMEOUT_MS }, (error, stdout, stderr) =>
55
+ error ? reject(new Error(stderr.trim() || error.message)) : resolve(stdout),
56
+ );
57
+ });
58
+
59
+ /**
60
+ * Parse one record.
61
+ *
62
+ * MPRIS reports position and length in **microseconds**, which is the detail
63
+ * most likely to be got wrong and the one worth stating twice.
64
+ */
65
+ export function parseMpris(raw: string): NowPlayingReading | null {
66
+ const trimmed = raw.trim();
67
+ if (!trimmed) return null;
68
+
69
+ const parts = trimmed.split(UNIT);
70
+ if (parts.length < 7) return null;
71
+
72
+ const [player = '', status = '', title = '', artist = '', album = '', position = '', length = ''] =
73
+ parts;
74
+
75
+ // Same filter as the macOS side, for the same reason: something that made a
76
+ // noise is not necessarily something with a transport worth showing.
77
+ if (!title.trim()) return null;
78
+ const durationMs = micros(length);
79
+ if (durationMs <= 0) return null;
80
+
81
+ return {
82
+ bundleId: player.trim(),
83
+ label: player.trim(),
84
+ playing: status.trim().toLowerCase() === 'playing',
85
+ title: title.trim(),
86
+ artist: artist.trim(),
87
+ ...(album.trim() ? { album: album.trim() } : {}),
88
+ elapsedMs: Math.max(0, micros(position)),
89
+ durationMs,
90
+ };
91
+ }
92
+
93
+ export async function readMpris(run: Runner = runPlayerctl): Promise<NowPlayingReading | null> {
94
+ try {
95
+ return parseMpris(await run(['metadata', '--format', FORMAT]));
96
+ } catch {
97
+ // `playerctl` exits non-zero with "No players found" when nothing is
98
+ // running, which is an ordinary state rather than a fault.
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * playerctl's names for the transport commands, where they differ from ours.
105
+ *
106
+ * Only `play-pause` actually differs, but going through a map means a command
107
+ * this file has not thought about cannot be passed through to the shell
108
+ * unchecked.
109
+ */
110
+ const PLAYERCTL: Record<MprisCommand, string> = {
111
+ play: 'play',
112
+ pause: 'pause',
113
+ togglePlayPause: 'play-pause',
114
+ next: 'next',
115
+ previous: 'previous',
116
+ };
117
+
118
+ export type MprisCommand = 'play' | 'pause' | 'togglePlayPause' | 'next' | 'previous';
119
+
120
+ export async function sendMpris(
121
+ command: MprisCommand,
122
+ run: Runner = runPlayerctl,
123
+ ): Promise<boolean> {
124
+ const verb = PLAYERCTL[command];
125
+ if (!verb) return false;
126
+ try {
127
+ await run([verb]);
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /** Whether `playerctl` is here at all. Nothing needs to be playing. */
135
+ export async function mprisAvailable(run: Runner = runPlayerctl): Promise<boolean> {
136
+ if (process.platform !== 'linux') return false;
137
+ try {
138
+ await run(['--version']);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ function micros(value: string): number {
146
+ const n = Number.parseFloat(value);
147
+ // Microseconds. Getting this wrong by a thousand is the classic MPRIS bug.
148
+ return Number.isFinite(n) ? Math.round(n / 1000) : 0;
149
+ }
package/src/source.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { isAvailable as macAvailable, readNowPlaying as macRead, sendTransport as macSend } from './mediaremote.js';
2
+ import type { TransportCommand } from './mediaremote.js';
3
+ import { mprisAvailable, readMpris, sendMpris } from './mpris.js';
4
+ import type { NowPlayingReading } from './reading.js';
5
+
6
+ /**
7
+ * Which system register to ask, on this machine.
8
+ *
9
+ * macOS and Linux answer the same question through completely different
10
+ * plumbing — a private Apple framework reached through JXA, and a D-Bus
11
+ * interface reached through `playerctl`. A host should not have to care, and
12
+ * neither should the queue: `nowplaying:current` means the same thing on both.
13
+ *
14
+ * Windows has an equivalent in SMTC and is not implemented. It would slot in
15
+ * here, and nothing above this file would change.
16
+ */
17
+ export interface NowPlayingSource {
18
+ readonly platform: string;
19
+ available(): Promise<boolean>;
20
+ read(): Promise<NowPlayingReading | null>;
21
+ send(command: TransportCommand): Promise<boolean>;
22
+ }
23
+
24
+ const MAC: NowPlayingSource = {
25
+ platform: 'darwin',
26
+ available: () => macAvailable(),
27
+ read: () => macRead(),
28
+ send: (command) => macSend(command),
29
+ };
30
+
31
+ const LINUX: NowPlayingSource = {
32
+ platform: 'linux',
33
+ available: () => mprisAvailable(),
34
+ read: () => readMpris(),
35
+ send: (command) => sendMpris(command),
36
+ };
37
+
38
+ /** Null on a platform with no implementation, which the adapter reports. */
39
+ export function sourceFor(platform: string = process.platform): NowPlayingSource | null {
40
+ if (platform === 'darwin') return MAC;
41
+ if (platform === 'linux') return LINUX;
42
+ return null;
43
+ }
44
+
45
+ export const UNSUPPORTED =
46
+ 'no system Now Playing register here — this adapter supports macOS (MediaRemote) ' +
47
+ 'and Linux (MPRIS, via playerctl)';
48
+
49
+ /**
50
+ * The package's public reads and commands, on whichever platform this is.
51
+ *
52
+ * These are what a host imports, and what the `upnext` CLI's `now`, `pause`
53
+ * and `next` are built on. They dispatch rather than reaching for MediaRemote
54
+ * directly — which they used to, quietly making the CLI macOS-only even after
55
+ * the Linux register worked.
56
+ */
57
+ export async function readNowPlaying(): Promise<NowPlayingReading | null> {
58
+ const source = sourceFor();
59
+ return source ? source.read() : null;
60
+ }
61
+
62
+ export async function sendTransport(command: TransportCommand): Promise<boolean> {
63
+ const source = sourceFor();
64
+ return source ? source.send(command) : false;
65
+ }
66
+
67
+ /** Whether this machine has a register we can reach at all. */
68
+ export async function isAvailable(): Promise<boolean> {
69
+ const source = sourceFor();
70
+ return source ? source.available() : false;
71
+ }