@joewinke/jatui 0.60.0 → 0.62.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/index.d.ts +2 -2
- package/dist/ambience/index.js +2 -2
- package/dist/ambience/scapes/playlist.d.ts +31 -0
- package/dist/ambience/scapes/playlist.js +82 -1
- package/dist/ambience/shared.d.ts +33 -2
- package/dist/ambience/shared.js +32 -5
- package/dist/components/AmbienceToggle.svelte +203 -3
- package/dist/components/AmbienceToggle.svelte.d.ts +11 -1
- 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 +7 -0
- package/dist/index.js +8 -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 = 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`.
|
|
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; plus a Download IconButton for tracks whose licence permits redistribution — allowlist CC0/public-domain/CC-BY/CC-BY-SA via `isTrackDownloadable` (NC/ND refused unless the manifest flags `downloadable: true`); the url must survive `safeAmbienceHref` (absolute http/https — before the fetch AND the new-tab CORS-failure fallback; remotes re-validate the leader's publication via `safeSharedTrackDownload`); fetch→Blob→`a[download="Composer - Title.ext"]` (`trackDownloadFilename`); licence + source in the tooltip and the `aria-describedby` line; busy spinner never blocks playback) + 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/trackDownload`) and route pause/resume/skip/prev via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `isTrackDownloadable`, `trackLicenseAllowsRedistribution`, `trackDownloadFilename`, `safeSharedTrackDownload`, `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/index.d.ts
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export { createAmbience, createAmbienceLocal } from './shared';
|
|
13
13
|
export { TIMING } from './engine';
|
|
14
|
-
export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, type SharedAmbience, type SharedAmbienceState, type SharedAmbienceOptions, type SharedChannel, type SharedLockManager } from './shared';
|
|
14
|
+
export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, safeSharedTrackDownload, type SharedAmbience, type SharedAmbienceState, type SharedAmbienceOptions, type SharedChannel, type SharedLockManager, type SharedTrackDownload } from './shared';
|
|
15
15
|
export { createDefaultAmbience, DEFAULT_SCAPES, type DefaultAmbienceOptions } from './default';
|
|
16
16
|
export { hibiscusScape } from './scapes/hibiscus';
|
|
17
17
|
export { focusScape } from './scapes/focus';
|
|
18
18
|
export { noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape } from './scapes/noise';
|
|
19
19
|
export { rain, waves } from './scapes/nature';
|
|
20
|
-
export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
|
|
20
|
+
export { createPlaylistScape, isPlaylistScape, trackAttribution, trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './scapes/playlist';
|
|
21
21
|
export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './scapes/playlist';
|
|
22
22
|
export { BreathClock, BREATHS_PER_MINUTE } from './breath';
|
|
23
23
|
export { MASTER } from './master';
|
package/dist/ambience/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// the plain engine until a host passes { shared: true }.
|
|
14
14
|
export { createAmbience, createAmbienceLocal } from './shared';
|
|
15
15
|
export { TIMING } from './engine';
|
|
16
|
-
export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING } from './shared';
|
|
16
|
+
export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, safeSharedTrackDownload } from './shared';
|
|
17
17
|
export { createDefaultAmbience, DEFAULT_SCAPES } from './default';
|
|
18
18
|
// The shipped scapes — hosts that want a hand-picked registry import them
|
|
19
19
|
// individually instead of using createDefaultAmbience().
|
|
@@ -25,7 +25,7 @@ export { rain, waves } from './scapes/nature';
|
|
|
25
25
|
// recordings. NOTE: never auto-registered in DEFAULT_SCAPES — a playlist is
|
|
26
26
|
// per-host (its manifest URL and licensing claims are the host's); build one
|
|
27
27
|
// with createPlaylistScape() and engine.register() it yourself.
|
|
28
|
-
export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
|
|
28
|
+
export { createPlaylistScape, isPlaylistScape, trackAttribution, trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './scapes/playlist';
|
|
29
29
|
export { BreathClock, BREATHS_PER_MINUTE } from './breath';
|
|
30
30
|
export { MASTER } from './master';
|
|
31
31
|
export { ScapeRegistry } from './registry';
|
|
@@ -73,6 +73,11 @@ export interface PlaylistTrack {
|
|
|
73
73
|
license: string;
|
|
74
74
|
/** Page where the recording lives — the attribution link target. */
|
|
75
75
|
sourceUrl?: string;
|
|
76
|
+
/** Explicit download override (jatui-0vxcu): `true` offers the file even
|
|
77
|
+
* when the licence string carries NC/ND terms; `false` hides the download
|
|
78
|
+
* button even for a redistributable licence. Absent: the licence gate
|
|
79
|
+
* (`isTrackDownloadable`) decides from the `license` string. */
|
|
80
|
+
downloadable?: boolean;
|
|
76
81
|
}
|
|
77
82
|
export interface PlaylistManifest {
|
|
78
83
|
title?: string;
|
|
@@ -159,3 +164,29 @@ export declare function isPlaylistScape(scape: unknown): scape is PlaylistScape;
|
|
|
159
164
|
* (performer only when present and different from the composer).
|
|
160
165
|
*/
|
|
161
166
|
export declare function trackAttribution(track: PlaylistTrack | null | undefined): string;
|
|
167
|
+
/**
|
|
168
|
+
* Does this LICENCE STRING permit redistributing the file? The download
|
|
169
|
+
* allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
|
|
170
|
+
* and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
|
|
171
|
+
* derivatives) terms is refused — the host's only way past that is an
|
|
172
|
+
* explicit `downloadable: true` on the track (`isTrackDownloadable`).
|
|
173
|
+
* Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
|
|
174
|
+
* "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
|
|
175
|
+
* (default-deny) do not.
|
|
176
|
+
*/
|
|
177
|
+
export declare function trackLicenseAllowsRedistribution(license: string | null | undefined): boolean;
|
|
178
|
+
/**
|
|
179
|
+
* May this track be downloaded? The licence gate behind the toggle's
|
|
180
|
+
* download button (jatui-0vxcu): an explicit boolean `downloadable` on the
|
|
181
|
+
* track overrides either way; otherwise the `license` string must permit
|
|
182
|
+
* redistribution (`trackLicenseAllowsRedistribution`). A track without a
|
|
183
|
+
* usable url is never downloadable.
|
|
184
|
+
*/
|
|
185
|
+
export declare function isTrackDownloadable(track: PlaylistTrack | null | undefined): boolean;
|
|
186
|
+
/**
|
|
187
|
+
* The download filename — attribution travels with the file:
|
|
188
|
+
* "<Composer> - <Title>.<ext>". The extension comes from the track url
|
|
189
|
+
* (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
|
|
190
|
+
* sanitized for real filesystems and the result stays deterministic.
|
|
191
|
+
*/
|
|
192
|
+
export declare function trackDownloadFilename(track: PlaylistTrack | null | undefined): string;
|
|
@@ -657,7 +657,8 @@ function isValidTrack(t) {
|
|
|
657
657
|
o.license.trim() !== '' &&
|
|
658
658
|
(o.performer === undefined || typeof o.performer === 'string') &&
|
|
659
659
|
(o.sourceUrl === undefined ||
|
|
660
|
-
(typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== ''))
|
|
660
|
+
(typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')) &&
|
|
661
|
+
(o.downloadable === undefined || typeof o.downloadable === 'boolean'));
|
|
661
662
|
}
|
|
662
663
|
/** Narrow a registered scape to a playlist (to reach its state/subscribe). */
|
|
663
664
|
export function isPlaylistScape(scape) {
|
|
@@ -679,3 +680,83 @@ export function trackAttribution(track) {
|
|
|
679
680
|
parts.push(track.license);
|
|
680
681
|
return parts.filter(Boolean).join(' · ');
|
|
681
682
|
}
|
|
683
|
+
// ---- download gate + filename (jatui-0vxcu) ---------------------------------
|
|
684
|
+
/**
|
|
685
|
+
* Does this LICENCE STRING permit redistributing the file? The download
|
|
686
|
+
* allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
|
|
687
|
+
* and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
|
|
688
|
+
* derivatives) terms is refused — the host's only way past that is an
|
|
689
|
+
* explicit `downloadable: true` on the track (`isTrackDownloadable`).
|
|
690
|
+
* Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
|
|
691
|
+
* "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
|
|
692
|
+
* (default-deny) do not.
|
|
693
|
+
*/
|
|
694
|
+
export function trackLicenseAllowsRedistribution(license) {
|
|
695
|
+
if (typeof license !== 'string' || license.trim() === '')
|
|
696
|
+
return false;
|
|
697
|
+
const l = license.toLowerCase().replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
698
|
+
// NC / ND terms — as their own tokens ("by nc", "by-nc" → "by nc") or as
|
|
699
|
+
// the spelled-out words.
|
|
700
|
+
if (/(^|\s)(nc|nd|noncommercial|non commercial|noderivs|no derivs|no derivatives)(\s|$)/.test(l))
|
|
701
|
+
return false;
|
|
702
|
+
return /(^|\s)(cc0|cc by|cc by sa|public domain|pd|pdm)(\s|$)/.test(l);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* May this track be downloaded? The licence gate behind the toggle's
|
|
706
|
+
* download button (jatui-0vxcu): an explicit boolean `downloadable` on the
|
|
707
|
+
* track overrides either way; otherwise the `license` string must permit
|
|
708
|
+
* redistribution (`trackLicenseAllowsRedistribution`). A track without a
|
|
709
|
+
* usable url is never downloadable.
|
|
710
|
+
*/
|
|
711
|
+
export function isTrackDownloadable(track) {
|
|
712
|
+
if (!track || typeof track.url !== 'string' || track.url.trim() === '')
|
|
713
|
+
return false;
|
|
714
|
+
if (typeof track.downloadable === 'boolean')
|
|
715
|
+
return track.downloadable;
|
|
716
|
+
return trackLicenseAllowsRedistribution(track.license);
|
|
717
|
+
}
|
|
718
|
+
/** Cap on one filename part — a "Composer - Title.ext" name stays well under
|
|
719
|
+
* the 255-byte filename floor even with long attribution strings. */
|
|
720
|
+
const FILENAME_PART_MAX = 96;
|
|
721
|
+
/** Extension for a url with no readable one — the Musopen-style manifests
|
|
722
|
+
* this feature targets serve mp3. */
|
|
723
|
+
const DEFAULT_AUDIO_EXT = 'mp3';
|
|
724
|
+
/** One filename part: filesystem-hostile characters and control chars →
|
|
725
|
+
* spaces, runs collapsed, no leading/trailing dots or spaces (Windows
|
|
726
|
+
* rejects both), clamped to {@link FILENAME_PART_MAX}. */
|
|
727
|
+
function sanitizeFilenamePart(part) {
|
|
728
|
+
return part
|
|
729
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
730
|
+
.replace(/[\\/:*?"<>|]/g, ' ')
|
|
731
|
+
.replace(/\s+/g, ' ')
|
|
732
|
+
.trim()
|
|
733
|
+
.replace(/^[. ]+|[. ]+$/g, '')
|
|
734
|
+
.slice(0, FILENAME_PART_MAX)
|
|
735
|
+
.replace(/[. ]+$/g, ''); // the clamp can re-expose a trailing dot
|
|
736
|
+
}
|
|
737
|
+
/** The audio extension of `url`'s last path segment ('' when there is none
|
|
738
|
+
* or it is not 1–5 alphanumeric chars — query strings excluded). */
|
|
739
|
+
function urlAudioExt(url) {
|
|
740
|
+
if (typeof url !== 'string')
|
|
741
|
+
return '';
|
|
742
|
+
const path = url.split(/[?#]/)[0];
|
|
743
|
+
const file = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1);
|
|
744
|
+
const dot = file.lastIndexOf('.');
|
|
745
|
+
if (dot <= 0)
|
|
746
|
+
return '';
|
|
747
|
+
const ext = file.slice(dot + 1).toLowerCase();
|
|
748
|
+
return /^[a-z0-9]{1,5}$/.test(ext) ? ext : '';
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* The download filename — attribution travels with the file:
|
|
752
|
+
* "<Composer> - <Title>.<ext>". The extension comes from the track url
|
|
753
|
+
* (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
|
|
754
|
+
* sanitized for real filesystems and the result stays deterministic.
|
|
755
|
+
*/
|
|
756
|
+
export function trackDownloadFilename(track) {
|
|
757
|
+
if (!track)
|
|
758
|
+
return '';
|
|
759
|
+
const composer = sanitizeFilenamePart(track.composer) || 'Unknown composer';
|
|
760
|
+
const title = sanitizeFilenamePart(track.title) || 'Untitled';
|
|
761
|
+
return `${composer} - ${title}.${urlAudioExt(track.url) || DEFAULT_AUDIO_EXT}`;
|
|
762
|
+
}
|
|
@@ -57,6 +57,31 @@ export declare const SHARED_TIMING: Readonly<{
|
|
|
57
57
|
/** A remote play command waits this long (ms) for the leader's echo. */
|
|
58
58
|
REMOTE_PLAY_TIMEOUT_MS: 2500;
|
|
59
59
|
}>;
|
|
60
|
+
/** The current track's download info (jatui-0vxcu), published by the leader
|
|
61
|
+
* alongside the now-playing parts — present ONLY when the licence permits
|
|
62
|
+
* redistribution (`isTrackDownloadable`), so a remote tab can offer the
|
|
63
|
+
* same download button without re-deriving the gate or the filename. */
|
|
64
|
+
export interface SharedTrackDownload {
|
|
65
|
+
/** The track's audio url (the leader's manifest entry). */
|
|
66
|
+
url: string;
|
|
67
|
+
/** Ready-made filename — "Composer - Title.ext", built leader-side by
|
|
68
|
+
* `trackDownloadFilename` so attribution travels with the file. */
|
|
69
|
+
filename: string;
|
|
70
|
+
/** The licence string, for the button's tooltip and describedby line. */
|
|
71
|
+
license: string;
|
|
72
|
+
/** Page the recording came from (attribution link target). */
|
|
73
|
+
sourceUrl?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A published download re-validated on the CONSUMING side (jatui-0vxcu):
|
|
77
|
+
* the leader is another tab, not a trusted source — its `url` must survive
|
|
78
|
+
* `safeAmbienceHref` (absolute http/https only: a `javascript:`/`data:` or
|
|
79
|
+
* scheme-relative url from a compromised manifest or a hostile tab would
|
|
80
|
+
* otherwise reach this tab's `window.open` fallback) and the filename and
|
|
81
|
+
* licence must be usable strings. Anything else returns null — the remote
|
|
82
|
+
* tab's download button hides. The validated url replaces the original.
|
|
83
|
+
*/
|
|
84
|
+
export declare function safeSharedTrackDownload(download: SharedTrackDownload | null | undefined): SharedTrackDownload | null;
|
|
60
85
|
/** The live cross-tab state every tab renders. */
|
|
61
86
|
export interface SharedAmbienceState {
|
|
62
87
|
/** Currently playing (or preselected) scape id, or null. */
|
|
@@ -92,6 +117,10 @@ export interface SharedAmbienceState {
|
|
|
92
117
|
/** True while the leader is PAUSED (engine.pause() — the scape is frozen
|
|
93
118
|
* in place, not torn down; resume continues it). */
|
|
94
119
|
paused: boolean;
|
|
120
|
+
/** The published track's download info (url + filename + licence), or
|
|
121
|
+
* null when nothing is playing or the licence forbids redistribution
|
|
122
|
+
* (jatui-0vxcu). A remote tab's download button reads this. */
|
|
123
|
+
trackDownload: SharedTrackDownload | null;
|
|
95
124
|
}
|
|
96
125
|
/** An Ambience whose playback is shared across tabs (see module doc). */
|
|
97
126
|
export interface SharedAmbience extends Ambience {
|
|
@@ -105,8 +134,10 @@ export interface SharedAmbience extends Ambience {
|
|
|
105
134
|
/** Leader-side hook: the host publishes the playing track's attribution
|
|
106
135
|
* (and playlist position + progress) so remote tabs show it too.
|
|
107
136
|
* `position`/`duration`/`title` (jatui-153tz) feed the remote tabs'
|
|
108
|
-
* now-playing row; nulls mean unknown.
|
|
109
|
-
|
|
137
|
+
* now-playing row; nulls mean unknown. `download` (jatui-0vxcu) carries
|
|
138
|
+
* the track's download info when its licence permits redistribution —
|
|
139
|
+
* null clears it. */
|
|
140
|
+
setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null, download?: SharedTrackDownload | null): void;
|
|
110
141
|
/** Now-playing-row transport (jatui-153tz): skip / go back one track on a
|
|
111
142
|
* playlist scape. A remote routes the command to the leader's instance. */
|
|
112
143
|
skipTrack(id: string): void;
|
package/dist/ambience/shared.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { createAmbience as createLocalEngine } from './engine';
|
|
44
44
|
import { isPlaylistScape } from './scapes/playlist';
|
|
45
|
+
import { safeAmbienceHref } from './groups';
|
|
45
46
|
import { loadPersisted, savePersisted } from './persist';
|
|
46
47
|
/** BroadcastChannel name — the ambience control channel for the origin. */
|
|
47
48
|
export const AMBIENCE_CHANNEL = 'jatui:ambience';
|
|
@@ -58,6 +59,27 @@ export const SHARED_TIMING = Object.freeze({
|
|
|
58
59
|
/** A remote play command waits this long (ms) for the leader's echo. */
|
|
59
60
|
REMOTE_PLAY_TIMEOUT_MS: 2500
|
|
60
61
|
});
|
|
62
|
+
/**
|
|
63
|
+
* A published download re-validated on the CONSUMING side (jatui-0vxcu):
|
|
64
|
+
* the leader is another tab, not a trusted source — its `url` must survive
|
|
65
|
+
* `safeAmbienceHref` (absolute http/https only: a `javascript:`/`data:` or
|
|
66
|
+
* scheme-relative url from a compromised manifest or a hostile tab would
|
|
67
|
+
* otherwise reach this tab's `window.open` fallback) and the filename and
|
|
68
|
+
* licence must be usable strings. Anything else returns null — the remote
|
|
69
|
+
* tab's download button hides. The validated url replaces the original.
|
|
70
|
+
*/
|
|
71
|
+
export function safeSharedTrackDownload(download) {
|
|
72
|
+
if (!download || typeof download !== 'object')
|
|
73
|
+
return null;
|
|
74
|
+
const url = safeAmbienceHref(download.url);
|
|
75
|
+
if (!url)
|
|
76
|
+
return null;
|
|
77
|
+
if (typeof download.filename !== 'string' || download.filename.trim() === '')
|
|
78
|
+
return null;
|
|
79
|
+
if (typeof download.license !== 'string' || download.license.trim() === '')
|
|
80
|
+
return null;
|
|
81
|
+
return { ...download, url };
|
|
82
|
+
}
|
|
61
83
|
function randomTabId() {
|
|
62
84
|
try {
|
|
63
85
|
return `tab-${crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
|
|
@@ -120,6 +142,8 @@ export function createSharedAmbience(options = {}) {
|
|
|
120
142
|
let nowPlayingTitle = null;
|
|
121
143
|
let nowPlayingPosition = null;
|
|
122
144
|
let nowPlayingDuration = null;
|
|
145
|
+
/** The published track's download info (jatui-0vxcu). */
|
|
146
|
+
let nowPlayingDownload = null;
|
|
123
147
|
const ambienceSubs = new Set();
|
|
124
148
|
const sharedSubs = new Set();
|
|
125
149
|
function sharedState() {
|
|
@@ -137,7 +161,8 @@ export function createSharedAmbience(options = {}) {
|
|
|
137
161
|
trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null),
|
|
138
162
|
trackTitle: role === 'leader' ? nowPlayingTitle : (remoteS?.trackTitle ?? null),
|
|
139
163
|
trackPosition: role === 'leader' ? nowPlayingPosition : (remoteS?.trackPosition ?? null),
|
|
140
|
-
trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null)
|
|
164
|
+
trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null),
|
|
165
|
+
trackDownload: role === 'leader' ? nowPlayingDownload : (remoteS?.trackDownload ?? null)
|
|
141
166
|
};
|
|
142
167
|
}
|
|
143
168
|
function ambienceState() {
|
|
@@ -206,6 +231,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
206
231
|
nowPlayingTitle = null;
|
|
207
232
|
nowPlayingPosition = null;
|
|
208
233
|
nowPlayingDuration = null;
|
|
234
|
+
nowPlayingDownload = null;
|
|
209
235
|
remoteS = null;
|
|
210
236
|
if (heartbeatTimer) {
|
|
211
237
|
clearInterval(heartbeatTimer);
|
|
@@ -404,7 +430,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
404
430
|
// The leader tab is closing: mark it gone at once — no staleness
|
|
405
431
|
// wait, and never a surprise autoplay in a background tab.
|
|
406
432
|
if (remoteS && (remoteS.leaderTabId === msg.from || lastBeatTab === msg.from)) {
|
|
407
|
-
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
433
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
|
|
408
434
|
leaderGone = true;
|
|
409
435
|
emitShared();
|
|
410
436
|
}
|
|
@@ -487,7 +513,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
487
513
|
if (remoteS?.playing &&
|
|
488
514
|
lastBeatAt > 0 &&
|
|
489
515
|
Date.now() - lastBeatAt > staleMs) {
|
|
490
|
-
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
516
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
|
|
491
517
|
leaderGone = true;
|
|
492
518
|
emitShared();
|
|
493
519
|
}
|
|
@@ -571,7 +597,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
571
597
|
post({ t: 'cmd', cmd: 'stop', from: tabId });
|
|
572
598
|
// Optimistic: the leader echoes the authoritative state right after.
|
|
573
599
|
if (remoteS)
|
|
574
|
-
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
600
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
|
|
575
601
|
emitShared();
|
|
576
602
|
}
|
|
577
603
|
/** Freeze in place (the scape is not torn down; a playlist keeps its track
|
|
@@ -697,12 +723,13 @@ export function createSharedAmbience(options = {}) {
|
|
|
697
723
|
setVolume,
|
|
698
724
|
setMuted,
|
|
699
725
|
toggleMute,
|
|
700
|
-
setNowPlaying(line, index, position, duration, title) {
|
|
726
|
+
setNowPlaying(line, index, position, duration, title, download) {
|
|
701
727
|
nowPlaying = line;
|
|
702
728
|
nowPlayingIndex = line ? (index ?? null) : null;
|
|
703
729
|
nowPlayingPosition = line ? (position ?? null) : null;
|
|
704
730
|
nowPlayingDuration = line ? (duration ?? null) : null;
|
|
705
731
|
nowPlayingTitle = line ? (title ?? null) : null;
|
|
732
|
+
nowPlayingDownload = line ? (download ?? null) : null;
|
|
706
733
|
if (role === 'leader') {
|
|
707
734
|
publish();
|
|
708
735
|
emitShared();
|