@joewinke/jatui 0.46.0 → 0.48.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/README.md +46 -0
- package/dist/ambience/README.md +131 -0
- package/dist/ambience/breath.d.ts +22 -0
- package/dist/ambience/breath.js +43 -0
- package/dist/ambience/default.d.ts +24 -0
- package/dist/ambience/default.js +34 -0
- package/dist/ambience/engine.d.ts +21 -0
- package/dist/ambience/engine.js +313 -0
- package/dist/ambience/index.d.ts +23 -0
- package/dist/ambience/index.js +27 -0
- package/dist/ambience/master.d.ts +49 -0
- package/dist/ambience/master.js +83 -0
- package/dist/ambience/persist.d.ts +11 -0
- package/dist/ambience/persist.js +24 -0
- package/dist/ambience/registry.d.ts +11 -0
- package/dist/ambience/registry.js +16 -0
- package/dist/ambience/scapes/focus.d.ts +55 -0
- package/dist/ambience/scapes/focus.js +245 -0
- package/dist/ambience/scapes/hibiscus.d.ts +103 -0
- package/dist/ambience/scapes/hibiscus.js +345 -0
- package/dist/ambience/scapes/nature.d.ts +36 -0
- package/dist/ambience/scapes/nature.js +325 -0
- package/dist/ambience/scapes/noise.d.ts +77 -0
- package/dist/ambience/scapes/noise.js +226 -0
- package/dist/ambience/scapes/playlist.d.ts +129 -0
- package/dist/ambience/scapes/playlist.js +569 -0
- package/dist/ambience/types.d.ts +80 -0
- package/dist/ambience/types.js +9 -0
- package/dist/components/AmbienceToggle.svelte +599 -0
- package/dist/components/AmbienceToggle.svelte.d.ts +74 -0
- package/dist/components/VideoPlayer.svelte +73 -6
- package/dist/components/VideoPlayer.svelte.d.ts +9 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -0
- package/dist/video/keys.d.ts +4 -3
- package/dist/video/keys.js +5 -4
- package/dist/video/settings/remembered.d.ts +9 -0
- package/dist/video/settings/remembered.js +9 -0
- package/package.json +6 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambience engine — public types. The scape contract lives here so the
|
|
3
|
+
* parallel scape tasks build against it without importing the engine.
|
|
4
|
+
*
|
|
5
|
+
* Design rules (jatui-km0qy): headless — no UI, no window at import, one
|
|
6
|
+
* AudioContext per engine created lazily on the first gesture-driven play,
|
|
7
|
+
* generative Web Audio only (no files, no libraries).
|
|
8
|
+
*/
|
|
9
|
+
/** A node scapes connect INTO; the shared master chain owns what happens after. */
|
|
10
|
+
export interface ScapeContext {
|
|
11
|
+
/** The engine's single AudioContext (created lazily, already running). */
|
|
12
|
+
audio: AudioContext;
|
|
13
|
+
/** Connect your scape's output(s) here — never to `destination` directly. */
|
|
14
|
+
input: AudioNode;
|
|
15
|
+
/** The one shared breath LFO (see breath.ts). */
|
|
16
|
+
breath: BreathClock;
|
|
17
|
+
/** Lookahead scheduler: call `cb` on every engine tick (~250 ms). Registering
|
|
18
|
+
* returns an unsubscribe so a scape can stop being ticked on stop(). */
|
|
19
|
+
lookahead: (cb: (now: number) => void) => () => void;
|
|
20
|
+
/** Deterministic-ish random source scoped to the scape instance. */
|
|
21
|
+
rand: () => number;
|
|
22
|
+
}
|
|
23
|
+
export interface ScapeInstance {
|
|
24
|
+
/** Begin sounding at audio time `at` (usually ctx.currentTime). */
|
|
25
|
+
start(at: number): void;
|
|
26
|
+
/** Fade yourself out over `fade` seconds and tear your own nodes down. */
|
|
27
|
+
stop(at: number, fade: number): void;
|
|
28
|
+
/** Optional live parameter (scape-defined keys). */
|
|
29
|
+
setParam?(key: string, value: number): void;
|
|
30
|
+
}
|
|
31
|
+
export type ScapeGroup = 'meditation' | 'focus' | 'noise' | 'nature' | 'music';
|
|
32
|
+
export interface Scape {
|
|
33
|
+
id: string;
|
|
34
|
+
label: string;
|
|
35
|
+
group: ScapeGroup;
|
|
36
|
+
description?: string;
|
|
37
|
+
/** Build the graph. Called once per play — a scape must be re-buildable. */
|
|
38
|
+
build(ctx: ScapeContext): ScapeInstance;
|
|
39
|
+
}
|
|
40
|
+
/** Shape of the AudioContext we need — the real one satisfies it; tests fake it. */
|
|
41
|
+
import type { BreathClock } from './breath';
|
|
42
|
+
export type AudioContextLike = AudioContext;
|
|
43
|
+
export interface AmbienceState {
|
|
44
|
+
/** Currently selected scape id (persisted preselect counts), or null. */
|
|
45
|
+
currentId: string | null;
|
|
46
|
+
playing: boolean;
|
|
47
|
+
volume: number;
|
|
48
|
+
}
|
|
49
|
+
export interface BreathState {
|
|
50
|
+
/** 0..1 within the current half-breath (inhale or exhale). */
|
|
51
|
+
value: number;
|
|
52
|
+
/** True while inhaling. */
|
|
53
|
+
rising: boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface Ambience {
|
|
56
|
+
register(scape: Scape): void;
|
|
57
|
+
list(): Scape[];
|
|
58
|
+
/** MUST be called from a user gesture (a click/keydown handler). Rejects
|
|
59
|
+
* otherwise. Resolves once the scape is scheduled and audibly started. */
|
|
60
|
+
play(id: string): Promise<void>;
|
|
61
|
+
/** Fade out ~0.55 s; the AudioContext closes ~3 s later. A play() inside
|
|
62
|
+
* that window cancels the close and fades back in over ~4 s. */
|
|
63
|
+
stop(): void;
|
|
64
|
+
/** Immediate: no fades, context closed, timers cleared. */
|
|
65
|
+
kill(): void;
|
|
66
|
+
setVolume(v: number): void;
|
|
67
|
+
get state(): AmbienceState;
|
|
68
|
+
subscribe(fn: (state: AmbienceState) => void): () => void;
|
|
69
|
+
onBreath(fn: (b: BreathState) => void): () => void;
|
|
70
|
+
}
|
|
71
|
+
export interface AmbienceOptions {
|
|
72
|
+
storageKey?: string;
|
|
73
|
+
defaultVolume?: number;
|
|
74
|
+
/** Gesture probe, injectable for tests. Default: navigator.userActivation
|
|
75
|
+
* when it exists (trusted-UI truth), otherwise permissive (SSR/node).
|
|
76
|
+
*/
|
|
77
|
+
isGesture?: () => boolean;
|
|
78
|
+
/** Context factory, injectable for tests (default: `new AudioContext()`). */
|
|
79
|
+
createContext?: () => AudioContextLike;
|
|
80
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambience engine — public types. The scape contract lives here so the
|
|
3
|
+
* parallel scape tasks build against it without importing the engine.
|
|
4
|
+
*
|
|
5
|
+
* Design rules (jatui-km0qy): headless — no UI, no window at import, one
|
|
6
|
+
* AudioContext per engine created lazily on the first gesture-driven play,
|
|
7
|
+
* generative Web Audio only (no files, no libraries).
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* AmbienceToggle — the audio twin of ThemeToggle (jatui-b7qvr): one small
|
|
4
|
+
* speaker button for the app shell over the headless ambience engine
|
|
5
|
+
* (`createAmbience()` from `src/lib/ambience/`).
|
|
6
|
+
*
|
|
7
|
+
* Interaction contract:
|
|
8
|
+
* - Click (or Space/Enter) = play / stop the last scape. A gesture-gated
|
|
9
|
+
* engine call, made synchronously inside the click handler.
|
|
10
|
+
* - The scape menu (menu button pattern) opens with ArrowDown/ArrowUp,
|
|
11
|
+
* a press-and-hold (~450 ms) or a right-click (Android/Chrome fire
|
|
12
|
+
* contextmenu on touch holds; iOS may cancel the pointer instead —
|
|
13
|
+
* the keyboard path always works). It lists every
|
|
14
|
+
* registered scape grouped meditation / focus / noise / nature / music
|
|
15
|
+
* (`menuitemradio` items, current scape checked), carries a volume
|
|
16
|
+
* slider wired to `engine.setVolume`, and shows each scape's
|
|
17
|
+
* `description` as its secondary line — playlist scapes put their
|
|
18
|
+
* track attribution there.
|
|
19
|
+
*
|
|
20
|
+
* A11y: the trigger is `aria-pressed` while playing and carries the full
|
|
21
|
+
* menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
|
|
22
|
+
* An sr-only description teaches the menu gestures, and an `aria-live`
|
|
23
|
+
* status line announces state changes ("Ambience: Hibiscus, playing").
|
|
24
|
+
* The speaker glyph morphs off↔on on the motion tokens and, while playing,
|
|
25
|
+
* breathes with `engine.onBreath` — ambient motion, so it is static under
|
|
26
|
+
* `prefers-reduced-motion` and `data-motion="calm"`.
|
|
27
|
+
*
|
|
28
|
+
* Token-only theming (DaisyUI + `--jatui-*` + `--motion-*`). SSR-safe: the
|
|
29
|
+
* engine is lazy and nothing here touches `window` at import.
|
|
30
|
+
*/
|
|
31
|
+
import type { Ambience, Scape, ScapeGroup } from '../ambience/types';
|
|
32
|
+
|
|
33
|
+
/** Canonical menu group order (the Scape contract's groups). */
|
|
34
|
+
export const AMBIENCE_GROUP_ORDER: readonly ScapeGroup[] = [
|
|
35
|
+
'meditation',
|
|
36
|
+
'focus',
|
|
37
|
+
'noise',
|
|
38
|
+
'nature',
|
|
39
|
+
'music'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Human labels for the canonical groups. */
|
|
43
|
+
export const GROUP_LABELS: Readonly<Record<ScapeGroup, string>> = {
|
|
44
|
+
meditation: 'Meditation',
|
|
45
|
+
focus: 'Focus',
|
|
46
|
+
noise: 'Noise',
|
|
47
|
+
nature: 'Nature',
|
|
48
|
+
music: 'Music'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** One grouped section of the scape menu. */
|
|
52
|
+
export interface AmbienceScapeGroup {
|
|
53
|
+
group: ScapeGroup;
|
|
54
|
+
label: string;
|
|
55
|
+
scapes: Scape[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Group scapes for the menu: canonical group order, registration order
|
|
60
|
+
* within a group, empty groups omitted. A group outside the union (a
|
|
61
|
+
* registry from a newer engine) still renders, after the known ones, in
|
|
62
|
+
* first-seen order — a menu never silently drops a registered scape.
|
|
63
|
+
*/
|
|
64
|
+
export function groupScapes(scapes: Scape[]): AmbienceScapeGroup[] {
|
|
65
|
+
const byGroup = new Map<string, Scape[]>();
|
|
66
|
+
for (const s of scapes) {
|
|
67
|
+
const list = byGroup.get(s.group) ?? [];
|
|
68
|
+
list.push(s);
|
|
69
|
+
byGroup.set(s.group, list);
|
|
70
|
+
}
|
|
71
|
+
const out: AmbienceScapeGroup[] = [];
|
|
72
|
+
for (const g of AMBIENCE_GROUP_ORDER) {
|
|
73
|
+
const list = byGroup.get(g);
|
|
74
|
+
if (!list?.length) continue;
|
|
75
|
+
out.push({ group: g, label: GROUP_LABELS[g], scapes: list });
|
|
76
|
+
byGroup.delete(g);
|
|
77
|
+
}
|
|
78
|
+
for (const [g, list] of byGroup) {
|
|
79
|
+
const label = g ? g.charAt(0).toUpperCase() + g.slice(1) : 'Other';
|
|
80
|
+
out.push({ group: g as ScapeGroup, label, scapes: list });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The live label — "Ambience: Hibiscus, playing" while sounding,
|
|
87
|
+
* "Ambience: Hibiscus, stopped" once a scape has been selected but is not
|
|
88
|
+
* playing, "Ambience: off" before anything was selected.
|
|
89
|
+
*/
|
|
90
|
+
export function ambienceLiveLabel(currentLabel: string | null, playing: boolean): string {
|
|
91
|
+
if (!currentLabel) return 'Ambience: off';
|
|
92
|
+
return `Ambience: ${currentLabel}, ${playing ? 'playing' : 'stopped'}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Transient failure label — a rejected engine.play() (gesture probe,
|
|
96
|
+
* context suspend, unknown scape) must never surface as an unhandled
|
|
97
|
+
* rejection OR as silence: the trigger visibly fails to respond, so the
|
|
98
|
+
* sr-only status explains it. Cleared by the next successful play. */
|
|
99
|
+
export const PLAY_ERROR_LABEL = 'Ambience: couldn\'t start — tap again';
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Glyph scale for a breath value (0..1): the speaker grows on the inhale
|
|
103
|
+
* and settles on the exhale — 6% total, clamped against out-of-range input.
|
|
104
|
+
*/
|
|
105
|
+
export function breathScale(value: number): number {
|
|
106
|
+
const v = Math.min(1, Math.max(0, value));
|
|
107
|
+
return 1 + 0.06 * v;
|
|
108
|
+
}
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<script lang="ts">
|
|
112
|
+
import { getMotionContext } from '../motion/level';
|
|
113
|
+
|
|
114
|
+
let {
|
|
115
|
+
engine,
|
|
116
|
+
compact = false,
|
|
117
|
+
placement = 'bottom-end'
|
|
118
|
+
}: {
|
|
119
|
+
/** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. */
|
|
120
|
+
engine: Ambience;
|
|
121
|
+
/** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
|
|
122
|
+
compact?: boolean;
|
|
123
|
+
/** Which way the menu opens from the trigger. Auto-flips vertically when the viewport has no room on that side. */
|
|
124
|
+
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
125
|
+
} = $props();
|
|
126
|
+
|
|
127
|
+
const menuId = $props.id();
|
|
128
|
+
const hintId = `${menuId}-hint`;
|
|
129
|
+
|
|
130
|
+
/** Long-press duration for the pointer path to the menu. */
|
|
131
|
+
const LONG_PRESS_MS = 450;
|
|
132
|
+
/** Worst-case menu height used for the open-direction flip decision. */
|
|
133
|
+
const MENU_EST_HEIGHT = 340;
|
|
134
|
+
|
|
135
|
+
let playing = $state(false);
|
|
136
|
+
let currentId = $state<string | null>(null);
|
|
137
|
+
let volume = $state(0.6);
|
|
138
|
+
let scapes = $state<Scape[]>([]);
|
|
139
|
+
let open = $state(false);
|
|
140
|
+
// svelte-ignore state_referenced_locally -- only the seed; resolvePlacement() reads the live prop
|
|
141
|
+
let effectivePlacement = $state<'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'>(
|
|
142
|
+
placement
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
/** Set by a long-press so the click that follows the release is swallowed —
|
|
146
|
+
* but only the mouse click (detail > 0), never a keyboard activation. */
|
|
147
|
+
let longPressed = false;
|
|
148
|
+
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
149
|
+
let pressStart: { x: number; y: number } | null = null;
|
|
150
|
+
|
|
151
|
+
let btnRef = $state<HTMLButtonElement | null>(null);
|
|
152
|
+
let panelRef = $state<HTMLDivElement | null>(null);
|
|
153
|
+
let glyphRef = $state<HTMLSpanElement | null>(null);
|
|
154
|
+
|
|
155
|
+
let playError = $state(false);
|
|
156
|
+
|
|
157
|
+
const currentScape = $derived(scapes.find((s) => s.id === currentId) ?? null);
|
|
158
|
+
const currentLabel = $derived(currentScape?.label ?? currentId);
|
|
159
|
+
const groups = $derived(groupScapes(scapes));
|
|
160
|
+
const liveLabel = $derived(
|
|
161
|
+
playError ? PLAY_ERROR_LABEL : ambienceLiveLabel(currentLabel, playing)
|
|
162
|
+
);
|
|
163
|
+
const disabled = $derived(scapes.length === 0);
|
|
164
|
+
const sliderValue = $derived(Math.round(volume * 100));
|
|
165
|
+
|
|
166
|
+
const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
|
|
167
|
+
const ICON_SIZE = $derived(compact ? 18 : 20);
|
|
168
|
+
|
|
169
|
+
// Engine state → component state. subscribe() fires once immediately, so
|
|
170
|
+
// this also fills the initial (persisted-preselect) state at mount.
|
|
171
|
+
$effect(() => {
|
|
172
|
+
return engine.subscribe((s) => {
|
|
173
|
+
playing = s.playing;
|
|
174
|
+
currentId = s.currentId;
|
|
175
|
+
volume = s.volume;
|
|
176
|
+
scapes = engine.list();
|
|
177
|
+
// A successful play (state flips to playing) clears the failure.
|
|
178
|
+
if (s.playing) playError = false;
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Breathing glyph: follow the engine's shared breath clock while playing,
|
|
183
|
+
// and only where ambient motion is allowed (getMotionContext().ambient is
|
|
184
|
+
// false under prefers-reduced-motion AND under data-motion="calm"). The
|
|
185
|
+
// value lands in a CSS variable the stylesheet turns into a smoothed
|
|
186
|
+
// scale — the callbacks run at the engine's ~4 Hz tick, the transition
|
|
187
|
+
// interpolates between them.
|
|
188
|
+
$effect(() => {
|
|
189
|
+
if (!playing) return;
|
|
190
|
+
if (!getMotionContext(btnRef).ambient) return;
|
|
191
|
+
const glyph = glyphRef;
|
|
192
|
+
if (!glyph) return;
|
|
193
|
+
const off = engine.onBreath((b) => {
|
|
194
|
+
glyph.style.setProperty('--jatui-ambience-breath', String(breathScale(b.value)));
|
|
195
|
+
});
|
|
196
|
+
return () => {
|
|
197
|
+
off();
|
|
198
|
+
// Drop the frozen scale so the stopped glyph sits at rest.
|
|
199
|
+
glyph.style.removeProperty('--jatui-ambience-breath');
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// While open: click-outside dismissal (capture phase, so a press that
|
|
204
|
+
// begins outside never reaches the page first) and APG focus movement into
|
|
205
|
+
// the menu — focus the first nav item once the panel exists.
|
|
206
|
+
$effect(() => {
|
|
207
|
+
if (!open) return;
|
|
208
|
+
const panel = panelRef;
|
|
209
|
+
if (!panel) return;
|
|
210
|
+
navItems()[0]?.focus();
|
|
211
|
+
const onDocPointerDown = (e: PointerEvent) => {
|
|
212
|
+
const t = e.target as Node | null;
|
|
213
|
+
if (btnRef?.contains(t) || panelRef?.contains(t)) return;
|
|
214
|
+
closeMenu(false);
|
|
215
|
+
};
|
|
216
|
+
document.addEventListener('pointerdown', onDocPointerDown, true);
|
|
217
|
+
return () => document.removeEventListener('pointerdown', onDocPointerDown, true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
function navItems(): HTMLElement[] {
|
|
221
|
+
return panelRef ? Array.from(panelRef.querySelectorAll<HTMLElement>('[data-ambience-nav]')) : [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function resolvePlacement(): 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' {
|
|
225
|
+
const el = btnRef;
|
|
226
|
+
if (!el || typeof window === 'undefined') return placement;
|
|
227
|
+
const r = el.getBoundingClientRect();
|
|
228
|
+
const spaceBelow = window.innerHeight - r.bottom;
|
|
229
|
+
const spaceAbove = r.top;
|
|
230
|
+
if (placement.startsWith('bottom') && spaceBelow < MENU_EST_HEIGHT && spaceAbove > spaceBelow)
|
|
231
|
+
return placement === 'bottom-end' ? 'top-end' : 'top-start';
|
|
232
|
+
if (placement.startsWith('top') && spaceAbove < MENU_EST_HEIGHT && spaceBelow > spaceAbove)
|
|
233
|
+
return placement === 'top-end' ? 'bottom-end' : 'bottom-start';
|
|
234
|
+
return placement;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function openMenu() {
|
|
238
|
+
if (disabled) return;
|
|
239
|
+
scapes = engine.list(); // freshest registry snapshot for the menu
|
|
240
|
+
effectivePlacement = resolvePlacement();
|
|
241
|
+
open = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function closeMenu(refocus = true) {
|
|
245
|
+
if (!open) return;
|
|
246
|
+
open = false;
|
|
247
|
+
if (refocus) btnRef?.focus();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function togglePlay(e: MouseEvent) {
|
|
251
|
+
// A long-press already opened the menu — don't also fire the toggle
|
|
252
|
+
// (keyboard activations, detail 0, are never suppressed).
|
|
253
|
+
if (longPressed && e.detail > 0) {
|
|
254
|
+
longPressed = false;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
longPressed = false;
|
|
258
|
+
if (open) closeMenu(false);
|
|
259
|
+
if (playing) {
|
|
260
|
+
engine.stop();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const id = engine.state.currentId ?? engine.list()[0]?.id;
|
|
264
|
+
if (!id) return;
|
|
265
|
+
attemptPlay(id);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A rejected engine.play() sets the transient error state (the sr-only
|
|
269
|
+
* status announces it); success clears it. Never an unhandled rejection. */
|
|
270
|
+
function attemptPlay(id: string) {
|
|
271
|
+
void engine
|
|
272
|
+
.play(id)
|
|
273
|
+
.then(() => {
|
|
274
|
+
playError = false;
|
|
275
|
+
})
|
|
276
|
+
.catch(() => {
|
|
277
|
+
playError = true;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function selectScape(scape: Scape) {
|
|
282
|
+
closeMenu(true);
|
|
283
|
+
// Radio on the already-playing scape is a no-op (no restart crossfade);
|
|
284
|
+
// the checked-but-stopped scape resumes.
|
|
285
|
+
if (playing && scape.id === currentId) return;
|
|
286
|
+
attemptPlay(scape.id);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function onVolumeInput(e: Event) {
|
|
290
|
+
const v = Number((e.currentTarget as HTMLInputElement).value);
|
|
291
|
+
engine.setVolume(v / 100);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- pointer path to the menu: press-and-hold ----------------------------
|
|
295
|
+
function onPointerDown(e: PointerEvent) {
|
|
296
|
+
longPressed = false;
|
|
297
|
+
if (e.button !== 0 || disabled) return;
|
|
298
|
+
pressStart = { x: e.clientX, y: e.clientY };
|
|
299
|
+
pressTimer = setTimeout(() => {
|
|
300
|
+
pressTimer = null;
|
|
301
|
+
longPressed = true;
|
|
302
|
+
openMenu();
|
|
303
|
+
}, LONG_PRESS_MS);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function clearPress() {
|
|
307
|
+
if (pressTimer) {
|
|
308
|
+
clearTimeout(pressTimer);
|
|
309
|
+
pressTimer = null;
|
|
310
|
+
}
|
|
311
|
+
pressStart = null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function onPointerMove(e: PointerEvent) {
|
|
315
|
+
if (!pressTimer || !pressStart) return;
|
|
316
|
+
const dx = e.clientX - pressStart.x;
|
|
317
|
+
const dy = e.clientY - pressStart.y;
|
|
318
|
+
if (dx * dx + dy * dy > 64) clearPress(); // moved ~8px — a drag, not a hold
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function onContextMenu(e: MouseEvent) {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
clearPress();
|
|
324
|
+
openMenu();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function onButtonKeydown(e: KeyboardEvent) {
|
|
328
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
openMenu();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- menu keyboard: APG roving among the nav items -----------------------
|
|
335
|
+
function onMenuKeydown(e: KeyboardEvent) {
|
|
336
|
+
if (e.key === 'Escape') {
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
closeMenu(true);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (e.key === 'Tab') {
|
|
342
|
+
// Menus are not tab stops: close and let focus move on.
|
|
343
|
+
closeMenu(false);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// The volume slider owns its own arrows/Home/End.
|
|
347
|
+
if (e.target instanceof HTMLInputElement) return;
|
|
348
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
const items = navItems();
|
|
351
|
+
if (!items.length) return;
|
|
352
|
+
const current = items.indexOf(document.activeElement as HTMLElement);
|
|
353
|
+
let next: number;
|
|
354
|
+
if (e.key === 'Home') next = 0;
|
|
355
|
+
else if (e.key === 'End') next = items.length - 1;
|
|
356
|
+
else if (current === -1) next = e.key === 'ArrowUp' ? items.length - 1 : 0;
|
|
357
|
+
else if (e.key === 'ArrowDown') next = (current + 1) % items.length;
|
|
358
|
+
else next = (current - 1 + items.length) % items.length;
|
|
359
|
+
items[next]?.focus();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const PLACEMENT_CLASS = {
|
|
363
|
+
'bottom-start': 'top-full left-0 mt-2',
|
|
364
|
+
'bottom-end': 'top-full right-0 mt-2',
|
|
365
|
+
'top-start': 'bottom-full left-0 mb-2',
|
|
366
|
+
'top-end': 'bottom-full right-0 mb-2'
|
|
367
|
+
} as const;
|
|
368
|
+
</script>
|
|
369
|
+
|
|
370
|
+
<div class="relative inline-flex shrink-0">
|
|
371
|
+
<button
|
|
372
|
+
bind:this={btnRef}
|
|
373
|
+
type="button"
|
|
374
|
+
class="jatui-ambience-toggle inline-flex shrink-0 select-none items-center justify-center rounded-full
|
|
375
|
+
touch-manipulation
|
|
376
|
+
text-base-content transition-colors duration-[var(--motion-instant,80ms)]
|
|
377
|
+
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary
|
|
378
|
+
hover:bg-base-content/10 focus-visible:bg-base-content/10
|
|
379
|
+
active:bg-base-content/15 disabled:pointer-events-none disabled:opacity-50
|
|
380
|
+
{BUTTON_SIZE}"
|
|
381
|
+
class:is-playing={playing}
|
|
382
|
+
aria-label={liveLabel}
|
|
383
|
+
aria-pressed={playing}
|
|
384
|
+
aria-haspopup="menu"
|
|
385
|
+
aria-expanded={open}
|
|
386
|
+
aria-controls={open ? menuId : undefined}
|
|
387
|
+
aria-describedby={hintId}
|
|
388
|
+
title={liveLabel}
|
|
389
|
+
disabled={disabled}
|
|
390
|
+
onclick={togglePlay}
|
|
391
|
+
onkeydown={onButtonKeydown}
|
|
392
|
+
onpointerdown={onPointerDown}
|
|
393
|
+
onpointerup={clearPress}
|
|
394
|
+
onpointercancel={clearPress}
|
|
395
|
+
onpointermove={onPointerMove}
|
|
396
|
+
onpointerleave={clearPress}
|
|
397
|
+
oncontextmenu={onContextMenu}
|
|
398
|
+
>
|
|
399
|
+
<span
|
|
400
|
+
bind:this={glyphRef}
|
|
401
|
+
class="glyphs relative block"
|
|
402
|
+
style="width: {ICON_SIZE}px; height: {ICON_SIZE}px"
|
|
403
|
+
aria-hidden="true"
|
|
404
|
+
>
|
|
405
|
+
<!-- Stopped: speaker with a strike, lucide volume-x geometry. -->
|
|
406
|
+
<svg
|
|
407
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
408
|
+
viewBox="0 0 24 24"
|
|
409
|
+
fill="none"
|
|
410
|
+
stroke="currentColor"
|
|
411
|
+
stroke-width="2"
|
|
412
|
+
stroke-linecap="round"
|
|
413
|
+
stroke-linejoin="round"
|
|
414
|
+
class="glyph glyph-off absolute inset-0"
|
|
415
|
+
width={ICON_SIZE}
|
|
416
|
+
height={ICON_SIZE}
|
|
417
|
+
>
|
|
418
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
419
|
+
<line x1="22" y1="9" x2="16" y2="15" />
|
|
420
|
+
<line x1="16" y1="9" x2="22" y2="15" />
|
|
421
|
+
</svg>
|
|
422
|
+
<!-- Playing: speaker with waves, lucide volume-2 geometry. -->
|
|
423
|
+
<svg
|
|
424
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
425
|
+
viewBox="0 0 24 24"
|
|
426
|
+
fill="none"
|
|
427
|
+
stroke="currentColor"
|
|
428
|
+
stroke-width="2"
|
|
429
|
+
stroke-linecap="round"
|
|
430
|
+
stroke-linejoin="round"
|
|
431
|
+
class="glyph glyph-on absolute inset-0"
|
|
432
|
+
width={ICON_SIZE}
|
|
433
|
+
height={ICON_SIZE}
|
|
434
|
+
>
|
|
435
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
436
|
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
437
|
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
438
|
+
</svg>
|
|
439
|
+
</span>
|
|
440
|
+
</button>
|
|
441
|
+
|
|
442
|
+
<!-- Teaches the menu gestures to assistive tech (the gestures are not
|
|
443
|
+
obvious from a button that both toggles and opens a menu). -->
|
|
444
|
+
<span id={hintId} class="sr-only">Press the down arrow, or press and hold or right-click, to choose a scape.</span>
|
|
445
|
+
|
|
446
|
+
{#if open}
|
|
447
|
+
<div
|
|
448
|
+
bind:this={panelRef}
|
|
449
|
+
id={menuId}
|
|
450
|
+
role="menu"
|
|
451
|
+
tabindex="-1"
|
|
452
|
+
aria-label="Ambience"
|
|
453
|
+
class="jatui-ambience-menu absolute z-50 w-72 max-w-[calc(100vw-1rem)] overflow-y-auto
|
|
454
|
+
rounded-box border border-base-300 bg-base-100 p-1.5 text-base-content shadow-xl
|
|
455
|
+
max-h-[min(24rem,calc(100vh-1rem))] menu-pop
|
|
456
|
+
{PLACEMENT_CLASS[effectivePlacement]}"
|
|
457
|
+
onkeydown={onMenuKeydown}
|
|
458
|
+
>
|
|
459
|
+
<!-- Volume: a real slider in the roving set; it owns its own arrows. -->
|
|
460
|
+
<div role="none" class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
|
|
461
|
+
<div class="mb-1.5 flex items-center justify-between">
|
|
462
|
+
<span class="text-jatui-label text-base-content/60">Volume</span>
|
|
463
|
+
<span class="text-2xs text-base-content/50 tabular-nums">{sliderValue}%</span>
|
|
464
|
+
</div>
|
|
465
|
+
<input
|
|
466
|
+
data-ambience-nav
|
|
467
|
+
tabindex="-1"
|
|
468
|
+
type="range"
|
|
469
|
+
class="range range-xs w-full"
|
|
470
|
+
min="0"
|
|
471
|
+
max="100"
|
|
472
|
+
step="1"
|
|
473
|
+
value={sliderValue}
|
|
474
|
+
aria-label="Ambience volume"
|
|
475
|
+
aria-valuetext="{sliderValue}%"
|
|
476
|
+
oninput={onVolumeInput}
|
|
477
|
+
/>
|
|
478
|
+
</div>
|
|
479
|
+
|
|
480
|
+
{#each groups as g (g.group)}
|
|
481
|
+
<div role="group" aria-label={g.label} class="py-1">
|
|
482
|
+
<div class="text-jatui-label text-base-content/45 px-2 py-1" aria-hidden="true">
|
|
483
|
+
{g.label}
|
|
484
|
+
</div>
|
|
485
|
+
{#each g.scapes as s (s.id)}
|
|
486
|
+
<button
|
|
487
|
+
type="button"
|
|
488
|
+
role="menuitemradio"
|
|
489
|
+
aria-checked={s.id === currentId}
|
|
490
|
+
tabindex="-1"
|
|
491
|
+
data-ambience-nav
|
|
492
|
+
class="flex w-full items-start gap-2 rounded px-2 py-1.5 text-left
|
|
493
|
+
transition-colors duration-[var(--motion-instant,80ms)]
|
|
494
|
+
hover:bg-base-content/10 focus-visible:bg-base-content/10
|
|
495
|
+
focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary
|
|
496
|
+
{s.id === currentId ? 'bg-base-content/5' : ''}"
|
|
497
|
+
onclick={() => selectScape(s)}
|
|
498
|
+
>
|
|
499
|
+
<span class="min-w-0 flex-1">
|
|
500
|
+
<span class="block truncate text-sm font-medium">{s.label}</span>
|
|
501
|
+
{#if s.description}
|
|
502
|
+
<!-- Secondary line: for playlist scapes this is the track attribution. -->
|
|
503
|
+
<span class="text-2xs text-base-content/55 block truncate">{s.description}</span>
|
|
504
|
+
{/if}
|
|
505
|
+
</span>
|
|
506
|
+
{#if s.id === currentId}
|
|
507
|
+
<svg
|
|
508
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
509
|
+
viewBox="0 0 24 24"
|
|
510
|
+
fill="currentColor"
|
|
511
|
+
class="mt-1 size-3.5 shrink-0 text-primary"
|
|
512
|
+
aria-hidden="true"
|
|
513
|
+
>
|
|
514
|
+
<path d="M20.285 2l-11.285 11.567-5.286-5.011-3.714 3.716 9 8.728 15-15.285z"></path>
|
|
515
|
+
</svg>
|
|
516
|
+
{:else}
|
|
517
|
+
<span class="mt-1 size-3.5 shrink-0" aria-hidden="true"></span>
|
|
518
|
+
{/if}
|
|
519
|
+
</button>
|
|
520
|
+
{/each}
|
|
521
|
+
</div>
|
|
522
|
+
{/each}
|
|
523
|
+
</div>
|
|
524
|
+
{/if}
|
|
525
|
+
|
|
526
|
+
<!-- Live status: announces play/stop/switch without stealing focus. -->
|
|
527
|
+
<span role="status" aria-live="polite" class="sr-only">{liveLabel}</span>
|
|
528
|
+
</div>
|
|
529
|
+
|
|
530
|
+
<style>
|
|
531
|
+
/*
|
|
532
|
+
* Off↔on morph: transform + opacity only (compositor-friendly), spring for
|
|
533
|
+
* the small-element settle, glide for the fade — the ThemeToggle recipe.
|
|
534
|
+
*/
|
|
535
|
+
.glyph {
|
|
536
|
+
transition:
|
|
537
|
+
transform var(--motion-fast, 150ms) var(--motion-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
|
|
538
|
+
opacity var(--motion-fast, 150ms) var(--motion-glide, cubic-bezier(0.16, 1, 0.3, 1));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.glyph-off {
|
|
542
|
+
opacity: 1;
|
|
543
|
+
transform: rotate(0deg) scale(1);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.glyph-on {
|
|
547
|
+
opacity: 0;
|
|
548
|
+
transform: rotate(-90deg) scale(0.5);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.is-playing .glyph-off {
|
|
552
|
+
opacity: 0;
|
|
553
|
+
transform: rotate(90deg) scale(0.5);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.is-playing .glyph-on {
|
|
557
|
+
opacity: 1;
|
|
558
|
+
transform: rotate(0deg) scale(1);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/*
|
|
562
|
+
* The breath: the engine's shared breath clock drives
|
|
563
|
+
* --jatui-ambience-breath at its ~4 Hz tick; the transition interpolates
|
|
564
|
+
* between the steps so the glyph visibly inhales and exhales.
|
|
565
|
+
*/
|
|
566
|
+
.glyphs {
|
|
567
|
+
transform: scale(var(--jatui-ambience-breath, 1));
|
|
568
|
+
transition: transform 420ms var(--motion-glide, cubic-bezier(0.16, 1, 0.3, 1));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/* Ambient decorative loop — static under reduced motion … */
|
|
572
|
+
@media (prefers-reduced-motion: reduce) {
|
|
573
|
+
.glyph {
|
|
574
|
+
transition: none;
|
|
575
|
+
}
|
|
576
|
+
.glyphs {
|
|
577
|
+
transform: none;
|
|
578
|
+
transition: none;
|
|
579
|
+
}
|
|
580
|
+
.menu-pop {
|
|
581
|
+
animation: none;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* … and under data-motion="calm" (the house rule for ambient loops). */
|
|
586
|
+
:global([data-motion='calm']) .glyphs {
|
|
587
|
+
transform: none;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.menu-pop {
|
|
591
|
+
animation: jatui-menu-in var(--motion-enter, 200ms) var(--motion-glide, cubic-bezier(0.16, 1, 0.3, 1));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
@keyframes jatui-menu-in {
|
|
595
|
+
from {
|
|
596
|
+
opacity: 0;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
</style>
|