@swiftbrowser/web 0.17.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.17.6",
3
+ "version": "0.17.7",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -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 url = `${import.meta.env.BASE_URL}bundle/${app}/${file.path.split('/').map(encodeURIComponent).join('/')}`;
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
- this.onEvent({ type: 'slide', id: drag.id, value: drag.pending });
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 {