upnext-adapter-nowplaying 0.1.0 → 0.3.0
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 +56 -11
- package/dist/src/adapter.d.ts.map +1 -1
- package/dist/src/adapter.js +14 -5
- package/dist/src/adapter.js.map +1 -1
- package/dist/src/index.d.ts +7 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +9 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mpris.d.ts +24 -0
- package/dist/src/mpris.d.ts.map +1 -0
- package/dist/src/mpris.js +132 -0
- package/dist/src/mpris.js.map +1 -0
- package/dist/src/source.d.ts +35 -0
- package/dist/src/source.d.ts.map +1 -0
- package/dist/src/source.js +46 -0
- package/dist/src/source.js.map +1 -0
- package/package.json +5 -4
- package/src/adapter.ts +12 -5
- package/src/index.ts +17 -1
- package/src/mpris.ts +149 -0
- package/src/source.ts +71 -0
package/README.md
CHANGED
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/upnext-adapter-nowplaying)
|
|
4
4
|
[](https://github.com/tothienbao6a0/upnext/blob/main/LICENSE)
|
|
5
5
|
|
|
6
|
-
**Read and control whatever your
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
**
|
|
96
|
-
|
|
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
|
-
|
|
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;
|
|
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"}
|
package/dist/src/adapter.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defaultCapabilities } from 'upnext-core';
|
|
2
|
-
import {
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
130
|
+
if (this.#options.read)
|
|
131
|
+
return this.#options.read();
|
|
132
|
+
return sourceFor()?.read() ?? Promise.resolve(null);
|
|
126
133
|
}
|
|
127
134
|
#send(command) {
|
|
128
|
-
|
|
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
|
package/dist/src/adapter.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.js","sourceRoot":"","sources":["../../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAQlD,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"adapter.js","sourceRoot":"","sources":["../../src/adapter.ts"],"names":[],"mappings":"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"}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/src/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
package/dist/src/index.js.map
CHANGED
|
@@ -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;
|
|
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.
|
|
4
|
-
"description": "Read and control whatever macOS is playing
|
|
3
|
+
"version": "0.3.0",
|
|
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.
|
|
18
|
+
"upnext-core": "0.3.0"
|
|
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 {
|
|
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
|
-
|
|
73
|
+
|
|
74
|
+
const source = sourceFor();
|
|
75
|
+
if (!source) throw new Error(UNSUPPORTED);
|
|
76
|
+
if (!(await source.available())) {
|
|
74
77
|
throw new Error(
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|