@joewinke/jatui 0.27.1 → 0.29.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 +27 -14
- package/dist/assets/sounds/attention.d.ts +1 -0
- package/dist/assets/sounds/attention.js +4 -0
- package/dist/assets/sounds/chime.d.ts +1 -0
- package/dist/assets/sounds/chime.js +4 -0
- package/dist/assets/sounds/error-soft.d.ts +1 -0
- package/dist/assets/sounds/error-soft.js +4 -0
- package/dist/assets/sounds/select.d.ts +1 -0
- package/dist/assets/sounds/select.js +4 -0
- package/dist/assets/sounds/tick.d.ts +1 -0
- package/dist/assets/sounds/tick.js +4 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +10 -2
- 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 +24 -0
- package/dist/sound/backend.js +96 -0
- package/dist/sound/index.d.ts +20 -0
- package/dist/sound/index.js +14 -0
- package/dist/sound/level.d.ts +42 -0
- package/dist/sound/level.js +63 -0
- package/dist/sound/names.d.ts +62 -0
- package/dist/sound/names.js +196 -0
- package/dist/sound/player.d.ts +73 -0
- package/dist/sound/player.js +158 -0
- package/dist/sound/policy.d.ts +36 -0
- package/dist/sound/policy.js +56 -0
- package/dist/sound/preferences.d.ts +76 -0
- package/dist/sound/preferences.js +152 -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 +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion-level store — a Svelte store (subscribe contract, usable as
|
|
3
|
+
* `$motion`) that owns a surface's `data-motion` attribute, optionally
|
|
4
|
+
* persists the user's choice, and tracks `prefers-reduced-motion` live.
|
|
5
|
+
*
|
|
6
|
+
* const motion = createMotionLevelStore({ storageKey: 'app.motion' })
|
|
7
|
+
* motion.set('calm') // writes data-motion="calm" on <html>
|
|
8
|
+
* $motion.level / $motion.reduced // reactive in components
|
|
9
|
+
* $motion.effective // 'reduced' when the OS asks, else level
|
|
10
|
+
*
|
|
11
|
+
* Plain store (no runes), so it works in .ts, in SSR (no DOM access until a
|
|
12
|
+
* method or subscriber needs it) and under node:test.
|
|
13
|
+
*/
|
|
14
|
+
import { type MotionLevel } from './level';
|
|
15
|
+
export interface MotionState {
|
|
16
|
+
/** The level chosen for the surface. */
|
|
17
|
+
level: MotionLevel;
|
|
18
|
+
/** `prefers-reduced-motion: reduce` is on — every level yields to it. */
|
|
19
|
+
reduced: boolean;
|
|
20
|
+
/** What actually runs: 'reduced' when reduced, else the level. */
|
|
21
|
+
effective: MotionLevel | 'reduced';
|
|
22
|
+
}
|
|
23
|
+
export interface MotionLevelStoreOptions {
|
|
24
|
+
/** Element that carries `data-motion`. Default `document.documentElement`, resolved lazily. */
|
|
25
|
+
target?: Element | (() => Element | null) | null;
|
|
26
|
+
/** Persist the chosen level in localStorage under this key. Omit = no persistence. */
|
|
27
|
+
storageKey?: string;
|
|
28
|
+
/** Level when nothing is stored and the target has no attribute. Default 'standard'. */
|
|
29
|
+
initial?: MotionLevel;
|
|
30
|
+
}
|
|
31
|
+
export interface MotionLevelStore {
|
|
32
|
+
subscribe(run: (state: MotionState) => void): () => void;
|
|
33
|
+
/** Set the level (writes the attribute, persists when configured). */
|
|
34
|
+
set(level: MotionLevel): void;
|
|
35
|
+
/** Clear the stored choice and fall back to `initial`. */
|
|
36
|
+
reset(): void;
|
|
37
|
+
/** Current state, without subscribing. */
|
|
38
|
+
get(): MotionState;
|
|
39
|
+
}
|
|
40
|
+
export declare function createMotionLevelStore(options?: MotionLevelStoreOptions): MotionLevelStore;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion-level store — a Svelte store (subscribe contract, usable as
|
|
3
|
+
* `$motion`) that owns a surface's `data-motion` attribute, optionally
|
|
4
|
+
* persists the user's choice, and tracks `prefers-reduced-motion` live.
|
|
5
|
+
*
|
|
6
|
+
* const motion = createMotionLevelStore({ storageKey: 'app.motion' })
|
|
7
|
+
* motion.set('calm') // writes data-motion="calm" on <html>
|
|
8
|
+
* $motion.level / $motion.reduced // reactive in components
|
|
9
|
+
* $motion.effective // 'reduced' when the OS asks, else level
|
|
10
|
+
*
|
|
11
|
+
* Plain store (no runes), so it works in .ts, in SSR (no DOM access until a
|
|
12
|
+
* method or subscriber needs it) and under node:test.
|
|
13
|
+
*/
|
|
14
|
+
import { DEFAULT_MOTION_LEVEL, parseMotionLevel, prefersReducedMotion, resolveMotionLevel, setMotionLevel } from './level';
|
|
15
|
+
function stateOf(level, reduced) {
|
|
16
|
+
return { level, reduced, effective: reduced ? 'reduced' : level };
|
|
17
|
+
}
|
|
18
|
+
function readStorage(key) {
|
|
19
|
+
if (!key)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function writeStorage(key, value) {
|
|
29
|
+
if (!key)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
if (typeof localStorage === 'undefined')
|
|
33
|
+
return;
|
|
34
|
+
if (value === null)
|
|
35
|
+
localStorage.removeItem(key);
|
|
36
|
+
else
|
|
37
|
+
localStorage.setItem(key, value);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// storage blocked (private mode, quota) — the attribute still applies
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function createMotionLevelStore(options = {}) {
|
|
44
|
+
const { storageKey } = options;
|
|
45
|
+
const initial = options.initial ?? DEFAULT_MOTION_LEVEL;
|
|
46
|
+
const subscribers = new Set();
|
|
47
|
+
let state = null;
|
|
48
|
+
let stopMedia = null;
|
|
49
|
+
function target() {
|
|
50
|
+
const t = options.target;
|
|
51
|
+
if (typeof t === 'function')
|
|
52
|
+
return t();
|
|
53
|
+
if (t)
|
|
54
|
+
return t;
|
|
55
|
+
return typeof document !== 'undefined' ? document.documentElement : null;
|
|
56
|
+
}
|
|
57
|
+
function load() {
|
|
58
|
+
if (state)
|
|
59
|
+
return state;
|
|
60
|
+
const stored = readStorage(storageKey);
|
|
61
|
+
const el = target();
|
|
62
|
+
let level;
|
|
63
|
+
if (stored !== null) {
|
|
64
|
+
level = parseMotionLevel(stored, initial);
|
|
65
|
+
setMotionLevel(level, el);
|
|
66
|
+
}
|
|
67
|
+
else if (el?.hasAttribute?.('data-motion')) {
|
|
68
|
+
level = resolveMotionLevel(el);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
level = initial;
|
|
72
|
+
}
|
|
73
|
+
state = stateOf(level, prefersReducedMotion());
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
function emit(next) {
|
|
77
|
+
state = next;
|
|
78
|
+
for (const run of subscribers)
|
|
79
|
+
run(next);
|
|
80
|
+
}
|
|
81
|
+
function watchReducedMotion() {
|
|
82
|
+
if (stopMedia || typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
83
|
+
return;
|
|
84
|
+
let mql;
|
|
85
|
+
try {
|
|
86
|
+
mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const onChange = (e) => {
|
|
92
|
+
const cur = load();
|
|
93
|
+
if (cur.reduced !== e.matches)
|
|
94
|
+
emit(stateOf(cur.level, e.matches));
|
|
95
|
+
};
|
|
96
|
+
if (typeof mql.addEventListener === 'function') {
|
|
97
|
+
mql.addEventListener('change', onChange);
|
|
98
|
+
stopMedia = () => mql.removeEventListener('change', onChange);
|
|
99
|
+
}
|
|
100
|
+
else if (typeof mql.addListener === 'function') {
|
|
101
|
+
mql.addListener(onChange);
|
|
102
|
+
stopMedia = () => mql.removeListener(onChange);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
subscribe(run) {
|
|
107
|
+
subscribers.add(run);
|
|
108
|
+
if (subscribers.size === 1)
|
|
109
|
+
watchReducedMotion();
|
|
110
|
+
run(load());
|
|
111
|
+
return () => {
|
|
112
|
+
subscribers.delete(run);
|
|
113
|
+
if (subscribers.size === 0 && stopMedia) {
|
|
114
|
+
stopMedia();
|
|
115
|
+
stopMedia = null;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
set(level) {
|
|
120
|
+
const next = parseMotionLevel(level, initial);
|
|
121
|
+
setMotionLevel(next, target());
|
|
122
|
+
writeStorage(storageKey, next);
|
|
123
|
+
emit(stateOf(next, load().reduced));
|
|
124
|
+
},
|
|
125
|
+
reset() {
|
|
126
|
+
writeStorage(storageKey, null);
|
|
127
|
+
setMotionLevel(initial, target());
|
|
128
|
+
emit(stateOf(initial, load().reduced));
|
|
129
|
+
},
|
|
130
|
+
get: load
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -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,24 @@
|
|
|
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
|
+
/** Playback rate (1 = as recorded; 2 = an octave up and twice as short). */
|
|
15
|
+
playbackRate?: number;
|
|
16
|
+
/** Called once when a non-looping voice finishes on its own. */
|
|
17
|
+
onEnded?: () => void;
|
|
18
|
+
}
|
|
19
|
+
export interface SoundBackend {
|
|
20
|
+
/** Decode a data URI or URL. Resolves `null` when it can't be played here. */
|
|
21
|
+
load(src: string): Promise<unknown | null>;
|
|
22
|
+
start(clip: unknown, options: StartOptions): SoundVoice | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function createWebAudioBackend(): SoundBackend;
|