@joewinke/jatui 0.59.0 → 0.61.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 +4 -1
- package/dist/actions/tooltip.d.ts +7 -0
- package/dist/actions/tooltip.js +28 -0
- package/dist/ambience/README.md +9 -2
- package/dist/ambience/engine.d.ts +2 -0
- package/dist/ambience/engine.js +101 -4
- package/dist/ambience/scapes/playlist.d.ts +15 -0
- package/dist/ambience/scapes/playlist.js +81 -4
- package/dist/ambience/shared.d.ts +69 -2
- package/dist/ambience/shared.js +110 -8
- package/dist/ambience/types.d.ts +19 -0
- package/dist/components/AmbienceToggle.svelte +260 -11
- package/dist/components/AmbienceToggle.svelte.d.ts +17 -3
- package/dist/components/stats/StatusBanner.svelte +119 -0
- package/dist/components/stats/StatusBanner.svelte.d.ts +13 -0
- package/dist/components/stats/StatusCard.svelte +176 -0
- package/dist/components/stats/StatusCard.svelte.d.ts +18 -0
- package/dist/components/stats/UptimeBar.svelte +254 -0
- package/dist/components/stats/UptimeBar.svelte.d.ts +20 -0
- package/dist/components/stats/uptime.d.ts +105 -0
- package/dist/components/stats/uptime.js +213 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -84,6 +84,9 @@ Then import:
|
|
|
84
84
|
| `ChartCard` | "Headline + period switch + chart" shell that draws NO chart (bring layerchart / d3 / SVG). The `chart` snippet gets `{ period, hover, hovered }`; call `hover({value, label, delta?})` as the pointer moves and the headline tweens to that point and the caption shows its label; `hover(null)` (or pointer leave) restores the total. Periods use SegmentedControl. | `label`, `value`, `format?`, `delta?`, `invertDelta?`, `caption?`, `periods?`, `period` (bindable), `onPeriodChange?`, `chartHeight?`, `loading?`, `chart` (snippet), `actions?`/`footer?` (snippets) |
|
|
85
85
|
| `BarList` | Ranked horizontal bars ("top pages / top callers"): label + value as text, a decorative token-tinted bar sized against the max, optional `href` or `onSelect` per row, `hint`, per-row `tone`, `limit` + "Show N more". Pure HTML/CSS, no chart lib. | `items: BarListItem[]`, `sort?` (`desc`), `limit?`, `format?`, `formatValue?(item, total)`, `labelHeader?`/`valueHeader?`, `tone?`, `size?`, `onSelect?`, `label?` (snippet) |
|
|
86
86
|
| `ActivityHeatmap` | GitHub-style activity-per-day calendar (calls/day, tasks closed/day): 7 weekday rows × week columns, default range = last 12 months, `weekStart`, 4-step `color-mix` ramp from one theme `tone` (no hex), one floating tooltip, legend + summary ("1,204 calls in the last year"). `role="grid"` with ONE tab stop: arrows move by day (↑↓) / week (←→), PgUp/PgDn 4 weeks, Home/End row ends, mod+Home/End range ends, Enter/Space select. `fill` (default `true`) sizes the cells to the container — pure CSS (`container-type: inline-size` + cqi), clamped 8–20px (`--hm-cell-min`/`--hm-cell-max`), scrolling only below the floor and the legend parked under the grid's right edge; `fill={false}` is the legacy fixed 12px grid (and the one to pass in a shrink-to-fit host, where an inline-size container collapses). Pure layout helpers exported (`buildHeatmap`, `moveHeatFocus`). | `data` (`{date, count}[]` or `getDate`/`getCount`), `start?`/`end?`, `weekStart?`, `unit?` ('call' or ['call','calls']), `formatLabel?`, `tone?`, `levels?`, `levelOf?`, `compact?`, `fill?` (true), `showLegend?`/`showWeekdays?`/`showMonths?`, `summary?`, `selected` (bindable), `onSelectDay?(date, count)`, `locale?` |
|
|
87
|
+
| `UptimeBar` | One component's 90-day status strip (status-page style): one thin bar per day, coloured by status tone (operational green, degraded amber, partial orange, outage red, `no-data` hatched neutral — never coloured as up), a hover/touch/focus tooltip with date + minutes down + incident note, and the footer "90 days ago ——— 99.97% uptime ——— Today" (uptime % truncated to 2 decimals — never overclaims, e.g. 99.996% shows 99.99% never 100%). `role="grid"` with ONE tab stop and roving focus: arrows move by day, PgUp/PgDn by 7, Home/End to the window ends. Bars size to the container width (pure CSS cqi, clamped 3–14px, `--up-bar-min`/`--up-bar-max`); below the floor the strip scrolls horizontally inside the component, starting at the latest day. Token-only colours (`--jatui-status-*` + `color-mix`). Pure helpers exported (`buildUptime`, `moveUptimeFocus`, `uptimeWord`, `bannerStatus`, `aggregateStatus`, `defaultDownMinutes`, `formatUptimePct`). | `data` (`{date, status, downMinutes?, note?}[]`), `days?` (90), `end?`, `label?`, `formatLabel?`, `showUptime?`/`showRangeLabels?` (true), `locale?`, `class?` |
|
|
88
|
+
| `StatusCard` | One measured component on a status page: name + info tooltip (what is measured), status icon at the right, the 90-day `UptimeBar` strip, and the status word (Normal / Degraded / Outage / No data — from the worst status in the window; `statusWord` overrides). | `name`, `description?`, `data`, `days?`, `end?`, `statusWord?`, `locale?`, `class?` |
|
|
89
|
+
| `StatusBanner` | Status page headline banner: "All systems operational" / degraded / outage, token colours, check / warning / x icon. | `status` ('operational' \| 'degraded' \| 'outage'), `message?`, `info?`, `class?` |
|
|
87
90
|
| `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
|
|
88
91
|
| `Card` | Content card container | `title`, `children` (snippet) |
|
|
89
92
|
| `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **On open, focus goes to the first fillable field** (WAI-ARIA APG): a `[data-autofocus]`/`autofocus` element, else the first enabled **rendered** text-entry control (readOnly/disabled textareas excluded, unrendered candidates skipped), else the dialog itself — `initialFocus` overrides the order (`'dialog'` restores the old container focus); the focus landing is verified, else the container is focused. When the dialog is the fallback focus, **Escape is owned by the dialog element itself** (not the window handler), so `trapKeys`' stopPropagation can't swallow Esc; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?`, `initialFocus?` |
|
|
@@ -333,7 +336,7 @@ const rec = createAudioRecorder({
|
|
|
333
336
|
|
|
334
337
|
### Ambience (subpath: `jatui/ambience`, components on the root)
|
|
335
338
|
|
|
336
|
-
`createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = volume + one row per section (Radio / Focus / Noise / Meditation … — host order via `groups: Array<ScapeGroup | {id, label}>`, default radio/focus/noise/meditation/nature/music, unlisted groups appended), each opening a submenu (→/Enter/Space open, ←/Esc close; ContextMenu submenu pattern). `radioStation({id, label, href})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
|
|
339
|
+
`createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = a now-playing row above volume (playing/paused only; playlists: track title — composer · license, elapsed/total progress bar, prev / play-pause / next transport — `prev()` restarts the track when >3 s are in, else goes back one; play-pause uses engine `pause()`/`resume()` which freeze the scape in place — generative scapes get play/pause only) + one row per section (Radio / Focus / Noise / Meditation … — host order via `groups: Array<ScapeGroup | {id, label}>`, default radio/focus/noise/meditation/nature/music, unlisted groups appended), each opening a submenu (→/Enter/Space open, ←/Esc close; ContextMenu submenu pattern). `radioStation({id, label, href})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license and exposes `state().position/duration` + `skip()`/`prev()`. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Shared mode: remotes render the leader's published now-playing parts (`trackTitle/trackPosition/trackDuration`) and route pause/resume/skip/prev via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
|
|
337
340
|
|
|
338
341
|
### Sound (first-class sibling of motion)
|
|
339
342
|
|
|
@@ -15,12 +15,19 @@
|
|
|
15
15
|
* Accessibility: sets aria-describedby on the host element.
|
|
16
16
|
* Escape key hides the active tooltip.
|
|
17
17
|
* Focus events (focusin/focusout) trigger alongside mouse hover.
|
|
18
|
+
* Touch/pen: a tap shows the tooltip immediately — only when the host sets
|
|
19
|
+
* `tapToShow` (default false, so plain buttons and badges don't pop on tap);
|
|
20
|
+
* the next tap elsewhere hides it.
|
|
18
21
|
*/
|
|
19
22
|
export interface TooltipOptions {
|
|
20
23
|
text: string;
|
|
21
24
|
placement?: 'top' | 'bottom' | 'left' | 'right';
|
|
22
25
|
/** ms to wait before showing (default 400). 0 = instant. */
|
|
23
26
|
delayMs?: number;
|
|
27
|
+
/** Show the tooltip on a touch/pen TAP (default false — only components
|
|
28
|
+
* that ask for it, e.g. UptimeBar's day bars, pop a tooltip on tap; plain
|
|
29
|
+
* buttons and badges must not). */
|
|
30
|
+
tapToShow?: boolean;
|
|
24
31
|
}
|
|
25
32
|
export declare function tooltip(node: HTMLElement, options: TooltipOptions): {
|
|
26
33
|
update(next: TooltipOptions): void;
|
package/dist/actions/tooltip.js
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* Accessibility: sets aria-describedby on the host element.
|
|
16
16
|
* Escape key hides the active tooltip.
|
|
17
17
|
* Focus events (focusin/focusout) trigger alongside mouse hover.
|
|
18
|
+
* Touch/pen: a tap shows the tooltip immediately — only when the host sets
|
|
19
|
+
* `tapToShow` (default false, so plain buttons and badges don't pop on tap);
|
|
20
|
+
* the next tap elsewhere hides it.
|
|
18
21
|
*/
|
|
19
22
|
const COOL_WINDOW_MS = 500;
|
|
20
23
|
const GAP = 8;
|
|
@@ -173,10 +176,29 @@ export function tooltip(node, options) {
|
|
|
173
176
|
if (e.key === 'Escape')
|
|
174
177
|
hide();
|
|
175
178
|
}
|
|
179
|
+
let outTap = null;
|
|
180
|
+
function handlePointerdown(e) {
|
|
181
|
+
// Touch/pen: there is no hover — a TAP shows the tooltip. Only when the
|
|
182
|
+
// host asks for it (tapToShow): plain buttons and badges must not pop a
|
|
183
|
+
// tooltip on every tap. The next tap anywhere else hides it again (the
|
|
184
|
+
// tooltip itself has pointer-events: none, so the tap always reaches
|
|
185
|
+
// whatever is under it).
|
|
186
|
+
if (!opts.tapToShow)
|
|
187
|
+
return;
|
|
188
|
+
if (e.pointerType !== 'touch' && e.pointerType !== 'pen')
|
|
189
|
+
return;
|
|
190
|
+
scheduleShow();
|
|
191
|
+
outTap = (e2) => {
|
|
192
|
+
if (!node.contains(e2.target))
|
|
193
|
+
hide();
|
|
194
|
+
};
|
|
195
|
+
document.addEventListener('pointerdown', outTap, { capture: true, once: true });
|
|
196
|
+
}
|
|
176
197
|
node.addEventListener('mouseenter', scheduleShow);
|
|
177
198
|
node.addEventListener('mouseleave', hide);
|
|
178
199
|
node.addEventListener('focusin', scheduleShow);
|
|
179
200
|
node.addEventListener('focusout', hide);
|
|
201
|
+
node.addEventListener('pointerdown', handlePointerdown);
|
|
180
202
|
document.addEventListener('keydown', handleKeydown);
|
|
181
203
|
return {
|
|
182
204
|
update(next) {
|
|
@@ -188,10 +210,16 @@ export function tooltip(node, options) {
|
|
|
188
210
|
},
|
|
189
211
|
destroy() {
|
|
190
212
|
hide();
|
|
213
|
+
if (outTap) {
|
|
214
|
+
// The once-listener can outlive the node — remove it explicitly.
|
|
215
|
+
document.removeEventListener('pointerdown', outTap, { capture: true });
|
|
216
|
+
outTap = null;
|
|
217
|
+
}
|
|
191
218
|
node.removeEventListener('mouseenter', scheduleShow);
|
|
192
219
|
node.removeEventListener('mouseleave', hide);
|
|
193
220
|
node.removeEventListener('focusin', scheduleShow);
|
|
194
221
|
node.removeEventListener('focusout', hide);
|
|
222
|
+
node.removeEventListener('pointerdown', handlePointerdown);
|
|
195
223
|
document.removeEventListener('keydown', handleKeydown);
|
|
196
224
|
},
|
|
197
225
|
};
|
package/dist/ambience/README.md
CHANGED
|
@@ -120,12 +120,19 @@ scape.subscribe((s) => {
|
|
|
120
120
|
// s.status: 'idle' | 'loading' | 'playing' | 'error'
|
|
121
121
|
// s.track: { title, composer, performer?, license, sourceUrl? } | null
|
|
122
122
|
// s.index, s.count, s.error?
|
|
123
|
+
// s.position, s.duration — elapsed/total seconds of the current track
|
|
124
|
+
// (0 while unknown; refreshed on the ~250 ms engine tick — jatui-153tz)
|
|
123
125
|
});
|
|
124
|
-
scape.skip();
|
|
126
|
+
scape.skip(); // crossfade to the next track now
|
|
127
|
+
scape.prev(); // restart the track when >3 s are in, else the previous track
|
|
125
128
|
scape.setShuffle(true);
|
|
126
129
|
scape.tracks(); // resolved list, or null until the manifest resolved
|
|
127
130
|
```
|
|
128
131
|
|
|
129
132
|
Cast a scape from `ambience.list()` to `PlaylistScape` in the toggle to reach
|
|
130
133
|
it. The toggle renders the attribution line — `composer · performer · license`
|
|
131
|
-
— linked to `sourceUrl` while a playlist scape is playing
|
|
134
|
+
— linked to `sourceUrl` while a playlist scape is playing, and (jatui-153tz) a
|
|
135
|
+
now-playing row above the volume with the track, a progress bar fed by
|
|
136
|
+
`position`/`duration`, and a prev / play-pause / next transport. The engine's
|
|
137
|
+
`pause()`/`resume()` freeze the scape in place — the playlist pauses its media
|
|
138
|
+
elements and keeps the track position.
|
|
@@ -12,6 +12,8 @@ export declare const TIMING: Readonly<{
|
|
|
12
12
|
RESUME_FADE: 4;
|
|
13
13
|
/** setVolume ramp (s). */
|
|
14
14
|
VOLUME_RAMP: 0.3;
|
|
15
|
+
/** pause() fade-out before the scape freezes (s). */
|
|
16
|
+
PAUSE_FADE: 0.15;
|
|
15
17
|
/** The fade handed to instance.stop() on kill — 0 = immediate. */
|
|
16
18
|
KILL_FADE: 0;
|
|
17
19
|
/** After a scape's stop fade, its un-released lookahead cbs are retired
|
package/dist/ambience/engine.js
CHANGED
|
@@ -27,6 +27,8 @@ export const TIMING = Object.freeze({
|
|
|
27
27
|
RESUME_FADE: 4,
|
|
28
28
|
/** setVolume ramp (s). */
|
|
29
29
|
VOLUME_RAMP: 0.3,
|
|
30
|
+
/** pause() fade-out before the scape freezes (s). */
|
|
31
|
+
PAUSE_FADE: 0.15,
|
|
30
32
|
/** The fade handed to instance.stop() on kill — 0 = immediate. */
|
|
31
33
|
KILL_FADE: 0,
|
|
32
34
|
/** After a scape's stop fade, its un-released lookahead cbs are retired
|
|
@@ -50,6 +52,12 @@ export function createAmbience(options = {}) {
|
|
|
50
52
|
let volume = clamp(typeof persisted.volume === 'number' ? persisted.volume : (options.defaultVolume ?? 0.6));
|
|
51
53
|
let currentId = persisted.lastScape ?? null;
|
|
52
54
|
let playing = false;
|
|
55
|
+
/** True between pause() and resume()/play()/stop(): the instance stays
|
|
56
|
+
* built and frozen (playlist position kept), the context suspended. */
|
|
57
|
+
let paused = false;
|
|
58
|
+
/** The deferred freeze at the end of pause()'s fade — a resume()/play()
|
|
59
|
+
* inside the window aborts it. */
|
|
60
|
+
let pauseTimer = null;
|
|
53
61
|
let ctx = null;
|
|
54
62
|
let chain = null;
|
|
55
63
|
let active = null;
|
|
@@ -62,7 +70,7 @@ export function createAmbience(options = {}) {
|
|
|
62
70
|
const breathSubs = new Set();
|
|
63
71
|
const lookaheadCbs = new Set();
|
|
64
72
|
function state() {
|
|
65
|
-
return { currentId, playing, volume };
|
|
73
|
+
return { currentId, playing, paused, volume };
|
|
66
74
|
}
|
|
67
75
|
function emit() {
|
|
68
76
|
const s = state();
|
|
@@ -145,8 +153,11 @@ export function createAmbience(options = {}) {
|
|
|
145
153
|
// one is suspended at `await c.resume()` invalidates it — the loser
|
|
146
154
|
// returns without building or starting anything.
|
|
147
155
|
const gen = ++playGen;
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
const wasPaused = paused;
|
|
157
|
+
paused = false; // a play() is a fresh start — never a continuation
|
|
158
|
+
// Inside the post-stop close window (or resuming from a paused scape):
|
|
159
|
+
// cancel the close / unfreeze, and fade the bus back in.
|
|
160
|
+
const resuming = closeTimer !== null || wasPaused;
|
|
150
161
|
if (closeTimer !== null) {
|
|
151
162
|
clearTimeout(closeTimer);
|
|
152
163
|
closeTimer = null;
|
|
@@ -201,7 +212,15 @@ export function createAmbience(options = {}) {
|
|
|
201
212
|
}
|
|
202
213
|
function stop() {
|
|
203
214
|
playGen++; // invalidate any in-flight play()
|
|
204
|
-
if (
|
|
215
|
+
if (pauseTimer) {
|
|
216
|
+
clearTimeout(pauseTimer);
|
|
217
|
+
pauseTimer = null;
|
|
218
|
+
}
|
|
219
|
+
const wasPaused = paused;
|
|
220
|
+
paused = false;
|
|
221
|
+
// A paused scape still owns a live (frozen) instance + context — its
|
|
222
|
+
// stop must tear them down like a playing stop would.
|
|
223
|
+
if (!ctx || (!playing && !wasPaused)) {
|
|
205
224
|
playing = false;
|
|
206
225
|
emit();
|
|
207
226
|
return;
|
|
@@ -223,12 +242,88 @@ export function createAmbience(options = {}) {
|
|
|
223
242
|
}, TIMING.CLOSE_DELAY_MS);
|
|
224
243
|
emit();
|
|
225
244
|
}
|
|
245
|
+
function pause() {
|
|
246
|
+
if (!playing)
|
|
247
|
+
return;
|
|
248
|
+
playing = false;
|
|
249
|
+
paused = true;
|
|
250
|
+
emit();
|
|
251
|
+
if (!ctx || !chain || !active)
|
|
252
|
+
return;
|
|
253
|
+
// A short fade so the freeze is click-free, then the scape freezes in
|
|
254
|
+
// place (media elements) and the context suspends — generative scapes
|
|
255
|
+
// stop evolving and the audio thread rests.
|
|
256
|
+
const now = ctx.currentTime;
|
|
257
|
+
const g = chain.bus.gain;
|
|
258
|
+
g.cancelScheduledValues(now);
|
|
259
|
+
g.setValueAtTime(g.value, now);
|
|
260
|
+
g.linearRampToValueAtTime(0, now + TIMING.PAUSE_FADE);
|
|
261
|
+
if (pauseTimer)
|
|
262
|
+
clearTimeout(pauseTimer);
|
|
263
|
+
pauseTimer = setTimeout(() => {
|
|
264
|
+
pauseTimer = null;
|
|
265
|
+
// A resume()/play()/stop() inside the fade window aborted the pause.
|
|
266
|
+
if (!paused || playing || !active || !ctx)
|
|
267
|
+
return;
|
|
268
|
+
try {
|
|
269
|
+
active.inst.pause?.();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* a broken scape must not take the engine down */
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
void ctx.suspend();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* already closed */
|
|
279
|
+
}
|
|
280
|
+
}, TIMING.PAUSE_FADE * 1000 + 20);
|
|
281
|
+
}
|
|
282
|
+
async function resume() {
|
|
283
|
+
if (playing || !currentId || !paused)
|
|
284
|
+
return; // only a paused scape resumes
|
|
285
|
+
if (!isGesture())
|
|
286
|
+
throw new Error('resume() must be called from a user gesture');
|
|
287
|
+
const gen = ++playGen; // a stop()/kill() during the resume invalidates it
|
|
288
|
+
paused = false;
|
|
289
|
+
// Defensive: a paused engine always still owns its context + instance
|
|
290
|
+
// (kill/stop clear the paused flag) — bail rather than guess.
|
|
291
|
+
if (!ctx || !chain || !active) {
|
|
292
|
+
emit();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (ctx.state === 'suspended')
|
|
296
|
+
await ctx.resume();
|
|
297
|
+
if (gen !== playGen || !ctx || !chain || !active)
|
|
298
|
+
return; // superseded mid-resume
|
|
299
|
+
try {
|
|
300
|
+
active.inst.resume?.();
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
/* a broken scape must not take the engine down */
|
|
304
|
+
}
|
|
305
|
+
const g = chain.bus.gain;
|
|
306
|
+
g.cancelScheduledValues(ctx.currentTime);
|
|
307
|
+
g.setValueAtTime(g.value, ctx.currentTime);
|
|
308
|
+
g.linearRampToValueAtTime(1, ctx.currentTime + TIMING.VOLUME_RAMP);
|
|
309
|
+
// A pause did not stop the breath clock, but make sure it runs.
|
|
310
|
+
if (!breath.running)
|
|
311
|
+
breath.start(ctx.currentTime);
|
|
312
|
+
playing = true;
|
|
313
|
+
persist();
|
|
314
|
+
emit();
|
|
315
|
+
}
|
|
226
316
|
function kill() {
|
|
227
317
|
playGen++; // invalidate any in-flight play()
|
|
228
318
|
if (closeTimer) {
|
|
229
319
|
clearTimeout(closeTimer);
|
|
230
320
|
closeTimer = null;
|
|
231
321
|
}
|
|
322
|
+
if (pauseTimer) {
|
|
323
|
+
clearTimeout(pauseTimer);
|
|
324
|
+
pauseTimer = null;
|
|
325
|
+
}
|
|
326
|
+
paused = false;
|
|
232
327
|
if (tick) {
|
|
233
328
|
clearInterval(tick);
|
|
234
329
|
tick = null;
|
|
@@ -298,6 +393,8 @@ export function createAmbience(options = {}) {
|
|
|
298
393
|
},
|
|
299
394
|
play,
|
|
300
395
|
stop,
|
|
396
|
+
pause,
|
|
397
|
+
resume,
|
|
301
398
|
kill,
|
|
302
399
|
setVolume,
|
|
303
400
|
get state() {
|
|
@@ -47,6 +47,9 @@ export declare const PLAYLIST: Readonly<{
|
|
|
47
47
|
/** Start the next track when at most this much of the current track is
|
|
48
48
|
* left ON TOP of the crossfade (s). */
|
|
49
49
|
END_MARGIN: 0.25;
|
|
50
|
+
/** prev() restarts the current track when more than this many seconds are
|
|
51
|
+
* in; below it, it goes back one track (jatui-153tz). */
|
|
52
|
+
PREV_RESTART: 3;
|
|
50
53
|
/** setParam('gain') ramp (s). */
|
|
51
54
|
GAIN_RAMP: 0.15;
|
|
52
55
|
/** Disconnect + pause this many ms after a stop fade ends — before the
|
|
@@ -84,6 +87,13 @@ export interface PlaylistState {
|
|
|
84
87
|
index: number;
|
|
85
88
|
/** Number of tracks in the resolved playlist (0 until resolved). */
|
|
86
89
|
count: number;
|
|
90
|
+
/** Elapsed seconds of the current track (0 while none is sounding).
|
|
91
|
+
* Updated on the engine's ~250 ms lookahead tick — the cadence the
|
|
92
|
+
* toggle's progress bar renders at (jatui-153tz). */
|
|
93
|
+
position: number;
|
|
94
|
+
/** Total seconds of the current track (0 until metadata reports one —
|
|
95
|
+
* a stream without a duration stays 0). */
|
|
96
|
+
duration: number;
|
|
87
97
|
/** Present on error. */
|
|
88
98
|
error?: string;
|
|
89
99
|
}
|
|
@@ -124,6 +134,11 @@ export interface PlaylistScape extends Scape {
|
|
|
124
134
|
subscribe(fn: (s: PlaylistState) => void): () => void;
|
|
125
135
|
/** Crossfade to the next track now. No-op while loading/released. */
|
|
126
136
|
skip(): void;
|
|
137
|
+
/** Back one step (jatui-153tz): restart the current track when more than
|
|
138
|
+
* PLAYLIST.PREV_RESTART (3 s) are in, else crossfade to the previous
|
|
139
|
+
* track in the play order (at the very first track, restart). No-op
|
|
140
|
+
* while loading/released or mid-transition. */
|
|
141
|
+
prev(): void;
|
|
127
142
|
/** Toggle shuffle; the current track stays, the rest reorder at once. */
|
|
128
143
|
setShuffle(on: boolean): void;
|
|
129
144
|
/** Crossfade to an absolute track index — used on leadership handover
|
|
@@ -8,6 +8,9 @@ export const PLAYLIST = Object.freeze({
|
|
|
8
8
|
/** Start the next track when at most this much of the current track is
|
|
9
9
|
* left ON TOP of the crossfade (s). */
|
|
10
10
|
END_MARGIN: 0.25,
|
|
11
|
+
/** prev() restarts the current track when more than this many seconds are
|
|
12
|
+
* in; below it, it goes back one track (jatui-153tz). */
|
|
13
|
+
PREV_RESTART: 3,
|
|
11
14
|
/** setParam('gain') ramp (s). */
|
|
12
15
|
GAIN_RAMP: 0.15,
|
|
13
16
|
/** Disconnect + pause this many ms after a stop fade ends — before the
|
|
@@ -43,7 +46,7 @@ export function createPlaylistScape(options) {
|
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
/** Shared UI snapshot + subscribers. Only the latest build writes it. */
|
|
46
|
-
let snap = { status: 'idle', track: null, index: 0, count: 0 };
|
|
49
|
+
let snap = { status: 'idle', track: null, index: 0, count: 0, position: 0, duration: 0 };
|
|
47
50
|
const subs = new Set();
|
|
48
51
|
/** Handle to the most recent build — skip()/setShuffle() route here. */
|
|
49
52
|
let latest = null;
|
|
@@ -178,6 +181,12 @@ export function createPlaylistScape(options) {
|
|
|
178
181
|
if (slot.trackIndex < 0)
|
|
179
182
|
return;
|
|
180
183
|
const el = slot.el;
|
|
184
|
+
// Live progress for the toggle's now-playing row (jatui-153tz):
|
|
185
|
+
// elapsed/total on the ~250 ms tick cadence the bar renders at.
|
|
186
|
+
const position = Number.isFinite(el.currentTime) ? Math.max(0, el.currentTime) : 0;
|
|
187
|
+
const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : 0;
|
|
188
|
+
if (snap.position !== position || snap.duration !== duration)
|
|
189
|
+
setSnap({ position, duration });
|
|
181
190
|
const d = el.duration;
|
|
182
191
|
// Stateless per-tick check (no while-loop to re-anchor): at most ONE
|
|
183
192
|
// transition per tick, always anchored at THIS tick's now, so a
|
|
@@ -274,7 +283,15 @@ export function createPlaylistScape(options) {
|
|
|
274
283
|
catch {
|
|
275
284
|
onTrackFailed();
|
|
276
285
|
}
|
|
277
|
-
setSnap({
|
|
286
|
+
setSnap({
|
|
287
|
+
status: 'playing',
|
|
288
|
+
track,
|
|
289
|
+
index: pos,
|
|
290
|
+
count: resolved.length,
|
|
291
|
+
position: 0,
|
|
292
|
+
duration: 0,
|
|
293
|
+
error: undefined
|
|
294
|
+
});
|
|
278
295
|
preloadNext();
|
|
279
296
|
}
|
|
280
297
|
function quietSlot(slot) {
|
|
@@ -357,6 +374,8 @@ export function createPlaylistScape(options) {
|
|
|
357
374
|
track: null,
|
|
358
375
|
index: 0,
|
|
359
376
|
count: resolved?.length ?? 0,
|
|
377
|
+
position: 0,
|
|
378
|
+
duration: 0,
|
|
360
379
|
error: undefined
|
|
361
380
|
});
|
|
362
381
|
const f = Math.max(0, fade);
|
|
@@ -432,7 +451,9 @@ export function createPlaylistScape(options) {
|
|
|
432
451
|
error: message,
|
|
433
452
|
track: null,
|
|
434
453
|
index: 0,
|
|
435
|
-
count: resolved?.length ?? 0
|
|
454
|
+
count: resolved?.length ?? 0,
|
|
455
|
+
position: 0,
|
|
456
|
+
duration: 0
|
|
436
457
|
});
|
|
437
458
|
const now = ctx.currentTime;
|
|
438
459
|
const g = mix.gain;
|
|
@@ -461,6 +482,57 @@ export function createPlaylistScape(options) {
|
|
|
461
482
|
if (ni !== -1)
|
|
462
483
|
startTrack(ni, 'cross');
|
|
463
484
|
}
|
|
485
|
+
/** Back one step (jatui-153tz): > 3 s into the track restarts it, else
|
|
486
|
+
* the previous track in the play order (the first track restarts). */
|
|
487
|
+
function prevInternal() {
|
|
488
|
+
if (released || !started || order.length === 0 || active < 0)
|
|
489
|
+
return;
|
|
490
|
+
const slot = slots[active];
|
|
491
|
+
const cur = order[pos];
|
|
492
|
+
if (slot.trackIndex !== cur)
|
|
493
|
+
return; // mid-transition — the skip owns it
|
|
494
|
+
if (slot.el.currentTime > PLAYLIST.PREV_RESTART || pos === 0) {
|
|
495
|
+
// Restart the current track: a quick fade into the idle slot
|
|
496
|
+
// loaded with the same track, play order pointer unchanged.
|
|
497
|
+
startTrack(cur, 'ended');
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
pos -= 1;
|
|
501
|
+
startTrack(order[pos], 'cross');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Engine pause(): freeze in place — both elements stop, the track and
|
|
505
|
+
* its position are kept for resume(). Gains/schedules stay as they are
|
|
506
|
+
* (the context suspension freezes the audio clock mid-ramp). */
|
|
507
|
+
function pauseInternal() {
|
|
508
|
+
if (released || !started)
|
|
509
|
+
return;
|
|
510
|
+
for (const slot of slots) {
|
|
511
|
+
try {
|
|
512
|
+
slot.el.pause();
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
/* element already unusable */
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** Engine resume(): the active element continues from where it paused.
|
|
520
|
+
* The idle (preloading) slot stays paused. */
|
|
521
|
+
function resumeInternal() {
|
|
522
|
+
if (released || !started || active < 0)
|
|
523
|
+
return;
|
|
524
|
+
const slot = slots[active];
|
|
525
|
+
if (slot.trackIndex < 0)
|
|
526
|
+
return;
|
|
527
|
+
try {
|
|
528
|
+
const p = slot.el.play();
|
|
529
|
+
if (p)
|
|
530
|
+
p.catch(() => { }); // the engine's failed-track path owns errors
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
/* element already unusable */
|
|
534
|
+
}
|
|
535
|
+
}
|
|
464
536
|
/** Leadership handover (jatui-lm1mf): crossfade to an absolute track
|
|
465
537
|
* index so the new leader tab picks up the position the old one left. */
|
|
466
538
|
function startAtInternal(index) {
|
|
@@ -489,8 +561,9 @@ export function createPlaylistScape(options) {
|
|
|
489
561
|
}
|
|
490
562
|
wireLookahead();
|
|
491
563
|
return {
|
|
492
|
-
api: { start, stop, setParam },
|
|
564
|
+
api: { start, stop, setParam, pause: pauseInternal, resume: resumeInternal },
|
|
493
565
|
skip: skipInternal,
|
|
566
|
+
prev: prevInternal,
|
|
494
567
|
startAt: startAtInternal,
|
|
495
568
|
reshuffle,
|
|
496
569
|
isReleased: () => released
|
|
@@ -519,6 +592,10 @@ export function createPlaylistScape(options) {
|
|
|
519
592
|
if (latest && !latest.isReleased())
|
|
520
593
|
latest.skip();
|
|
521
594
|
},
|
|
595
|
+
prev() {
|
|
596
|
+
if (latest && !latest.isReleased())
|
|
597
|
+
latest.prev();
|
|
598
|
+
},
|
|
522
599
|
setShuffle(on) {
|
|
523
600
|
shuffle = on;
|
|
524
601
|
if (latest && !latest.isReleased())
|
|
@@ -1,3 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cross-tab shared player (jatui-lm1mf): exactly ONE tab owns the
|
|
3
|
+
* AudioContext and plays; every other tab is a REMOTE that renders the same
|
|
4
|
+
* live state and sends commands. Open MOS in ten tabs and the tab playing
|
|
5
|
+
* ambience is no longer unfindable — any tab's rail toggle shows and controls
|
|
6
|
+
* the one that's sounding.
|
|
7
|
+
*
|
|
8
|
+
* Leader election:
|
|
9
|
+
* - Web Locks API (`navigator.locks.request(lockName)`) when available —
|
|
10
|
+
* the lock IS the leadership, so the browser guarantees mutual exclusion.
|
|
11
|
+
* - Fallback (no Web Locks): a BroadcastChannel heartbeat. The leader
|
|
12
|
+
* heartbeats every HEARTBEAT_MS; a tab that hears no beat for STALE_MS
|
|
13
|
+
* may claim, guarded by a short window in which a live leader reasserts.
|
|
14
|
+
*
|
|
15
|
+
* Transport (always BroadcastChannel 'jatui:ambience'):
|
|
16
|
+
* - `state` / `heartbeat` — the leader publishes the live shared state
|
|
17
|
+
* (playing scape, volume, muted, track attribution, its tab id).
|
|
18
|
+
* - `cmd` — a remote tells the leader to stop / pause / resume / skip / go
|
|
19
|
+
* back / setVolume / toggleMute / play (play only when the fallback
|
|
20
|
+
* election defers to a live leader).
|
|
21
|
+
* - `want` / `claim` — the heartbeat election.
|
|
22
|
+
* - `gone` — the leader's pagehide: remotes immediately mark the leader
|
|
23
|
+
* gone (playback stops — never a surprise autoplay in a background tab).
|
|
24
|
+
*
|
|
25
|
+
* Starting a scape in tab B while tab A plays makes B the leader: with
|
|
26
|
+
* Web Locks, B's user-gesture play STEALS the lock, A's held request promise
|
|
27
|
+
* rejects (AbortError) and A fades out — never two tabs audible. Playlist
|
|
28
|
+
* scapes hand over their track position (published by the leader, applied
|
|
29
|
+
* by the new leader via PlaylistScape.startAt).
|
|
30
|
+
*
|
|
31
|
+
* Resuming after the leader tab closes/reloads: playback is stopped and no
|
|
32
|
+
* remote autoplays; pressing play (the toggle's existing button — a user
|
|
33
|
+
* gesture, satisfying the autoplay policy) claims leadership and plays.
|
|
34
|
+
*
|
|
35
|
+
* Volume and mute persist in localStorage (shared by all tabs of the
|
|
36
|
+
* origin). The `storage` event is deliberately NOT the transport — it skips
|
|
37
|
+
* the writer tab; BroadcastChannel delivers to every OTHER tab.
|
|
38
|
+
*
|
|
39
|
+
* NO UI here. Hosts get everything through createAmbience({ shared: true }) —
|
|
40
|
+
* the Ambience interface is unchanged, so AmbienceToggle needs no structural
|
|
41
|
+
* changes beyond reading the shared now-playing line.
|
|
42
|
+
*/
|
|
43
|
+
import { createAmbience as createLocalEngine } from './engine';
|
|
1
44
|
import type { Ambience, AmbienceOptions } from './types';
|
|
2
45
|
/** BroadcastChannel name — the ambience control channel for the origin. */
|
|
3
46
|
export declare const AMBIENCE_CHANNEL = "jatui:ambience";
|
|
@@ -37,6 +80,18 @@ export interface SharedAmbienceState {
|
|
|
37
80
|
/** Position of the published now-playing track in its playlist (0-based),
|
|
38
81
|
* for leadership handover of playlist scapes. */
|
|
39
82
|
trackIndex: number | null;
|
|
83
|
+
/** The published track's title, so a remote can show title and
|
|
84
|
+
* attribution on their own lines (jatui-153tz). */
|
|
85
|
+
trackTitle: string | null;
|
|
86
|
+
/** Elapsed seconds within the published track (leader-published at the
|
|
87
|
+
* playlist's ~250 ms state cadence); null when unknown. */
|
|
88
|
+
trackPosition: number | null;
|
|
89
|
+
/** Total seconds of the published track; null while unknown (no metadata
|
|
90
|
+
* yet, or a stream without a duration). */
|
|
91
|
+
trackDuration: number | null;
|
|
92
|
+
/** True while the leader is PAUSED (engine.pause() — the scape is frozen
|
|
93
|
+
* in place, not torn down; resume continues it). */
|
|
94
|
+
paused: boolean;
|
|
40
95
|
}
|
|
41
96
|
/** An Ambience whose playback is shared across tabs (see module doc). */
|
|
42
97
|
export interface SharedAmbience extends Ambience {
|
|
@@ -48,8 +103,14 @@ export interface SharedAmbience extends Ambience {
|
|
|
48
103
|
setMuted(muted: boolean): void;
|
|
49
104
|
toggleMute(): void;
|
|
50
105
|
/** Leader-side hook: the host publishes the playing track's attribution
|
|
51
|
-
* (and playlist position) so remote tabs show it too.
|
|
52
|
-
|
|
106
|
+
* (and playlist position + progress) so remote tabs show it too.
|
|
107
|
+
* `position`/`duration`/`title` (jatui-153tz) feed the remote tabs'
|
|
108
|
+
* now-playing row; nulls mean unknown. */
|
|
109
|
+
setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null): void;
|
|
110
|
+
/** Now-playing-row transport (jatui-153tz): skip / go back one track on a
|
|
111
|
+
* playlist scape. A remote routes the command to the leader's instance. */
|
|
112
|
+
skipTrack(id: string): void;
|
|
113
|
+
prevTrack(id: string): void;
|
|
53
114
|
/** Release the lock, stop the heartbeat and close the channel. The local
|
|
54
115
|
* engine is killed if this tab was the leader. */
|
|
55
116
|
destroy(): void;
|
|
@@ -95,3 +156,9 @@ export declare function createSharedAmbience(options?: SharedAmbienceOptions): S
|
|
|
95
156
|
* Ambience interface (AmbienceToggle needs no changes beyond reading the
|
|
96
157
|
* shared now-playing line). */
|
|
97
158
|
export declare function createAmbience(options?: AmbienceOptions): Ambience;
|
|
159
|
+
/** The always-local engine factory — the barrel's `createAmbienceLocal`.
|
|
160
|
+
* The ambience barrel has re-exported this name since the shared player
|
|
161
|
+
* landed (jatui-lm1mf), but shared.ts never defined it: real ESM (the built
|
|
162
|
+
* dist, Node) failed to import the subpath at all (jatui-153tz rebuild). It
|
|
163
|
+
* is the plain engine: `shared` is never honored, whatever the options. */
|
|
164
|
+
export declare const createAmbienceLocal: typeof createLocalEngine;
|