@tristan2828/ui-foundation 4.43.0 → 4.44.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.
@@ -6,11 +6,11 @@
6
6
  - [The view](#the-view) · lines 36–109
7
7
  - [The dark stage](#the-dark-stage) · lines 73–109
8
8
  - [The clock](#the-clock) · lines 111–141
9
- - [Sound](#sound) · lines 143–190
10
- - [Background tabs](#background-tabs) · lines 192–246
11
- - [Interruptions](#interruptions) · lines 226–246
12
- - [Motion](#motion) · lines 248–252
13
- - [Testing a timed screen in Playwright](#testing-a-timed-screen-in-playwright) · lines 254–408
9
+ - [Sound](#sound) · lines 143–193
10
+ - [Background tabs](#background-tabs) · lines 195–249
11
+ - [Interruptions](#interruptions) · lines 229–249
12
+ - [Motion](#motion) · lines 251–255
13
+ - [Testing a timed screen in Playwright](#testing-a-timed-screen-in-playwright) · lines 257–411
14
14
 
15
15
  <!-- /index -->
16
16
 
@@ -176,7 +176,10 @@ Browsers block sound until the person has clicked or tapped. So:
176
176
  another app's music. It returns `{ pause(), stop() }`: `pause()` shows
177
177
  Play (without calling `onToggle`), `stop()` ends it (on done, on leaving);
178
178
  to start again after `pause()`, call `keepAudioAlive` again from a click.
179
- Only one runs at a time.
179
+ Only one runs at a time. Where Safari has an audio session
180
+ (`navigator.audioSession`) it's set to `"playback"` while the track
181
+ runs, so the ringer switch doesn't mute the cues either, and put back on
182
+ `stop()`.
180
183
  - Every one of them is a no-op where the browser can't make the sound, so
181
184
  none needs a check around it.
182
185
  - **Mute** is the app's: `const [muted, setMuted] = useMuted('my-app:routine:muted')`,
@@ -22,6 +22,10 @@ export type KeepAudioAliveEnvironment = {
22
22
  MediaMetadata?: new (init?: MediaMetadataInit) => MediaMetadata;
23
23
  createObjectURL?: (blob: Blob) => string;
24
24
  revokeObjectURL?: (url: string) => void;
25
+ /** Safari's `navigator.audioSession`, where it has one. */
26
+ audioSession?: {
27
+ type: string;
28
+ };
25
29
  };
26
30
  /** One second of silent 8-bit mono audio as a WAV file. */
27
31
  export declare function silentWav(): Uint8Array;
@@ -30,6 +34,8 @@ export declare function createKeepAudioAlive(env: KeepAudioAliveEnvironment): ({
30
34
  * Plays a silent looped track so iOS keeps the page's sound going with the
31
35
  * phone locked, with `title` and Pause and Play on the lock screen. Call it
32
36
  * from the click that starts the run, behind a setting that's off by default:
33
- * it pauses other apps' music. A no-op where the browser can't play audio.
37
+ * it pauses other apps' music. Where Safari has an audio session it's set to
38
+ * "playback" meanwhile, so the ringer switch doesn't mute the cues. A no-op
39
+ * where the browser can't play audio.
34
40
  */
35
41
  export declare const keepAudioAlive: (options: KeepAudioAliveOptions) => KeepAudioAlive;
@@ -1,9 +1,12 @@
1
- // Keeps a page's audio going with the phone locked (#217). iOS pauses a
1
+ // Keeps a page's audio going with the phone locked (#217, #261). iOS pauses a
2
2
  // page's sound on the lock screen unless the page is playing media, so
3
3
  // keepAudioAlive() plays a silent looped track, which also puts the page's
4
4
  // title and a Pause and Play on the lock screen (the Media Session). It's
5
5
  // opt-in and off by default: it takes the device's audio session, which
6
6
  // pauses another app's music (docs/foundation/timed-screens.md "Sound").
7
+ // Where Safari has an audio session (navigator.audioSession, iOS 17+) it's
8
+ // set to "playback" while the track runs, so the ringer switch doesn't mute
9
+ // the page's cues either, and put back on stop().
7
10
  //
8
11
  // The track is a few bytes of silence made here, so there is no file to
9
12
  // ship. createKeepAudioAlive takes the browser's APIs, so the rules are
@@ -55,6 +58,18 @@ export function createKeepAudioAlive(env) {
55
58
  }
56
59
  audio.loop = true;
57
60
  const session = env.mediaSession;
61
+ const audioSession = env.audioSession;
62
+ let typeBefore = null;
63
+ try {
64
+ if (audioSession) {
65
+ typeBefore = audioSession.type;
66
+ audioSession.type = 'playback';
67
+ }
68
+ }
69
+ catch {
70
+ // A type this Safari doesn't take: the silent track alone.
71
+ typeBefore = null;
72
+ }
58
73
  const play = () => {
59
74
  // Refused without a click: the lock screen then shows nothing to keep.
60
75
  Promise.resolve(audio.play()).catch(() => { });
@@ -102,6 +117,14 @@ export function createKeepAudioAlive(env) {
102
117
  audio.removeAttribute('src');
103
118
  audio.load();
104
119
  env.revokeObjectURL?.(url);
120
+ if (audioSession && typeBefore !== null) {
121
+ try {
122
+ audioSession.type = typeBefore;
123
+ }
124
+ catch {
125
+ // Left as playback: harmless once the track has stopped.
126
+ }
127
+ }
105
128
  if (session) {
106
129
  handle('play', null);
107
130
  handle('pause', null);
@@ -119,7 +142,9 @@ let shared = null;
119
142
  * Plays a silent looped track so iOS keeps the page's sound going with the
120
143
  * phone locked, with `title` and Pause and Play on the lock screen. Call it
121
144
  * from the click that starts the run, behind a setting that's off by default:
122
- * it pauses other apps' music. A no-op where the browser can't play audio.
145
+ * it pauses other apps' music. Where Safari has an audio session it's set to
146
+ * "playback" meanwhile, so the ringer switch doesn't mute the cues. A no-op
147
+ * where the browser can't play audio.
123
148
  */
124
149
  export const keepAudioAlive = (options) => {
125
150
  if (!shared) {
@@ -130,6 +155,7 @@ export const keepAudioAlive = (options) => {
130
155
  MediaMetadata: hasWindow ? window.MediaMetadata : undefined,
131
156
  createObjectURL: typeof URL !== 'undefined' ? (blob) => URL.createObjectURL(blob) : undefined,
132
157
  revokeObjectURL: typeof URL !== 'undefined' ? (url) => URL.revokeObjectURL(url) : undefined,
158
+ audioSession: typeof navigator !== 'undefined' ? navigator.audioSession : undefined,
133
159
  });
134
160
  }
135
161
  return shared(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.43.0",
3
+ "version": "4.44.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {