@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.
@@ -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
- resume = async () => {}
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';
@@ -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. */
@@ -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
- const unbook = (ctx, booking) => {
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
- context ??= new env.AudioContext();
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
- context.resume().then(() => (resuming = false), () => (resuming = false));
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.29.3",
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": {