@joewinke/jatui 0.61.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 +1 -1
- 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/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -336,7 +336,7 @@ const rec = createAudioRecorder({
|
|
|
336
336
|
|
|
337
337
|
### Ambience (subpath: `jatui/ambience`, components on the root)
|
|
338
338
|
|
|
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`.
|
|
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`.
|
|
340
340
|
|
|
341
341
|
### Sound (first-class sibling of motion)
|
|
342
342
|
|
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();
|
|
@@ -32,6 +32,16 @@
|
|
|
32
32
|
* focus) get the play/pause control only. The transport joins the
|
|
33
33
|
* menu's roving order; a paused row stays visible (its play button is
|
|
34
34
|
* the resume) and a stopped one hides.
|
|
35
|
+
* - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
|
|
36
|
+
* public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
|
|
37
|
+
* marks the track `downloadable: true`) also get a Download button
|
|
38
|
+
* next to the transport: the track url is fetched to a Blob (a plain
|
|
39
|
+
* cross-origin `<a download>` is ignored), saved under
|
|
40
|
+
* "Composer - Title.ext" so attribution travels with the file, and a
|
|
41
|
+
* CORS failure falls back to opening the url in a new tab. The busy
|
|
42
|
+
* spinner never blocks playback. Shared mode: a remote tab downloads
|
|
43
|
+
* the leader's published track url (`SharedAmbienceState.trackDownload`)
|
|
44
|
+
* — or hides the button when the leader published none.
|
|
35
45
|
* - A playing playlist shows its current track's attribution (title,
|
|
36
46
|
* composer, license) under the playlist item and its section row.
|
|
37
47
|
* - With a menu or submenu open, the mouse wheel over that surface
|
|
@@ -233,8 +243,18 @@
|
|
|
233
243
|
import { getMotionContext } from '../motion/level';
|
|
234
244
|
import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
|
|
235
245
|
import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
|
|
246
|
+
import {
|
|
247
|
+
isTrackDownloadable,
|
|
248
|
+
trackDownloadFilename
|
|
249
|
+
} from '../ambience/scapes/playlist';
|
|
250
|
+
import IconButton from './IconButton.svelte';
|
|
251
|
+
import { safeSharedTrackDownload } from '../ambience/shared';
|
|
252
|
+
import type {
|
|
253
|
+
SharedAmbience,
|
|
254
|
+
SharedAmbienceState,
|
|
255
|
+
SharedTrackDownload
|
|
256
|
+
} from '../ambience/shared';
|
|
236
257
|
import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
|
|
237
|
-
import type { SharedAmbience, SharedAmbienceState } from '../ambience/shared';
|
|
238
258
|
|
|
239
259
|
let {
|
|
240
260
|
engine,
|
|
@@ -252,6 +272,8 @@
|
|
|
252
272
|
const menuId = $props.id();
|
|
253
273
|
const hintId = `${menuId}-hint`;
|
|
254
274
|
const subId = `${menuId}-sub`;
|
|
275
|
+
/** The download button's licence/source description line (jatui-0vxcu). */
|
|
276
|
+
const downloadHintId = `${menuId}-dl`;
|
|
255
277
|
|
|
256
278
|
/** Long-press duration for the pointer path to the menu. */
|
|
257
279
|
const LONG_PRESS_MS = 450;
|
|
@@ -309,6 +331,14 @@
|
|
|
309
331
|
let sharedTitle = $state<string | null>(null);
|
|
310
332
|
let sharedPosition = $state<number | null>(null);
|
|
311
333
|
let sharedDuration = $state<number | null>(null);
|
|
334
|
+
/** The leader's published download info (jatui-0vxcu) — a remote tab's
|
|
335
|
+
* download button reads this; null hides the button (not published, or
|
|
336
|
+
* the licence forbids redistribution). */
|
|
337
|
+
let sharedDownload = $state<SharedTrackDownload | null>(null);
|
|
338
|
+
/** True while a download fetch is in flight — the button shows its busy
|
|
339
|
+
* spinner and refuses re-entry. The fetch runs beside playback; nothing
|
|
340
|
+
* here touches the engine. */
|
|
341
|
+
let downloadBusy = $state(false);
|
|
312
342
|
|
|
313
343
|
// Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
|
|
314
344
|
// the pending partial notch between wheel events so trackpad pixel deltas
|
|
@@ -360,6 +390,46 @@
|
|
|
360
390
|
);
|
|
361
391
|
const npPercent = $derived(progressPercent(npPosition, npDuration));
|
|
362
392
|
|
|
393
|
+
// --- download button (jatui-0vxcu) ---------------------------------------
|
|
394
|
+
/** What the download button offers right now: the playing (or paused)
|
|
395
|
+
* playlist track's url + ready-made filename + licence, gated by the
|
|
396
|
+
* licence AND by `safeAmbienceHref` — the url has to be an absolute
|
|
397
|
+
* http(s) one, because it is fetched and (on CORS failure) opened in a
|
|
398
|
+
* new tab; a `javascript:`/`data:`/scheme-relative entry means no button.
|
|
399
|
+
* On a remote tab the leader's publication is re-validated the same way
|
|
400
|
+
* (the leader is another tab, not a trusted source) — null → no button.
|
|
401
|
+
* Generative scapes never have one. */
|
|
402
|
+
const currentDownload = $derived.by(() => {
|
|
403
|
+
if (!showNowPlaying) return null;
|
|
404
|
+
if (isRemote) return safeSharedTrackDownload(sharedDownload);
|
|
405
|
+
const t = currentIsPlaylist ? (playlist?.track ?? null) : null;
|
|
406
|
+
if (!t || !isTrackDownloadable(t)) return null;
|
|
407
|
+
const url = safeAmbienceHref(t.url);
|
|
408
|
+
if (!url) return null;
|
|
409
|
+
return {
|
|
410
|
+
url,
|
|
411
|
+
filename: trackDownloadFilename(t),
|
|
412
|
+
license: t.license,
|
|
413
|
+
sourceUrl: t.sourceUrl
|
|
414
|
+
} satisfies SharedTrackDownload;
|
|
415
|
+
});
|
|
416
|
+
/** The button's tooltip: licence + source link — CC-BY needs attribution. */
|
|
417
|
+
const downloadTooltip = $derived(
|
|
418
|
+
currentDownload
|
|
419
|
+
? `Download ${currentDownload.filename} · ${currentDownload.license}${
|
|
420
|
+
currentDownload.sourceUrl ? ` · source: ${currentDownload.sourceUrl}` : ''
|
|
421
|
+
}`
|
|
422
|
+
: ''
|
|
423
|
+
);
|
|
424
|
+
/** The sr-only line the button's aria-describedby points at. */
|
|
425
|
+
const downloadHint = $derived(
|
|
426
|
+
currentDownload
|
|
427
|
+
? `Download as ${currentDownload.filename}. Licence: ${currentDownload.license}.${
|
|
428
|
+
currentDownload.sourceUrl ? ` Source: ${currentDownload.sourceUrl}.` : ''
|
|
429
|
+
}`
|
|
430
|
+
: ''
|
|
431
|
+
);
|
|
432
|
+
|
|
363
433
|
const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
|
|
364
434
|
const ICON_SIZE = $derived(compact ? 18 : 20);
|
|
365
435
|
|
|
@@ -399,6 +469,7 @@
|
|
|
399
469
|
sharedTitle = s.trackTitle;
|
|
400
470
|
sharedPosition = s.trackPosition;
|
|
401
471
|
sharedDuration = s.trackDuration;
|
|
472
|
+
sharedDownload = s.trackDownload ?? null;
|
|
402
473
|
});
|
|
403
474
|
});
|
|
404
475
|
|
|
@@ -416,12 +487,25 @@
|
|
|
416
487
|
return item.subscribe((s) => {
|
|
417
488
|
playlist = s;
|
|
418
489
|
track = s.status === 'playing' ? s.track : null;
|
|
490
|
+
// The download rides the same publication (jatui-0vxcu): only a track
|
|
491
|
+
// whose licence permits redistribution AND whose url survives
|
|
492
|
+
// safeAmbienceHref (absolute http/https) is offered to the other tabs.
|
|
493
|
+
const t = s.status === 'playing' ? s.track : null;
|
|
494
|
+
const dlUrl = t && isTrackDownloadable(t) ? safeAmbienceHref(t.url) : null;
|
|
419
495
|
engine.setNowPlaying?.(
|
|
420
|
-
s.status === 'playing' &&
|
|
496
|
+
s.status === 'playing' && t ? nowPlayingLine(t) : null,
|
|
421
497
|
s.index,
|
|
422
498
|
s.status === 'playing' ? s.position : null,
|
|
423
499
|
s.status === 'playing' ? s.duration : null,
|
|
424
|
-
s.track?.title ?? null
|
|
500
|
+
s.track?.title ?? null,
|
|
501
|
+
t && dlUrl
|
|
502
|
+
? {
|
|
503
|
+
url: dlUrl,
|
|
504
|
+
filename: trackDownloadFilename(t),
|
|
505
|
+
license: t.license,
|
|
506
|
+
...(t.sourceUrl ? { sourceUrl: t.sourceUrl } : {})
|
|
507
|
+
}
|
|
508
|
+
: null
|
|
425
509
|
);
|
|
426
510
|
});
|
|
427
511
|
});
|
|
@@ -682,6 +766,47 @@
|
|
|
682
766
|
if (isPlaylistScape(currentItem)) currentItem.skip();
|
|
683
767
|
}
|
|
684
768
|
|
|
769
|
+
/**
|
|
770
|
+
* The download button (jatui-0vxcu). A cross-origin `<a download>` is
|
|
771
|
+
* ignored by browsers, so the file is fetched to a Blob and saved through
|
|
772
|
+
* an object URL — the attribution filename was built by the pure helper.
|
|
773
|
+
* A failed fetch (CORS) falls back to opening the url in a new tab. Both
|
|
774
|
+
* the fetch and the fallback go through `safeAmbienceHref` — a url that
|
|
775
|
+
* is not absolute http(s) (a hostile manifest, or a hostile shared-mode
|
|
776
|
+
* leader) never reaches `window.open`. The fetch runs beside the engine —
|
|
777
|
+
* playback is never blocked or touched.
|
|
778
|
+
*/
|
|
779
|
+
async function onDownloadClick() {
|
|
780
|
+
const info = currentDownload;
|
|
781
|
+
const href = safeAmbienceHref(info?.url);
|
|
782
|
+
if (!info || !href || downloadBusy) return;
|
|
783
|
+
downloadBusy = true;
|
|
784
|
+
try {
|
|
785
|
+
const res = await fetch(href);
|
|
786
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
787
|
+
const blob = await res.blob();
|
|
788
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
789
|
+
const a = document.createElement('a');
|
|
790
|
+
a.href = objectUrl;
|
|
791
|
+
a.download = info.filename;
|
|
792
|
+
document.body.appendChild(a);
|
|
793
|
+
a.click();
|
|
794
|
+
a.remove();
|
|
795
|
+
// The browser has the blob reference the moment it accepts the
|
|
796
|
+
// download; the revoke is belt-and-braces housekeeping.
|
|
797
|
+
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
|
|
798
|
+
} catch {
|
|
799
|
+
// CORS refused or the network failed — the url still opens.
|
|
800
|
+
try {
|
|
801
|
+
window.open(href, '_blank', 'noopener,noreferrer');
|
|
802
|
+
} catch {
|
|
803
|
+
/* a blocked popup is not worth crashing over */
|
|
804
|
+
}
|
|
805
|
+
} finally {
|
|
806
|
+
downloadBusy = false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
685
810
|
// --- wheel volume over the open surfaces (jatui-xomny) -------------------
|
|
686
811
|
// While the menu (or one of its submenus) is open the wheel adjusts volume
|
|
687
812
|
// instead of scrolling the page — EXCEPT over a scrollable list that can
|
|
@@ -1064,7 +1189,67 @@
|
|
|
1064
1189
|
</svg>
|
|
1065
1190
|
</button>
|
|
1066
1191
|
{/if}
|
|
1192
|
+
{#if currentDownload}
|
|
1193
|
+
<!-- Download (jatui-0vxcu): only for tracks whose licence
|
|
1194
|
+
permits redistribution (or the leader's published url
|
|
1195
|
+
on a remote tab). aria-describedby points at the
|
|
1196
|
+
licence line below — the tooltip's own action also
|
|
1197
|
+
rewrites describedby while it shows, so the line stays
|
|
1198
|
+
in the row as readable content either way. -->
|
|
1199
|
+
<IconButton
|
|
1200
|
+
label="Download {npTitle}"
|
|
1201
|
+
variant="ghost"
|
|
1202
|
+
size="sm"
|
|
1203
|
+
shape="circle"
|
|
1204
|
+
tooltip={downloadTooltip}
|
|
1205
|
+
tooltipPlacement="top"
|
|
1206
|
+
aria-describedby={downloadHintId}
|
|
1207
|
+
tabindex={-1}
|
|
1208
|
+
data-ambience-nav
|
|
1209
|
+
disabled={downloadBusy}
|
|
1210
|
+
onclick={onDownloadClick}
|
|
1211
|
+
>
|
|
1212
|
+
{#if downloadBusy}
|
|
1213
|
+
<!-- busy: the fetch is in flight (playback unaffected) -->
|
|
1214
|
+
<svg
|
|
1215
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1216
|
+
viewBox="0 0 24 24"
|
|
1217
|
+
fill="none"
|
|
1218
|
+
stroke="currentColor"
|
|
1219
|
+
stroke-width="2"
|
|
1220
|
+
stroke-linecap="round"
|
|
1221
|
+
class="jatui-dl-spin size-4"
|
|
1222
|
+
aria-hidden="true"
|
|
1223
|
+
>
|
|
1224
|
+
<circle cx="12" cy="12" r="9" opacity="0.25" />
|
|
1225
|
+
<path d="M21 12a9 9 0 0 0-9-9" />
|
|
1226
|
+
</svg>
|
|
1227
|
+
{:else}
|
|
1228
|
+
<!-- download, lucide geometry -->
|
|
1229
|
+
<svg
|
|
1230
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1231
|
+
viewBox="0 0 24 24"
|
|
1232
|
+
fill="none"
|
|
1233
|
+
stroke="currentColor"
|
|
1234
|
+
stroke-width="2"
|
|
1235
|
+
stroke-linecap="round"
|
|
1236
|
+
stroke-linejoin="round"
|
|
1237
|
+
class="size-4"
|
|
1238
|
+
aria-hidden="true"
|
|
1239
|
+
>
|
|
1240
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
1241
|
+
<polyline points="7 10 12 15 17 10" />
|
|
1242
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
1243
|
+
</svg>
|
|
1244
|
+
{/if}
|
|
1245
|
+
</IconButton>
|
|
1246
|
+
{/if}
|
|
1067
1247
|
</div>
|
|
1248
|
+
{#if currentDownload}
|
|
1249
|
+
<!-- The licence + source the download carries — CC-BY needs
|
|
1250
|
+
attribution, so the filename alone is not enough. -->
|
|
1251
|
+
<p id={downloadHintId} class="sr-only">{downloadHint}</p>
|
|
1252
|
+
{/if}
|
|
1068
1253
|
</div>
|
|
1069
1254
|
{/if}
|
|
1070
1255
|
|
|
@@ -1315,5 +1500,20 @@
|
|
|
1315
1500
|
.now-progress-fill {
|
|
1316
1501
|
transition: none;
|
|
1317
1502
|
}
|
|
1503
|
+
.jatui-dl-spin {
|
|
1504
|
+
animation: none;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/* The download button's busy spinner — task feedback, not ambient motion,
|
|
1509
|
+
* but it still holds still under reduced motion. */
|
|
1510
|
+
.jatui-dl-spin {
|
|
1511
|
+
animation: jatui-dl-rotate 900ms linear infinite;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
@keyframes jatui-dl-rotate {
|
|
1515
|
+
to {
|
|
1516
|
+
transform: rotate(360deg);
|
|
1517
|
+
}
|
|
1318
1518
|
}
|
|
1319
1519
|
</style>
|
|
@@ -31,6 +31,16 @@
|
|
|
31
31
|
* focus) get the play/pause control only. The transport joins the
|
|
32
32
|
* menu's roving order; a paused row stays visible (its play button is
|
|
33
33
|
* the resume) and a stopped one hides.
|
|
34
|
+
* - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
|
|
35
|
+
* public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
|
|
36
|
+
* marks the track `downloadable: true`) also get a Download button
|
|
37
|
+
* next to the transport: the track url is fetched to a Blob (a plain
|
|
38
|
+
* cross-origin `<a download>` is ignored), saved under
|
|
39
|
+
* "Composer - Title.ext" so attribution travels with the file, and a
|
|
40
|
+
* CORS failure falls back to opening the url in a new tab. The busy
|
|
41
|
+
* spinner never blocks playback. Shared mode: a remote tab downloads
|
|
42
|
+
* the leader's published track url (`SharedAmbienceState.trackDownload`)
|
|
43
|
+
* — or hides the button when the leader published none.
|
|
34
44
|
* - A playing playlist shows its current track's attribution (title,
|
|
35
45
|
* composer, license) under the playlist item and its section row.
|
|
36
46
|
* - With a menu or submenu open, the mouse wheel over that surface
|
|
@@ -141,8 +151,8 @@ export declare function wheelVolumeSteps(acc: {
|
|
|
141
151
|
* slider itself would produce for the same value.
|
|
142
152
|
*/
|
|
143
153
|
export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
|
|
144
|
-
import type { Ambience } from '../ambience/types';
|
|
145
154
|
import type { SharedAmbience } from '../ambience/shared';
|
|
155
|
+
import type { Ambience } from '../ambience/types';
|
|
146
156
|
type $$ComponentProps = {
|
|
147
157
|
/** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. With the cross-tab shared player (jatui-lm1mf, `createAmbience({ shared: true })`) a plain `Ambience` is also accepted: the shared now-playing line is only read when the engine publishes one. */
|
|
148
158
|
engine: Ambience | SharedAmbience;
|
package/dist/index.d.ts
CHANGED
|
@@ -423,4 +423,5 @@ export type { SharedAmbience, SharedAmbienceState, SharedAmbienceOptions } from
|
|
|
423
423
|
export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './ambience';
|
|
424
424
|
export { createPlaylistScape } from './ambience';
|
|
425
425
|
export { isPlaylistScape, trackAttribution } from './ambience';
|
|
426
|
+
export { trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './ambience';
|
|
426
427
|
export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './ambience';
|
package/dist/index.js
CHANGED
|
@@ -469,3 +469,7 @@ export { createAmbience, createAmbienceLocal, createSharedAmbience, AMBIENCE_CHA
|
|
|
469
469
|
// createPlaylistScape() and register on the engine yourself.
|
|
470
470
|
export { createPlaylistScape } from './ambience';
|
|
471
471
|
export { isPlaylistScape, trackAttribution } from './ambience';
|
|
472
|
+
// Download gate + filename builder (jatui-0vxcu) — the licence allowlist
|
|
473
|
+
// (CC0 / public domain / CC-BY / CC-BY-SA), the per-track override and the
|
|
474
|
+
// "Composer - Title.ext" name attribution travels under.
|
|
475
|
+
export { trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './ambience';
|