upnext-adapter-nowplaying 0.1.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # upnext-adapter-nowplaying
2
+
3
+ [![npm](https://img.shields.io/npm/v/upnext-adapter-nowplaying)](https://www.npmjs.com/package/upnext-adapter-nowplaying)
4
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/tothienbao6a0/upnext/blob/main/LICENSE)
5
+
6
+ **Read and control whatever your Mac is playing — including a browser tab —
7
+ without an extension.**
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.
13
+
14
+ ```ts
15
+ import { readNowPlaying, sendTransport } from 'upnext-adapter-nowplaying';
16
+
17
+ await readNowPlaying();
18
+ // { bundleId: 'com.google.Chrome', label: 'Google Chrome', playing: true,
19
+ // title: 'Acquired — Jensen Huang', artist: 'YouTube',
20
+ // elapsedMs: 60000, durationMs: 3600000 }
21
+
22
+ await sendTransport('pause'); // pauses it, whatever it is
23
+ ```
24
+
25
+ That works with no browser extension, no accessibility permission, and no
26
+ per-site integration.
27
+
28
+ ## As a queue entry
29
+
30
+ The interesting use isn't observation — it's **not interrupting people**.
31
+
32
+ ```ts
33
+ import { Runtime } from 'upnext-core';
34
+ import { NowPlayingAdapter, NOW_PLAYING_URI } from 'upnext-adapter-nowplaying';
35
+
36
+ const runtime = new Runtime({ adapters: [new NowPlayingAdapter(), ...others] });
37
+
38
+ runtime.enqueue(NOW_PLAYING_URI); // let their podcast finish
39
+ runtime.enqueue({ title: 'Bad Habit' }); // then take over
40
+
41
+ await runtime.play();
42
+ ```
43
+
44
+ An agent adding to someone's listening instead of cutting across it. When their
45
+ episode ends, the queue moves on by itself.
46
+
47
+ And if they skip to something else in that tab, the runtime notices and adopts
48
+ it — their choice wins, which is the default everywhere in upnext.
49
+
50
+ ## What it can and cannot do
51
+
52
+ This adapter answers to exactly one entry, `nowplaying:current`, meaning *the
53
+ thing that is on*. It cannot be handed a track, because there is no way to ask
54
+ the register to start one — so it scores zero for every other locator rather
55
+ than claiming it could.
56
+
57
+ ```ts
58
+ {
59
+ endOfTrack: 'poll', // the register does not notify; we sample it
60
+ position: 'authoritative', // elapsed comes from the player itself
61
+ pause: true,
62
+ seek: false, // the bridge cannot pass seek options cleanly
63
+ volume: false, // the register exposes no volume
64
+ search: false,
65
+ externalControl: true, // the strongest case in the library
66
+ }
67
+ ```
68
+
69
+ `externalControl` has never meant more than it does here. The runtime is a
70
+ guest: the item was chosen by somebody else, in an app it does not control, and
71
+ it can change underneath at any moment.
72
+
73
+ `stop()` pauses rather than quitting. The queue moving on is not a reason to
74
+ take somebody's podcast away from them.
75
+
76
+ ## How it works, and the honest caveats
77
+
78
+ It reaches `MediaRemote`, a **private Apple framework**, through JXA's
79
+ Objective-C bridge. Private API is normally a bad trade, so:
80
+
81
+ - **There is no public equivalent.** `MPNowPlayingInfoCenter` publishes *your
82
+ own* app's state. Nothing public reads another app's.
83
+ - **Apple began gating MediaRemote behind an entitlement in macOS 15.4**, which
84
+ broke the usual command-line tools. The gate applies to processes asking
85
+ directly; script execution through `osascript` still resolves it. Verified
86
+ working on **macOS 26.5.2**.
87
+ - **The alternative is worse.** Shipping a helper binary that borrows a system
88
+ binary's entitlements is a far larger and more fragile dependency than a
89
+ script you can read in full — and you can: it's in
90
+ [`src/mediaremote.ts`](./src/mediaremote.ts), about forty lines.
91
+ - **Every failure answers null.** The day Apple closes this, the adapter reports
92
+ itself unavailable, the registry excludes it, and the rest of your queue
93
+ carries on.
94
+
95
+ **macOS only.** On any other platform `init()` fails cleanly and
96
+ `getState().adapters` shows `available: false` with the reason.
97
+
98
+ ### It filters out things that aren't media
99
+
100
+ macOS's now-playing client is *whatever last made a sound*. A received voice
101
+ message leaves Messages sitting there as the now-playing app, with no title and
102
+ a seven-second "track". Requiring a title and a real duration is what separates
103
+ *something is playing* from *something made a noise once*.
104
+
105
+ ## Compared with the other adapters
106
+
107
+ | | starts tracks | who chose it | can be changed under you |
108
+ |---|:---:|---|:---:|
109
+ | `upnext-adapter-local` | ✅ | you | no |
110
+ | `upnext-adapter-browser` | ✅ | you | no |
111
+ | `upnext-adapter-spotify` | ✅ | you, mostly | **yes** |
112
+ | **`upnext-adapter-nowplaying`** | ❌ | **somebody else** | **yes** |
113
+
114
+ This is the far end of the capability spectrum the whole library is built
115
+ around — and a useful proof that the contract stretches that far without
116
+ bending.
117
+
118
+ ---
119
+
120
+ Full docs: **https://github.com/tothienbao6a0/upnext** · Apache-2.0
@@ -0,0 +1,61 @@
1
+ import type { Adapter, AdapterReading, Binding, Capabilities, MediaRef } from 'upnext-core';
2
+ import { type NowPlayingReading } from './reading.js';
3
+ /** The one thing this adapter can be asked for. */
4
+ export declare const NOW_PLAYING_URI = "nowplaying:current";
5
+ export interface NowPlayingAdapterOptions {
6
+ id?: string;
7
+ /** Injected for tests, and for a host that has its own source of truth. */
8
+ read?: () => Promise<NowPlayingReading | null>;
9
+ send?: (command: 'play' | 'pause') => Promise<boolean>;
10
+ }
11
+ /**
12
+ * Whatever the machine is already playing, as a queue entry.
13
+ *
14
+ * Every other adapter is handed a locator and told to play it. This one cannot
15
+ * be: there is no way to ask macOS's Now Playing register to start a particular
16
+ * track. What it *can* do is see what some other app is playing — a browser tab,
17
+ * VLC, anything in Control Center — and work the transport.
18
+ *
19
+ * So it answers to exactly one entry, `nowplaying:current`, meaning *the thing
20
+ * that is on*. That turns out to be worth having:
21
+ *
22
+ * runtime.enqueue('nowplaying:current'); // let their podcast finish
23
+ * runtime.enqueue({ title: 'Bad Habit' }); // then take over
24
+ *
25
+ * which is an agent adding to someone's listening instead of interrupting it.
26
+ *
27
+ * It also sits at the far end of the capability spectrum — further than the
28
+ * Spotify adapter, which at least starts the tracks it plays. Here the runtime
29
+ * is a guest: the item was chosen by somebody else, in an app it does not
30
+ * control, and it can be changed underneath at any moment. `externalControl`
31
+ * has never meant more than it does here.
32
+ */
33
+ export declare class NowPlayingAdapter implements Adapter {
34
+ #private;
35
+ readonly id: string;
36
+ readonly capabilities: Capabilities;
37
+ constructor(options?: NowPlayingAdapterOptions);
38
+ init(): Promise<void>;
39
+ /**
40
+ * Only ever `nowplaying:current`. This adapter cannot be handed a track.
41
+ *
42
+ * Scoring anything else above zero would be claiming it could start a file or
43
+ * a Spotify URI, and it cannot start anything at all.
44
+ */
45
+ match(ref: MediaRef): number;
46
+ resolve(ref: MediaRef): Promise<Binding | null>;
47
+ /** Nothing to load. Something else already did. */
48
+ load(binding: Binding): Promise<void>;
49
+ play(): Promise<void>;
50
+ pause(): Promise<void>;
51
+ /**
52
+ * Pause, never kill.
53
+ *
54
+ * The runtime moving on to its own next entry is not a reason to take
55
+ * somebody's podcast away from them — and there is no "stop" in this register
56
+ * anyway, only a transport.
57
+ */
58
+ stop(): Promise<void>;
59
+ poll(): Promise<AdapterReading>;
60
+ }
61
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,131 @@
1
+ import { defaultCapabilities } from 'upnext-core';
2
+ import { isAvailable, readNowPlaying, sendTransport } from './mediaremote.js';
3
+ import { hasEnded, readingUri } from './reading.js';
4
+ /** The one thing this adapter can be asked for. */
5
+ export const NOW_PLAYING_URI = 'nowplaying:current';
6
+ /**
7
+ * Whatever the machine is already playing, as a queue entry.
8
+ *
9
+ * Every other adapter is handed a locator and told to play it. This one cannot
10
+ * be: there is no way to ask macOS's Now Playing register to start a particular
11
+ * track. What it *can* do is see what some other app is playing — a browser tab,
12
+ * VLC, anything in Control Center — and work the transport.
13
+ *
14
+ * So it answers to exactly one entry, `nowplaying:current`, meaning *the thing
15
+ * that is on*. That turns out to be worth having:
16
+ *
17
+ * runtime.enqueue('nowplaying:current'); // let their podcast finish
18
+ * runtime.enqueue({ title: 'Bad Habit' }); // then take over
19
+ *
20
+ * which is an agent adding to someone's listening instead of interrupting it.
21
+ *
22
+ * It also sits at the far end of the capability spectrum — further than the
23
+ * Spotify adapter, which at least starts the tracks it plays. Here the runtime
24
+ * is a guest: the item was chosen by somebody else, in an app it does not
25
+ * control, and it can be changed underneath at any moment. `externalControl`
26
+ * has never meant more than it does here.
27
+ */
28
+ export class NowPlayingAdapter {
29
+ id;
30
+ capabilities = {
31
+ ...defaultCapabilities,
32
+ // No notifications from the register; we sample it.
33
+ endOfTrack: 'poll',
34
+ // Elapsed time comes from the player itself, not from a clock of ours.
35
+ position: 'authoritative',
36
+ pause: true,
37
+ // The register exposes no volume, and its seek command takes options the
38
+ // JXA bridge cannot pass cleanly. Declaring either would be a lie.
39
+ seek: false,
40
+ volume: false,
41
+ search: false,
42
+ // The strongest case in the library: we did not even start this.
43
+ externalControl: true,
44
+ };
45
+ #options;
46
+ #binding = null;
47
+ constructor(options = {}) {
48
+ this.id = options.id ?? 'nowplaying';
49
+ this.#options = options;
50
+ }
51
+ async init() {
52
+ if (this.#options.read)
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');
56
+ }
57
+ }
58
+ /**
59
+ * Only ever `nowplaying:current`. This adapter cannot be handed a track.
60
+ *
61
+ * Scoring anything else above zero would be claiming it could start a file or
62
+ * a Spotify URI, and it cannot start anything at all.
63
+ */
64
+ match(ref) {
65
+ return ref.uri === NOW_PLAYING_URI ? 1 : 0;
66
+ }
67
+ async resolve(ref) {
68
+ if (this.match(ref) === 0)
69
+ return null;
70
+ const reading = await this.#read();
71
+ // Nothing is playing, so there is nothing to adopt. Failing here is right:
72
+ // the entry meant "the thing that is on" and there is no such thing.
73
+ if (!reading)
74
+ return null;
75
+ return {
76
+ adapterId: this.id,
77
+ // The synthesised locator changes with the track, which is what lets the
78
+ // runtime notice the person skipping in their browser.
79
+ nativeUri: readingUri(reading),
80
+ ref: {
81
+ ...ref,
82
+ title: reading.title,
83
+ artist: reading.artist,
84
+ durationMs: reading.durationMs,
85
+ meta: { app: reading.label, bundleId: reading.bundleId },
86
+ },
87
+ };
88
+ }
89
+ /** Nothing to load. Something else already did. */
90
+ async load(binding) {
91
+ this.#binding = binding;
92
+ }
93
+ async play() {
94
+ await this.#send('play');
95
+ }
96
+ async pause() {
97
+ await this.#send('pause');
98
+ }
99
+ /**
100
+ * Pause, never kill.
101
+ *
102
+ * The runtime moving on to its own next entry is not a reason to take
103
+ * somebody's podcast away from them — and there is no "stop" in this register
104
+ * anyway, only a transport.
105
+ */
106
+ async stop() {
107
+ await this.#send('pause');
108
+ this.#binding = null;
109
+ }
110
+ async poll() {
111
+ const reading = await this.#read();
112
+ if (!reading) {
113
+ // The app went away, or stopped publishing. Whatever we were following is
114
+ // over as far as anyone can tell from here.
115
+ return { status: 'ended', nativeUri: null };
116
+ }
117
+ return {
118
+ status: hasEnded(reading) ? 'ended' : reading.playing ? 'playing' : 'paused',
119
+ positionMs: reading.elapsedMs,
120
+ durationMs: reading.durationMs,
121
+ nativeUri: readingUri(reading),
122
+ };
123
+ }
124
+ #read() {
125
+ return (this.#options.read ?? readNowPlaying)();
126
+ }
127
+ #send(command) {
128
+ return (this.#options.send ?? sendTransport)(command);
129
+ }
130
+ }
131
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../../src/adapter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ export { NowPlayingAdapter, NOW_PLAYING_URI } from './adapter.js';
2
+ export type { NowPlayingAdapterOptions } from './adapter.js';
3
+ export { readNowPlaying, sendTransport, isAvailable, COMMANDS } from './mediaremote.js';
4
+ export type { TransportCommand } from './mediaremote.js';
5
+ export { parseReading, readingUri, hasEnded } from './reading.js';
6
+ export type { NowPlayingReading } from './reading.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,4 @@
1
+ export { NowPlayingAdapter, NOW_PLAYING_URI } from './adapter.js';
2
+ export { readNowPlaying, sendTransport, isAvailable, COMMANDS } from './mediaremote.js';
3
+ export { parseReading, readingUri, hasEnded } from './reading.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"}
@@ -0,0 +1,30 @@
1
+ import { type NowPlayingReading } from './reading.js';
2
+ /**
3
+ * MediaRemote's command numbers.
4
+ *
5
+ * Only the transport is here. The register exposes no volume, and its seek
6
+ * command takes options this bridge cannot pass cleanly — which is exactly why
7
+ * the adapter declares `seek: false` and `volume: false` rather than pretending.
8
+ */
9
+ export declare const COMMANDS: {
10
+ readonly play: 0;
11
+ readonly pause: 1;
12
+ readonly togglePlayPause: 2;
13
+ readonly next: 4;
14
+ readonly previous: 5;
15
+ };
16
+ export type TransportCommand = keyof typeof COMMANDS;
17
+ /**
18
+ * What macOS is playing right now, whichever app is playing it.
19
+ *
20
+ * Exported on its own because it is useful without a queue: a host that only
21
+ * wants to show what is on, or to know whether to duck, needs this and nothing
22
+ * else. Answers null when nothing is playing, when the reading is not media, or
23
+ * when the framework is unavailable.
24
+ */
25
+ export declare function readNowPlaying(): Promise<NowPlayingReading | null>;
26
+ /** Send a transport command to whatever the system considers now-playing. */
27
+ export declare function sendTransport(command: TransportCommand): Promise<boolean>;
28
+ /** Whether this machine can answer at all. Used by the adapter's `init`. */
29
+ export declare function isAvailable(): Promise<boolean>;
30
+ //# sourceMappingURL=mediaremote.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mediaremote.d.ts","sourceRoot":"","sources":["../../src/mediaremote.ts"],"names":[],"mappings":"AACA,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAC;AA4DpE;;;;;;GAMG;AACH,eAAO,MAAM,QAAQ;;;;;;CAMX,CAAC;AAEX,MAAM,MAAM,gBAAgB,GAAG,MAAM,OAAO,QAAQ,CAAC;AAqCrD;;;;;;;GAOG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAOxE;AAED,6EAA6E;AAC7E,wBAAsB,aAAa,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAQ/E;AAED,4EAA4E;AAC5E,wBAAsB,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC,CAUpD"}
@@ -0,0 +1,144 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { parseReading } from './reading.js';
3
+ /**
4
+ * macOS's own Now Playing register — how this reaches everything that isn't a
5
+ * dedicated integration: a browser tab, VLC, Doppler, anything that shows up in
6
+ * Control Center.
7
+ *
8
+ * This talks to `MediaRemote`, a private framework, through JXA's Objective-C
9
+ * bridge. Private API is normally a bad trade, so the justification:
10
+ *
11
+ * - There is no public equivalent. `MPNowPlayingInfoCenter` publishes *your
12
+ * own* app's state; nothing public reads another app's.
13
+ * - Apple began gating MediaRemote behind an entitlement in macOS 15.4, which
14
+ * broke the usual command-line tools. The gate applies to processes asking
15
+ * directly; script execution through `osascript` still resolves it, and
16
+ * that was verified on macOS 26.5.
17
+ * - The alternative is shipping a helper binary that borrows a system
18
+ * binary's entitlements — a far larger and more fragile dependency than a
19
+ * script you can read in full, here, above.
20
+ *
21
+ * Every failure answers null rather than throwing, so the day Apple closes this
22
+ * the adapter reports itself unavailable and the rest of the queue carries on.
23
+ */
24
+ const FRAMEWORK = '/System/Library/PrivateFrameworks/MediaRemote.framework/';
25
+ const SCRIPT_TIMEOUT_MS = 4000;
26
+ const READ_SCRIPT = `
27
+ ObjC.import('Foundation')
28
+ function run() {
29
+ var bundle = $.NSBundle.bundleWithPath('${FRAMEWORK}')
30
+ if (!bundle || !bundle.load) return ''
31
+ var R = $.NSClassFromString('MRNowPlayingRequest')
32
+ if (!R) return ''
33
+ var path = R.localNowPlayingPlayerPath
34
+ if (!path || path.isNil()) return ''
35
+ var client = path.client
36
+ if (!client || client.isNil()) return ''
37
+ var item = R.localNowPlayingItem
38
+ if (!item || item.isNil()) return ''
39
+ var info = item.nowPlayingInfo
40
+ if (!info || info.isNil()) return ''
41
+
42
+ var str = function (v) { try { return v && !v.isNil() ? String(v.js) : '' } catch (e) { return '' } }
43
+ var key = function (k) { try { var v = info.valueForKey(k); return v && !v.isNil() ? v.js : null } catch (e) { return null } }
44
+ var num = function (k) { var v = key(k); var n = typeof v === 'number' ? v : parseFloat(v); return isFinite(n) ? n : 0 }
45
+
46
+ return JSON.stringify({
47
+ bundleId: str(client.bundleIdentifier),
48
+ label: str(client.displayName),
49
+ playing: R.localIsPlaying === true,
50
+ title: String(key('kMRMediaRemoteNowPlayingInfoTitle') || ''),
51
+ artist: String(key('kMRMediaRemoteNowPlayingInfoArtist') || ''),
52
+ album: String(key('kMRMediaRemoteNowPlayingInfoAlbum') || ''),
53
+ elapsed: num('kMRMediaRemoteNowPlayingInfoElapsedTime'),
54
+ duration: num('kMRMediaRemoteNowPlayingInfoDuration')
55
+ })
56
+ }
57
+ `;
58
+ /**
59
+ * MediaRemote's command numbers.
60
+ *
61
+ * Only the transport is here. The register exposes no volume, and its seek
62
+ * command takes options this bridge cannot pass cleanly — which is exactly why
63
+ * the adapter declares `seek: false` and `volume: false` rather than pretending.
64
+ */
65
+ export const COMMANDS = {
66
+ play: 0,
67
+ pause: 1,
68
+ togglePlayPause: 2,
69
+ next: 4,
70
+ previous: 5,
71
+ };
72
+ /**
73
+ * The queue argument is load-bearing: passing nil there hangs the script
74
+ * indefinitely, and a hung `osascript` would stall every poll behind it.
75
+ */
76
+ const commandScript = (code) => `
77
+ ObjC.import('Foundation')
78
+ function run() {
79
+ var bundle = $.NSBundle.bundleWithPath('${FRAMEWORK}')
80
+ if (!bundle || !bundle.load) return ''
81
+ var R = $.NSClassFromString('MRNowPlayingRequest')
82
+ if (!R) return ''
83
+ var path = R.localNowPlayingPlayerPath
84
+ if (!path || path.isNil()) return ''
85
+ var req = R.alloc.initWithPlayerPath(path)
86
+ req.sendCommandOptionsQueueCompletion(
87
+ ${code},
88
+ $.NSDictionary.dictionary,
89
+ $.NSOperationQueue.mainQueue.underlyingQueue,
90
+ $()
91
+ )
92
+ return 'ok'
93
+ }
94
+ `;
95
+ function jxa(script) {
96
+ return new Promise((resolve, reject) => {
97
+ execFile('osascript', ['-l', 'JavaScript', '-e', script], { timeout: SCRIPT_TIMEOUT_MS }, (error, stdout) => (error ? reject(error) : resolve(stdout)));
98
+ });
99
+ }
100
+ /**
101
+ * What macOS is playing right now, whichever app is playing it.
102
+ *
103
+ * Exported on its own because it is useful without a queue: a host that only
104
+ * wants to show what is on, or to know whether to duck, needs this and nothing
105
+ * else. Answers null when nothing is playing, when the reading is not media, or
106
+ * when the framework is unavailable.
107
+ */
108
+ export async function readNowPlaying() {
109
+ if (process.platform !== 'darwin')
110
+ return null;
111
+ try {
112
+ return parseReading(await jxa(READ_SCRIPT));
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ /** Send a transport command to whatever the system considers now-playing. */
119
+ export async function sendTransport(command) {
120
+ if (process.platform !== 'darwin')
121
+ return false;
122
+ try {
123
+ const out = await jxa(commandScript(COMMANDS[command]));
124
+ return out.trim() === 'ok';
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ /** Whether this machine can answer at all. Used by the adapter's `init`. */
131
+ export async function isAvailable() {
132
+ if (process.platform !== 'darwin')
133
+ return false;
134
+ try {
135
+ // An empty answer still means the bridge resolved — nothing is playing,
136
+ // which is a fine state. Only a thrown error means it cannot work here.
137
+ await jxa(READ_SCRIPT);
138
+ return true;
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ //# sourceMappingURL=mediaremote.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mediaremote.js","sourceRoot":"","sources":["../../src/mediaremote.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAA0B,MAAM,cAAc,CAAC;AAEpE;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,SAAS,GAAG,0DAA0D,CAAC;AAC7E,MAAM,iBAAiB,GAAG,IAAI,CAAC;AAE/B,MAAM,WAAW,GAAG;;;4CAGwB,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BpD,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,IAAI,EAAE,CAAC;IACP,KAAK,EAAE,CAAC;IACR,eAAe,EAAE,CAAC;IAClB,IAAI,EAAE,CAAC;IACP,QAAQ,EAAE,CAAC;CACH,CAAC;AAIX;;;GAGG;AACH,MAAM,aAAa,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC;;;4CAGI,SAAS;;;;;;;;MAQ/C,IAAI;;;;;;;CAOT,CAAC;AAEF,SAAS,GAAG,CAAC,MAAc;IACzB,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,QAAQ,CACN,WAAW,EACX,CAAC,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,CAAC,EAClC,EAAE,OAAO,EAAE,iBAAiB,EAAE,EAC9B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAC7D,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc;IAClC,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC/C,IAAI,CAAC;QACH,OAAO,YAAY,CAAC,MAAM,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,6EAA6E;AAC7E,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAyB;IAC3D,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAChD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,aAAa,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;QACxD,OAAO,GAAG,CAAC,IAAI,EAAE,KAAK,IAAI,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,4EAA4E;AAC5E,MAAM,CAAC,KAAK,UAAU,WAAW;IAC/B,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAChD,IAAI,CAAC;QACH,wEAAwE;QACxE,wEAAwE;QACxE,MAAM,GAAG,CAAC,WAAW,CAAC,CAAC;QACvB,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What the system says is playing, and whether to believe it is music.
3
+ *
4
+ * Kept pure and separate from the script that fetches it, so every decision
5
+ * here is testable without macOS — and so a captured reading from a real
6
+ * machine can be replayed verbatim in a test.
7
+ */
8
+ export interface NowPlayingReading {
9
+ /** The app that owns Now Playing: `com.google.Chrome`, `com.spotify.client`. */
10
+ bundleId: string;
11
+ /** Its human name, as Control Center shows it. */
12
+ label: string;
13
+ playing: boolean;
14
+ title: string;
15
+ artist: string;
16
+ album?: string;
17
+ elapsedMs: number;
18
+ durationMs: number;
19
+ }
20
+ /**
21
+ * Parse a reading, or answer null when there is nothing worth showing.
22
+ *
23
+ * The filter is the load-bearing part. macOS's Now Playing client is *whatever
24
+ * last made a sound*, which includes things that are not media at all — a
25
+ * received voice message leaves Messages sitting there as the now-playing app
26
+ * with no title and a seven-second "track". Treating that as a queue entry
27
+ * would put a transport bar on a notification chime.
28
+ *
29
+ * Requiring a title and a real duration is what separates "something is
30
+ * playing" from "something made a noise once".
31
+ */
32
+ export declare function parseReading(raw: string): NowPlayingReading | null;
33
+ /**
34
+ * A locator for something we do not own.
35
+ *
36
+ * Every other adapter has a real identifier to point at — a file path, a
37
+ * Spotify URI. Here there is only "whatever is playing", so identity has to be
38
+ * synthesised from what the system reports. It matters that this *changes when
39
+ * the track does*: it is the only way the runtime can tell that the person
40
+ * skipped to something else in their browser, which is the entire reason this
41
+ * adapter declares `externalControl`.
42
+ */
43
+ export declare function readingUri(reading: NowPlayingReading): string;
44
+ /**
45
+ * Has this finished?
46
+ *
47
+ * Players park at exactly their duration and go on reporting the finished track
48
+ * indefinitely, so "at the end and not playing" has to count as over — waiting
49
+ * for the position to exceed the duration waits forever.
50
+ */
51
+ export declare function hasEnded(reading: NowPlayingReading): boolean;
52
+ //# sourceMappingURL=reading.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reading.d.ts","sourceRoot":"","sources":["../../src/reading.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,WAAW,iBAAiB;IAChC,gFAAgF;IAChF,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAyBlE;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,MAAM,CAI7D;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAE5D"}
Binary file
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reading.js","sourceRoot":"","sources":["../../src/reading.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "upnext-adapter-nowplaying",
3
+ "version": "0.1.0",
4
+ "description": "Read and control whatever macOS is playing \u2014 a browser tab, VLC, Doppler, anything that publishes to Control Center.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/src/index.js",
8
+ "types": "./dist/src/index.d.ts",
9
+ "exports": {
10
+ ".": "./dist/src/index.js"
11
+ },
12
+ "files": [
13
+ "dist/src",
14
+ "src"
15
+ ],
16
+ "dependencies": {
17
+ "upnext-core": "0.1.0"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/tothienbao6a0/upnext.git",
25
+ "directory": "packages/adapter-nowplaying"
26
+ },
27
+ "homepage": "https://github.com/tothienbao6a0/upnext#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/tothienbao6a0/upnext/issues"
30
+ },
31
+ "keywords": [
32
+ "audio",
33
+ "macos",
34
+ "now-playing",
35
+ "mediaremote",
36
+ "control-center",
37
+ "upnext-adapter"
38
+ ]
39
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,162 @@
1
+ import { defaultCapabilities } from 'upnext-core';
2
+ import type {
3
+ Adapter,
4
+ AdapterReading,
5
+ Binding,
6
+ Capabilities,
7
+ MediaRef,
8
+ } from 'upnext-core';
9
+ import { isAvailable, readNowPlaying, sendTransport } from './mediaremote.js';
10
+ import { hasEnded, readingUri, type NowPlayingReading } from './reading.js';
11
+
12
+ /** The one thing this adapter can be asked for. */
13
+ export const NOW_PLAYING_URI = 'nowplaying:current';
14
+
15
+ export interface NowPlayingAdapterOptions {
16
+ id?: string;
17
+ /** Injected for tests, and for a host that has its own source of truth. */
18
+ read?: () => Promise<NowPlayingReading | null>;
19
+ send?: (command: 'play' | 'pause') => Promise<boolean>;
20
+ }
21
+
22
+ /**
23
+ * Whatever the machine is already playing, as a queue entry.
24
+ *
25
+ * Every other adapter is handed a locator and told to play it. This one cannot
26
+ * be: there is no way to ask macOS's Now Playing register to start a particular
27
+ * track. What it *can* do is see what some other app is playing — a browser tab,
28
+ * VLC, anything in Control Center — and work the transport.
29
+ *
30
+ * So it answers to exactly one entry, `nowplaying:current`, meaning *the thing
31
+ * that is on*. That turns out to be worth having:
32
+ *
33
+ * runtime.enqueue('nowplaying:current'); // let their podcast finish
34
+ * runtime.enqueue({ title: 'Bad Habit' }); // then take over
35
+ *
36
+ * which is an agent adding to someone's listening instead of interrupting it.
37
+ *
38
+ * It also sits at the far end of the capability spectrum — further than the
39
+ * Spotify adapter, which at least starts the tracks it plays. Here the runtime
40
+ * is a guest: the item was chosen by somebody else, in an app it does not
41
+ * control, and it can be changed underneath at any moment. `externalControl`
42
+ * has never meant more than it does here.
43
+ */
44
+ export class NowPlayingAdapter implements Adapter {
45
+ readonly id: string;
46
+
47
+ readonly capabilities: Capabilities = {
48
+ ...defaultCapabilities,
49
+ // No notifications from the register; we sample it.
50
+ endOfTrack: 'poll',
51
+ // Elapsed time comes from the player itself, not from a clock of ours.
52
+ position: 'authoritative',
53
+ pause: true,
54
+ // The register exposes no volume, and its seek command takes options the
55
+ // JXA bridge cannot pass cleanly. Declaring either would be a lie.
56
+ seek: false,
57
+ volume: false,
58
+ search: false,
59
+ // The strongest case in the library: we did not even start this.
60
+ externalControl: true,
61
+ };
62
+
63
+ #options: NowPlayingAdapterOptions;
64
+ #binding: Binding | null = null;
65
+
66
+ constructor(options: NowPlayingAdapterOptions = {}) {
67
+ this.id = options.id ?? 'nowplaying';
68
+ this.#options = options;
69
+ }
70
+
71
+ async init(): Promise<void> {
72
+ if (this.#options.read) return; // Injected: nothing to probe.
73
+ if (!(await isAvailable())) {
74
+ throw new Error(
75
+ 'macOS Now Playing is unavailable here — this adapter needs macOS and the MediaRemote framework',
76
+ );
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Only ever `nowplaying:current`. This adapter cannot be handed a track.
82
+ *
83
+ * Scoring anything else above zero would be claiming it could start a file or
84
+ * a Spotify URI, and it cannot start anything at all.
85
+ */
86
+ match(ref: MediaRef): number {
87
+ return ref.uri === NOW_PLAYING_URI ? 1 : 0;
88
+ }
89
+
90
+ async resolve(ref: MediaRef): Promise<Binding | null> {
91
+ if (this.match(ref) === 0) return null;
92
+
93
+ const reading = await this.#read();
94
+ // Nothing is playing, so there is nothing to adopt. Failing here is right:
95
+ // the entry meant "the thing that is on" and there is no such thing.
96
+ if (!reading) return null;
97
+
98
+ return {
99
+ adapterId: this.id,
100
+ // The synthesised locator changes with the track, which is what lets the
101
+ // runtime notice the person skipping in their browser.
102
+ nativeUri: readingUri(reading),
103
+ ref: {
104
+ ...ref,
105
+ title: reading.title,
106
+ artist: reading.artist,
107
+ durationMs: reading.durationMs,
108
+ meta: { app: reading.label, bundleId: reading.bundleId },
109
+ },
110
+ };
111
+ }
112
+
113
+ /** Nothing to load. Something else already did. */
114
+ async load(binding: Binding): Promise<void> {
115
+ this.#binding = binding;
116
+ }
117
+
118
+ async play(): Promise<void> {
119
+ await this.#send('play');
120
+ }
121
+
122
+ async pause(): Promise<void> {
123
+ await this.#send('pause');
124
+ }
125
+
126
+ /**
127
+ * Pause, never kill.
128
+ *
129
+ * The runtime moving on to its own next entry is not a reason to take
130
+ * somebody's podcast away from them — and there is no "stop" in this register
131
+ * anyway, only a transport.
132
+ */
133
+ async stop(): Promise<void> {
134
+ await this.#send('pause');
135
+ this.#binding = null;
136
+ }
137
+
138
+ async poll(): Promise<AdapterReading> {
139
+ const reading = await this.#read();
140
+
141
+ if (!reading) {
142
+ // The app went away, or stopped publishing. Whatever we were following is
143
+ // over as far as anyone can tell from here.
144
+ return { status: 'ended', nativeUri: null };
145
+ }
146
+
147
+ return {
148
+ status: hasEnded(reading) ? 'ended' : reading.playing ? 'playing' : 'paused',
149
+ positionMs: reading.elapsedMs,
150
+ durationMs: reading.durationMs,
151
+ nativeUri: readingUri(reading),
152
+ };
153
+ }
154
+
155
+ #read(): Promise<NowPlayingReading | null> {
156
+ return (this.#options.read ?? readNowPlaying)();
157
+ }
158
+
159
+ #send(command: 'play' | 'pause'): Promise<boolean> {
160
+ return (this.#options.send ?? sendTransport)(command);
161
+ }
162
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { NowPlayingAdapter, NOW_PLAYING_URI } from './adapter.js';
2
+ export type { NowPlayingAdapterOptions } from './adapter.js';
3
+
4
+ export { readNowPlaying, sendTransport, isAvailable, COMMANDS } from './mediaremote.js';
5
+ export type { TransportCommand } from './mediaremote.js';
6
+
7
+ export { parseReading, readingUri, hasEnded } from './reading.js';
8
+ export type { NowPlayingReading } from './reading.js';
@@ -0,0 +1,153 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { parseReading, type NowPlayingReading } from './reading.js';
3
+
4
+ /**
5
+ * macOS's own Now Playing register — how this reaches everything that isn't a
6
+ * dedicated integration: a browser tab, VLC, Doppler, anything that shows up in
7
+ * Control Center.
8
+ *
9
+ * This talks to `MediaRemote`, a private framework, through JXA's Objective-C
10
+ * bridge. Private API is normally a bad trade, so the justification:
11
+ *
12
+ * - There is no public equivalent. `MPNowPlayingInfoCenter` publishes *your
13
+ * own* app's state; nothing public reads another app's.
14
+ * - Apple began gating MediaRemote behind an entitlement in macOS 15.4, which
15
+ * broke the usual command-line tools. The gate applies to processes asking
16
+ * directly; script execution through `osascript` still resolves it, and
17
+ * that was verified on macOS 26.5.
18
+ * - The alternative is shipping a helper binary that borrows a system
19
+ * binary's entitlements — a far larger and more fragile dependency than a
20
+ * script you can read in full, here, above.
21
+ *
22
+ * Every failure answers null rather than throwing, so the day Apple closes this
23
+ * the adapter reports itself unavailable and the rest of the queue carries on.
24
+ */
25
+
26
+ const FRAMEWORK = '/System/Library/PrivateFrameworks/MediaRemote.framework/';
27
+ const SCRIPT_TIMEOUT_MS = 4000;
28
+
29
+ const READ_SCRIPT = `
30
+ ObjC.import('Foundation')
31
+ function run() {
32
+ var bundle = $.NSBundle.bundleWithPath('${FRAMEWORK}')
33
+ if (!bundle || !bundle.load) return ''
34
+ var R = $.NSClassFromString('MRNowPlayingRequest')
35
+ if (!R) return ''
36
+ var path = R.localNowPlayingPlayerPath
37
+ if (!path || path.isNil()) return ''
38
+ var client = path.client
39
+ if (!client || client.isNil()) return ''
40
+ var item = R.localNowPlayingItem
41
+ if (!item || item.isNil()) return ''
42
+ var info = item.nowPlayingInfo
43
+ if (!info || info.isNil()) return ''
44
+
45
+ var str = function (v) { try { return v && !v.isNil() ? String(v.js) : '' } catch (e) { return '' } }
46
+ var key = function (k) { try { var v = info.valueForKey(k); return v && !v.isNil() ? v.js : null } catch (e) { return null } }
47
+ var num = function (k) { var v = key(k); var n = typeof v === 'number' ? v : parseFloat(v); return isFinite(n) ? n : 0 }
48
+
49
+ return JSON.stringify({
50
+ bundleId: str(client.bundleIdentifier),
51
+ label: str(client.displayName),
52
+ playing: R.localIsPlaying === true,
53
+ title: String(key('kMRMediaRemoteNowPlayingInfoTitle') || ''),
54
+ artist: String(key('kMRMediaRemoteNowPlayingInfoArtist') || ''),
55
+ album: String(key('kMRMediaRemoteNowPlayingInfoAlbum') || ''),
56
+ elapsed: num('kMRMediaRemoteNowPlayingInfoElapsedTime'),
57
+ duration: num('kMRMediaRemoteNowPlayingInfoDuration')
58
+ })
59
+ }
60
+ `;
61
+
62
+ /**
63
+ * MediaRemote's command numbers.
64
+ *
65
+ * Only the transport is here. The register exposes no volume, and its seek
66
+ * command takes options this bridge cannot pass cleanly — which is exactly why
67
+ * the adapter declares `seek: false` and `volume: false` rather than pretending.
68
+ */
69
+ export const COMMANDS = {
70
+ play: 0,
71
+ pause: 1,
72
+ togglePlayPause: 2,
73
+ next: 4,
74
+ previous: 5,
75
+ } as const;
76
+
77
+ export type TransportCommand = keyof typeof COMMANDS;
78
+
79
+ /**
80
+ * The queue argument is load-bearing: passing nil there hangs the script
81
+ * indefinitely, and a hung `osascript` would stall every poll behind it.
82
+ */
83
+ const commandScript = (code: number) => `
84
+ ObjC.import('Foundation')
85
+ function run() {
86
+ var bundle = $.NSBundle.bundleWithPath('${FRAMEWORK}')
87
+ if (!bundle || !bundle.load) return ''
88
+ var R = $.NSClassFromString('MRNowPlayingRequest')
89
+ if (!R) return ''
90
+ var path = R.localNowPlayingPlayerPath
91
+ if (!path || path.isNil()) return ''
92
+ var req = R.alloc.initWithPlayerPath(path)
93
+ req.sendCommandOptionsQueueCompletion(
94
+ ${code},
95
+ $.NSDictionary.dictionary,
96
+ $.NSOperationQueue.mainQueue.underlyingQueue,
97
+ $()
98
+ )
99
+ return 'ok'
100
+ }
101
+ `;
102
+
103
+ function jxa(script: string): Promise<string> {
104
+ return new Promise((resolve, reject) => {
105
+ execFile(
106
+ 'osascript',
107
+ ['-l', 'JavaScript', '-e', script],
108
+ { timeout: SCRIPT_TIMEOUT_MS },
109
+ (error, stdout) => (error ? reject(error) : resolve(stdout)),
110
+ );
111
+ });
112
+ }
113
+
114
+ /**
115
+ * What macOS is playing right now, whichever app is playing it.
116
+ *
117
+ * Exported on its own because it is useful without a queue: a host that only
118
+ * wants to show what is on, or to know whether to duck, needs this and nothing
119
+ * else. Answers null when nothing is playing, when the reading is not media, or
120
+ * when the framework is unavailable.
121
+ */
122
+ export async function readNowPlaying(): Promise<NowPlayingReading | null> {
123
+ if (process.platform !== 'darwin') return null;
124
+ try {
125
+ return parseReading(await jxa(READ_SCRIPT));
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** Send a transport command to whatever the system considers now-playing. */
132
+ export async function sendTransport(command: TransportCommand): Promise<boolean> {
133
+ if (process.platform !== 'darwin') return false;
134
+ try {
135
+ const out = await jxa(commandScript(COMMANDS[command]));
136
+ return out.trim() === 'ok';
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /** Whether this machine can answer at all. Used by the adapter's `init`. */
143
+ export async function isAvailable(): Promise<boolean> {
144
+ if (process.platform !== 'darwin') return false;
145
+ try {
146
+ // An empty answer still means the bridge resolved — nothing is playing,
147
+ // which is a fine state. Only a thrown error means it cannot work here.
148
+ await jxa(READ_SCRIPT);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
package/src/reading.ts ADDED
Binary file