@joewinke/jatui 0.27.0 → 0.28.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 +37 -0
- package/dist/assets/sounds/README.md +16 -14
- package/dist/index.d.ts +4 -2
- package/dist/index.js +15 -3
- package/dist/motion/easing.d.ts +14 -0
- package/dist/motion/easing.js +67 -0
- package/dist/motion/index.d.ts +17 -0
- package/dist/motion/index.js +11 -0
- package/dist/motion/level.d.ts +88 -0
- package/dist/motion/level.js +132 -0
- package/dist/motion/store.d.ts +40 -0
- package/dist/motion/store.js +132 -0
- package/dist/motion/transitions.d.ts +72 -0
- package/dist/motion/transitions.js +112 -0
- package/dist/sound/SoundSettings.svelte +209 -0
- package/dist/sound/SoundSettings.svelte.d.ts +18 -0
- package/dist/sound/backend.d.ts +22 -0
- package/dist/sound/backend.js +95 -0
- package/dist/sound/index.d.ts +18 -0
- package/dist/sound/index.js +13 -0
- package/dist/sound/names.d.ts +39 -0
- package/dist/sound/names.js +121 -0
- package/dist/sound/player.d.ts +60 -0
- package/dist/sound/player.js +141 -0
- package/dist/sound/policy.d.ts +34 -0
- package/dist/sound/policy.js +56 -0
- package/dist/sound/preferences.d.ts +70 -0
- package/dist/sound/preferences.js +145 -0
- package/dist/styles/motion.css +88 -9
- package/dist/transitions/glide.d.ts +18 -6
- package/dist/transitions/glide.js +26 -31
- package/dist/utils/sound.d.ts +6 -0
- package/dist/utils/sound.js +12 -54
- package/package.json +2 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-built Svelte transitions — drop-in replacements for ad-hoc
|
|
3
|
+
* `svelte/transition` usage that read the `--motion-*` scheme and honour the
|
|
4
|
+
* surface's motion level (`data-motion`) and `prefers-reduced-motion`.
|
|
5
|
+
*
|
|
6
|
+
* <div transition:motionFade> fade, --motion-enter
|
|
7
|
+
* <div in:motionScale={{ easing: 'spring' }}> pop (spring → glide when calm)
|
|
8
|
+
* <section transition:motionSlide> height collapse, --motion-move
|
|
9
|
+
* <aside in:glide={{ from: 'right' }}> directional (transitions/glide)
|
|
10
|
+
*
|
|
11
|
+
* Shared params (all optional):
|
|
12
|
+
* duration a duration token ('instant'|'fast'|'enter'|'move') or a
|
|
13
|
+
* STANDARD-level ms value — either way it is scaled by the level.
|
|
14
|
+
* delay standard-level ms, scaled the same way.
|
|
15
|
+
* easing an easing token ('glide'|'spring'|'standard') or a function.
|
|
16
|
+
* level pin the level instead of reading the nearest [data-motion].
|
|
17
|
+
*
|
|
18
|
+
* Reduced motion: duration and delay become 0 — the element appears/leaves
|
|
19
|
+
* in its end state. This matches the global floor in styles/motion.css.
|
|
20
|
+
*/
|
|
21
|
+
import { type EasingFn } from './easing';
|
|
22
|
+
import { type MotionContext, type MotionDurationToken, type MotionEasingToken, type MotionLevel } from './level';
|
|
23
|
+
export interface MotionTransitionParams {
|
|
24
|
+
duration?: MotionDurationToken | number;
|
|
25
|
+
delay?: number;
|
|
26
|
+
easing?: MotionEasingToken | EasingFn;
|
|
27
|
+
level?: MotionLevel;
|
|
28
|
+
}
|
|
29
|
+
export interface MotionTransitionConfig {
|
|
30
|
+
delay: number;
|
|
31
|
+
duration: number;
|
|
32
|
+
easing: EasingFn;
|
|
33
|
+
css: (t: number, u: number) => string;
|
|
34
|
+
}
|
|
35
|
+
export interface ResolvedMotionTiming {
|
|
36
|
+
ctx: MotionContext;
|
|
37
|
+
delay: number;
|
|
38
|
+
duration: number;
|
|
39
|
+
easing: EasingFn;
|
|
40
|
+
}
|
|
41
|
+
/** Resolve the level-aware timing for a transition on `node`. */
|
|
42
|
+
export declare function resolveMotionTiming(node: Element | null, params: MotionTransitionParams, defaults: {
|
|
43
|
+
duration: MotionDurationToken | number;
|
|
44
|
+
easing: MotionEasingToken;
|
|
45
|
+
}): ResolvedMotionTiming;
|
|
46
|
+
export interface MotionFadeParams extends MotionTransitionParams {
|
|
47
|
+
}
|
|
48
|
+
/** Opacity fade. Defaults: --motion-enter, glide. */
|
|
49
|
+
export declare function motionFade(node: Element, params?: MotionFadeParams): MotionTransitionConfig;
|
|
50
|
+
export interface MotionScaleParams extends MotionTransitionParams {
|
|
51
|
+
/** Scale at the start of the enter. Default 0.95. */
|
|
52
|
+
start?: number;
|
|
53
|
+
/** Also fade. Default true. */
|
|
54
|
+
opacity?: boolean;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Scale + fade. Defaults: --motion-enter, glide. Pass `easing: 'spring'` for
|
|
58
|
+
* a state-confirmation pop on a SMALL element (policy: never on containers);
|
|
59
|
+
* calm and reduced surfaces get glide instead.
|
|
60
|
+
*/
|
|
61
|
+
export declare function motionScale(node: Element, params?: MotionScaleParams): MotionTransitionConfig;
|
|
62
|
+
export interface MotionSlideParams extends MotionTransitionParams {
|
|
63
|
+
/** Collapse axis. Default 'y' (height). */
|
|
64
|
+
axis?: 'x' | 'y';
|
|
65
|
+
/** Also fade. Default false (matches svelte's slide). */
|
|
66
|
+
opacity?: boolean;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Size collapse/expand along an axis (svelte `slide`, on tokens).
|
|
70
|
+
* Defaults: --motion-move, standard — a container morph, symmetric move.
|
|
71
|
+
*/
|
|
72
|
+
export declare function motionSlide(node: Element, params?: MotionSlideParams): MotionTransitionConfig;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-built Svelte transitions — drop-in replacements for ad-hoc
|
|
3
|
+
* `svelte/transition` usage that read the `--motion-*` scheme and honour the
|
|
4
|
+
* surface's motion level (`data-motion`) and `prefers-reduced-motion`.
|
|
5
|
+
*
|
|
6
|
+
* <div transition:motionFade> fade, --motion-enter
|
|
7
|
+
* <div in:motionScale={{ easing: 'spring' }}> pop (spring → glide when calm)
|
|
8
|
+
* <section transition:motionSlide> height collapse, --motion-move
|
|
9
|
+
* <aside in:glide={{ from: 'right' }}> directional (transitions/glide)
|
|
10
|
+
*
|
|
11
|
+
* Shared params (all optional):
|
|
12
|
+
* duration a duration token ('instant'|'fast'|'enter'|'move') or a
|
|
13
|
+
* STANDARD-level ms value — either way it is scaled by the level.
|
|
14
|
+
* delay standard-level ms, scaled the same way.
|
|
15
|
+
* easing an easing token ('glide'|'spring'|'standard') or a function.
|
|
16
|
+
* level pin the level instead of reading the nearest [data-motion].
|
|
17
|
+
*
|
|
18
|
+
* Reduced motion: duration and delay become 0 — the element appears/leaves
|
|
19
|
+
* in its end state. This matches the global floor in styles/motion.css.
|
|
20
|
+
*/
|
|
21
|
+
import { motionEasing } from './easing';
|
|
22
|
+
import { effectiveEasing, getMotionContext, motionContextFor, motionDuration, prefersReducedMotion } from './level';
|
|
23
|
+
/** Resolve the level-aware timing for a transition on `node`. */
|
|
24
|
+
export function resolveMotionTiming(node, params, defaults) {
|
|
25
|
+
const ctx = params.level
|
|
26
|
+
? motionContextFor(params.level, prefersReducedMotion())
|
|
27
|
+
: getMotionContext(node);
|
|
28
|
+
const duration = motionDuration(params.duration ?? defaults.duration, ctx);
|
|
29
|
+
const delay = motionDuration(params.delay ?? 0, ctx);
|
|
30
|
+
const easing = typeof params.easing === 'function'
|
|
31
|
+
? params.easing
|
|
32
|
+
: motionEasing(effectiveEasing(params.easing ?? defaults.easing, ctx));
|
|
33
|
+
return { ctx, delay, duration, easing };
|
|
34
|
+
}
|
|
35
|
+
const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
|
|
36
|
+
function baseOpacity(node) {
|
|
37
|
+
if (typeof getComputedStyle !== 'function')
|
|
38
|
+
return 1;
|
|
39
|
+
const o = parseFloat(getComputedStyle(node).opacity);
|
|
40
|
+
return Number.isFinite(o) ? o : 1;
|
|
41
|
+
}
|
|
42
|
+
/** Opacity fade. Defaults: --motion-enter, glide. */
|
|
43
|
+
export function motionFade(node, params = {}) {
|
|
44
|
+
const { delay, duration, easing } = resolveMotionTiming(node, params, {
|
|
45
|
+
duration: 'enter',
|
|
46
|
+
easing: 'glide'
|
|
47
|
+
});
|
|
48
|
+
const o = baseOpacity(node);
|
|
49
|
+
return { delay, duration, easing, css: (t) => `opacity: ${clamp01(t) * o}` };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Scale + fade. Defaults: --motion-enter, glide. Pass `easing: 'spring'` for
|
|
53
|
+
* a state-confirmation pop on a SMALL element (policy: never on containers);
|
|
54
|
+
* calm and reduced surfaces get glide instead.
|
|
55
|
+
*/
|
|
56
|
+
export function motionScale(node, params = {}) {
|
|
57
|
+
const { delay, duration, easing } = resolveMotionTiming(node, params, {
|
|
58
|
+
duration: 'enter',
|
|
59
|
+
easing: 'glide'
|
|
60
|
+
});
|
|
61
|
+
const start = params.start ?? 0.95;
|
|
62
|
+
const fade = params.opacity ?? true;
|
|
63
|
+
const o = baseOpacity(node);
|
|
64
|
+
const existing = typeof getComputedStyle === 'function' ? getComputedStyle(node).transform : 'none';
|
|
65
|
+
const prefix = existing && existing !== 'none' ? `${existing} ` : '';
|
|
66
|
+
return {
|
|
67
|
+
delay,
|
|
68
|
+
duration,
|
|
69
|
+
easing,
|
|
70
|
+
css: (t) => `transform: ${prefix}scale(${start + (1 - start) * t});` +
|
|
71
|
+
(fade ? ` opacity: ${clamp01(t) * o};` : '')
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Size collapse/expand along an axis (svelte `slide`, on tokens).
|
|
76
|
+
* Defaults: --motion-move, standard — a container morph, symmetric move.
|
|
77
|
+
*/
|
|
78
|
+
export function motionSlide(node, params = {}) {
|
|
79
|
+
const { delay, duration, easing } = resolveMotionTiming(node, params, {
|
|
80
|
+
duration: 'move',
|
|
81
|
+
easing: 'standard'
|
|
82
|
+
});
|
|
83
|
+
const axis = params.axis ?? 'y';
|
|
84
|
+
const fade = params.opacity ?? false;
|
|
85
|
+
if (typeof getComputedStyle !== 'function') {
|
|
86
|
+
return { delay, duration, easing, css: () => '' };
|
|
87
|
+
}
|
|
88
|
+
const style = getComputedStyle(node);
|
|
89
|
+
const o = +style.opacity || 1;
|
|
90
|
+
const size = axis === 'y' ? 'height' : 'width';
|
|
91
|
+
const [a, b] = axis === 'y' ? ['top', 'bottom'] : ['left', 'right'];
|
|
92
|
+
const px = (prop) => parseFloat(style.getPropertyValue(prop)) || 0;
|
|
93
|
+
const full = px(size);
|
|
94
|
+
const padA = px(`padding-${a}`);
|
|
95
|
+
const padB = px(`padding-${b}`);
|
|
96
|
+
const marA = px(`margin-${a}`);
|
|
97
|
+
const marB = px(`margin-${b}`);
|
|
98
|
+
const borA = px(`border-${a}-width`);
|
|
99
|
+
const borB = px(`border-${b}-width`);
|
|
100
|
+
return {
|
|
101
|
+
delay,
|
|
102
|
+
duration,
|
|
103
|
+
easing,
|
|
104
|
+
css: (t) => 'overflow: hidden;' +
|
|
105
|
+
(fade ? ` opacity: ${Math.min(t * 20, 1) * o};` : '') +
|
|
106
|
+
` ${size}: ${t * full}px;` +
|
|
107
|
+
` padding-${a}: ${t * padA}px; padding-${b}: ${t * padB}px;` +
|
|
108
|
+
` margin-${a}: ${t * marA}px; margin-${b}: ${t * marB}px;` +
|
|
109
|
+
` border-${a}-width: ${t * borA}px; border-${b}-width: ${t * borB}px;` +
|
|
110
|
+
(axis === 'y' ? ' min-height: 0;' : ' min-width: 0;')
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SoundSettings — drop-in "Sounds" section for an account/settings page.
|
|
4
|
+
* A view over the shared sound preferences store: master on/off, volume,
|
|
5
|
+
* per-category switches, the quiet (reduced-motion) mode and quiet hours.
|
|
6
|
+
* Every change persists immediately (per device), so no save button.
|
|
7
|
+
*/
|
|
8
|
+
import { onDestroy } from 'svelte';
|
|
9
|
+
import Button from '../components/Button.svelte';
|
|
10
|
+
import { SOUND_CATEGORIES, type SoundCategory } from './names';
|
|
11
|
+
import {
|
|
12
|
+
getSoundPreferences,
|
|
13
|
+
type SoundPreferences,
|
|
14
|
+
type SoundPreferencesStore,
|
|
15
|
+
type SoundQuietMode
|
|
16
|
+
} from './preferences';
|
|
17
|
+
import { getSoundPlayer, type SoundId, type SoundPlayer } from './player';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Defaults to the app-wide store. */
|
|
21
|
+
preferences?: SoundPreferencesStore;
|
|
22
|
+
/** Player used by the preview buttons. Defaults to the app-wide player. */
|
|
23
|
+
player?: SoundPlayer;
|
|
24
|
+
/** Which category rows to show (an app without calls hides that row). */
|
|
25
|
+
categories?: SoundCategory[];
|
|
26
|
+
/** Show the quiet-hours row. */
|
|
27
|
+
showQuietHours?: boolean;
|
|
28
|
+
title?: string;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
preferences = getSoundPreferences(),
|
|
34
|
+
player,
|
|
35
|
+
categories = [...SOUND_CATEGORIES],
|
|
36
|
+
showQuietHours = true,
|
|
37
|
+
title = 'Sounds',
|
|
38
|
+
class: className = ''
|
|
39
|
+
}: Props = $props();
|
|
40
|
+
|
|
41
|
+
const CATEGORY_COPY: Record<SoundCategory, { label: string; hint: string; preview: SoundId }> = {
|
|
42
|
+
ui: { label: 'Interface', hint: 'Confirmations, errors and toggles', preview: 'confirm' },
|
|
43
|
+
notifications: { label: 'Notifications', hint: 'Messages and alerts you didn’t trigger', preview: 'notify' },
|
|
44
|
+
calls: { label: 'Calls', hint: 'Incoming call ringer — plays even in a background tab', preview: 'incoming-call' }
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const QUIET_OPTIONS: { value: SoundQuietMode; label: string }[] = [
|
|
48
|
+
{ value: 'auto', label: 'When my device asks for reduced motion' },
|
|
49
|
+
{ value: 'on', label: 'Always' },
|
|
50
|
+
{ value: 'off', label: 'Never' }
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
let prefs = $state<SoundPreferences>(preferences.get());
|
|
54
|
+
const unsubscribe = preferences.subscribe((p) => (prefs = p));
|
|
55
|
+
onDestroy(unsubscribe);
|
|
56
|
+
|
|
57
|
+
const uid = `sound-settings-${Math.random().toString(36).slice(2, 9)}`;
|
|
58
|
+
const volumePct = $derived(Math.round(prefs.volume * 100));
|
|
59
|
+
|
|
60
|
+
// A preview is capped so a long host ringer file can't keep playing.
|
|
61
|
+
const PREVIEW_MAX_MS = 3000;
|
|
62
|
+
let previewing: { stop(): void } | null = null;
|
|
63
|
+
let previewTimer: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
function stopPreview() {
|
|
65
|
+
clearTimeout(previewTimer);
|
|
66
|
+
previewing?.stop();
|
|
67
|
+
previewing = null;
|
|
68
|
+
}
|
|
69
|
+
function preview(name: SoundId) {
|
|
70
|
+
stopPreview();
|
|
71
|
+
previewing = (player ?? getSoundPlayer()).play(name, { preview: true, loop: false });
|
|
72
|
+
previewTimer = setTimeout(stopPreview, PREVIEW_MAX_MS);
|
|
73
|
+
}
|
|
74
|
+
onDestroy(stopPreview);
|
|
75
|
+
|
|
76
|
+
function setQuietHours(on: boolean) {
|
|
77
|
+
preferences.set({ quietHours: on ? (prefs.quietHours ?? { start: '22:00', end: '07:00' }) : null });
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<fieldset class="flex flex-col gap-4 {className}">
|
|
82
|
+
<legend class="text-base-content mb-2 text-base font-semibold">{title}</legend>
|
|
83
|
+
|
|
84
|
+
<label class="flex cursor-pointer items-center justify-between gap-4">
|
|
85
|
+
<span>
|
|
86
|
+
<span class="text-base-content block text-sm font-medium">Play interface & notification sounds</span>
|
|
87
|
+
<span class="text-base-content/60 block text-xs">Off by default. Calls ring unless muted below. Saved on this device only.</span>
|
|
88
|
+
</span>
|
|
89
|
+
<input
|
|
90
|
+
type="checkbox"
|
|
91
|
+
class="toggle toggle-primary"
|
|
92
|
+
checked={prefs.enabled}
|
|
93
|
+
onchange={(e) => preferences.set({ enabled: e.currentTarget.checked })}
|
|
94
|
+
/>
|
|
95
|
+
</label>
|
|
96
|
+
|
|
97
|
+
<div class="flex flex-col gap-4">
|
|
98
|
+
<div class="flex flex-col gap-1">
|
|
99
|
+
<label for="{uid}-volume" class="text-base-content flex justify-between text-sm font-medium">
|
|
100
|
+
<span>Volume</span>
|
|
101
|
+
<span class="text-base-content/60 tabular-nums">{volumePct}%</span>
|
|
102
|
+
</label>
|
|
103
|
+
<input
|
|
104
|
+
id="{uid}-volume"
|
|
105
|
+
type="range"
|
|
106
|
+
class="range range-sm range-primary"
|
|
107
|
+
min="0"
|
|
108
|
+
max="100"
|
|
109
|
+
step="5"
|
|
110
|
+
value={volumePct}
|
|
111
|
+
aria-valuetext="{volumePct} percent"
|
|
112
|
+
oninput={(e) => preferences.set({ volume: Number(e.currentTarget.value) / 100 })}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<ul class="flex flex-col gap-3">
|
|
117
|
+
{#each categories as cat (cat)}
|
|
118
|
+
{@const copy = CATEGORY_COPY[cat]}
|
|
119
|
+
{@const gated = cat !== 'calls' && !prefs.enabled}
|
|
120
|
+
<li class="flex items-center justify-between gap-4" class:opacity-50={gated}>
|
|
121
|
+
<label class="flex flex-1 cursor-pointer items-center justify-between gap-4">
|
|
122
|
+
<span>
|
|
123
|
+
<span class="text-base-content block text-sm font-medium">{copy.label}</span>
|
|
124
|
+
<span class="text-base-content/60 block text-xs">{copy.hint}</span>
|
|
125
|
+
</span>
|
|
126
|
+
<input
|
|
127
|
+
type="checkbox"
|
|
128
|
+
class="toggle toggle-sm"
|
|
129
|
+
checked={prefs.categories[cat]}
|
|
130
|
+
disabled={gated}
|
|
131
|
+
onchange={(e) => preferences.setCategory(cat, e.currentTarget.checked)}
|
|
132
|
+
/>
|
|
133
|
+
</label>
|
|
134
|
+
<Button
|
|
135
|
+
variant="ghost"
|
|
136
|
+
size="sm"
|
|
137
|
+
disabled={gated || !prefs.categories[cat]}
|
|
138
|
+
aria-label="Preview {copy.label} sound"
|
|
139
|
+
onclick={() => preview(copy.preview)}
|
|
140
|
+
>
|
|
141
|
+
▷
|
|
142
|
+
</Button>
|
|
143
|
+
</li>
|
|
144
|
+
{/each}
|
|
145
|
+
</ul>
|
|
146
|
+
|
|
147
|
+
<div class="flex flex-col gap-1" class:opacity-50={!prefs.enabled}>
|
|
148
|
+
<label for="{uid}-quiet" class="text-base-content text-sm font-medium">
|
|
149
|
+
Silence interface sounds
|
|
150
|
+
</label>
|
|
151
|
+
<select
|
|
152
|
+
id="{uid}-quiet"
|
|
153
|
+
class="select select-sm w-full"
|
|
154
|
+
value={prefs.quiet}
|
|
155
|
+
disabled={!prefs.enabled}
|
|
156
|
+
onchange={(e) => preferences.set({ quiet: e.currentTarget.value as SoundQuietMode })}
|
|
157
|
+
>
|
|
158
|
+
{#each QUIET_OPTIONS as opt (opt.value)}
|
|
159
|
+
<option value={opt.value}>{opt.label}</option>
|
|
160
|
+
{/each}
|
|
161
|
+
</select>
|
|
162
|
+
<span class="text-base-content/60 text-xs">Errors still play. Notifications and calls are unaffected.</span>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{#if showQuietHours}
|
|
166
|
+
<div class="flex flex-col gap-2" class:opacity-50={!prefs.enabled}>
|
|
167
|
+
<label class="flex cursor-pointer items-center justify-between gap-4">
|
|
168
|
+
<span>
|
|
169
|
+
<span class="text-base-content block text-sm font-medium">Quiet hours</span>
|
|
170
|
+
<span class="text-base-content/60 block text-xs">Only calls ring inside this window.</span>
|
|
171
|
+
</span>
|
|
172
|
+
<input
|
|
173
|
+
type="checkbox"
|
|
174
|
+
class="toggle toggle-sm"
|
|
175
|
+
checked={prefs.quietHours != null}
|
|
176
|
+
disabled={!prefs.enabled}
|
|
177
|
+
onchange={(e) => setQuietHours(e.currentTarget.checked)}
|
|
178
|
+
/>
|
|
179
|
+
</label>
|
|
180
|
+
{#if prefs.quietHours}
|
|
181
|
+
<div class="flex items-center gap-2 text-sm">
|
|
182
|
+
<label for="{uid}-qs" class="text-base-content/70">From</label>
|
|
183
|
+
<input
|
|
184
|
+
id="{uid}-qs"
|
|
185
|
+
type="time"
|
|
186
|
+
class="input input-sm w-32"
|
|
187
|
+
value={prefs.quietHours.start}
|
|
188
|
+
disabled={!prefs.enabled}
|
|
189
|
+
onchange={(e) =>
|
|
190
|
+
e.currentTarget.value &&
|
|
191
|
+
preferences.set({ quietHours: { start: e.currentTarget.value, end: prefs.quietHours!.end } })}
|
|
192
|
+
/>
|
|
193
|
+
<label for="{uid}-qe" class="text-base-content/70">to</label>
|
|
194
|
+
<input
|
|
195
|
+
id="{uid}-qe"
|
|
196
|
+
type="time"
|
|
197
|
+
class="input input-sm w-32"
|
|
198
|
+
value={prefs.quietHours.end}
|
|
199
|
+
disabled={!prefs.enabled}
|
|
200
|
+
onchange={(e) =>
|
|
201
|
+
e.currentTarget.value &&
|
|
202
|
+
preferences.set({ quietHours: { start: prefs.quietHours!.start, end: e.currentTarget.value } })}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
{/if}
|
|
206
|
+
</div>
|
|
207
|
+
{/if}
|
|
208
|
+
</div>
|
|
209
|
+
</fieldset>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SoundCategory } from './names';
|
|
2
|
+
import { type SoundPreferencesStore } from './preferences';
|
|
3
|
+
import { type SoundPlayer } from './player';
|
|
4
|
+
interface Props {
|
|
5
|
+
/** Defaults to the app-wide store. */
|
|
6
|
+
preferences?: SoundPreferencesStore;
|
|
7
|
+
/** Player used by the preview buttons. Defaults to the app-wide player. */
|
|
8
|
+
player?: SoundPlayer;
|
|
9
|
+
/** Which category rows to show (an app without calls hides that row). */
|
|
10
|
+
categories?: SoundCategory[];
|
|
11
|
+
/** Show the quiet-hours row. */
|
|
12
|
+
showQuietHours?: boolean;
|
|
13
|
+
title?: string;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
declare const SoundSettings: import("svelte").Component<Props, {}, "">;
|
|
17
|
+
type SoundSettings = ReturnType<typeof SoundSettings>;
|
|
18
|
+
export default SoundSettings;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio output backend. The player talks to this interface so its policy and
|
|
3
|
+
* bookkeeping run in node tests with a fake; `createWebAudioBackend` is the
|
|
4
|
+
* real one (lazy AudioContext, decoded-buffer playback through a GainNode).
|
|
5
|
+
*/
|
|
6
|
+
export interface SoundVoice {
|
|
7
|
+
stop(): void;
|
|
8
|
+
}
|
|
9
|
+
export interface StartOptions {
|
|
10
|
+
gain: number;
|
|
11
|
+
loop: boolean;
|
|
12
|
+
/** Gap between repeats when looping; 0 = seamless. */
|
|
13
|
+
loopGapMs: number;
|
|
14
|
+
/** Called once when a non-looping voice finishes on its own. */
|
|
15
|
+
onEnded?: () => void;
|
|
16
|
+
}
|
|
17
|
+
export interface SoundBackend {
|
|
18
|
+
/** Decode a data URI or URL. Resolves `null` when it can't be played here. */
|
|
19
|
+
load(src: string): Promise<unknown | null>;
|
|
20
|
+
start(clip: unknown, options: StartOptions): SoundVoice | null;
|
|
21
|
+
}
|
|
22
|
+
export declare function createWebAudioBackend(): SoundBackend;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio output backend. The player talks to this interface so its policy and
|
|
3
|
+
* bookkeeping run in node tests with a fake; `createWebAudioBackend` is the
|
|
4
|
+
* real one (lazy AudioContext, decoded-buffer playback through a GainNode).
|
|
5
|
+
*/
|
|
6
|
+
function dataUriBytes(uri) {
|
|
7
|
+
const binary = atob(uri.slice(uri.indexOf(',') + 1));
|
|
8
|
+
const bytes = new Uint8Array(binary.length);
|
|
9
|
+
for (let i = 0; i < binary.length; i++)
|
|
10
|
+
bytes[i] = binary.charCodeAt(i);
|
|
11
|
+
return bytes.buffer;
|
|
12
|
+
}
|
|
13
|
+
export function createWebAudioBackend() {
|
|
14
|
+
let ctx = null;
|
|
15
|
+
function context() {
|
|
16
|
+
if (ctx)
|
|
17
|
+
return ctx;
|
|
18
|
+
if (typeof AudioContext === 'undefined')
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
ctx = new AudioContext();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
async load(src) {
|
|
30
|
+
const audio = context();
|
|
31
|
+
if (!audio)
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
const bytes = src.startsWith('data:')
|
|
35
|
+
? dataUriBytes(src)
|
|
36
|
+
: await (await fetch(src)).arrayBuffer();
|
|
37
|
+
return await audio.decodeAudioData(bytes);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null; // codec unsupported / network error — stay silent
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
start(clip, { gain, loop, loopGapMs, onEnded }) {
|
|
44
|
+
const audio = context();
|
|
45
|
+
if (!audio || !clip)
|
|
46
|
+
return null;
|
|
47
|
+
// Autoplay policy: the context starts suspended until a user gesture.
|
|
48
|
+
if (audio.state === 'suspended')
|
|
49
|
+
audio.resume().catch(() => { });
|
|
50
|
+
const out = audio.createGain();
|
|
51
|
+
out.gain.value = gain;
|
|
52
|
+
out.connect(audio.destination);
|
|
53
|
+
let source = null;
|
|
54
|
+
let timer = null;
|
|
55
|
+
let stopped = false;
|
|
56
|
+
const once = () => {
|
|
57
|
+
if (stopped)
|
|
58
|
+
return;
|
|
59
|
+
source = audio.createBufferSource();
|
|
60
|
+
source.buffer = clip;
|
|
61
|
+
source.loop = loop && loopGapMs <= 0;
|
|
62
|
+
source.connect(out);
|
|
63
|
+
source.onended = () => {
|
|
64
|
+
if (stopped)
|
|
65
|
+
return;
|
|
66
|
+
if (loop && loopGapMs > 0)
|
|
67
|
+
timer = setTimeout(once, loopGapMs);
|
|
68
|
+
else if (!loop) {
|
|
69
|
+
stopped = true;
|
|
70
|
+
out.disconnect();
|
|
71
|
+
onEnded?.();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
source.start();
|
|
75
|
+
};
|
|
76
|
+
once();
|
|
77
|
+
return {
|
|
78
|
+
stop() {
|
|
79
|
+
if (stopped)
|
|
80
|
+
return;
|
|
81
|
+
stopped = true;
|
|
82
|
+
if (timer)
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
try {
|
|
85
|
+
source?.stop();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// already ended
|
|
89
|
+
}
|
|
90
|
+
out.disconnect();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound — first-class sibling of motion (jatui-kf62q).
|
|
3
|
+
*
|
|
4
|
+
* One preferences store every app sound goes through, semantic sound names
|
|
5
|
+
* mapped to bundled CC0 clips (or a host's own files), a pure policy function,
|
|
6
|
+
* and a drop-in SoundSettings section. Discipline: docs/sound.md.
|
|
7
|
+
*/
|
|
8
|
+
export { SOUND_CATEGORIES, SOUND_NAMES, SOUND_SPECS, CUSTOM_SOUND_SPEC, BUILTIN_CLIPS, isSoundName, soundSpecFor } from './names';
|
|
9
|
+
export type { SoundCategory, SoundName, SoundSpec } from './names';
|
|
10
|
+
export { DEFAULT_SOUND_PREFERENCES, SOUND_PREFS_STORAGE_KEY, LEGACY_SOUND_ENABLED_KEY, createSoundPreferences, getSoundPreferences, setSoundPreferences, normalizeSoundPreferences } from './preferences';
|
|
11
|
+
export type { SoundPreferences, SoundPreferencesPatch, SoundPreferencesStore, SoundPreferencesStorage, SoundQuietMode, QuietHours, CreateSoundPreferencesOptions } from './preferences';
|
|
12
|
+
export { decideSound, inQuietHours, readSoundEnvironment } from './policy';
|
|
13
|
+
export type { SoundEnvironment, SoundRequest, SoundDecision, SoundBlockReason } from './policy';
|
|
14
|
+
export { createSoundPlayer, getSoundPlayer, setSoundPlayer, playSound } from './player';
|
|
15
|
+
export type { SoundId, SoundPlayer, SoundHandle, PlaySoundOptions, CreateSoundPlayerOptions } from './player';
|
|
16
|
+
export { createWebAudioBackend } from './backend';
|
|
17
|
+
export type { SoundBackend, SoundVoice, StartOptions as SoundStartOptions } from './backend';
|
|
18
|
+
export { default as SoundSettings } from './SoundSettings.svelte';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound — first-class sibling of motion (jatui-kf62q).
|
|
3
|
+
*
|
|
4
|
+
* One preferences store every app sound goes through, semantic sound names
|
|
5
|
+
* mapped to bundled CC0 clips (or a host's own files), a pure policy function,
|
|
6
|
+
* and a drop-in SoundSettings section. Discipline: docs/sound.md.
|
|
7
|
+
*/
|
|
8
|
+
export { SOUND_CATEGORIES, SOUND_NAMES, SOUND_SPECS, CUSTOM_SOUND_SPEC, BUILTIN_CLIPS, isSoundName, soundSpecFor } from './names';
|
|
9
|
+
export { DEFAULT_SOUND_PREFERENCES, SOUND_PREFS_STORAGE_KEY, LEGACY_SOUND_ENABLED_KEY, createSoundPreferences, getSoundPreferences, setSoundPreferences, normalizeSoundPreferences } from './preferences';
|
|
10
|
+
export { decideSound, inQuietHours, readSoundEnvironment } from './policy';
|
|
11
|
+
export { createSoundPlayer, getSoundPlayer, setSoundPlayer, playSound } from './player';
|
|
12
|
+
export { createWebAudioBackend } from './backend';
|
|
13
|
+
export { default as SoundSettings } from './SoundSettings.svelte';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic sound names — what an app SAYS ("confirm", "incoming-call"), not
|
|
3
|
+
* which file it plays. Each name carries the policy facts the preferences
|
|
4
|
+
* layer needs: its category, a base volume, whether it is essential (survives
|
|
5
|
+
* the quiet/reduced-motion preference), and how it loops.
|
|
6
|
+
*
|
|
7
|
+
* Hosts can re-point any name at their own file (`player.register('incoming-call',
|
|
8
|
+
* '/ringer.mp3')`) and can register names of their own; unknown names fall back
|
|
9
|
+
* to `CUSTOM_SOUND_SPEC` (a non-essential UI sound).
|
|
10
|
+
*/
|
|
11
|
+
export type SoundCategory = 'ui' | 'notifications' | 'calls';
|
|
12
|
+
export declare const SOUND_CATEGORIES: readonly SoundCategory[];
|
|
13
|
+
export type SoundName = 'confirm' | 'error' | 'notify' | 'message' | 'send' | 'toggle' | 'tap' | 'incoming-call';
|
|
14
|
+
export declare const SOUND_NAMES: readonly SoundName[];
|
|
15
|
+
export interface SoundSpec {
|
|
16
|
+
category: SoundCategory;
|
|
17
|
+
/** Base gain 0–1 before the master volume is applied. */
|
|
18
|
+
volume: number;
|
|
19
|
+
/** Essential UI sounds still play when the quiet preference is active. */
|
|
20
|
+
essential: boolean;
|
|
21
|
+
/** Loop until the returned handle is stopped (call ringers). */
|
|
22
|
+
loop: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Silence between repeats when looping, in ms. 0 = seamless loop (a real
|
|
25
|
+
* ringer file); >0 replays a short clip on an interval (the built-in ring).
|
|
26
|
+
*/
|
|
27
|
+
loopGapMs: number;
|
|
28
|
+
description: string;
|
|
29
|
+
}
|
|
30
|
+
export declare const SOUND_SPECS: Record<SoundName, SoundSpec>;
|
|
31
|
+
/** Spec for a host-registered name that is not one of the semantic names. */
|
|
32
|
+
export declare const CUSTOM_SOUND_SPEC: SoundSpec;
|
|
33
|
+
export declare function isSoundName(name: string): name is SoundName;
|
|
34
|
+
export declare function soundSpecFor(name: string): SoundSpec;
|
|
35
|
+
/**
|
|
36
|
+
* The bundled CC0 clip for each semantic name, loaded on first use so apps
|
|
37
|
+
* that never play a sound never ship the base64 payloads in their entry chunk.
|
|
38
|
+
*/
|
|
39
|
+
export declare const BUILTIN_CLIPS: Record<SoundName, () => Promise<string>>;
|