@swiftbrowser/web 0.17.5 → 0.17.7
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/package.json +1 -1
- package/src/hostRequests.ts +8 -0
- package/src/main.ts +9 -2
- package/src/media.ts +23 -1
- package/src/mediaSession.ts +163 -0
- package/src/protocol.ts +29 -2
- package/src/renderer.ts +8 -3
package/package.json
CHANGED
package/src/hostRequests.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
OpenURLRequestParams,
|
|
33
33
|
WebAuthRequestParams,
|
|
34
34
|
MediaRequestParams,
|
|
35
|
+
MediaSessionRequestParams,
|
|
35
36
|
DownloadRequestParams,
|
|
36
37
|
ClipboardRequestParams,
|
|
37
38
|
TranslateRequestParams,
|
|
@@ -40,6 +41,7 @@ import type {
|
|
|
40
41
|
} from './protocol';
|
|
41
42
|
import { browserClipboardHost, performClipboard, type ClipboardHost } from './clipboard';
|
|
42
43
|
import { browserMediaHost, performMedia, type MediaHost } from './media';
|
|
44
|
+
import { browserMediaSessionHost, MediaSessionBridge, performMediaSession } from './mediaSession';
|
|
43
45
|
import { browserScannerHost, performScanner, type ScannerHost, type ScannerResult } from './scanner';
|
|
44
46
|
import { browserTranslationHost, performTranslate, type TranslationHost } from './translation';
|
|
45
47
|
import { chooseColor, performPickColor, type ColorChooser } from './colorPicker';
|
|
@@ -123,6 +125,8 @@ export interface HostRequestEnvironment {
|
|
|
123
125
|
webAuth?: WebAuthSessions;
|
|
124
126
|
/** AVFoundation's media files and probes, QuickLook's previews (`media`, media.ts). */
|
|
125
127
|
media?: MediaHost;
|
|
128
|
+
/** MediaPlayer's now-playing info and remote commands (src/mediaSession.ts); absent without the Media Session API. */
|
|
129
|
+
mediaSession?: MediaSessionBridge;
|
|
126
130
|
/** The system clipboard (`clipboard`, clipboard.ts). */
|
|
127
131
|
clipboard?: ClipboardHost;
|
|
128
132
|
/** The browser's built-in translator (`translate`, translation.ts), where it has one. */
|
|
@@ -177,6 +181,7 @@ const notificationHost = browserNotificationHost();
|
|
|
177
181
|
const scannerHost = browserScannerHost();
|
|
178
182
|
const webAuthHost = browserWebAuthHost();
|
|
179
183
|
const mediaHost = browserMediaHost(typeof document !== 'undefined' ? downloadBlob : undefined);
|
|
184
|
+
const mediaSessionHost = browserMediaSessionHost();
|
|
180
185
|
const clipboardHost = browserClipboardHost();
|
|
181
186
|
const translationHost = browserTranslationHost();
|
|
182
187
|
|
|
@@ -209,6 +214,7 @@ export const browserEnvironment: HostRequestEnvironment = {
|
|
|
209
214
|
: {}),
|
|
210
215
|
...(webAuthHost ? { webAuth: new WebAuthSessions(webAuthHost) } : {}),
|
|
211
216
|
...(mediaHost ? { media: mediaHost } : {}),
|
|
217
|
+
...(mediaSessionHost ? { mediaSession: new MediaSessionBridge(mediaSessionHost) } : {}),
|
|
212
218
|
...(clipboardHost ? { clipboard: clipboardHost } : {}),
|
|
213
219
|
...(translationHost ? { translation: translationHost } : {}),
|
|
214
220
|
...(typeof document !== 'undefined' ? { pickColor: chooseColor } : {}),
|
|
@@ -256,6 +262,8 @@ export async function performHostRequest(
|
|
|
256
262
|
case 'media':
|
|
257
263
|
// `open` is called before any await, while the tap's activation may still count.
|
|
258
264
|
return performMedia(op.params as MediaRequestParams, env.media);
|
|
265
|
+
case 'mediaSession':
|
|
266
|
+
return performMediaSession(op.params as MediaSessionRequestParams, env.mediaSession);
|
|
259
267
|
case 'download':
|
|
260
268
|
return performDownload(op.params as DownloadRequestParams, env);
|
|
261
269
|
case 'clipboard': {
|
package/src/main.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { Renderer, RendererError } from './renderer';
|
|
|
19
19
|
import { withBlockedHosts } from './blockedHosts';
|
|
20
20
|
import { withCORSProxy } from './corsProxy';
|
|
21
21
|
import { browserEnvironment } from './hostRequests';
|
|
22
|
+
import { locateBundleFiles } from './media';
|
|
22
23
|
import { callbackPageURL } from './webAuth';
|
|
23
24
|
import { bitmapStore } from './bitmaps';
|
|
24
25
|
import { DEFAULT_TYPE_SIZE, applyTypeSizeVariables } from './typography';
|
|
@@ -460,6 +461,11 @@ function saveHome(home: ReturnType<typeof homeDirectory>): void {
|
|
|
460
461
|
files?.apply(changes).catch((error: unknown) => console.warn('[sb] could not save the app\'s files to IndexedDB', error));
|
|
461
462
|
}
|
|
462
463
|
|
|
464
|
+
/** Where the page serves the resource at `path` (inside the bundle) of `app`. */
|
|
465
|
+
function bundleFileURL(app: string, path: string): string {
|
|
466
|
+
return `${import.meta.env.BASE_URL}bundle/${app}/${path.split('/').map(encodeURIComponent).join('/')}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
463
469
|
/** The app's resources (`/bundle/<App>.json` and its files), for the WASI `/bundle` mount; empty without a manifest. */
|
|
464
470
|
async function loadBundle(app: string): Promise<Map<string, Uint8Array>> {
|
|
465
471
|
const files = new Map<string, Uint8Array>();
|
|
@@ -470,8 +476,7 @@ async function loadBundle(app: string): Promise<Map<string, Uint8Array>> {
|
|
|
470
476
|
if (!Array.isArray(manifest.files)) return files;
|
|
471
477
|
await Promise.all(
|
|
472
478
|
manifest.files.map(async (file) => {
|
|
473
|
-
const
|
|
474
|
-
const data = await fetch(url, { cache: 'no-cache' });
|
|
479
|
+
const data = await fetch(bundleFileURL(app, file.path), { cache: 'no-cache' });
|
|
475
480
|
if (data.ok) files.set(file.path, new Uint8Array(await data.arrayBuffer()));
|
|
476
481
|
else console.warn(`[sb] resource ${file.path} of ${app}: HTTP ${data.status}`);
|
|
477
482
|
}),
|
|
@@ -601,6 +606,8 @@ async function boot(): Promise<void> {
|
|
|
601
606
|
console.info(`[sb] restoring state for ${requestedApp}: ${restored}`);
|
|
602
607
|
}
|
|
603
608
|
const bundle = await loadBundle(requestedApp);
|
|
609
|
+
// A player given a bundle file streams it from here rather than through a copy of its bytes (src/media.ts).
|
|
610
|
+
locateBundleFiles((path) => (bundle.has(path) ? bundleFileURL(requestedApp, path) : null));
|
|
604
611
|
const stores = launchValue(await loadSwiftData());
|
|
605
612
|
if (stores !== null) env.push(`SB_SWIFTDATA=${stores}`);
|
|
606
613
|
savedHome = await loadHome();
|
package/src/media.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* AVFoundation's files and metadata probes, and QuickLook's previews.
|
|
4
4
|
*
|
|
5
5
|
* - `file` (`name`, `type`, `data` base64): a blob of the bytes; answers
|
|
6
|
-
* `{ url }`, an object URL a `video` element can play.
|
|
6
|
+
* `{ url }`, an object URL a `video` element can play. With `path` in
|
|
7
|
+
* place of `data` (a resource of the app's bundle), the URL the page
|
|
8
|
+
* serves that resource at, so the browser streams it and nothing is
|
|
9
|
+
* copied; fails `unsupported: …` when the page has no such resource,
|
|
10
|
+
* and the app sends the bytes instead.
|
|
7
11
|
* - `probe` (`url`): reads the media's metadata in a detached `<video>`;
|
|
8
12
|
* answers `{ duration, width, height }` (`duration` null for a stream),
|
|
9
13
|
* fails (`network: …`) when the browser cannot load or decode it.
|
|
@@ -37,12 +41,29 @@ export interface MediaHost {
|
|
|
37
41
|
/** Opens `url` in a new tab; false when the browser blocked it. */
|
|
38
42
|
open(url: string): boolean;
|
|
39
43
|
download?(blob: Blob, filename: string): void;
|
|
44
|
+
/** The URL the page serves the bundle resource at `path` from, or null for a resource it did not load. */
|
|
45
|
+
bundleURL?(path: string): string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let bundleLocator: ((path: string) => string | null) | null = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tells the media host where the app's bundle resources are served from
|
|
52
|
+
* (src/main.ts, once the bundle is loaded), for `file` requests by `path`.
|
|
53
|
+
*/
|
|
54
|
+
export function locateBundleFiles(locator: ((path: string) => string | null) | null): void {
|
|
55
|
+
bundleLocator = locator;
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
export async function performMedia(params: MediaRequestParams, host: MediaHost | undefined): Promise<Record<string, unknown>> {
|
|
43
59
|
if (!host) throw new Error('unsupported: this page has no media host');
|
|
44
60
|
switch (params.action) {
|
|
45
61
|
case 'file': {
|
|
62
|
+
if (params.path !== undefined) {
|
|
63
|
+
const url = host.bundleURL?.(params.path) ?? null;
|
|
64
|
+
if (url === null) throw new Error(`unsupported: the page has no bundle resource ${params.path}`);
|
|
65
|
+
return { url };
|
|
66
|
+
}
|
|
46
67
|
const blob = new Blob([decode(params.data ?? '') as BlobPart], { type: params.type ?? '' });
|
|
47
68
|
return { url: host.objectURL(blob) };
|
|
48
69
|
}
|
|
@@ -128,5 +149,6 @@ export function browserMediaHost(download?: (blob: Blob, filename: string) => vo
|
|
|
128
149
|
return true;
|
|
129
150
|
},
|
|
130
151
|
...(download ? { download } : {}),
|
|
152
|
+
bundleURL: (path) => bundleLocator?.(path) ?? null,
|
|
131
153
|
};
|
|
132
154
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mediaSession` host requests (docs/ops-protocol.md, "Now playing"):
|
|
3
|
+
* MediaPlayer's now-playing info and remote commands over the browser's
|
|
4
|
+
* Media Session API, which puts what plays on the lock screen and the
|
|
5
|
+
* system's media controls and routes their buttons (and hardware media
|
|
6
|
+
* keys) back to the page.
|
|
7
|
+
*
|
|
8
|
+
* - `update` (`metadata`, `playbackState`, `position`, `commands`, `skip`)
|
|
9
|
+
* sets what the session shows and which commands the app handles; the
|
|
10
|
+
* browser extrapolates the position at the rate, so the app sends it
|
|
11
|
+
* only when it changes (a play, a pause, a seek). Answers `{}`.
|
|
12
|
+
* - `receive` answers the next command the viewer gave: `{ command }`, with
|
|
13
|
+
* `position` for `seekTo` (seconds) and `interval` for the skips (the
|
|
14
|
+
* offset the system asked for, else the app's preferred one). Commands
|
|
15
|
+
* that arrive with nobody receiving wait in a queue, as WebSocket
|
|
16
|
+
* messages do (src/webSocket.ts). Fails `unsupported: …` on a page with
|
|
17
|
+
* no Media Session API, which ends the app's listening.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { MediaSessionCommand, MediaSessionRequestParams } from './protocol';
|
|
21
|
+
|
|
22
|
+
/** The part of `navigator.mediaSession` the requests use; tests substitute a fake. */
|
|
23
|
+
export interface MediaSessionHost {
|
|
24
|
+
setMetadata(metadata: { title: string; artist: string; album: string; artwork: { src: string }[] } | null): void;
|
|
25
|
+
setPlaybackState(state: 'none' | 'playing' | 'paused'): void;
|
|
26
|
+
setPositionState(state: { duration: number; playbackRate: number; position: number } | null): void;
|
|
27
|
+
/** Registers (or, with null, removes) the handler of a Media Session action; may throw for an action the browser lacks. */
|
|
28
|
+
setActionHandler(action: MediaSessionAction, handler: ((details: MediaSessionActionDetails) => void) | null): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A command the viewer gave, as `receive` answers it. */
|
|
32
|
+
export interface ReceivedCommand {
|
|
33
|
+
command: MediaSessionCommand;
|
|
34
|
+
position?: number;
|
|
35
|
+
interval?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The Media Session action each command listens to. */
|
|
39
|
+
const ACTIONS: Record<MediaSessionCommand, MediaSessionAction> = {
|
|
40
|
+
play: 'play',
|
|
41
|
+
pause: 'pause',
|
|
42
|
+
stop: 'stop',
|
|
43
|
+
nextTrack: 'nexttrack',
|
|
44
|
+
previousTrack: 'previoustrack',
|
|
45
|
+
seekTo: 'seekto',
|
|
46
|
+
skipForward: 'seekforward',
|
|
47
|
+
skipBackward: 'seekbackward',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** One page's media session: what it shows, the commands it listens to, and the ones received. */
|
|
51
|
+
export class MediaSessionBridge {
|
|
52
|
+
private readonly registered = new Set<MediaSessionCommand>();
|
|
53
|
+
private readonly queue: ReceivedCommand[] = [];
|
|
54
|
+
private readonly waiting: ((command: ReceivedCommand) => void)[] = [];
|
|
55
|
+
private skip: { forward?: number; backward?: number } = {};
|
|
56
|
+
|
|
57
|
+
constructor(private readonly host: MediaSessionHost) {}
|
|
58
|
+
|
|
59
|
+
perform(params: MediaSessionRequestParams): Promise<Record<string, unknown>> {
|
|
60
|
+
switch (params.action) {
|
|
61
|
+
case 'update':
|
|
62
|
+
this.update(params);
|
|
63
|
+
return Promise.resolve({});
|
|
64
|
+
case 'receive':
|
|
65
|
+
return this.receive();
|
|
66
|
+
default:
|
|
67
|
+
return Promise.reject(new Error(`unsupported: mediaSession action ${String((params as { action: unknown }).action)}`));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private update(params: MediaSessionRequestParams): void {
|
|
72
|
+
if (params.metadata !== undefined) {
|
|
73
|
+
const metadata = params.metadata;
|
|
74
|
+
this.host.setMetadata(
|
|
75
|
+
metadata === null
|
|
76
|
+
? null
|
|
77
|
+
: { title: metadata.title ?? '', artist: metadata.artist ?? '', album: metadata.album ?? '', artwork: metadata.artwork ? [{ src: metadata.artwork }] : [] },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (params.playbackState !== undefined) this.host.setPlaybackState(params.playbackState);
|
|
81
|
+
if (params.position !== undefined) {
|
|
82
|
+
const position = params.position;
|
|
83
|
+
this.host.setPositionState(position === null ? null : { duration: position.duration, playbackRate: position.rate, position: position.position });
|
|
84
|
+
}
|
|
85
|
+
if (params.skip !== undefined) this.skip = params.skip;
|
|
86
|
+
if (params.commands !== undefined) this.listen(new Set(params.commands));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Listens to exactly `wanted`: handlers for the new ones, none for the dropped ones. */
|
|
90
|
+
private listen(wanted: Set<MediaSessionCommand>): void {
|
|
91
|
+
for (const command of this.registered) {
|
|
92
|
+
if (wanted.has(command)) continue;
|
|
93
|
+
this.setHandler(command, null);
|
|
94
|
+
this.registered.delete(command);
|
|
95
|
+
}
|
|
96
|
+
for (const command of wanted) {
|
|
97
|
+
if (this.registered.has(command)) continue;
|
|
98
|
+
this.setHandler(command, (details) => this.deliver(this.received(command, details)));
|
|
99
|
+
this.registered.add(command);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private setHandler(command: MediaSessionCommand, handler: ((details: MediaSessionActionDetails) => void) | null): void {
|
|
104
|
+
try {
|
|
105
|
+
this.host.setActionHandler(ACTIONS[command], handler);
|
|
106
|
+
} catch {
|
|
107
|
+
// An action this browser does not know (`seekto` on an older Safari): the button never shows.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private received(command: MediaSessionCommand, details: MediaSessionActionDetails): ReceivedCommand {
|
|
112
|
+
const received: ReceivedCommand = { command };
|
|
113
|
+
if (command === 'seekTo' && typeof details.seekTime === 'number') received.position = details.seekTime;
|
|
114
|
+
if (command === 'skipForward' || command === 'skipBackward') {
|
|
115
|
+
const preferred = command === 'skipForward' ? this.skip.forward : this.skip.backward;
|
|
116
|
+
const interval = typeof details.seekOffset === 'number' ? details.seekOffset : preferred;
|
|
117
|
+
if (interval !== undefined) received.interval = interval;
|
|
118
|
+
}
|
|
119
|
+
return received;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private deliver(command: ReceivedCommand): void {
|
|
123
|
+
const wake = this.waiting.shift();
|
|
124
|
+
if (wake) wake(command);
|
|
125
|
+
else this.queue.push(command);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private receive(): Promise<Record<string, unknown>> {
|
|
129
|
+
const queued = this.queue.shift();
|
|
130
|
+
if (queued) return Promise.resolve({ ...queued });
|
|
131
|
+
return new Promise((resolve) => this.waiting.push((command) => resolve({ ...command })));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function performMediaSession(params: MediaSessionRequestParams, bridge: MediaSessionBridge | undefined): Promise<Record<string, unknown>> {
|
|
136
|
+
if (!bridge) {
|
|
137
|
+
// No Media Session API: nothing to show, and no command will ever come.
|
|
138
|
+
if (params.action === 'update') return {};
|
|
139
|
+
throw new Error('unsupported: this browser has no Media Session');
|
|
140
|
+
}
|
|
141
|
+
return bridge.perform(params);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `navigator.mediaSession`, where the browser has it. */
|
|
145
|
+
export function browserMediaSessionHost(): MediaSessionHost | undefined {
|
|
146
|
+
if (typeof navigator === 'undefined' || !('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return undefined;
|
|
147
|
+
const session = navigator.mediaSession;
|
|
148
|
+
return {
|
|
149
|
+
setMetadata: (metadata) => {
|
|
150
|
+
session.metadata = metadata === null ? null : new MediaMetadata(metadata);
|
|
151
|
+
},
|
|
152
|
+
setPlaybackState: (state) => {
|
|
153
|
+
session.playbackState = state;
|
|
154
|
+
},
|
|
155
|
+
setPositionState: (state) => {
|
|
156
|
+
if (typeof session.setPositionState !== 'function') return;
|
|
157
|
+
if (state === null) session.setPositionState();
|
|
158
|
+
// A position past the duration, or a duration that is not a finite number, throws.
|
|
159
|
+
else if (Number.isFinite(state.duration) && state.duration >= 0) session.setPositionState({ ...state, position: Math.min(Math.max(state.position, 0), state.duration) });
|
|
160
|
+
},
|
|
161
|
+
setActionHandler: (action, handler) => session.setActionHandler(action, handler),
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -1076,7 +1076,7 @@ export interface DefaultsOp {
|
|
|
1076
1076
|
export interface RequestOp {
|
|
1077
1077
|
op: 'request';
|
|
1078
1078
|
id: number;
|
|
1079
|
-
kind: 'fetch' | 'image' | 'share' | 'pickFiles' | 'notification' | 'scanner' | 'websocket' | 'openURL' | 'webAuth' | 'media' | 'download' | 'clipboard' | 'translate' | 'pickColor';
|
|
1079
|
+
kind: 'fetch' | 'image' | 'share' | 'pickFiles' | 'notification' | 'scanner' | 'websocket' | 'openURL' | 'webAuth' | 'media' | 'download' | 'clipboard' | 'translate' | 'pickColor' | 'mediaSession';
|
|
1080
1080
|
params:
|
|
1081
1081
|
| FetchRequestParams
|
|
1082
1082
|
| ImageRequestParams
|
|
@@ -1091,7 +1091,8 @@ export interface RequestOp {
|
|
|
1091
1091
|
| DownloadRequestParams
|
|
1092
1092
|
| ClipboardRequestParams
|
|
1093
1093
|
| TranslateRequestParams
|
|
1094
|
-
| PickColorRequestParams
|
|
1094
|
+
| PickColorRequestParams
|
|
1095
|
+
| MediaSessionRequestParams;
|
|
1095
1096
|
}
|
|
1096
1097
|
/**
|
|
1097
1098
|
* `translate` (Translation; src/translation.ts): `availability` of a pair
|
|
@@ -2127,6 +2128,8 @@ export interface SlideEvent {
|
|
|
2127
2128
|
type: 'slide';
|
|
2128
2129
|
id: ElementId;
|
|
2129
2130
|
value: number;
|
|
2131
|
+
/** `onEditingChanged`: true with the first value of a drag, false with its last; absent for the values between. */
|
|
2132
|
+
editing?: boolean;
|
|
2130
2133
|
}
|
|
2131
2134
|
/** A `datepicker` changed: seconds since 1970 (an instant; the zone only shaped the reading). */
|
|
2132
2135
|
export interface DateEvent {
|
|
@@ -2556,10 +2559,34 @@ export interface VideoEvent {
|
|
|
2556
2559
|
}
|
|
2557
2560
|
|
|
2558
2561
|
/** `media` requests (src/media.ts): AVFoundation's files and probes, QuickLook's previews. */
|
|
2562
|
+
/** A remote command MediaPlayer's `MPRemoteCommandCenter` handles (docs/ops-protocol.md, "Now playing"). */
|
|
2563
|
+
export type MediaSessionCommand = 'play' | 'pause' | 'stop' | 'nextTrack' | 'previousTrack' | 'seekTo' | 'skipForward' | 'skipBackward';
|
|
2564
|
+
|
|
2565
|
+
/**
|
|
2566
|
+
* `mediaSession` (src/mediaSession.ts): MediaPlayer's now-playing info and
|
|
2567
|
+
* remote commands over the browser's Media Session API. `update` sets what
|
|
2568
|
+
* the session shows and which commands the app handles (a field left out
|
|
2569
|
+
* stays as it was); `receive` answers the next command the viewer gave.
|
|
2570
|
+
*/
|
|
2571
|
+
export interface MediaSessionRequestParams {
|
|
2572
|
+
action: 'update' | 'receive';
|
|
2573
|
+
/** `update`: what plays, for the lock screen and the media controls (`artwork` a URL); null clears it. */
|
|
2574
|
+
metadata?: { title?: string; artist?: string; album?: string; artwork?: string } | null;
|
|
2575
|
+
playbackState?: 'none' | 'playing' | 'paused';
|
|
2576
|
+
/** `update`: the position in seconds, which the browser extrapolates at `rate`; null clears it. */
|
|
2577
|
+
position?: { duration: number; position: number; rate: number } | null;
|
|
2578
|
+
/** `update`: the commands the app handles; the others get no button. */
|
|
2579
|
+
commands?: MediaSessionCommand[];
|
|
2580
|
+
/** `update`: the app's preferred skip intervals in seconds, answered with a skip the system gives no offset for. */
|
|
2581
|
+
skip?: { forward?: number; backward?: number };
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2559
2584
|
export interface MediaRequestParams {
|
|
2560
2585
|
action: 'file' | 'probe' | 'release' | 'open';
|
|
2561
2586
|
url?: string;
|
|
2562
2587
|
name?: string;
|
|
2563
2588
|
type?: string;
|
|
2564
2589
|
data?: string;
|
|
2590
|
+
/** `file`: a resource's path inside the app's bundle, in place of `data`: the page answers the URL it serves the resource at. */
|
|
2591
|
+
path?: string;
|
|
2565
2592
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -786,7 +786,7 @@ export class Renderer {
|
|
|
786
786
|
const id = Number(slider.dataset[ID_ATTR]);
|
|
787
787
|
if (!track || !Number.isInteger(id)) return;
|
|
788
788
|
event.preventDefault();
|
|
789
|
-
const drag: SliderDrag = { slider, track, id, pointerId: event.pointerId, pending: null, sent: null, frame: null };
|
|
789
|
+
const drag: SliderDrag = { slider, track, id, pointerId: event.pointerId, pending: null, sent: null, frame: null, starting: true };
|
|
790
790
|
this.sliderDrag = drag;
|
|
791
791
|
window.addEventListener('pointermove', this.onSliderMove);
|
|
792
792
|
window.addEventListener('pointerup', this.onSliderUp);
|
|
@@ -817,9 +817,11 @@ export class Renderer {
|
|
|
817
817
|
}
|
|
818
818
|
|
|
819
819
|
private flushSlide(drag: SliderDrag): void {
|
|
820
|
-
if (drag.pending === null || drag.pending === drag.sent) return;
|
|
820
|
+
if (drag.pending === null || (drag.pending === drag.sent && !drag.starting)) return;
|
|
821
821
|
drag.sent = drag.pending;
|
|
822
|
-
|
|
822
|
+
// The drag's first value says editing began (`onEditingChanged(true)`); the last, on release, that it ended.
|
|
823
|
+
this.onEvent(drag.starting ? { type: 'slide', id: drag.id, value: drag.pending, editing: true } : { type: 'slide', id: drag.id, value: drag.pending });
|
|
824
|
+
drag.starting = false;
|
|
823
825
|
}
|
|
824
826
|
|
|
825
827
|
private readonly onSliderMove = (event: PointerEvent): void => {
|
|
@@ -838,6 +840,7 @@ export class Renderer {
|
|
|
838
840
|
if (drag.frame !== null) window.cancelAnimationFrame(drag.frame);
|
|
839
841
|
drag.frame = null;
|
|
840
842
|
this.flushSlide(drag);
|
|
843
|
+
if (drag.sent !== null) this.onEvent({ type: 'slide', id: drag.id, value: drag.sent, editing: false });
|
|
841
844
|
// The click that follows is the slide itself, not a tap on an enclosing control.
|
|
842
845
|
this.swallowNextClick();
|
|
843
846
|
};
|
|
@@ -3351,6 +3354,8 @@ interface SliderDrag {
|
|
|
3351
3354
|
/** The last value sent, so an unchanged frame sends nothing. */
|
|
3352
3355
|
sent: number | null;
|
|
3353
3356
|
frame: number | null;
|
|
3357
|
+
/** Whether the drag's first `slide` (with `editing: true`) is still to be sent. */
|
|
3358
|
+
starting: boolean;
|
|
3354
3359
|
}
|
|
3355
3360
|
|
|
3356
3361
|
function applyAlert(el: HTMLElement, props: AlertProps): void {
|