upnext-adapter-browser 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 +152 -0
- package/dist/src/element.d.ts +64 -0
- package/dist/src/element.d.ts.map +1 -0
- package/dist/src/element.js +176 -0
- package/dist/src/element.js.map +1 -0
- package/dist/src/index.d.ts +6 -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/remote.d.ts +65 -0
- package/dist/src/remote.d.ts.map +1 -0
- package/dist/src/remote.js +183 -0
- package/dist/src/remote.js.map +1 -0
- package/dist/src/sources.d.ts +14 -0
- package/dist/src/sources.d.ts.map +1 -0
- package/dist/src/sources.js +118 -0
- package/dist/src/sources.js.map +1 -0
- package/package.json +21 -0
- package/src/element.ts +232 -0
- package/src/index.ts +7 -0
- package/src/remote.ts +250 -0
- package/src/sources.ts +123 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Adapter, AdapterEvent, Binding, Capabilities, MediaRef } from 'upnext-core';
|
|
2
|
+
import { type MediaElementLike } from './element.js';
|
|
3
|
+
/**
|
|
4
|
+
* Driving a media element that lives somewhere else.
|
|
5
|
+
*
|
|
6
|
+
* In an Electron app the queue runs in the main process and the only thing that
|
|
7
|
+
* can hold an `<audio>` is a renderer. In a web app the element may sit inside
|
|
8
|
+
* an iframe or a worker-owned document. Either way the adapter and the element
|
|
9
|
+
* are separated by a boundary that only passes messages.
|
|
10
|
+
*
|
|
11
|
+
* The transport is deliberately not named. A host that has `ipcMain`/`ipcRenderer`
|
|
12
|
+
* uses that; one with `postMessage` uses that; a test uses two functions and no
|
|
13
|
+
* boundary at all. This library takes a `Channel` and asks no further questions,
|
|
14
|
+
* the same way `upnext-adapter-process` takes a command and does not care what
|
|
15
|
+
* language is on the other end.
|
|
16
|
+
*/
|
|
17
|
+
export interface Channel {
|
|
18
|
+
send(message: unknown): void;
|
|
19
|
+
/** Returns an unsubscribe function. */
|
|
20
|
+
subscribe(listener: (message: unknown) => void): () => void;
|
|
21
|
+
}
|
|
22
|
+
export interface RemoteMediaAdapterOptions {
|
|
23
|
+
id?: string;
|
|
24
|
+
channel: Channel;
|
|
25
|
+
/** How long to wait for the far side before giving up. */
|
|
26
|
+
requestTimeoutMs?: number;
|
|
27
|
+
extraExtensions?: string[];
|
|
28
|
+
}
|
|
29
|
+
/** The side that lives with the `Runtime`. */
|
|
30
|
+
export declare class RemoteMediaAdapter implements Adapter {
|
|
31
|
+
#private;
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly capabilities: Capabilities;
|
|
34
|
+
constructor(options: RemoteMediaAdapterOptions);
|
|
35
|
+
init(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Evaluated locally, never over the wire. `match` is synchronous by contract
|
|
38
|
+
* and asking across a boundary on every resolution would be absurd — and
|
|
39
|
+
* unnecessary, since what a media element accepts is knowable from the URL.
|
|
40
|
+
*/
|
|
41
|
+
match(ref: MediaRef): number;
|
|
42
|
+
resolve(ref: MediaRef): Promise<Binding | null>;
|
|
43
|
+
load(binding: Binding, opts?: {
|
|
44
|
+
startAtMs?: number;
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
play(): Promise<void>;
|
|
47
|
+
pause(): Promise<void>;
|
|
48
|
+
stop(): Promise<void>;
|
|
49
|
+
seek(positionMs: number): Promise<void>;
|
|
50
|
+
setVolume(volume: number): Promise<void>;
|
|
51
|
+
subscribe(listener: (event: AdapterEvent) => void): () => void;
|
|
52
|
+
dispose(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The side that lives with the element. Call it once, wherever the DOM is.
|
|
56
|
+
*
|
|
57
|
+
* It drives a real `MediaElementAdapter`, so the behaviour on the far side of
|
|
58
|
+
* the boundary is not a reimplementation that can drift — it is the same class
|
|
59
|
+
* the in-process case uses.
|
|
60
|
+
*/
|
|
61
|
+
export declare function serveMediaElement(element: MediaElementLike | (() => MediaElementLike), channel: Channel, options?: {
|
|
62
|
+
id?: string;
|
|
63
|
+
extraExtensions?: string[];
|
|
64
|
+
}): () => void;
|
|
65
|
+
//# sourceMappingURL=remote.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote.d.ts","sourceRoot":"","sources":["../../src/remote.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,OAAO,EACP,YAAY,EACZ,OAAO,EACP,YAAY,EACZ,QAAQ,EACT,MAAM,aAAa,CAAC;AACrB,OAAO,EAAuB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG1E;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAC7B,uCAAuC;IACvC,SAAS,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CAC7D;AAiCD,MAAM,WAAW,yBAAyB;IACxC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;IACjB,0DAA0D;IAC1D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;CAC5B;AAED,8CAA8C;AAC9C,qBAAa,kBAAmB,YAAW,OAAO;;IAChD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,YAAY,eAAuB;gBAQhC,OAAO,EAAE,yBAAyB;IAKxC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B;;;;OAIG;IACH,KAAK,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM;IAItB,OAAO,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAK/C,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAGpE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAGrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAGtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAGrB,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAGvC,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAwC/B;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,gBAAgB,GAAG,CAAC,MAAM,gBAAgB,CAAC,EACpD,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;CAAO,GACxD,MAAM,IAAI,CAsBZ"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { defaultCapabilities } from 'upnext-core';
|
|
2
|
+
import { MediaElementAdapter } from './element.js';
|
|
3
|
+
import { score } from './sources.js';
|
|
4
|
+
/**
|
|
5
|
+
* Both ends run the same capabilities, because both ends are the same adapter —
|
|
6
|
+
* `serveMediaElement` drives a real `MediaElementAdapter`. There is no handshake
|
|
7
|
+
* to negotiate: a media element is a media element wherever it is sitting.
|
|
8
|
+
*/
|
|
9
|
+
const REMOTE_CAPABILITIES = {
|
|
10
|
+
...defaultCapabilities,
|
|
11
|
+
endOfTrack: 'event',
|
|
12
|
+
position: 'authoritative',
|
|
13
|
+
seek: true,
|
|
14
|
+
pause: true,
|
|
15
|
+
volume: true,
|
|
16
|
+
search: false,
|
|
17
|
+
externalControl: false,
|
|
18
|
+
};
|
|
19
|
+
/** The side that lives with the `Runtime`. */
|
|
20
|
+
export class RemoteMediaAdapter {
|
|
21
|
+
id;
|
|
22
|
+
capabilities = REMOTE_CAPABILITIES;
|
|
23
|
+
#options;
|
|
24
|
+
#listeners = new Set();
|
|
25
|
+
#pending = new Map();
|
|
26
|
+
#unsubscribe = null;
|
|
27
|
+
#nextId = 1;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.id = options.id ?? 'browser';
|
|
30
|
+
this.#options = options;
|
|
31
|
+
}
|
|
32
|
+
async init() {
|
|
33
|
+
this.#unsubscribe ??= this.#options.channel.subscribe((message) => this.#receive(message));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Evaluated locally, never over the wire. `match` is synchronous by contract
|
|
37
|
+
* and asking across a boundary on every resolution would be absurd — and
|
|
38
|
+
* unnecessary, since what a media element accepts is knowable from the URL.
|
|
39
|
+
*/
|
|
40
|
+
match(ref) {
|
|
41
|
+
return score(ref, this.#options.extraExtensions);
|
|
42
|
+
}
|
|
43
|
+
async resolve(ref) {
|
|
44
|
+
if (!ref.uri || this.match(ref) === 0)
|
|
45
|
+
return null;
|
|
46
|
+
return { adapterId: this.id, nativeUri: ref.uri, ref: { ...ref } };
|
|
47
|
+
}
|
|
48
|
+
async load(binding, opts) {
|
|
49
|
+
await this.#request('load', { binding, ...opts });
|
|
50
|
+
}
|
|
51
|
+
async play() {
|
|
52
|
+
await this.#request('play');
|
|
53
|
+
}
|
|
54
|
+
async pause() {
|
|
55
|
+
await this.#request('pause');
|
|
56
|
+
}
|
|
57
|
+
async stop() {
|
|
58
|
+
await this.#request('stop');
|
|
59
|
+
}
|
|
60
|
+
async seek(positionMs) {
|
|
61
|
+
await this.#request('seek', { positionMs });
|
|
62
|
+
}
|
|
63
|
+
async setVolume(volume) {
|
|
64
|
+
await this.#request('setVolume', { volume });
|
|
65
|
+
}
|
|
66
|
+
subscribe(listener) {
|
|
67
|
+
this.#listeners.add(listener);
|
|
68
|
+
return () => this.#listeners.delete(listener);
|
|
69
|
+
}
|
|
70
|
+
async dispose() {
|
|
71
|
+
for (const [, pending] of this.#pending)
|
|
72
|
+
clearTimeout(pending.timer);
|
|
73
|
+
this.#pending.clear();
|
|
74
|
+
this.#unsubscribe?.();
|
|
75
|
+
this.#unsubscribe = null;
|
|
76
|
+
this.#listeners.clear();
|
|
77
|
+
}
|
|
78
|
+
#request(method, params) {
|
|
79
|
+
void this.init();
|
|
80
|
+
const id = this.#nextId++;
|
|
81
|
+
const timeoutMs = this.#options.requestTimeoutMs ?? 5000;
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
this.#pending.delete(id);
|
|
85
|
+
reject(new Error(`${this.id}: ${method} timed out after ${timeoutMs}ms`));
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
this.#pending.set(id, { resolve, reject, timer });
|
|
88
|
+
this.#options.channel.send({ id, method, params });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
#receive(message) {
|
|
92
|
+
if (!message || typeof message !== 'object')
|
|
93
|
+
return;
|
|
94
|
+
if ('event' in message) {
|
|
95
|
+
const { event } = message;
|
|
96
|
+
for (const listener of [...this.#listeners])
|
|
97
|
+
listener(event);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const reply = message;
|
|
101
|
+
const pending = this.#pending.get(reply.id);
|
|
102
|
+
if (!pending)
|
|
103
|
+
return;
|
|
104
|
+
this.#pending.delete(reply.id);
|
|
105
|
+
clearTimeout(pending.timer);
|
|
106
|
+
if (reply.error)
|
|
107
|
+
pending.reject(new Error(`${this.id}: ${reply.error}`));
|
|
108
|
+
else
|
|
109
|
+
pending.resolve();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The side that lives with the element. Call it once, wherever the DOM is.
|
|
114
|
+
*
|
|
115
|
+
* It drives a real `MediaElementAdapter`, so the behaviour on the far side of
|
|
116
|
+
* the boundary is not a reimplementation that can drift — it is the same class
|
|
117
|
+
* the in-process case uses.
|
|
118
|
+
*/
|
|
119
|
+
export function serveMediaElement(element, channel, options = {}) {
|
|
120
|
+
const adapter = new MediaElementAdapter({ element, ...options });
|
|
121
|
+
const offEvents = adapter.subscribe((event) => channel.send({ event }));
|
|
122
|
+
const offCommands = channel.subscribe((message) => {
|
|
123
|
+
if (!message || typeof message !== 'object' || !('method' in message))
|
|
124
|
+
return;
|
|
125
|
+
const command = message;
|
|
126
|
+
void run(adapter, command)
|
|
127
|
+
.then(() => channel.send({ id: command.id }))
|
|
128
|
+
.catch((err) => channel.send({
|
|
129
|
+
id: command.id,
|
|
130
|
+
error: err instanceof Error ? err.message : String(err),
|
|
131
|
+
}));
|
|
132
|
+
});
|
|
133
|
+
return () => {
|
|
134
|
+
offCommands();
|
|
135
|
+
offEvents();
|
|
136
|
+
void adapter.dispose();
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Apply one command, checking what came across the boundary.
|
|
141
|
+
*
|
|
142
|
+
* These arrive as `unknown` from another process — a renderer that reloaded
|
|
143
|
+
* mid-flight, a host wiring the channel to the wrong window, a future version
|
|
144
|
+
* sending a field this one has never heard of. Validating here means a bad
|
|
145
|
+
* message becomes an error reply the runtime can fail over from, rather than a
|
|
146
|
+
* `TypeError` thrown inside somebody's renderer where nothing is listening.
|
|
147
|
+
*/
|
|
148
|
+
async function run(adapter, command) {
|
|
149
|
+
const params = (command.params ?? {});
|
|
150
|
+
switch (command.method) {
|
|
151
|
+
case 'load': {
|
|
152
|
+
const binding = params.binding;
|
|
153
|
+
if (!binding || typeof binding.nativeUri !== 'string') {
|
|
154
|
+
throw new Error('load requires a binding with a nativeUri');
|
|
155
|
+
}
|
|
156
|
+
const startAtMs = typeof params.startAtMs === 'number' ? params.startAtMs : undefined;
|
|
157
|
+
return adapter.load(binding, startAtMs === undefined ? undefined : { startAtMs });
|
|
158
|
+
}
|
|
159
|
+
case 'play':
|
|
160
|
+
return adapter.play();
|
|
161
|
+
case 'pause':
|
|
162
|
+
return adapter.pause();
|
|
163
|
+
case 'stop':
|
|
164
|
+
return adapter.stop();
|
|
165
|
+
case 'seek': {
|
|
166
|
+
const positionMs = params.positionMs;
|
|
167
|
+
if (typeof positionMs !== 'number' || !Number.isFinite(positionMs)) {
|
|
168
|
+
throw new Error('seek requires a finite positionMs');
|
|
169
|
+
}
|
|
170
|
+
return adapter.seek(positionMs);
|
|
171
|
+
}
|
|
172
|
+
case 'setVolume': {
|
|
173
|
+
const volume = params.volume;
|
|
174
|
+
if (typeof volume !== 'number' || !Number.isFinite(volume)) {
|
|
175
|
+
throw new Error('setVolume requires a finite volume');
|
|
176
|
+
}
|
|
177
|
+
return adapter.setVolume(volume);
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
throw new Error(`unknown method ${String(command.method)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=remote.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote.js","sourceRoot":"","sources":["../../src/remote.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAQlD,OAAO,EAAE,mBAAmB,EAAyB,MAAM,cAAc,CAAC;AAC1E,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAqCrC;;;;GAIG;AACH,MAAM,mBAAmB,GAAiB;IACxC,GAAG,mBAAmB;IACtB,UAAU,EAAE,OAAO;IACnB,QAAQ,EAAE,eAAe;IACzB,IAAI,EAAE,IAAI;IACV,KAAK,EAAE,IAAI;IACX,MAAM,EAAE,IAAI;IACZ,MAAM,EAAE,KAAK;IACb,eAAe,EAAE,KAAK;CACvB,CAAC;AAUF,8CAA8C;AAC9C,MAAM,OAAO,kBAAkB;IACpB,EAAE,CAAS;IACX,YAAY,GAAG,mBAAmB,CAAC;IAE5C,QAAQ,CAA4B;IACpC,UAAU,GAAG,IAAI,GAAG,EAAiC,CAAC;IACtD,QAAQ,GAAG,IAAI,GAAG,EAAqG,CAAC;IACxH,YAAY,GAAwB,IAAI,CAAC;IACzC,OAAO,GAAG,CAAC,CAAC;IAEZ,YAAY,OAAkC;QAC5C,IAAI,CAAC,EAAE,GAAG,OAAO,CAAC,EAAE,IAAI,SAAS,CAAC;QAClC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;IAC1B,CAAC;IAED,KAAK,CAAC,IAAI;QACR,IAAI,CAAC,YAAY,KAAK,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;IAC7F,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,GAAa;QACjB,OAAO,KAAK,CAAC,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACnD,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAa;QACzB,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACnD,OAAO,EAAE,SAAS,EAAE,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,GAAG,GAAG,EAAE,EAAE,CAAC;IACrE,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,OAAgB,EAAE,IAA6B;QACxD,MAAM,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,GAAG,IAAI,EAAE,CAAC,CAAC;IACpD,CAAC;IACD,KAAK,CAAC,IAAI;QACR,MAAM,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC9B,CAAC;IACD,KAAK,CAAC,KAAK;QACT,MAAM,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IACD,KAAK,CAAC,IAAI;QACR,MAAM,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC9B,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,UAAkB;QAC3B,MAAM,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,EAAE,UAAU,EAAE,CAAC,CAAC;IAC9C,CAAC;IACD,KAAK,CAAC,SAAS,CAAC,MAAc;QAC5B,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;IAC/C,CAAC;IAED,SAAS,CAAC,QAAuC;QAC/C,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC9B,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAChD,CAAC;IAED,KAAK,CAAC,OAAO;QACX,KAAK,MAAM,CAAC,EAAE,OAAO,CAAC,IAAI,IAAI,CAAC,QAAQ;YAAE,YAAY,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QACrE,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC;QACtB,IAAI,CAAC,YAAY,EAAE,EAAE,CAAC;QACtB,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;QACzB,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;IAC1B,CAAC;IAED,QAAQ,CAAC,MAAyB,EAAE,MAAgB;QAClD,KAAK,IAAI,CAAC,IAAI,EAAE,CAAC;QACjB,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAC1B,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,gBAAgB,IAAI,IAAI,CAAC;QAEzD,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC3C,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBAC5B,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;gBACzB,MAAM,CAAC,IAAI,KAAK,CAAC,GAAG,IAAI,CAAC,EAAE,KAAK,MAAM,oBAAoB,SAAS,IAAI,CAAC,CAAC,CAAC;YAC5E,CAAC,EAAE,SAAS,CAAC,CAAC;YACd,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;YAClD,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAoB,CAAC,CAAC;QACvE,CAAC,CAAC,CAAC;IACL,CAAC;IAED,QAAQ,CAAC,OAAgB;QACvB,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ;YAAE,OAAO;QAEpD,IAAI,OAAO,IAAI,OAAO,EAAE,CAAC;YACvB,MAAM,EAAE,KAAK,EAAE,GAAG,OAAiB,CAAC;YACpC,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC;gBAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;YAC7D,OAAO;QACT,CAAC;QAED,MAAM,KAAK,GAAG,OAAgB,CAAC;QAC/B,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC5C,IAAI,CAAC,OAAO;YAAE,OAAO;QACrB,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC/B,YAAY,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAC5B,IAAI,KAAK,CAAC,KAAK;YAAE,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,GAAG,IAAI,CAAC,EAAE,KAAK,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;;YACpE,OAAO,CAAC,OAAO,EAAE,CAAC;IACzB,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAC/B,OAAoD,EACpD,OAAgB,EAChB,UAAuD,EAAE;IAEzD,MAAM,OAAO,GAAG,IAAI,mBAAmB,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;IACjE,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,EAAmB,CAAC,CAAC,CAAC;IAEzF,MAAM,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,EAAE;QAChD,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,CAAC,CAAC,QAAQ,IAAI,OAAO,CAAC;YAAE,OAAO;QAC9E,MAAM,OAAO,GAAG,OAAkB,CAAC;QACnC,KAAK,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC;aACvB,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAkB,CAAC,CAAC;aAC5D,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE,CACtB,OAAO,CAAC,IAAI,CAAC;YACX,EAAE,EAAE,OAAO,CAAC,EAAE;YACd,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;SACxC,CAAC,CACnB,CAAC;IACN,CAAC,CAAC,CAAC;IAEH,OAAO,GAAG,EAAE;QACV,WAAW,EAAE,CAAC;QACd,SAAS,EAAE,CAAC;QACZ,KAAK,OAAO,CAAC,OAAO,EAAE,CAAC;IACzB,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,KAAK,UAAU,GAAG,CAAC,OAA4B,EAAE,OAAgB;IAC/D,MAAM,MAAM,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,EAAE,CAA4B,CAAC;IAEjE,QAAQ,OAAO,CAAC,MAAM,EAAE,CAAC;QACvB,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,MAAM,OAAO,GAAG,MAAM,CAAC,OAA8B,CAAC;YACtD,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,CAAC,SAAS,KAAK,QAAQ,EAAE,CAAC;gBACtD,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC;YAC9D,CAAC;YACD,MAAM,SAAS,GAAG,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC;YACtF,OAAO,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC;QACpF,CAAC;QACD,KAAK,MAAM;YACT,OAAO,OAAO,CAAC,IAAI,EAAE,CAAC;QACxB,KAAK,OAAO;YACV,OAAO,OAAO,CAAC,KAAK,EAAE,CAAC;QACzB,KAAK,MAAM;YACT,OAAO,OAAO,CAAC,IAAI,EAAE,CAAC;QACxB,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC;YACrC,IAAI,OAAO,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC;gBACnE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;YACvD,CAAC;YACD,OAAO,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QAClC,CAAC;QACD,KAAK,WAAW,CAAC,CAAC,CAAC;YACjB,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;YAC7B,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC3D,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;YACxD,CAAC;YACD,OAAO,OAAO,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QACnC,CAAC;QACD;YACE,MAAM,IAAI,KAAK,CAAC,kBAAkB,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAChE,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MediaRef } from 'upnext-core';
|
|
2
|
+
export declare function canPlay(uri: string, extraExtensions?: string[]): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* How confident this element is that it can play the ref.
|
|
5
|
+
*
|
|
6
|
+
* A direct media URL scores 1. An `http(s)` URL with no recognisable extension
|
|
7
|
+
* scores low but non-zero — podcast enclosures are routinely extensionless
|
|
8
|
+
* redirects, and "try me last" is the honest answer for those: if it fails,
|
|
9
|
+
* the binder moves to the next source, which is exactly the machinery for it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function score(ref: MediaRef, extraExtensions?: string[]): number;
|
|
12
|
+
/** A short label for a URL, so a queue does not show a column of raw links. */
|
|
13
|
+
export declare function describeSource(uri: string): string;
|
|
14
|
+
//# sourceMappingURL=sources.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["../../src/sources.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AA0C5C,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAExE;AAED;;;;;;;GAOG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,QAAQ,EAAE,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CA8BvE;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CASlD"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a media element can and cannot be handed.
|
|
3
|
+
*
|
|
4
|
+
* The temptation with a browser adapter is to claim the web, because "the
|
|
5
|
+
* browser can play anything" feels true. It is not. An `<audio>` element plays
|
|
6
|
+
* a *media stream*; it does not play a *page*. Handing it a YouTube watch URL
|
|
7
|
+
* gets you a lump of HTML and a decode error, and the difference matters
|
|
8
|
+
* because a queue full of things that fail at the last moment is worse than one
|
|
9
|
+
* that admitted it could not take them.
|
|
10
|
+
*/
|
|
11
|
+
/** Container and codec extensions a media element will normally accept. */
|
|
12
|
+
const PLAYABLE_EXTENSIONS = new Set([
|
|
13
|
+
'.mp3', '.m4a', '.m4b', '.aac', '.wav', '.flac', '.ogg', '.oga', '.opus',
|
|
14
|
+
'.weba', '.webm', '.mp4', '.m4v', '.mov', '.aiff', '.aif', '.caf',
|
|
15
|
+
// Adaptive manifests. Native only in Safari without a library, but a host
|
|
16
|
+
// that has attached hls.js to its element makes these work, and the element
|
|
17
|
+
// is the host's to configure.
|
|
18
|
+
'.m3u8', '.mpd',
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Pages that play media *inside* them, which is not the same as being media.
|
|
22
|
+
*
|
|
23
|
+
* Listed explicitly and scored zero rather than left to fall through the
|
|
24
|
+
* extension check, because these are exactly what somebody will try first. A
|
|
25
|
+
* silent zero sends the entry to another adapter or fails it honestly; a
|
|
26
|
+
* hopeful guess plays nothing and blames the file.
|
|
27
|
+
*
|
|
28
|
+
* Making these work needs a page-level integration — YouTube's IFrame player,
|
|
29
|
+
* a SoundCloud widget, a stream extractor — which is a different adapter with
|
|
30
|
+
* different terms of service, not a smarter regex here.
|
|
31
|
+
*/
|
|
32
|
+
const PAGE_HOSTS = [
|
|
33
|
+
'youtube.com', 'youtu.be', 'music.youtube.com',
|
|
34
|
+
'open.spotify.com', 'music.apple.com',
|
|
35
|
+
'soundcloud.com', 'bandcamp.com', 'mixcloud.com',
|
|
36
|
+
'vimeo.com', 'twitch.tv',
|
|
37
|
+
];
|
|
38
|
+
export function canPlay(uri, extraExtensions) {
|
|
39
|
+
return score({ uri }, extraExtensions) > 0;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* How confident this element is that it can play the ref.
|
|
43
|
+
*
|
|
44
|
+
* A direct media URL scores 1. An `http(s)` URL with no recognisable extension
|
|
45
|
+
* scores low but non-zero — podcast enclosures are routinely extensionless
|
|
46
|
+
* redirects, and "try me last" is the honest answer for those: if it fails,
|
|
47
|
+
* the binder moves to the next source, which is exactly the machinery for it.
|
|
48
|
+
*/
|
|
49
|
+
export function score(ref, extraExtensions) {
|
|
50
|
+
const uri = ref.uri;
|
|
51
|
+
if (!uri)
|
|
52
|
+
return 0;
|
|
53
|
+
// Anything the host has already turned into bytes is certain.
|
|
54
|
+
if (uri.startsWith('blob:'))
|
|
55
|
+
return 1;
|
|
56
|
+
if (uri.startsWith('data:audio/') || uri.startsWith('data:video/'))
|
|
57
|
+
return 1;
|
|
58
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(uri)?.[1]?.toLowerCase();
|
|
59
|
+
const extension = extensionOf(uri);
|
|
60
|
+
const known = extension !== null && isKnown(extension, extraExtensions);
|
|
61
|
+
if (scheme === 'file') {
|
|
62
|
+
// Works in an Electron renderer and in a webview; a plain web page will be
|
|
63
|
+
// refused by the browser itself. Below a direct http URL, above a guess.
|
|
64
|
+
return known ? 0.8 : 0;
|
|
65
|
+
}
|
|
66
|
+
if (scheme !== 'http' && scheme !== 'https')
|
|
67
|
+
return 0;
|
|
68
|
+
if (isPage(uri))
|
|
69
|
+
return 0;
|
|
70
|
+
if (known)
|
|
71
|
+
return 1;
|
|
72
|
+
// A URL that *names* a format we do not handle is a no, not a maybe —
|
|
73
|
+
// `.html` and `.json` are not things to try hopefully and fail on.
|
|
74
|
+
if (extension !== null)
|
|
75
|
+
return 0;
|
|
76
|
+
// No extension at all. Podcast enclosures are routinely extensionless
|
|
77
|
+
// redirects, so this is worth trying once nothing better has offered.
|
|
78
|
+
return 0.2;
|
|
79
|
+
}
|
|
80
|
+
/** A short label for a URL, so a queue does not show a column of raw links. */
|
|
81
|
+
export function describeSource(uri) {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(uri);
|
|
84
|
+
const last = url.pathname.split('/').filter(Boolean).pop();
|
|
85
|
+
if (last)
|
|
86
|
+
return decodeURIComponent(last.replace(/\.[^.]+$/, ''));
|
|
87
|
+
return url.hostname;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return uri;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function isPage(uri) {
|
|
94
|
+
let host;
|
|
95
|
+
try {
|
|
96
|
+
host = new URL(uri).hostname.toLowerCase().replace(/^www\./, '');
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return PAGE_HOSTS.some((page) => host === page || host.endsWith(`.${page}`));
|
|
102
|
+
}
|
|
103
|
+
/** The extension of the last path segment, or null when there isn't one. */
|
|
104
|
+
function extensionOf(uri) {
|
|
105
|
+
// Query strings and fragments are stripped first: signed podcast URLs carry
|
|
106
|
+
// `?updated=...` after the extension and would otherwise never match.
|
|
107
|
+
const path = uri.split(/[?#]/)[0] ?? '';
|
|
108
|
+
const segment = path.slice(path.lastIndexOf('/') + 1);
|
|
109
|
+
const dot = segment.lastIndexOf('.');
|
|
110
|
+
// A leading dot is a hidden file, not an extension.
|
|
111
|
+
if (dot <= 0)
|
|
112
|
+
return null;
|
|
113
|
+
return segment.slice(dot).toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
function isKnown(extension, extra) {
|
|
116
|
+
return PLAYABLE_EXTENSIONS.has(extension) || (extra?.includes(extension) ?? false);
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=sources.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sources.js","sourceRoot":"","sources":["../../src/sources.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,2EAA2E;AAC3E,MAAM,mBAAmB,GAAG,IAAI,GAAG,CAAC;IAClC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO;IACxE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM;IACjE,0EAA0E;IAC1E,4EAA4E;IAC5E,8BAA8B;IAC9B,OAAO,EAAE,MAAM;CAChB,CAAC,CAAC;AAEH;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,GAAG;IACjB,aAAa,EAAE,UAAU,EAAE,mBAAmB;IAC9C,kBAAkB,EAAE,iBAAiB;IACrC,gBAAgB,EAAE,cAAc,EAAE,cAAc;IAChD,WAAW,EAAE,WAAW;CACzB,CAAC;AAEF,MAAM,UAAU,OAAO,CAAC,GAAW,EAAE,eAA0B;IAC7D,OAAO,KAAK,CAAC,EAAE,GAAG,EAAE,EAAE,eAAe,CAAC,GAAG,CAAC,CAAC;AAC7C,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,KAAK,CAAC,GAAa,EAAE,eAA0B;IAC7D,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC;IACpB,IAAI,CAAC,GAAG;QAAE,OAAO,CAAC,CAAC;IAEnB,8DAA8D;IAC9D,IAAI,GAAG,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,CAAC,CAAC;IACtC,IAAI,GAAG,CAAC,UAAU,CAAC,aAAa,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,aAAa,CAAC;QAAE,OAAO,CAAC,CAAC;IAE7E,MAAM,MAAM,GAAG,wBAAwB,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC;IAEtE,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;IACnC,MAAM,KAAK,GAAG,SAAS,KAAK,IAAI,IAAI,OAAO,CAAC,SAAS,EAAE,eAAe,CAAC,CAAC;IAExE,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACtB,2EAA2E;QAC3E,yEAAyE;QACzE,OAAO,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACzB,CAAC;IAED,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,KAAK,OAAO;QAAE,OAAO,CAAC,CAAC;IACtD,IAAI,MAAM,CAAC,GAAG,CAAC;QAAE,OAAO,CAAC,CAAC;IAC1B,IAAI,KAAK;QAAE,OAAO,CAAC,CAAC;IAEpB,sEAAsE;IACtE,mEAAmE;IACnE,IAAI,SAAS,KAAK,IAAI;QAAE,OAAO,CAAC,CAAC;IAEjC,sEAAsE;IACtE,sEAAsE;IACtE,OAAO,GAAG,CAAC;AACb,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QACzB,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,CAAC;QAC3D,IAAI,IAAI;YAAE,OAAO,kBAAkB,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC,CAAC;QAClE,OAAO,GAAG,CAAC,QAAQ,CAAC;IACtB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,CAAC;IACb,CAAC;AACH,CAAC;AAED,SAAS,MAAM,CAAC,GAAW;IACzB,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IACnE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC;AAC/E,CAAC;AAED,4EAA4E;AAC5E,SAAS,WAAW,CAAC,GAAW;IAC9B,4EAA4E;IAC5E,sEAAsE;IACtE,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACxC,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IACtD,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IACrC,oDAAoD;IACpD,IAAI,GAAG,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1B,OAAO,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC;AAC1C,CAAC;AAED,SAAS,OAAO,CAAC,SAAiB,EAAE,KAAgB;IAClD,OAAO,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,SAAS,CAAC,IAAI,KAAK,CAAC,CAAC;AACrF,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "upnext-adapter-browser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Play audio through a media element you control — in a browser, an Electron renderer, or a webview. Works across a process boundary too.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/src/index.js",
|
|
8
|
+
"types": "./dist/src/index.d.ts",
|
|
9
|
+
"exports": { ".": "./dist/src/index.js" },
|
|
10
|
+
"files": ["dist/src", "src"],
|
|
11
|
+
"dependencies": { "upnext-core": "0.1.0" },
|
|
12
|
+
"engines": { "node": ">=20" },
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/tothienbao6a0/upnext.git",
|
|
16
|
+
"directory": "packages/adapter-browser"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/tothienbao6a0/upnext#readme",
|
|
19
|
+
"bugs": { "url": "https://github.com/tothienbao6a0/upnext/issues" },
|
|
20
|
+
"keywords": ["audio", "playback", "browser", "electron", "html5-audio", "upnext-adapter"]
|
|
21
|
+
}
|
package/src/element.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { defaultCapabilities } from 'upnext-core';
|
|
2
|
+
import type {
|
|
3
|
+
Adapter,
|
|
4
|
+
AdapterEvent,
|
|
5
|
+
Binding,
|
|
6
|
+
Capabilities,
|
|
7
|
+
MediaRef,
|
|
8
|
+
} from 'upnext-core';
|
|
9
|
+
import { canPlay, describeSource, score } from './sources.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The part of `HTMLMediaElement` this adapter actually uses.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately tiny. A real `<audio>` satisfies it, so does a `<video>`, so
|
|
15
|
+
* does a webview's element behind a proxy — and so does a fake in a test, which
|
|
16
|
+
* is the point: none of this needs a browser to be exercised.
|
|
17
|
+
*/
|
|
18
|
+
export interface MediaElementLike {
|
|
19
|
+
src: string;
|
|
20
|
+
currentTime: number;
|
|
21
|
+
readonly duration: number;
|
|
22
|
+
volume: number;
|
|
23
|
+
readonly paused: boolean;
|
|
24
|
+
play(): Promise<void> | void;
|
|
25
|
+
pause(): void;
|
|
26
|
+
load?(): void;
|
|
27
|
+
addEventListener(type: string, listener: () => void): void;
|
|
28
|
+
removeEventListener(type: string, listener: () => void): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MediaElementAdapterOptions {
|
|
32
|
+
id?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The element to drive. A function is allowed so the element can be created
|
|
35
|
+
* lazily — browsers refuse to construct audio contexts before a user
|
|
36
|
+
* gesture, and a host may not have a document yet when it builds its queue.
|
|
37
|
+
*/
|
|
38
|
+
element: MediaElementLike | (() => MediaElementLike);
|
|
39
|
+
/** Extra media types this element is known to handle, e.g. `['audio/flac']`. */
|
|
40
|
+
extraExtensions?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Plays audio through a media element you own.
|
|
45
|
+
*
|
|
46
|
+
* This sits at the fully-controlled end of the capability spectrum, and it is
|
|
47
|
+
* the adapter that makes the core's claim true: `upnext-core` has always said
|
|
48
|
+
* it runs anywhere JavaScript does, but until now the only thing that could
|
|
49
|
+
* make a sound shelled out to `ffplay`, so a browser or an Electron renderer
|
|
50
|
+
* had a queue and no way to play it.
|
|
51
|
+
*
|
|
52
|
+
* What it deliberately does **not** do is reach into pages the user already has
|
|
53
|
+
* open. Commandeering somebody's YouTube tab needs a browser extension and is a
|
|
54
|
+
* different feature with a different risk profile — this owns a player, which
|
|
55
|
+
* is what a *queue* needs. See the README for where that line is.
|
|
56
|
+
*/
|
|
57
|
+
export class MediaElementAdapter implements Adapter {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
|
|
60
|
+
readonly capabilities: Capabilities = {
|
|
61
|
+
...defaultCapabilities,
|
|
62
|
+
// The element tells us. No polling, no duration timer, no guessing.
|
|
63
|
+
endOfTrack: 'event',
|
|
64
|
+
position: 'authoritative',
|
|
65
|
+
seek: true,
|
|
66
|
+
pause: true,
|
|
67
|
+
volume: true,
|
|
68
|
+
// Nothing to search — this plays a URL it is handed.
|
|
69
|
+
search: false,
|
|
70
|
+
// Nobody else has a handle on this element. If something else could pause
|
|
71
|
+
// it, that would be the host's own UI, and the host already knows.
|
|
72
|
+
externalControl: false,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
#options: MediaElementAdapterOptions;
|
|
76
|
+
#element: MediaElementLike | null = null;
|
|
77
|
+
#listeners = new Set<(event: AdapterEvent) => void>();
|
|
78
|
+
#detach: Array<() => void> = [];
|
|
79
|
+
#binding: Binding | null = null;
|
|
80
|
+
/** Set while we are tearing down, so our own `pause` is not reported. */
|
|
81
|
+
#closing = false;
|
|
82
|
+
|
|
83
|
+
constructor(options: MediaElementAdapterOptions) {
|
|
84
|
+
this.id = options.id ?? 'browser';
|
|
85
|
+
this.#options = options;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
match(ref: MediaRef): number {
|
|
89
|
+
return score(ref, this.#options.extraExtensions);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async resolve(ref: MediaRef): Promise<Binding | null> {
|
|
93
|
+
if (!ref.uri || !canPlay(ref.uri, this.#options.extraExtensions)) return null;
|
|
94
|
+
return {
|
|
95
|
+
adapterId: this.id,
|
|
96
|
+
nativeUri: ref.uri,
|
|
97
|
+
ref: { ...ref, title: ref.title ?? describeSource(ref.uri) },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async load(binding: Binding, opts?: { startAtMs?: number }): Promise<void> {
|
|
102
|
+
const element = this.#ensure();
|
|
103
|
+
this.#binding = binding;
|
|
104
|
+
|
|
105
|
+
element.src = binding.nativeUri;
|
|
106
|
+
element.load?.();
|
|
107
|
+
if (opts?.startAtMs) element.currentTime = opts.startAtMs / 1000;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async play(): Promise<void> {
|
|
111
|
+
const element = this.#ensure();
|
|
112
|
+
// `play()` rejects when a browser blocks autoplay. That is a real failure
|
|
113
|
+
// the runtime should hear about, not a warning to swallow — it falls
|
|
114
|
+
// through to the next source, which may well be one that can make sound.
|
|
115
|
+
await element.play();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async pause(): Promise<void> {
|
|
119
|
+
this.#ensure().pause();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async stop(): Promise<void> {
|
|
123
|
+
const element = this.#element;
|
|
124
|
+
if (!element) return;
|
|
125
|
+
this.#closing = true;
|
|
126
|
+
try {
|
|
127
|
+
element.pause();
|
|
128
|
+
// Releases the network connection and stops buffering. Without it a long
|
|
129
|
+
// podcast keeps downloading behind whatever plays next.
|
|
130
|
+
element.src = '';
|
|
131
|
+
element.load?.();
|
|
132
|
+
} finally {
|
|
133
|
+
this.#closing = false;
|
|
134
|
+
this.#binding = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async seek(positionMs: number): Promise<void> {
|
|
139
|
+
this.#ensure().currentTime = Math.max(0, positionMs) / 1000;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async setVolume(volume: number): Promise<void> {
|
|
143
|
+
this.#ensure().volume = Math.min(1, Math.max(0, volume));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
subscribe(listener: (event: AdapterEvent) => void): () => void {
|
|
147
|
+
this.#listeners.add(listener);
|
|
148
|
+
// Wiring is deferred until someone is listening, so constructing an adapter
|
|
149
|
+
// never touches the DOM — a host can build its queue before it has one.
|
|
150
|
+
if (this.#listeners.size === 1) this.#wire();
|
|
151
|
+
return () => {
|
|
152
|
+
this.#listeners.delete(listener);
|
|
153
|
+
if (this.#listeners.size === 0) this.#unwire();
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async dispose(): Promise<void> {
|
|
158
|
+
await this.stop();
|
|
159
|
+
this.#unwire();
|
|
160
|
+
this.#listeners.clear();
|
|
161
|
+
this.#element = null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// -- internals ------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
#ensure(): MediaElementLike {
|
|
167
|
+
if (!this.#element) {
|
|
168
|
+
const source = this.#options.element;
|
|
169
|
+
this.#element = typeof source === 'function' ? source() : source;
|
|
170
|
+
}
|
|
171
|
+
return this.#element;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#wire(): void {
|
|
175
|
+
const element = this.#ensure();
|
|
176
|
+
|
|
177
|
+
const on = (type: string, handler: () => void) => {
|
|
178
|
+
element.addEventListener(type, handler);
|
|
179
|
+
this.#detach.push(() => element.removeEventListener(type, handler));
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
on('ended', () => this.#emit({ type: 'ended' }));
|
|
183
|
+
|
|
184
|
+
on('timeupdate', () => {
|
|
185
|
+
const durationMs = toMs(element.duration);
|
|
186
|
+
this.#emit({
|
|
187
|
+
type: 'position',
|
|
188
|
+
positionMs: Math.round(element.currentTime * 1000),
|
|
189
|
+
...(durationMs ? { durationMs } : {}),
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
on('loadedmetadata', () => {
|
|
194
|
+
const durationMs = toMs(element.duration);
|
|
195
|
+
if (durationMs) {
|
|
196
|
+
this.#emit({ type: 'position', positionMs: 0, durationMs });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
on('play', () => this.#emit({ type: 'status', status: 'playing' }));
|
|
201
|
+
|
|
202
|
+
on('pause', () => {
|
|
203
|
+
// Our own teardown pauses the element; reporting that would tell the
|
|
204
|
+
// runtime the listener paused when in fact the track was being replaced.
|
|
205
|
+
if (this.#closing) return;
|
|
206
|
+
this.#emit({ type: 'status', status: 'paused' });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
on('error', () => {
|
|
210
|
+
this.#emit({
|
|
211
|
+
type: 'error',
|
|
212
|
+
code: 'media_error',
|
|
213
|
+
message: `could not play ${this.#binding?.nativeUri ?? 'the loaded source'}`,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#unwire(): void {
|
|
219
|
+
for (const off of this.#detach) off();
|
|
220
|
+
this.#detach = [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#emit(event: AdapterEvent): void {
|
|
224
|
+
for (const listener of [...this.#listeners]) listener(event);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A media element reports `NaN` before metadata and `Infinity` for streams. */
|
|
229
|
+
function toMs(seconds: number): number | undefined {
|
|
230
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
|
|
231
|
+
return Math.round(seconds * 1000);
|
|
232
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { MediaElementAdapter } from './element.js';
|
|
2
|
+
export type { MediaElementLike, MediaElementAdapterOptions } from './element.js';
|
|
3
|
+
|
|
4
|
+
export { RemoteMediaAdapter, serveMediaElement } from './remote.js';
|
|
5
|
+
export type { Channel, RemoteMediaAdapterOptions } from './remote.js';
|
|
6
|
+
|
|
7
|
+
export { canPlay, score as scoreSource, describeSource } from './sources.js';
|