@tristan2828/ui-foundation 4.29.3 → 4.30.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/conventions/docs/timed-screens.md +48 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/lib/audio-cues.d.ts +25 -0
- package/dist/lib/audio-cues.js +105 -5
- package/dist/lib/keep-audio-alive.d.ts +35 -0
- package/dist/lib/keep-audio-alive.js +136 -0
- package/package.json +1 -1
|
@@ -113,6 +113,17 @@ Browsers block sound until the person has clicked or tapped. So:
|
|
|
113
113
|
session: `const [vibrates] = useState(canVibrate)`. Show the toggle (a
|
|
114
114
|
pressed button named "Vibrate", like Mute) only when `vibrates` is
|
|
115
115
|
true, and check the setting before each `vibrate()` call.
|
|
116
|
+
- **`keepAudioAlive({ title, onToggle? })`** keeps the sound going with the
|
|
117
|
+
phone locked. iOS pauses a page's audio on the lock screen unless the
|
|
118
|
+
page is playing media, so this plays a silent looped track and shows
|
|
119
|
+
`title` with Pause and Play on the lock screen; `onToggle(playing)` is
|
|
120
|
+
called when the person presses them, to pause or resume the run. Call it
|
|
121
|
+
from the click that starts the run, and keep it **behind a setting that
|
|
122
|
+
is off by default**: it takes the device's audio session, which pauses
|
|
123
|
+
another app's music. It returns `{ pause(), stop() }`: `pause()` shows
|
|
124
|
+
Play (without calling `onToggle`), `stop()` ends it (on done, on leaving);
|
|
125
|
+
to start again after `pause()`, call `keepAudioAlive` again from a click.
|
|
126
|
+
Only one runs at a time.
|
|
116
127
|
- Every one of them is a no-op where the browser can't make the sound, so
|
|
117
128
|
none needs a check around it.
|
|
118
129
|
- **Mute** is the app's: `const [muted, setMuted] = useMuted('my-app:routine:muted')`,
|
|
@@ -159,6 +170,28 @@ phase ends. Web Audio's own clock keeps time, so:
|
|
|
159
170
|
Safari may suspend audio in a background tab altogether; nothing in a
|
|
160
171
|
web page gets round that.
|
|
161
172
|
|
|
173
|
+
### Interruptions
|
|
174
|
+
|
|
175
|
+
On iOS the audio clock **stops** while the context is interrupted: speech
|
|
176
|
+
speaking, a notification, a call, the lock screen. Booked strikes after
|
|
177
|
+
that are silent, or come late by as long as the clock stood still. The
|
|
178
|
+
package resumes the context as soon as it stops (and again when the page is
|
|
179
|
+
visible, and on the next `unlockAudio()`), but only the screen knows where
|
|
180
|
+
its run is now, so it rebooks:
|
|
181
|
+
|
|
182
|
+
- **`onCuesRecovered(callback)`** calls back when the audio runs again
|
|
183
|
+
after stopping, and returns the unsubscribe. By then every booked strike
|
|
184
|
+
has been taken back, since each would have struck late, so the callback
|
|
185
|
+
books again from the run's position, the same way as when counting
|
|
186
|
+
starts. Subscribe in an effect and return the unsubscribe from its
|
|
187
|
+
cleanup. The context's first run, after the start click, isn't a
|
|
188
|
+
recovery.
|
|
189
|
+
- **`cuesInSync()`** is false when the audio clock has drifted from
|
|
190
|
+
`Date.now()` by more than 250 ms since the bookings were made, or the
|
|
191
|
+
audio is stopped. It's true when nothing is booked. Check it on a tick,
|
|
192
|
+
for where no recovery was announced: when it's false, `cancelCues()` and
|
|
193
|
+
book again.
|
|
194
|
+
|
|
162
195
|
## Motion
|
|
163
196
|
|
|
164
197
|
`usePrefersReducedMotion()` is true while the person's system asks for
|
|
@@ -218,9 +251,17 @@ test.beforeEach(async ({ page }) => {
|
|
|
218
251
|
Object.defineProperty(document, 'visibilityState', { get: () => visibility })
|
|
219
252
|
Object.defineProperty(document, 'hidden', { get: () => visibility === 'hidden' })
|
|
220
253
|
const booked: { pitch: number; at: number; cancelled: boolean }[] = []
|
|
254
|
+
const contexts: RecordingAudioContext[] = []
|
|
221
255
|
class RecordingAudioContext {
|
|
222
256
|
state = 'running'; currentTime = 0; destination = {}
|
|
223
|
-
|
|
257
|
+
listeners: (() => void)[] = []
|
|
258
|
+
addEventListener = (_type: string, listener: () => void) => void this.listeners.push(listener)
|
|
259
|
+
resume = async () => this.change('running')
|
|
260
|
+
change(state: string) {
|
|
261
|
+
this.state = state
|
|
262
|
+
this.listeners.forEach((listener) => listener())
|
|
263
|
+
}
|
|
264
|
+
constructor() { contexts.push(this) }
|
|
224
265
|
last = { pitch: 0, at: 0, cancelled: false }
|
|
225
266
|
createOscillator() {
|
|
226
267
|
const cue = (this.last = { pitch: 0, at: 0, cancelled: false })
|
|
@@ -243,6 +284,7 @@ test.beforeEach(async ({ page }) => {
|
|
|
243
284
|
Object.assign(window, {
|
|
244
285
|
AudioContext: RecordingAudioContext,
|
|
245
286
|
__booked: booked,
|
|
287
|
+
__interrupt: () => contexts.forEach((context) => context.change('interrupted')),
|
|
246
288
|
__hide() {
|
|
247
289
|
visibility = 'hidden'
|
|
248
290
|
window.requestAnimationFrame = () => 0 // a hidden tab draws no frames
|
|
@@ -268,6 +310,11 @@ test('the end is booked ahead, and the title counts down while hidden', async ({
|
|
|
268
310
|
Pause, Skip and Back are asserted the same way: the earlier booking's
|
|
269
311
|
`cancelled` turns true and a new one appears at the new time.
|
|
270
312
|
|
|
313
|
+
An interruption is the same again: `__interrupt()` stops the stand-in
|
|
314
|
+
context, which the package resumes. Expect the earlier booking's
|
|
315
|
+
`cancelled` to turn true and a new one to appear from the run's position,
|
|
316
|
+
the screen's `onCuesRecovered` callback having rebooked.
|
|
317
|
+
|
|
271
318
|
**An open-ended count-up: pause the fake clock.** After
|
|
272
319
|
`clock.install()` the fake clock still moves in real time between
|
|
273
320
|
steps, so every reading picks up however long the machine took since the
|
package/dist/index.d.ts
CHANGED
|
@@ -58,4 +58,5 @@ export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
|
58
58
|
export { useMuted } from './hooks/use-muted.js';
|
|
59
59
|
export { createPushController, usePushPermission, type PushController, type PushControllerOptions, type PushEnvironment, type PushPermission, type PushStatus, } from './hooks/use-push-permission.js';
|
|
60
60
|
export { cn } from './lib/utils.js';
|
|
61
|
-
export { canVibrate, cancelCues, chime, hush, say, sequence, unlockAudio, vibrate, type ChimeOptions, type Cue, type CueTime, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
|
|
61
|
+
export { canVibrate, cancelCues, chime, cuesInSync, hush, onCuesRecovered, say, sequence, unlockAudio, vibrate, type ChimeOptions, type Cue, type CueTime, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
|
|
62
|
+
export { keepAudioAlive, type KeepAudioAlive, type KeepAudioAliveOptions, } from './lib/keep-audio-alive.js';
|
package/dist/index.js
CHANGED
|
@@ -63,4 +63,5 @@ export { useMuted } from './hooks/use-muted.js';
|
|
|
63
63
|
export { createPushController, usePushPermission, } from './hooks/use-push-permission.js';
|
|
64
64
|
// Utilities
|
|
65
65
|
export { cn } from './lib/utils.js';
|
|
66
|
-
export { canVibrate, cancelCues, chime, hush, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
|
|
66
|
+
export { canVibrate, cancelCues, chime, cuesInSync, hush, onCuesRecovered, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
|
|
67
|
+
export { keepAudioAlive, } from './lib/keep-audio-alive.js';
|
package/dist/lib/audio-cues.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export type AudioCuesEnvironment = {
|
|
|
38
38
|
vibrate?: (pattern: number | number[]) => boolean;
|
|
39
39
|
/** The clock `at` is read on (default `Date.now`). */
|
|
40
40
|
now?: () => number;
|
|
41
|
+
/** The page, for noticing it's visible again after the audio stopped. */
|
|
42
|
+
document?: Pick<Document, 'addEventListener' | 'visibilityState'>;
|
|
41
43
|
};
|
|
42
44
|
export type AudioCues = {
|
|
43
45
|
/** Call from the click or tap that starts a session; sound is allowed from then on. */
|
|
@@ -47,6 +49,21 @@ export type AudioCues = {
|
|
|
47
49
|
sequence: (chimes: ChimeOptions[], options?: SequenceOptions) => Cue;
|
|
48
50
|
/** Takes back every booked strike that hasn't sounded yet. */
|
|
49
51
|
cancelCues: () => void;
|
|
52
|
+
/**
|
|
53
|
+
* Calls `callback` when the audio runs again after something stopped it (a
|
|
54
|
+
* call, a notification, speech, the lock screen on iOS). The package has
|
|
55
|
+
* already taken back every booked strike by then, since they would strike
|
|
56
|
+
* late, so the callback books again from where the run is now. Returns the
|
|
57
|
+
* unsubscribe.
|
|
58
|
+
*/
|
|
59
|
+
onCuesRecovered: (callback: () => void) => () => void;
|
|
60
|
+
/**
|
|
61
|
+
* False when the audio clock has drifted from `Date.now()` by more than
|
|
62
|
+
* 250 ms since the bookings were made, or the audio is stopped: rebook
|
|
63
|
+
* (`cancelCues()`, then book again). True when nothing is booked. Check it
|
|
64
|
+
* on a tick, for where no recovery was announced. `now` is for tests.
|
|
65
|
+
*/
|
|
66
|
+
cuesInSync: (now?: number) => boolean;
|
|
50
67
|
/** Speaks `text`, cutting off any line still being spoken. */
|
|
51
68
|
say: (text: string, options?: SayOptions) => void;
|
|
52
69
|
/** Stops whatever is being spoken. */
|
|
@@ -73,6 +90,14 @@ export declare const chime: AudioCues['chime'];
|
|
|
73
90
|
export declare const sequence: AudioCues['sequence'];
|
|
74
91
|
/** Takes back every booked chime that hasn't sounded yet: on pause, skip, back or leaving. */
|
|
75
92
|
export declare const cancelCues: AudioCues['cancelCues'];
|
|
93
|
+
/**
|
|
94
|
+
* Calls back when the audio runs again after an interruption, with every
|
|
95
|
+
* booked chime already taken back: book again from where the run is now.
|
|
96
|
+
* Returns the unsubscribe (call it in an effect's cleanup).
|
|
97
|
+
*/
|
|
98
|
+
export declare const onCuesRecovered: AudioCues['onCuesRecovered'];
|
|
99
|
+
/** False when booked chimes have drifted from `Date.now()` (or the audio is stopped): rebook. */
|
|
100
|
+
export declare const cuesInSync: AudioCues['cuesInSync'];
|
|
76
101
|
/** Speaks a line, cutting off the one before. A no-op without speech synthesis. */
|
|
77
102
|
export declare const say: AudioCues['say'];
|
|
78
103
|
/** Stops whatever is being spoken. */
|
package/dist/lib/audio-cues.js
CHANGED
|
@@ -14,12 +14,21 @@
|
|
|
14
14
|
// booked strike that hasn't sounded yet (on pause, skip or leaving), so a
|
|
15
15
|
// screen rebooks from its new position.
|
|
16
16
|
//
|
|
17
|
+
// On iOS the audio clock stops whenever the context is interrupted (speech
|
|
18
|
+
// speaking, a call, the lock screen) and booked strikes go silent or late.
|
|
19
|
+
// The context is resumed as soon as it stops, and once it runs again every
|
|
20
|
+
// booking is taken back and onCuesRecovered() callbacks fire, so the screen
|
|
21
|
+
// rebooks from where its run is now (#217). cuesInSync() catches the case
|
|
22
|
+
// where no statechange came, by comparing the audio clock with Date.now().
|
|
23
|
+
//
|
|
17
24
|
// createAudioCues takes the browser's APIs, so the rules are unit tested
|
|
18
25
|
// against fakes (tests/audio-cues.test.ts); the functions exported below
|
|
19
26
|
// are one shared instance over the real ones.
|
|
20
27
|
const ATTACK = 0.015;
|
|
21
28
|
// An exponential ramp can't reach zero, so a chime starts and ends here.
|
|
22
29
|
const SILENT = 0.0001;
|
|
30
|
+
// How far the audio clock may drift from Date.now() before cuesInSync() says so.
|
|
31
|
+
const SYNC_MARGIN = 250;
|
|
23
32
|
const NO_CUE = { cancel: () => { } };
|
|
24
33
|
export function createAudioCues(env) {
|
|
25
34
|
const now = env.now ?? Date.now;
|
|
@@ -29,6 +38,15 @@ export function createAudioCues(env) {
|
|
|
29
38
|
// A resume asked for from a click: the context is still "suspended" for
|
|
30
39
|
// a moment, and a chime from that same click should still play.
|
|
31
40
|
let resuming = false;
|
|
41
|
+
// Whether the context has run, and whether it has stopped since: a context
|
|
42
|
+
// that never ran is just waiting for the click.
|
|
43
|
+
let everRan = false;
|
|
44
|
+
let stopped = false;
|
|
45
|
+
let watchingPage = false;
|
|
46
|
+
const recovered = new Set();
|
|
47
|
+
// The audio clock against Date.now() when the first of the current bookings
|
|
48
|
+
// was made, for cuesInSync().
|
|
49
|
+
let anchor = null;
|
|
32
50
|
const playable = () => context !== null && (context.state === 'running' || resuming);
|
|
33
51
|
const strike = (ctx, { pitch = 660, volume = 0.25, duration = 1200 }, delay) => {
|
|
34
52
|
const start = ctx.currentTime + Math.max(delay, 0) / 1000;
|
|
@@ -48,15 +66,18 @@ export function createAudioCues(env) {
|
|
|
48
66
|
for (const old of booked)
|
|
49
67
|
if (old.end < ctx.currentTime)
|
|
50
68
|
booked.delete(old);
|
|
69
|
+
if (booked.size === 0)
|
|
70
|
+
anchor = { audio: ctx.currentTime, wall: now() };
|
|
51
71
|
booked.add(booking);
|
|
52
72
|
oscillator.onended = () => booked.delete(booking);
|
|
53
73
|
return booking;
|
|
54
74
|
};
|
|
55
|
-
// Silences a strike that hasn't sounded yet: one already sounding rings out
|
|
56
|
-
|
|
75
|
+
// Silences a strike that hasn't sounded yet: one already sounding rings out,
|
|
76
|
+
// unless `force`, for a clock that stood still (what was sounding is long over).
|
|
77
|
+
const unbook = (ctx, booking, force = false) => {
|
|
57
78
|
if (!booked.has(booking))
|
|
58
79
|
return;
|
|
59
|
-
if (booking.start <= ctx.currentTime)
|
|
80
|
+
if (!force && booking.start <= ctx.currentTime)
|
|
60
81
|
return;
|
|
61
82
|
booked.delete(booking);
|
|
62
83
|
try {
|
|
@@ -71,14 +92,72 @@ export function createAudioCues(env) {
|
|
|
71
92
|
cancel: () => strikes.forEach((booking) => unbook(ctx, booking)),
|
|
72
93
|
});
|
|
73
94
|
const delayUntil = (at) => (at === undefined ? 0 : at - now());
|
|
95
|
+
const resume = (ctx) => {
|
|
96
|
+
try {
|
|
97
|
+
Promise.resolve(ctx.resume()).catch(() => { });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Not allowed yet: the next click or return to the page tries again.
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
// Safari's own "interrupted" isn't in the DOM types, hence the string.
|
|
104
|
+
const stoppedState = (state) => state !== 'running' && state !== 'closed';
|
|
105
|
+
// Called on every statechange, on the page becoming visible again and
|
|
106
|
+
// after unlockAudio()'s resume: notices the audio stopping (and resumes
|
|
107
|
+
// it) and running again (and says so).
|
|
108
|
+
const check = () => {
|
|
109
|
+
const ctx = context;
|
|
110
|
+
if (!ctx)
|
|
111
|
+
return;
|
|
112
|
+
if (ctx.state === 'running') {
|
|
113
|
+
everRan = true;
|
|
114
|
+
if (!stopped)
|
|
115
|
+
return;
|
|
116
|
+
stopped = false;
|
|
117
|
+
for (const booking of [...booked])
|
|
118
|
+
unbook(ctx, booking, true);
|
|
119
|
+
anchor = null;
|
|
120
|
+
for (const callback of [...recovered]) {
|
|
121
|
+
try {
|
|
122
|
+
callback();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// One screen's callback failing mustn't keep the others from rebooking.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (everRan && stoppedState(ctx.state)) {
|
|
130
|
+
stopped = true;
|
|
131
|
+
resume(ctx);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const watch = (ctx) => {
|
|
135
|
+
ctx.addEventListener?.('statechange', check);
|
|
136
|
+
const page = env.document;
|
|
137
|
+
if (page && !watchingPage) {
|
|
138
|
+
watchingPage = true;
|
|
139
|
+
page.addEventListener('visibilitychange', () => {
|
|
140
|
+
if (page.visibilityState === 'visible')
|
|
141
|
+
check();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
};
|
|
74
145
|
return {
|
|
75
146
|
unlockAudio() {
|
|
76
147
|
if (env.AudioContext) {
|
|
77
148
|
try {
|
|
78
|
-
|
|
149
|
+
if (!context) {
|
|
150
|
+
context = new env.AudioContext();
|
|
151
|
+
watch(context);
|
|
152
|
+
}
|
|
153
|
+
check();
|
|
79
154
|
if (context.state !== 'running' && context.state !== 'closed') {
|
|
80
155
|
resuming = true;
|
|
81
|
-
|
|
156
|
+
const done = () => {
|
|
157
|
+
resuming = false;
|
|
158
|
+
check();
|
|
159
|
+
};
|
|
160
|
+
context.resume().then(done, done);
|
|
82
161
|
}
|
|
83
162
|
}
|
|
84
163
|
catch {
|
|
@@ -117,6 +196,18 @@ export function createAudioCues(env) {
|
|
|
117
196
|
for (const booking of [...booked])
|
|
118
197
|
unbook(ctx, booking);
|
|
119
198
|
},
|
|
199
|
+
onCuesRecovered(callback) {
|
|
200
|
+
recovered.add(callback);
|
|
201
|
+
return () => void recovered.delete(callback);
|
|
202
|
+
},
|
|
203
|
+
cuesInSync(at = now()) {
|
|
204
|
+
if (!context || !anchor || booked.size === 0)
|
|
205
|
+
return true;
|
|
206
|
+
if (stopped)
|
|
207
|
+
return false;
|
|
208
|
+
const audioElapsed = (context.currentTime - anchor.audio) * 1000;
|
|
209
|
+
return Math.abs(audioElapsed - (at - anchor.wall)) <= SYNC_MARGIN;
|
|
210
|
+
},
|
|
120
211
|
say(text, { lang, rate, volume } = {}) {
|
|
121
212
|
if (!env.speechSynthesis || !env.SpeechSynthesisUtterance)
|
|
122
213
|
return;
|
|
@@ -171,6 +262,7 @@ function browserCues() {
|
|
|
171
262
|
AudioContext: win?.AudioContext ?? win?.webkitAudioContext,
|
|
172
263
|
speechSynthesis: win?.speechSynthesis,
|
|
173
264
|
SpeechSynthesisUtterance: win?.SpeechSynthesisUtterance,
|
|
265
|
+
document: typeof document === 'undefined' ? undefined : document,
|
|
174
266
|
vibrate: canVibrate() ? (pattern) => navigator.vibrate(pattern) : undefined,
|
|
175
267
|
});
|
|
176
268
|
return shared;
|
|
@@ -186,6 +278,14 @@ export const chime = (options) => browserCues().chime(options);
|
|
|
186
278
|
export const sequence = (chimes, options) => browserCues().sequence(chimes, options);
|
|
187
279
|
/** Takes back every booked chime that hasn't sounded yet: on pause, skip, back or leaving. */
|
|
188
280
|
export const cancelCues = () => browserCues().cancelCues();
|
|
281
|
+
/**
|
|
282
|
+
* Calls back when the audio runs again after an interruption, with every
|
|
283
|
+
* booked chime already taken back: book again from where the run is now.
|
|
284
|
+
* Returns the unsubscribe (call it in an effect's cleanup).
|
|
285
|
+
*/
|
|
286
|
+
export const onCuesRecovered = (callback) => browserCues().onCuesRecovered(callback);
|
|
287
|
+
/** False when booked chimes have drifted from `Date.now()` (or the audio is stopped): rebook. */
|
|
288
|
+
export const cuesInSync = (now) => browserCues().cuesInSync(now);
|
|
189
289
|
/** Speaks a line, cutting off the one before. A no-op without speech synthesis. */
|
|
190
290
|
export const say = (text, options) => browserCues().say(text, options);
|
|
191
291
|
/** Stops whatever is being spoken. */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type KeepAudioAliveOptions = {
|
|
2
|
+
/** Shown on the lock screen and in the control centre. */
|
|
3
|
+
title: string;
|
|
4
|
+
/**
|
|
5
|
+
* Called when the person presses Pause or Play on the lock screen or in the
|
|
6
|
+
* control centre, with whether it's now playing: pause or resume the run.
|
|
7
|
+
*/
|
|
8
|
+
onToggle?: (playing: boolean) => void;
|
|
9
|
+
};
|
|
10
|
+
export type KeepAudioAlive = {
|
|
11
|
+
/** Pauses the track and shows Play; `onToggle` isn't called. Call keepAudioAlive() from a click to start again. */
|
|
12
|
+
pause: () => void;
|
|
13
|
+
/** Ends it and takes the title off the lock screen. */
|
|
14
|
+
stop: () => void;
|
|
15
|
+
};
|
|
16
|
+
export type KeepAudioAliveEnvironment = {
|
|
17
|
+
Audio?: new (src?: string) => Pick<HTMLAudioElement, 'loop' | 'src' | 'play' | 'pause' | 'removeAttribute' | 'load'>;
|
|
18
|
+
mediaSession?: Pick<MediaSession, 'setActionHandler'> & {
|
|
19
|
+
metadata: MediaMetadata | null;
|
|
20
|
+
playbackState: MediaSessionPlaybackState;
|
|
21
|
+
};
|
|
22
|
+
MediaMetadata?: new (init?: MediaMetadataInit) => MediaMetadata;
|
|
23
|
+
createObjectURL?: (blob: Blob) => string;
|
|
24
|
+
revokeObjectURL?: (url: string) => void;
|
|
25
|
+
};
|
|
26
|
+
/** One second of silent 8-bit mono audio as a WAV file. */
|
|
27
|
+
export declare function silentWav(): Uint8Array;
|
|
28
|
+
export declare function createKeepAudioAlive(env: KeepAudioAliveEnvironment): ({ title, onToggle }: KeepAudioAliveOptions) => KeepAudioAlive;
|
|
29
|
+
/**
|
|
30
|
+
* Plays a silent looped track so iOS keeps the page's sound going with the
|
|
31
|
+
* phone locked, with `title` and Pause and Play on the lock screen. Call it
|
|
32
|
+
* 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.
|
|
34
|
+
*/
|
|
35
|
+
export declare const keepAudioAlive: (options: KeepAudioAliveOptions) => KeepAudioAlive;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Keeps a page's audio going with the phone locked (#217). iOS pauses a
|
|
2
|
+
// page's sound on the lock screen unless the page is playing media, so
|
|
3
|
+
// keepAudioAlive() plays a silent looped track, which also puts the page's
|
|
4
|
+
// title and a Pause and Play on the lock screen (the Media Session). It's
|
|
5
|
+
// opt-in and off by default: it takes the device's audio session, which
|
|
6
|
+
// pauses another app's music (docs/foundation/timed-screens.md "Sound").
|
|
7
|
+
//
|
|
8
|
+
// The track is a few bytes of silence made here, so there is no file to
|
|
9
|
+
// ship. createKeepAudioAlive takes the browser's APIs, so the rules are
|
|
10
|
+
// unit tested against fakes (tests/keep-audio-alive.test.ts); the function
|
|
11
|
+
// exported below is one shared instance over the real ones.
|
|
12
|
+
const SAMPLE_RATE = 8000;
|
|
13
|
+
const SECONDS = 1;
|
|
14
|
+
/** One second of silent 8-bit mono audio as a WAV file. */
|
|
15
|
+
export function silentWav() {
|
|
16
|
+
const samples = SAMPLE_RATE * SECONDS;
|
|
17
|
+
const bytes = new Uint8Array(44 + samples);
|
|
18
|
+
const view = new DataView(bytes.buffer);
|
|
19
|
+
const text = (offset, value) => {
|
|
20
|
+
for (let i = 0; i < value.length; i++)
|
|
21
|
+
bytes[offset + i] = value.charCodeAt(i);
|
|
22
|
+
};
|
|
23
|
+
text(0, 'RIFF');
|
|
24
|
+
view.setUint32(4, 36 + samples, true);
|
|
25
|
+
text(8, 'WAVE');
|
|
26
|
+
text(12, 'fmt ');
|
|
27
|
+
view.setUint32(16, 16, true);
|
|
28
|
+
view.setUint16(20, 1, true); // PCM
|
|
29
|
+
view.setUint16(22, 1, true); // mono
|
|
30
|
+
view.setUint32(24, SAMPLE_RATE, true);
|
|
31
|
+
view.setUint32(28, SAMPLE_RATE, true); // bytes per second
|
|
32
|
+
view.setUint16(32, 1, true); // bytes per frame
|
|
33
|
+
view.setUint16(34, 8, true); // bits per sample
|
|
34
|
+
text(36, 'data');
|
|
35
|
+
view.setUint32(40, samples, true);
|
|
36
|
+
// 8-bit PCM is unsigned: silence is the midpoint.
|
|
37
|
+
bytes.fill(128, 44);
|
|
38
|
+
return bytes;
|
|
39
|
+
}
|
|
40
|
+
const NO_KEEP_ALIVE = { pause: () => { }, stop: () => { } };
|
|
41
|
+
export function createKeepAudioAlive(env) {
|
|
42
|
+
let active = null;
|
|
43
|
+
return function keepAudioAlive({ title, onToggle }) {
|
|
44
|
+
active?.stop();
|
|
45
|
+
if (!env.Audio || !env.createObjectURL)
|
|
46
|
+
return NO_KEEP_ALIVE;
|
|
47
|
+
let url;
|
|
48
|
+
let audio;
|
|
49
|
+
try {
|
|
50
|
+
url = env.createObjectURL(new Blob([silentWav()], { type: 'audio/wav' }));
|
|
51
|
+
audio = new env.Audio(url);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return NO_KEEP_ALIVE;
|
|
55
|
+
}
|
|
56
|
+
audio.loop = true;
|
|
57
|
+
const session = env.mediaSession;
|
|
58
|
+
const play = () => {
|
|
59
|
+
// Refused without a click: the lock screen then shows nothing to keep.
|
|
60
|
+
Promise.resolve(audio.play()).catch(() => { });
|
|
61
|
+
if (session)
|
|
62
|
+
session.playbackState = 'playing';
|
|
63
|
+
};
|
|
64
|
+
const pause = () => {
|
|
65
|
+
audio.pause();
|
|
66
|
+
if (session)
|
|
67
|
+
session.playbackState = 'paused';
|
|
68
|
+
};
|
|
69
|
+
const handle = (action, handler) => {
|
|
70
|
+
try {
|
|
71
|
+
session?.setActionHandler(action, handler);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// This browser doesn't offer the action.
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
if (session) {
|
|
78
|
+
try {
|
|
79
|
+
if (env.MediaMetadata)
|
|
80
|
+
session.metadata = new env.MediaMetadata({ title });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// No metadata: the lock screen shows the page's own.
|
|
84
|
+
}
|
|
85
|
+
handle('play', () => {
|
|
86
|
+
play();
|
|
87
|
+
onToggle?.(true);
|
|
88
|
+
});
|
|
89
|
+
handle('pause', () => {
|
|
90
|
+
pause();
|
|
91
|
+
onToggle?.(false);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
play();
|
|
95
|
+
const keeping = {
|
|
96
|
+
pause,
|
|
97
|
+
stop() {
|
|
98
|
+
if (active !== keeping)
|
|
99
|
+
return;
|
|
100
|
+
active = null;
|
|
101
|
+
audio.pause();
|
|
102
|
+
audio.removeAttribute('src');
|
|
103
|
+
audio.load();
|
|
104
|
+
env.revokeObjectURL?.(url);
|
|
105
|
+
if (session) {
|
|
106
|
+
handle('play', null);
|
|
107
|
+
handle('pause', null);
|
|
108
|
+
session.metadata = null;
|
|
109
|
+
session.playbackState = 'none';
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
active = keeping;
|
|
114
|
+
return keeping;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
let shared = null;
|
|
118
|
+
/**
|
|
119
|
+
* Plays a silent looped track so iOS keeps the page's sound going with the
|
|
120
|
+
* phone locked, with `title` and Pause and Play on the lock screen. Call it
|
|
121
|
+
* 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.
|
|
123
|
+
*/
|
|
124
|
+
export const keepAudioAlive = (options) => {
|
|
125
|
+
if (!shared) {
|
|
126
|
+
const hasWindow = typeof window !== 'undefined';
|
|
127
|
+
shared = createKeepAudioAlive({
|
|
128
|
+
Audio: hasWindow ? window.Audio : undefined,
|
|
129
|
+
mediaSession: typeof navigator !== 'undefined' && 'mediaSession' in navigator ? navigator.mediaSession : undefined,
|
|
130
|
+
MediaMetadata: hasWindow ? window.MediaMetadata : undefined,
|
|
131
|
+
createObjectURL: typeof URL !== 'undefined' ? (blob) => URL.createObjectURL(blob) : undefined,
|
|
132
|
+
revokeObjectURL: typeof URL !== 'undefined' ? (url) => URL.revokeObjectURL(url) : undefined,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return shared(options);
|
|
136
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.30.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": {
|