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 +201 -0
- package/README.md +120 -0
- package/dist/src/adapter.d.ts +61 -0
- package/dist/src/adapter.d.ts.map +1 -0
- package/dist/src/adapter.js +131 -0
- package/dist/src/adapter.js.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mediaremote.d.ts +30 -0
- package/dist/src/mediaremote.d.ts.map +1 -0
- package/dist/src/mediaremote.js +144 -0
- package/dist/src/mediaremote.js.map +1 -0
- package/dist/src/reading.d.ts +52 -0
- package/dist/src/reading.d.ts.map +1 -0
- package/dist/src/reading.js +0 -0
- package/dist/src/reading.js.map +1 -0
- package/package.json +39 -0
- package/src/adapter.ts +162 -0
- package/src/index.ts +8 -0
- package/src/mediaremote.ts +153 -0
- package/src/reading.ts +0 -0
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
|
+
[](https://www.npmjs.com/package/upnext-adapter-nowplaying)
|
|
4
|
+
[](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 @@
|
|
|
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
|