@joewinke/jatui 0.29.0 → 0.31.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/CLAUDE.md +5 -2
- package/dist/components/ThemeToggle.svelte +158 -0
- package/dist/components/ThemeToggle.svelte.d.ts +15 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sound/SoundSettings.svelte +5 -1
- package/dist/sound/backend.d.ts +19 -0
- package/dist/sound/backend.js +39 -2
- package/dist/sound/index.d.ts +1 -0
- package/dist/sound/index.js +3 -0
- package/dist/sound/player.d.ts +12 -0
- package/dist/sound/player.js +14 -0
- package/dist/sound/unlock.d.ts +42 -0
- package/dist/sound/unlock.js +63 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -229,7 +229,7 @@ const rec = createAudioRecorder({
|
|
|
229
229
|
|
|
230
230
|
### Sound (first-class sibling of motion)
|
|
231
231
|
|
|
232
|
-
One preferences store every app sound goes through. UI + notification sounds are **off by default** (opt-in); the `incoming-call` ringer is **opt-out** (plays out of the box, muted via `categories.calls`). Semantic names (`confirm`, `error`, `notify`, `message`, `send`, `toggle`, `tap`, `incoming-call`, plus the booking-delight set `tick` 0.03 / `select` 0.08 / `chime` 0.12 / `attention` 0.10) map to bundled CC0 clips or a host's own file (`SOUND_CLIP_VARIANTS['error-soft']` is a gentler `error`). Policy per category: background tab, quiet hours and the quiet/reduced-motion mode silence `ui`/`notifications`; calls always ring. **Throttle:** per-name `minIntervalMs` (tick 80 ms, select 120 ms) drops — never queues — a play inside the window (`blocked: 'throttled'`); per-call `minIntervalMs` overrides. **Pitch:** `playSound('tick', { pitch: pentatonicPitch(i) })` (semitones → `playbackRate`; `playbackRate` also accepted). `tick` is the one hover sound — call it only on pointer-fine devices. **Site level:** host stamps `<html data-sound="off|subtle">` (like `data-motion`; `setSoundLevel` / `resolveSoundLevel` / `soundLevelDefaults`); `subtle` only starts UI sound on for a visitor with no saved preference — a saved preference (the panel mute) always wins, and `decideSound` is unchanged. Discipline doc: `docs/sound.md`.
|
|
232
|
+
One preferences store every app sound goes through. UI + notification sounds are **off by default** (opt-in); the `incoming-call` ringer is **opt-out** (plays out of the box, muted via `categories.calls`). Semantic names (`confirm`, `error`, `notify`, `message`, `send`, `toggle`, `tap`, `incoming-call`, plus the booking-delight set `tick` 0.03 / `select` 0.08 / `chime` 0.12 / `attention` 0.10) map to bundled CC0 clips or a host's own file (`SOUND_CLIP_VARIANTS['error-soft']` is a gentler `error`). Policy per category: background tab, quiet hours and the quiet/reduced-motion mode silence `ui`/`notifications`; calls always ring. **Throttle:** per-name `minIntervalMs` (tick 80 ms, select 120 ms) drops — never queues — a play inside the window (`blocked: 'throttled'`); per-call `minIntervalMs` overrides. **Pitch:** `playSound('tick', { pitch: pentatonicPitch(i) })` (semitones → `playbackRate`; `playbackRate` also accepted). `tick` is the one hover sound — call it only on pointer-fine devices. **Site level:** host stamps `<html data-sound="off|subtle">` (like `data-motion`; `setSoundLevel` / `resolveSoundLevel` / `soundLevelDefaults`); `subtle` only starts UI sound on for a visitor with no saved preference — a saved preference (the panel mute) always wins, and `decideSound` is unchanged. **iOS unlock:** the player creates its AudioContext lazily (inside clip-decode promise chains), which iOS Safari leaves suspended forever — call `unlockSound()` from inside a user gesture (or `armSoundUnlock()` once for a one-shot first-gesture listener); custom backends implement optional `unlock()` / `unlocked` and the player delegates. Discipline doc: `docs/sound.md`.
|
|
233
233
|
|
|
234
234
|
| Component | Purpose | Key Props |
|
|
235
235
|
|-----------|---------|-----------|
|
|
@@ -238,10 +238,12 @@ One preferences store every app sound goes through. UI + notification sounds are
|
|
|
238
238
|
```typescript
|
|
239
239
|
import {
|
|
240
240
|
playSound, // playSound('confirm') → SoundHandle { stop, playing, blocked } — never throws
|
|
241
|
-
getSoundPlayer, // .register(name, src, spec?) · .play · .preload · .stopAll
|
|
241
|
+
getSoundPlayer, // .register(name, src, spec?) · .play · .preload · .stopAll · .unlock()/.unlocked
|
|
242
242
|
getSoundPreferences, // .get · .set(patch) · .setCategory · .reset · .subscribe (localStorage 'jatui-sound-prefs', cross-tab)
|
|
243
243
|
createSoundPreferences, createSoundPlayer, // isolated instances (tests, demos)
|
|
244
244
|
decideSound, // the pure policy, for custom players
|
|
245
|
+
unlockSound, // iOS/Safari gesture unlock — call inside a click/touchend handler
|
|
246
|
+
armSoundUnlock, // one-shot first-gesture listener (self-removing, re-armable)
|
|
245
247
|
SOUND_NAMES, SOUND_SPECS, SOUND_CATEGORIES
|
|
246
248
|
} from "jatui"
|
|
247
249
|
// handle.blocked: 'disabled' | 'category-muted' | 'hidden' | 'quiet-hours' | 'quiet' | 'silent'
|
|
@@ -310,6 +312,7 @@ const callbacks: AnnotationCallbacks = {
|
|
|
310
312
|
| Component | Purpose | Key Props |
|
|
311
313
|
|-----------|---------|-----------|
|
|
312
314
|
| `ThemeSelector` | DaisyUI theme switcher with preview dots | `themes`, `defaultTheme`, `dropUp`, `storageKey`, `onThemeChange` |
|
|
315
|
+
| `ThemeToggle` | Icon-only sun<->moon light/dark toggle with an animated morph (transform+opacity on motion tokens; instant under reduced-motion; spring flattens on `data-motion="calm"`). A pure control — flips the bindable `theme` and fires `onchange`; storage stays with the consumer. | `theme` (bindable), `lightTheme`, `darkTheme`, `onchange`, `size` |
|
|
313
316
|
|
|
314
317
|
### Keyboard Navigation
|
|
315
318
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ThemeToggle — icon-only light/dark switch with an animated sun<->moon morph
|
|
4
|
+
* (mos-p6o2d). The public-site counterpart to ThemeSelector: where a site
|
|
5
|
+
* offers exactly two themes, a dropdown with visible "Dark mode" text is
|
|
6
|
+
* chrome noise — this is a single icon button instead.
|
|
7
|
+
*
|
|
8
|
+
* The component is a CONTROL, not a theme manager: it flips the bindable
|
|
9
|
+
* `theme` between `lightTheme` and `darkTheme` and fires `onchange`. It does
|
|
10
|
+
* not touch localStorage, `document.documentElement` or any storage key —
|
|
11
|
+
* persistence, pre-paint system-preference behaviour and DB theme overrides
|
|
12
|
+
* stay with the consumer (see JST's PublicThemeToggle wrapper, which owns
|
|
13
|
+
* the exact storage contract ThemeSelector established).
|
|
14
|
+
*
|
|
15
|
+
* Motion: the morph uses transform + opacity only, driven by the jatui
|
|
16
|
+
* motion tokens (`--motion-fast` duration, spring for the settle, glide for
|
|
17
|
+
* the cross-fade). Honours `data-motion="calm"` (tokens re-declared there —
|
|
18
|
+
* springs flatten) and `prefers-reduced-motion` (instant swap, both via the
|
|
19
|
+
* motion.css global floor and a component-local guard, since not every
|
|
20
|
+
* consumer loads motion.css). Colours are token-only — no literals.
|
|
21
|
+
*/
|
|
22
|
+
let {
|
|
23
|
+
theme = $bindable('light'),
|
|
24
|
+
lightTheme = 'light',
|
|
25
|
+
darkTheme = 'dark',
|
|
26
|
+
onchange,
|
|
27
|
+
size = 'md'
|
|
28
|
+
}: {
|
|
29
|
+
/** Current theme name (bindable). Anything that is not `darkTheme` reads as light. */
|
|
30
|
+
theme?: string;
|
|
31
|
+
/** Theme name considered "light" (sun shown; the target when dark). */
|
|
32
|
+
lightTheme?: string;
|
|
33
|
+
/** Theme name considered "dark" (moon shown; the target when light). */
|
|
34
|
+
darkTheme?: string;
|
|
35
|
+
/** Fired after the toggle flips the bound theme. */
|
|
36
|
+
onchange?: (theme: string) => void;
|
|
37
|
+
/** Hit-target size — sm 40px, md 44px, lg 48px. Never below the 40px touch floor. */
|
|
38
|
+
size?: 'sm' | 'md' | 'lg';
|
|
39
|
+
} = $props();
|
|
40
|
+
|
|
41
|
+
let isDark = $derived(theme === darkTheme);
|
|
42
|
+
|
|
43
|
+
const BUTTON_SIZES = {
|
|
44
|
+
sm: 'size-10',
|
|
45
|
+
md: 'size-11',
|
|
46
|
+
lg: 'size-12'
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
const ICON_SIZES = { sm: 18, md: 20, lg: 24 } as const;
|
|
50
|
+
|
|
51
|
+
const label = $derived(isDark ? 'Switch to light mode' : 'Switch to dark mode');
|
|
52
|
+
|
|
53
|
+
function toggle() {
|
|
54
|
+
const next = isDark ? lightTheme : darkTheme;
|
|
55
|
+
theme = next;
|
|
56
|
+
onchange?.(next);
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
class="jatui-theme-toggle inline-flex shrink-0 items-center justify-center rounded-full
|
|
63
|
+
text-base-content transition-colors duration-[var(--motion-instant,80ms)]
|
|
64
|
+
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary
|
|
65
|
+
hover:bg-base-content/10 focus-visible:bg-base-content/10
|
|
66
|
+
active:bg-base-content/15 disabled:pointer-events-none disabled:opacity-50
|
|
67
|
+
{BUTTON_SIZES[size]}"
|
|
68
|
+
class:is-dark={isDark}
|
|
69
|
+
aria-label={label}
|
|
70
|
+
aria-pressed={isDark}
|
|
71
|
+
title={label}
|
|
72
|
+
onclick={toggle}
|
|
73
|
+
>
|
|
74
|
+
<span
|
|
75
|
+
class="glyphs relative block"
|
|
76
|
+
style="width: {ICON_SIZES[size]}px; height: {ICON_SIZES[size]}px"
|
|
77
|
+
aria-hidden="true"
|
|
78
|
+
>
|
|
79
|
+
<!-- Sun: ring + eight rays, lucide geometry. -->
|
|
80
|
+
<svg
|
|
81
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
82
|
+
viewBox="0 0 24 24"
|
|
83
|
+
fill="none"
|
|
84
|
+
stroke="currentColor"
|
|
85
|
+
stroke-width="2"
|
|
86
|
+
stroke-linecap="round"
|
|
87
|
+
stroke-linejoin="round"
|
|
88
|
+
class="glyph glyph-sun absolute inset-0"
|
|
89
|
+
width={ICON_SIZES[size]}
|
|
90
|
+
height={ICON_SIZES[size]}
|
|
91
|
+
>
|
|
92
|
+
<circle cx="12" cy="12" r="4" />
|
|
93
|
+
<path d="M12 2v2" />
|
|
94
|
+
<path d="M12 20v2" />
|
|
95
|
+
<path d="m4.93 4.93 1.41 1.41" />
|
|
96
|
+
<path d="m17.66 17.66 1.41 1.41" />
|
|
97
|
+
<path d="M2 12h2" />
|
|
98
|
+
<path d="M20 12h2" />
|
|
99
|
+
<path d="m6.34 17.66-1.41 1.41" />
|
|
100
|
+
<path d="m19.07 4.93-1.41 1.41" />
|
|
101
|
+
</svg>
|
|
102
|
+
<!-- Moon: crescent, lucide geometry. -->
|
|
103
|
+
<svg
|
|
104
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
105
|
+
viewBox="0 0 24 24"
|
|
106
|
+
fill="none"
|
|
107
|
+
stroke="currentColor"
|
|
108
|
+
stroke-width="2"
|
|
109
|
+
stroke-linecap="round"
|
|
110
|
+
stroke-linejoin="round"
|
|
111
|
+
class="glyph glyph-moon absolute inset-0"
|
|
112
|
+
width={ICON_SIZES[size]}
|
|
113
|
+
height={ICON_SIZES[size]}
|
|
114
|
+
>
|
|
115
|
+
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
|
116
|
+
</svg>
|
|
117
|
+
</span>
|
|
118
|
+
</button>
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
/*
|
|
122
|
+
* Morph: transform + opacity ONLY (compositor-friendly, no layout thrash).
|
|
123
|
+
* The outgoing glyph rotates away and shrinks while fading; the incoming
|
|
124
|
+
* one rotates in. Spring carries the small-element settle; glide the fade.
|
|
125
|
+
*/
|
|
126
|
+
.glyph {
|
|
127
|
+
transition:
|
|
128
|
+
transform var(--motion-fast, 150ms) var(--motion-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
|
|
129
|
+
opacity var(--motion-fast, 150ms) var(--motion-glide, cubic-bezier(0.16, 1, 0.3, 1));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.glyph-sun {
|
|
133
|
+
opacity: 1;
|
|
134
|
+
transform: rotate(0deg) scale(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.glyph-moon {
|
|
138
|
+
opacity: 0;
|
|
139
|
+
transform: rotate(-90deg) scale(0.5);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.is-dark .glyph-sun {
|
|
143
|
+
opacity: 0;
|
|
144
|
+
transform: rotate(90deg) scale(0.5);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.is-dark .glyph-moon {
|
|
148
|
+
opacity: 1;
|
|
149
|
+
transform: rotate(0deg) scale(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Component-local reduced-motion guard — not every consumer loads motion.css. */
|
|
153
|
+
@media (prefers-reduced-motion: reduce) {
|
|
154
|
+
.glyph {
|
|
155
|
+
transition: none;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
/** Current theme name (bindable). Anything that is not `darkTheme` reads as light. */
|
|
3
|
+
theme?: string;
|
|
4
|
+
/** Theme name considered "light" (sun shown; the target when dark). */
|
|
5
|
+
lightTheme?: string;
|
|
6
|
+
/** Theme name considered "dark" (moon shown; the target when light). */
|
|
7
|
+
darkTheme?: string;
|
|
8
|
+
/** Fired after the toggle flips the bound theme. */
|
|
9
|
+
onchange?: (theme: string) => void;
|
|
10
|
+
/** Hit-target size — sm 40px, md 44px, lg 48px. Never below the 40px touch floor. */
|
|
11
|
+
size?: 'sm' | 'md' | 'lg';
|
|
12
|
+
};
|
|
13
|
+
declare const ThemeToggle: import("svelte").Component<$$ComponentProps, {}, "theme">;
|
|
14
|
+
type ThemeToggle = ReturnType<typeof ThemeToggle>;
|
|
15
|
+
export default ThemeToggle;
|
package/dist/index.d.ts
CHANGED
|
@@ -93,6 +93,7 @@ export type { AudioRecordingResult } from './utils/audioRecorderCore';
|
|
|
93
93
|
export { createAudioRecorder } from './utils/audioRecorder';
|
|
94
94
|
export type { AudioRecorder, AudioRecorderCore, AudioRecorderError, AudioRecorderErrorKind, AudioRecorderFrame, AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecorderSnapshot } from './utils/audioRecorderCore';
|
|
95
95
|
export { default as ThemeSelector } from './components/ThemeSelector.svelte';
|
|
96
|
+
export { default as ThemeToggle } from './components/ThemeToggle.svelte';
|
|
96
97
|
export { default as Sparkline } from './components/Sparkline.svelte';
|
|
97
98
|
export { default as BurndownChart } from './components/BurndownChart.svelte';
|
|
98
99
|
export { default as AvatarUpload } from './components/AvatarUpload.svelte';
|
package/dist/index.js
CHANGED
|
@@ -127,6 +127,7 @@ export { default as AudioRecorder } from './components/AudioRecorder.svelte';
|
|
|
127
127
|
export { createAudioRecorder } from './utils/audioRecorder';
|
|
128
128
|
// Components — from Headcount
|
|
129
129
|
export { default as ThemeSelector } from './components/ThemeSelector.svelte';
|
|
130
|
+
export { default as ThemeToggle } from './components/ThemeToggle.svelte';
|
|
130
131
|
export { default as Sparkline } from './components/Sparkline.svelte';
|
|
131
132
|
export { default as BurndownChart } from './components/BurndownChart.svelte';
|
|
132
133
|
// Components — from JST
|
|
@@ -68,7 +68,11 @@
|
|
|
68
68
|
}
|
|
69
69
|
function preview(name: SoundId) {
|
|
70
70
|
stopPreview();
|
|
71
|
-
|
|
71
|
+
const p = player ?? getSoundPlayer();
|
|
72
|
+
// A preview click is a real gesture: unlock here too, so a first-ever
|
|
73
|
+
// preview on iOS actually sounds instead of resuming a dead context.
|
|
74
|
+
void p.unlock();
|
|
75
|
+
previewing = p.play(name, { preview: true, loop: false });
|
|
72
76
|
previewTimer = setTimeout(stopPreview, PREVIEW_MAX_MS);
|
|
73
77
|
}
|
|
74
78
|
onDestroy(stopPreview);
|
package/dist/sound/backend.d.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Audio output backend. The player talks to this interface so its policy and
|
|
3
3
|
* bookkeeping run in node tests with a fake; `createWebAudioBackend` is the
|
|
4
4
|
* real one (lazy AudioContext, decoded-buffer playback through a GainNode).
|
|
5
|
+
*
|
|
6
|
+
* Backends MAY implement `unlock()` / `unlocked` — the player's `unlock()`
|
|
7
|
+
* and `unlocked` delegate to them (see `sound/unlock.ts` for the host-facing
|
|
8
|
+
* helpers). A backend without them is assumed fine: it reports unlocked and
|
|
9
|
+
* the player's unlock is a no-op.
|
|
5
10
|
*/
|
|
6
11
|
export interface SoundVoice {
|
|
7
12
|
stop(): void;
|
|
@@ -20,5 +25,19 @@ export interface SoundBackend {
|
|
|
20
25
|
/** Decode a data URI or URL. Resolves `null` when it can't be played here. */
|
|
21
26
|
load(src: string): Promise<unknown | null>;
|
|
22
27
|
start(clip: unknown, options: StartOptions): SoundVoice | null;
|
|
28
|
+
/**
|
|
29
|
+
* Create/resume the output context from inside a user-gesture handler
|
|
30
|
+
* (click, touchend, keydown). OPTIONAL: only iOS/Safari needs it, and
|
|
31
|
+
* only for Web Audio — there the context must be created or resumed
|
|
32
|
+
* synchronously inside the gesture, or it stays suspended and every
|
|
33
|
+
* sound is silent (mos-rd6kw). `sound/unlock.ts` calls this for a host.
|
|
34
|
+
*/
|
|
35
|
+
unlock?(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* True when the output context is running. OPTIONAL: a backend that
|
|
38
|
+
* has neither `unlock` nor `unlocked` reports unlocked (nothing to
|
|
39
|
+
* unlock); one that unlocks should report its state honestly.
|
|
40
|
+
*/
|
|
41
|
+
readonly unlocked?: boolean;
|
|
23
42
|
}
|
|
24
43
|
export declare function createWebAudioBackend(): SoundBackend;
|
package/dist/sound/backend.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Audio output backend. The player talks to this interface so its policy and
|
|
3
3
|
* bookkeeping run in node tests with a fake; `createWebAudioBackend` is the
|
|
4
4
|
* real one (lazy AudioContext, decoded-buffer playback through a GainNode).
|
|
5
|
+
*
|
|
6
|
+
* Backends MAY implement `unlock()` / `unlocked` — the player's `unlock()`
|
|
7
|
+
* and `unlocked` delegate to them (see `sound/unlock.ts` for the host-facing
|
|
8
|
+
* helpers). A backend without them is assumed fine: it reports unlocked and
|
|
9
|
+
* the player's unlock is a no-op.
|
|
5
10
|
*/
|
|
6
11
|
function dataUriBytes(uri) {
|
|
7
12
|
const binary = atob(uri.slice(uri.indexOf(',') + 1));
|
|
@@ -12,13 +17,25 @@ function dataUriBytes(uri) {
|
|
|
12
17
|
}
|
|
13
18
|
export function createWebAudioBackend() {
|
|
14
19
|
let ctx = null;
|
|
20
|
+
/**
|
|
21
|
+
* The context, memoized. It is created on the FIRST call — unlock()
|
|
22
|
+
* makes that first call happen synchronously inside a user gesture,
|
|
23
|
+
* which is the only way iOS Safari ever leaves it running (mos-rd6kw).
|
|
24
|
+
* Memoizing also means playback after an unlock reuses the unlocked
|
|
25
|
+
* context instead of lazily racing a second one.
|
|
26
|
+
*/
|
|
15
27
|
function context() {
|
|
16
28
|
if (ctx)
|
|
17
29
|
return ctx;
|
|
18
|
-
|
|
30
|
+
// Old WebKit only ever exposed the prefixed constructor.
|
|
31
|
+
const Ctx = typeof AudioContext === 'undefined'
|
|
32
|
+
? globalThis
|
|
33
|
+
.webkitAudioContext
|
|
34
|
+
: AudioContext;
|
|
35
|
+
if (!Ctx)
|
|
19
36
|
return null;
|
|
20
37
|
try {
|
|
21
|
-
ctx = new
|
|
38
|
+
ctx = new Ctx();
|
|
22
39
|
}
|
|
23
40
|
catch {
|
|
24
41
|
return null;
|
|
@@ -91,6 +108,26 @@ export function createWebAudioBackend() {
|
|
|
91
108
|
out.disconnect();
|
|
92
109
|
}
|
|
93
110
|
};
|
|
111
|
+
},
|
|
112
|
+
/**
|
|
113
|
+
* Create and resume the context NOW — call from inside a user-gesture
|
|
114
|
+
* handler (see `sound/unlock.ts`). Everything up to and including the
|
|
115
|
+
* `resume()` call stays synchronous, which is what keeps the gesture;
|
|
116
|
+
* the await only wraps the promise. Desktop browsers permit this any
|
|
117
|
+
* time; iOS Safari is the reason it exists.
|
|
118
|
+
*/
|
|
119
|
+
async unlock() {
|
|
120
|
+
try {
|
|
121
|
+
const audio = context();
|
|
122
|
+
if (audio?.state === 'suspended')
|
|
123
|
+
await audio.resume();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* the unlock must never break the gesture it runs in */
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
get unlocked() {
|
|
130
|
+
return ctx?.state === 'running';
|
|
94
131
|
}
|
|
95
132
|
};
|
|
96
133
|
}
|
package/dist/sound/index.d.ts
CHANGED
|
@@ -17,4 +17,5 @@ export { createSoundPlayer, getSoundPlayer, setSoundPlayer, playSound, rateFor a
|
|
|
17
17
|
export type { SoundId, SoundPlayer, SoundHandle, PlaySoundOptions, CreateSoundPlayerOptions } from './player';
|
|
18
18
|
export { createWebAudioBackend } from './backend';
|
|
19
19
|
export type { SoundBackend, SoundVoice, StartOptions as SoundStartOptions } from './backend';
|
|
20
|
+
export { unlockSound, armSoundUnlock } from './unlock';
|
|
20
21
|
export { default as SoundSettings } from './SoundSettings.svelte';
|
package/dist/sound/index.js
CHANGED
|
@@ -11,4 +11,7 @@ export { SOUND_LEVELS, DEFAULT_SOUND_LEVEL, SOUND_LEVEL_ATTR, isSoundLevel, pars
|
|
|
11
11
|
export { decideSound, inQuietHours, readSoundEnvironment } from './policy';
|
|
12
12
|
export { createSoundPlayer, getSoundPlayer, setSoundPlayer, playSound, rateFor as soundPlaybackRate } from './player';
|
|
13
13
|
export { createWebAudioBackend } from './backend';
|
|
14
|
+
// The iOS/Safari gesture unlock (mos-rd6kw): unlockSound() from inside a
|
|
15
|
+
// gesture, or armSoundUnlock() once to listen for the first one.
|
|
16
|
+
export { unlockSound, armSoundUnlock } from './unlock';
|
|
14
17
|
export { default as SoundSettings } from './SoundSettings.svelte';
|
package/dist/sound/player.d.ts
CHANGED
|
@@ -50,6 +50,18 @@ export interface SoundPlayer {
|
|
|
50
50
|
/** Point a name at a clip (data URI or URL) and decode it. */
|
|
51
51
|
register(name: SoundId, src: string, spec?: Partial<SoundSpec>): Promise<void>;
|
|
52
52
|
play(name: SoundId, options?: PlaySoundOptions): SoundHandle;
|
|
53
|
+
/**
|
|
54
|
+
* Unlock audio output from inside a user-gesture handler (iOS/Safari
|
|
55
|
+
* keeps a context suspended unless the gesture created/resumed it —
|
|
56
|
+
* see `sound/unlock.ts`). Backends without an unlock concept resolve
|
|
57
|
+
* immediately. A cheap no-op once unlocked.
|
|
58
|
+
*/
|
|
59
|
+
unlock(): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* True when the backend reports a running output context; a backend
|
|
62
|
+
* with no unlock concept always reports true. False before any unlock.
|
|
63
|
+
*/
|
|
64
|
+
readonly unlocked: boolean;
|
|
53
65
|
/** Decode clips ahead of time (defaults to every semantic name). */
|
|
54
66
|
preload(names?: readonly SoundId[]): Promise<void>;
|
|
55
67
|
stopAll(): void;
|
package/dist/sound/player.js
CHANGED
|
@@ -126,6 +126,20 @@ export function createSoundPlayer(options = {}) {
|
|
|
126
126
|
async preload(names = SOUND_NAMES) {
|
|
127
127
|
await Promise.all(names.map((n) => clipFor(n)));
|
|
128
128
|
},
|
|
129
|
+
// The gesture unlock (mos-rd6kw): delegate to the backend — the
|
|
130
|
+
// default backend has the iOS-safe synchronous create+resume; a fake
|
|
131
|
+
// or a legacy backend without one resolves silently and reports
|
|
132
|
+
// unlocked, so a host that arms the unlock never has to branch.
|
|
133
|
+
async unlock() {
|
|
134
|
+
await audio().unlock?.();
|
|
135
|
+
},
|
|
136
|
+
get unlocked() {
|
|
137
|
+
// Reading this never creates the backend: nothing exists to be
|
|
138
|
+
// unlocked until something asked to play or unlock.
|
|
139
|
+
if (!backend)
|
|
140
|
+
return false;
|
|
141
|
+
return backend.unlocked ?? !backend.unlock;
|
|
142
|
+
},
|
|
129
143
|
stopAll() {
|
|
130
144
|
for (const a of [...active])
|
|
131
145
|
a.handle.stop();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The iOS/Safari audio unlock (mos-rd6kw, found in MOS's booking panel).
|
|
3
|
+
*
|
|
4
|
+
* Safari keeps an AudioContext suspended unless it was created or resumed
|
|
5
|
+
* inside a user gesture's synchronous call stack. The player creates its
|
|
6
|
+
* context lazily — inside clip-loading promise chains — which is never a
|
|
7
|
+
* gesture, so on an iPhone every sound stays silent no matter how many taps
|
|
8
|
+
* happened before it. The fix is one call from inside the gesture:
|
|
9
|
+
*
|
|
10
|
+
* button.addEventListener('click', () => void unlockSound());
|
|
11
|
+
*
|
|
12
|
+
* or, once at startup, the auto-installed one-shot listener:
|
|
13
|
+
*
|
|
14
|
+
* armSoundUnlock(); // first pointerup / touchend / click / keydown
|
|
15
|
+
*
|
|
16
|
+
* Unlock creates AND resumes the context synchronously (the constructor and
|
|
17
|
+
* the `resume()` call land inside the gesture; only the promise is awaited),
|
|
18
|
+
* so the later plays through the same player are allowed to sound. When a
|
|
19
|
+
* cue must be heard in the gesture itself, play it right after unlock in
|
|
20
|
+
* that same handler. iOS can re-suspend the context after the tab is
|
|
21
|
+
* backgrounded — re-arm (`armed` is cleared once the listener fires) or call
|
|
22
|
+
* `unlockSound()` again from the gesture that follows. And a "silent" phone
|
|
23
|
+
* can still be the hardware ring/silent switch, which mutes Web Audio on
|
|
24
|
+
* iOS no matter how the context was unlocked.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Create/resume the shared player's audio context. Call from inside a user
|
|
28
|
+
* gesture. Resolves even where audio is unsupported (no context here) and
|
|
29
|
+
* is a cheap no-op once the context is running.
|
|
30
|
+
*/
|
|
31
|
+
export declare function unlockSound(): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Install a ONE-SHOT unlock listener for the first pointer/key gesture on
|
|
34
|
+
* `target` (default: the document; pass an explicit target only where the
|
|
35
|
+
* gesture scope is narrower). Handy where no single call site owns "the
|
|
36
|
+
* first gesture" — a public page, a settings screen. Idempotent while
|
|
37
|
+
* armed: a second call before the first gesture is a no-op; the listeners
|
|
38
|
+
* remove themselves the first time any of the gestures fires, and calling
|
|
39
|
+
* this again re-arms (iOS can re-suspend the context after the tab is
|
|
40
|
+
* backgrounded — arm again on `visibilitychange`).
|
|
41
|
+
*/
|
|
42
|
+
export declare function armSoundUnlock(target?: Document | HTMLElement): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The iOS/Safari audio unlock (mos-rd6kw, found in MOS's booking panel).
|
|
3
|
+
*
|
|
4
|
+
* Safari keeps an AudioContext suspended unless it was created or resumed
|
|
5
|
+
* inside a user gesture's synchronous call stack. The player creates its
|
|
6
|
+
* context lazily — inside clip-loading promise chains — which is never a
|
|
7
|
+
* gesture, so on an iPhone every sound stays silent no matter how many taps
|
|
8
|
+
* happened before it. The fix is one call from inside the gesture:
|
|
9
|
+
*
|
|
10
|
+
* button.addEventListener('click', () => void unlockSound());
|
|
11
|
+
*
|
|
12
|
+
* or, once at startup, the auto-installed one-shot listener:
|
|
13
|
+
*
|
|
14
|
+
* armSoundUnlock(); // first pointerup / touchend / click / keydown
|
|
15
|
+
*
|
|
16
|
+
* Unlock creates AND resumes the context synchronously (the constructor and
|
|
17
|
+
* the `resume()` call land inside the gesture; only the promise is awaited),
|
|
18
|
+
* so the later plays through the same player are allowed to sound. When a
|
|
19
|
+
* cue must be heard in the gesture itself, play it right after unlock in
|
|
20
|
+
* that same handler. iOS can re-suspend the context after the tab is
|
|
21
|
+
* backgrounded — re-arm (`armed` is cleared once the listener fires) or call
|
|
22
|
+
* `unlockSound()` again from the gesture that follows. And a "silent" phone
|
|
23
|
+
* can still be the hardware ring/silent switch, which mutes Web Audio on
|
|
24
|
+
* iOS no matter how the context was unlocked.
|
|
25
|
+
*/
|
|
26
|
+
import { getSoundPlayer } from './player';
|
|
27
|
+
/**
|
|
28
|
+
* Create/resume the shared player's audio context. Call from inside a user
|
|
29
|
+
* gesture. Resolves even where audio is unsupported (no context here) and
|
|
30
|
+
* is a cheap no-op once the context is running.
|
|
31
|
+
*/
|
|
32
|
+
export async function unlockSound() {
|
|
33
|
+
await getSoundPlayer().unlock();
|
|
34
|
+
}
|
|
35
|
+
const GESTURES = ['pointerup', 'touchend', 'click', 'keydown'];
|
|
36
|
+
/** The target `armSoundUnlock` installed its one-shot listener on, if any. */
|
|
37
|
+
let armed = null;
|
|
38
|
+
/**
|
|
39
|
+
* Install a ONE-SHOT unlock listener for the first pointer/key gesture on
|
|
40
|
+
* `target` (default: the document; pass an explicit target only where the
|
|
41
|
+
* gesture scope is narrower). Handy where no single call site owns "the
|
|
42
|
+
* first gesture" — a public page, a settings screen. Idempotent while
|
|
43
|
+
* armed: a second call before the first gesture is a no-op; the listeners
|
|
44
|
+
* remove themselves the first time any of the gestures fires, and calling
|
|
45
|
+
* this again re-arms (iOS can re-suspend the context after the tab is
|
|
46
|
+
* backgrounded — arm again on `visibilitychange`).
|
|
47
|
+
*/
|
|
48
|
+
export function armSoundUnlock(target) {
|
|
49
|
+
if (armed)
|
|
50
|
+
return;
|
|
51
|
+
const t = target ?? (typeof document === 'undefined' ? undefined : document);
|
|
52
|
+
if (!t)
|
|
53
|
+
return;
|
|
54
|
+
armed = t;
|
|
55
|
+
const fire = () => {
|
|
56
|
+
armed = null;
|
|
57
|
+
for (const g of GESTURES)
|
|
58
|
+
t.removeEventListener(g, fire);
|
|
59
|
+
void unlockSound();
|
|
60
|
+
};
|
|
61
|
+
for (const g of GESTURES)
|
|
62
|
+
t.addEventListener(g, fire, { passive: true });
|
|
63
|
+
}
|