@jant/core 0.3.36 → 0.3.38
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/bin/commands/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4012 -3276
- package/dist/index.js +10285 -5809
- package/package.json +11 -3
- package/src/__tests__/helpers/app.ts +9 -9
- package/src/__tests__/helpers/db.ts +91 -93
- package/src/app.tsx +157 -27
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/client/avatar-upload.ts +3 -2
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
- package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
- package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +7 -9
- package/src/client/components/compose-types.ts +101 -4
- package/src/client/components/jant-collection-form.ts +43 -7
- package/src/client/components/jant-collection-sidebar.ts +88 -84
- package/src/client/components/jant-compose-dialog.ts +1655 -219
- package/src/client/components/jant-compose-editor.ts +732 -168
- package/src/client/components/jant-compose-fullscreen.ts +23 -78
- package/src/client/components/jant-media-lightbox.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +24 -284
- package/src/client/components/jant-post-form.ts +89 -9
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/client/components/jant-settings-avatar.ts +3 -3
- package/src/client/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/client/components/nav-manager-types.ts +4 -19
- package/src/client/components/post-form-template.ts +107 -12
- package/src/client/components/post-form-types.ts +11 -4
- package/src/client/compose-bridge.ts +410 -109
- package/src/client/image-processor.ts +26 -8
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/client/post-form-bridge.ts +52 -1
- package/src/client/settings-bridge.ts +0 -12
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/create-editor.ts +46 -0
- package/src/client/tiptap/extensions.ts +5 -0
- package/src/client/tiptap/image-node.ts +2 -8
- package/src/client/tiptap/paste-image.ts +2 -13
- package/src/client/tiptap/slash-commands.ts +173 -63
- package/src/client/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +15 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +5 -2
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -145
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +487 -1217
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +613 -996
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +624 -1007
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/schemas.test.ts +181 -63
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/view.test.ts +141 -66
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +885 -68
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +78 -32
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +12 -3
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +20 -2
- package/src/lib/resolve-config.ts +6 -2
- package/src/lib/schemas.ts +224 -55
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +66 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +74 -34
- package/src/lib/tiptap-render.ts +5 -10
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +190 -29
- package/src/lib/url.ts +31 -0
- package/src/lib/view.ts +204 -37
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +45 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +51 -42
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +43 -39
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +85 -19
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/setup.tsx +26 -33
- package/src/routes/auth/signin.tsx +3 -7
- package/src/routes/compose.tsx +10 -55
- package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +304 -232
- package/src/routes/feed/__tests__/rss.test.ts +27 -28
- package/src/routes/feed/rss.ts +6 -4
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +41 -22
- package/src/routes/pages/archive.tsx +175 -39
- package/src/routes/pages/collection.tsx +22 -10
- package/src/routes/pages/collections.tsx +3 -3
- package/src/routes/pages/featured.tsx +28 -4
- package/src/routes/pages/home.tsx +16 -15
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +713 -234
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +687 -154
- package/src/services/search.ts +160 -75
- package/src/styles/components.css +58 -7
- package/src/styles/tokens.css +84 -6
- package/src/styles/ui.css +2964 -457
- package/src/types/bindings.ts +4 -1
- package/src/types/config.ts +12 -4
- package/src/types/constants.ts +15 -3
- package/src/types/entities.ts +74 -35
- package/src/types/operations.ts +11 -24
- package/src/types/props.ts +51 -16
- package/src/types/views.ts +45 -22
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +239 -17
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +3 -1
- package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
- package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
- package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
- package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +3 -57
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +8 -0
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +105 -92
- package/src/ui/pages/ArchivePage.tsx +923 -98
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +181 -37
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +47 -37
- package/src/ui/shared/MediaGallery.tsx +445 -149
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/dist/client/assets/url-8Dj-5CLW.js +0 -1
- package/src/client/media-upload.ts +0 -161
- package/src/client/page-slug-bridge.ts +0 -42
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/index.tsx +0 -109
- package/src/routes/dash/media.tsx +0 -135
- package/src/routes/dash/pages.tsx +0 -245
- package/src/routes/dash/posts.tsx +0 -338
- package/src/routes/dash/redirects.tsx +0 -263
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -216
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/ui/dash/PageForm.tsx +0 -187
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/media/MediaListContent.tsx +0 -206
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -75
- package/src/ui/dash/posts/PostForm.tsx +0 -260
- package/src/ui/layouts/DashLayout.tsx +0 -247
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive Chip Dropdowns
|
|
3
|
+
*
|
|
4
|
+
* Handles popover open/close and option selection for chip-style filter
|
|
5
|
+
* dropdowns.
|
|
6
|
+
*
|
|
7
|
+
* - Regular chips (.archive-chip-dropdown): click option → navigate to URL.
|
|
8
|
+
* - Media chip (.archive-chip-media): multi-toggle for media kinds, with
|
|
9
|
+
* a special "Text only" option (data-navigate) that navigates immediately.
|
|
10
|
+
* Kind toggles navigate on popover close when the selection changed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
document.querySelectorAll(".archive-chip-dropdown").forEach((chip) => {
|
|
14
|
+
const trigger = chip.querySelector(":scope > button");
|
|
15
|
+
const popover = chip.querySelector(":scope > [data-popover]");
|
|
16
|
+
const listbox = popover
|
|
17
|
+
? popover.querySelector('[role="listbox"]')
|
|
18
|
+
: null;
|
|
19
|
+
if (!trigger || !popover || !listbox) return;
|
|
20
|
+
|
|
21
|
+
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
|
|
22
|
+
const isMedia = chip.classList.contains("archive-chip-media");
|
|
23
|
+
const filterKey = chip.dataset.filterKey;
|
|
24
|
+
|
|
25
|
+
// --- Multi-select state (media chip only) ---------------------------------
|
|
26
|
+
|
|
27
|
+
const selectedSet = new Set();
|
|
28
|
+
if (isMedia) {
|
|
29
|
+
options.forEach((opt) => {
|
|
30
|
+
if (
|
|
31
|
+
!opt.dataset.navigate &&
|
|
32
|
+
opt.getAttribute("aria-selected") === "true"
|
|
33
|
+
) {
|
|
34
|
+
selectedSet.add(opt.dataset.value);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
let snapshotSelection = new Set(selectedSet);
|
|
39
|
+
|
|
40
|
+
// --- Popover open / close -------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const open = () => {
|
|
43
|
+
document.dispatchEvent(
|
|
44
|
+
new CustomEvent("basecoat:popover", { detail: { source: chip } }),
|
|
45
|
+
);
|
|
46
|
+
popover.setAttribute("aria-hidden", "false");
|
|
47
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
48
|
+
if (isMedia) snapshotSelection = new Set(selectedSet);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const close = (focusTrigger = true) => {
|
|
52
|
+
if (popover.getAttribute("aria-hidden") === "true") return;
|
|
53
|
+
popover.setAttribute("aria-hidden", "true");
|
|
54
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
55
|
+
if (focusTrigger) trigger.focus();
|
|
56
|
+
|
|
57
|
+
// Media multi-select: navigate if selection changed
|
|
58
|
+
if (isMedia && filterKey) {
|
|
59
|
+
const changed =
|
|
60
|
+
selectedSet.size !== snapshotSelection.size ||
|
|
61
|
+
[...selectedSet].some((v) => !snapshotSelection.has(v));
|
|
62
|
+
if (changed) {
|
|
63
|
+
const url = new URL(window.location.href);
|
|
64
|
+
const values = [...selectedSet];
|
|
65
|
+
if (values.length > 0) {
|
|
66
|
+
url.searchParams.set(filterKey, values.join(","));
|
|
67
|
+
} else {
|
|
68
|
+
url.searchParams.delete(filterKey);
|
|
69
|
+
}
|
|
70
|
+
// Clear hasMedia when toggling kinds
|
|
71
|
+
url.searchParams.delete("hasMedia");
|
|
72
|
+
url.searchParams.delete("page");
|
|
73
|
+
window.location.href = url.pathname + (url.search || "");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
trigger.addEventListener("click", (e) => {
|
|
79
|
+
if (e.target.closest(".archive-chip-clear")) return;
|
|
80
|
+
if (trigger.getAttribute("aria-expanded") === "true") {
|
|
81
|
+
close();
|
|
82
|
+
} else {
|
|
83
|
+
open();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
document.addEventListener("click", (e) => {
|
|
88
|
+
if (!chip.contains(e.target)) close(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
document.addEventListener("basecoat:popover", (e) => {
|
|
92
|
+
if (e.detail.source !== chip) close(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// --- Option click ---------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
listbox.addEventListener("click", (e) => {
|
|
98
|
+
const opt = e.target.closest('[role="option"]');
|
|
99
|
+
if (!opt) return;
|
|
100
|
+
|
|
101
|
+
// Immediate-navigate options (e.g. "Text only")
|
|
102
|
+
if (opt.dataset.navigate) {
|
|
103
|
+
const value = opt.dataset.value;
|
|
104
|
+
if (typeof value === "string" && value.startsWith("/")) {
|
|
105
|
+
window.location.href = value;
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isMedia) {
|
|
111
|
+
// Multi-toggle for media kinds
|
|
112
|
+
const val = opt.dataset.value;
|
|
113
|
+
if (selectedSet.has(val)) {
|
|
114
|
+
selectedSet.delete(val);
|
|
115
|
+
opt.removeAttribute("aria-selected");
|
|
116
|
+
} else {
|
|
117
|
+
selectedSet.add(val);
|
|
118
|
+
opt.setAttribute("aria-selected", "true");
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
// Single-select: navigate to URL
|
|
122
|
+
const value = opt.dataset.value;
|
|
123
|
+
if (typeof value === "string" && value.startsWith("/")) {
|
|
124
|
+
window.location.href = value;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// --- Keyboard navigation --------------------------------------------------
|
|
130
|
+
|
|
131
|
+
let activeIndex = -1;
|
|
132
|
+
|
|
133
|
+
const setActive = (index) => {
|
|
134
|
+
if (activeIndex > -1 && options[activeIndex]) {
|
|
135
|
+
options[activeIndex].classList.remove("active");
|
|
136
|
+
}
|
|
137
|
+
activeIndex = index;
|
|
138
|
+
if (activeIndex > -1 && options[activeIndex]) {
|
|
139
|
+
options[activeIndex].classList.add("active");
|
|
140
|
+
options[activeIndex].scrollIntoView({ block: "nearest" });
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
listbox.addEventListener("mousemove", (e) => {
|
|
145
|
+
const opt = e.target.closest('[role="option"]');
|
|
146
|
+
if (opt) {
|
|
147
|
+
const index = options.indexOf(opt);
|
|
148
|
+
if (index !== -1 && index !== activeIndex) {
|
|
149
|
+
setActive(index);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
listbox.addEventListener("mouseleave", () => {
|
|
155
|
+
setActive(-1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
trigger.addEventListener("keydown", (e) => {
|
|
159
|
+
const isOpen = popover.getAttribute("aria-hidden") === "false";
|
|
160
|
+
|
|
161
|
+
if (e.key === "Escape") {
|
|
162
|
+
if (isOpen) {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
close();
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!isOpen && ["ArrowDown", "ArrowUp"].includes(e.key)) {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
open();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!isOpen) return;
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
|
|
178
|
+
if (e.key === "ArrowDown") {
|
|
179
|
+
setActive(Math.min(activeIndex + 1, options.length - 1));
|
|
180
|
+
} else if (e.key === "ArrowUp") {
|
|
181
|
+
setActive(Math.max(activeIndex - 1, 0));
|
|
182
|
+
} else if (e.key === "Enter" && activeIndex > -1) {
|
|
183
|
+
options[activeIndex].click();
|
|
184
|
+
if (!isMedia && !options[activeIndex].dataset.navigate) close();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Player — delegated event handler for custom audio cards.
|
|
3
|
+
*
|
|
4
|
+
* Play/pause via [data-audio-play] buttons, seek via [data-audio-waveform]
|
|
5
|
+
* canvas (primary) or [data-audio-range] range input (fallback before
|
|
6
|
+
* waveform loads). The card contains a hidden `<audio preload="none">`
|
|
7
|
+
* element that is only loaded on first play.
|
|
8
|
+
*
|
|
9
|
+
* Waveform peaks are extracted from the audio data on first play via the
|
|
10
|
+
* Web Audio API and rendered on a <canvas> inside the artwork area.
|
|
11
|
+
*
|
|
12
|
+
* No framework — vanilla delegated events + requestAnimationFrame.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let activeCard: HTMLElement | null = null;
|
|
16
|
+
let rafId = 0;
|
|
17
|
+
let isSeeking = false;
|
|
18
|
+
let seekingRange: HTMLInputElement | null = null;
|
|
19
|
+
let seekingCanvas: HTMLCanvasElement | null = null;
|
|
20
|
+
let seekRatio = 0;
|
|
21
|
+
|
|
22
|
+
// --- Helpers ---
|
|
23
|
+
|
|
24
|
+
function fmt(s: number): string {
|
|
25
|
+
if (!isFinite(s) || s < 0) return "0:00";
|
|
26
|
+
return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getAudio(card: HTMLElement): HTMLAudioElement | null {
|
|
30
|
+
return card.querySelector<HTMLAudioElement>("audio.media-audio-el");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getRange(card: HTMLElement): HTMLInputElement | null {
|
|
34
|
+
return card.querySelector<HTMLInputElement>("[data-audio-range]");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getCanvas(card: HTMLElement): HTMLCanvasElement | null {
|
|
38
|
+
return card.querySelector<HTMLCanvasElement>("[data-audio-waveform]");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Paint the filled portion of the range track via linear-gradient. */
|
|
42
|
+
function paintTrack(range: HTMLInputElement, ratio: number) {
|
|
43
|
+
const pct = `${(ratio * 100).toFixed(1)}%`;
|
|
44
|
+
range.style.background = `linear-gradient(to right, var(--site-text-primary) ${pct}, transparent ${pct})`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Waveform ---
|
|
48
|
+
|
|
49
|
+
const cardPeaks = new WeakMap<HTMLElement, number[]>();
|
|
50
|
+
const waveformLoading = new WeakSet<HTMLElement>();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract peak amplitudes from an audio file for waveform visualization.
|
|
54
|
+
*
|
|
55
|
+
* @param url - Audio file URL (served with proper cache headers)
|
|
56
|
+
* @param count - Number of bars to generate
|
|
57
|
+
* @returns Normalized peak values (0–1)
|
|
58
|
+
*/
|
|
59
|
+
async function extractPeaks(url: string, count: number): Promise<number[]> {
|
|
60
|
+
const response = await fetch(url);
|
|
61
|
+
const buffer = await response.arrayBuffer();
|
|
62
|
+
const audioCtx = new AudioContext();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const decoded = await audioCtx.decodeAudioData(buffer);
|
|
66
|
+
const raw = decoded.getChannelData(0);
|
|
67
|
+
const step = Math.max(1, Math.floor(raw.length / count));
|
|
68
|
+
const peaks: number[] = new Array(count);
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < count; i++) {
|
|
71
|
+
let max = 0;
|
|
72
|
+
const start = i * step;
|
|
73
|
+
const end = Math.min(start + step, raw.length);
|
|
74
|
+
for (let j = start; j < end; j++) {
|
|
75
|
+
const v = Math.abs(raw[j]);
|
|
76
|
+
if (v > max) max = v;
|
|
77
|
+
}
|
|
78
|
+
peaks[i] = max;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let maxPeak = 0;
|
|
82
|
+
for (const p of peaks) if (p > maxPeak) maxPeak = p;
|
|
83
|
+
if (maxPeak > 0) {
|
|
84
|
+
for (let i = 0; i < count; i++) peaks[i] /= maxPeak;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return peaks;
|
|
88
|
+
} finally {
|
|
89
|
+
await audioCtx.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Initialize pre-computed waveforms on page load.
|
|
95
|
+
* Queries all canvases with [data-audio-peaks], parses JSON peaks,
|
|
96
|
+
* stores in cardPeaks WeakMap, and draws the initial waveform.
|
|
97
|
+
*
|
|
98
|
+
* Drawing is deferred via requestAnimationFrame so the browser has
|
|
99
|
+
* time to lay out the canvas (which starts as display:none and only
|
|
100
|
+
* becomes visible when the `has-waveform` class is added).
|
|
101
|
+
*/
|
|
102
|
+
function initPrecomputedWaveforms() {
|
|
103
|
+
const canvases =
|
|
104
|
+
document.querySelectorAll<HTMLCanvasElement>("[data-audio-peaks]");
|
|
105
|
+
const cardsToRender: HTMLElement[] = [];
|
|
106
|
+
|
|
107
|
+
for (const canvas of canvases) {
|
|
108
|
+
const peaksJson = canvas.dataset.audioPeaks;
|
|
109
|
+
if (!peaksJson) continue;
|
|
110
|
+
|
|
111
|
+
const card = canvas.closest<HTMLElement>(".media-audio-card");
|
|
112
|
+
if (!card || cardPeaks.has(card)) continue;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const peaks = JSON.parse(peaksJson) as number[];
|
|
116
|
+
if (!Array.isArray(peaks)) continue;
|
|
117
|
+
cardPeaks.set(card, peaks);
|
|
118
|
+
card.classList.add("has-waveform");
|
|
119
|
+
cardsToRender.push(card);
|
|
120
|
+
} catch {
|
|
121
|
+
// Invalid JSON — skip
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Defer draw until after layout so the canvas has non-zero dimensions
|
|
126
|
+
if (cardsToRender.length > 0) {
|
|
127
|
+
requestAnimationFrame(() => {
|
|
128
|
+
for (const card of cardsToRender) {
|
|
129
|
+
drawWaveform(card, 0);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Load waveform for a card (called once on first play). */
|
|
136
|
+
async function loadWaveform(card: HTMLElement) {
|
|
137
|
+
if (cardPeaks.has(card) || waveformLoading.has(card)) return;
|
|
138
|
+
waveformLoading.add(card);
|
|
139
|
+
|
|
140
|
+
const source = card.querySelector<HTMLSourceElement>(
|
|
141
|
+
"audio.media-audio-el source",
|
|
142
|
+
);
|
|
143
|
+
const canvas = getCanvas(card);
|
|
144
|
+
if (!source?.src || !canvas) return;
|
|
145
|
+
|
|
146
|
+
const width = canvas.getBoundingClientRect().width;
|
|
147
|
+
const barCount = Math.max(20, Math.floor(width / 3));
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const peaks = await extractPeaks(source.src, barCount);
|
|
151
|
+
cardPeaks.set(card, peaks);
|
|
152
|
+
card.classList.add("has-waveform");
|
|
153
|
+
|
|
154
|
+
const audio = getAudio(card);
|
|
155
|
+
const dur = audio?.duration ?? 0;
|
|
156
|
+
const progress =
|
|
157
|
+
audio && isFinite(dur) && dur > 0 ? audio.currentTime / dur : 0;
|
|
158
|
+
drawWaveform(card, progress);
|
|
159
|
+
} catch {
|
|
160
|
+
// Extraction failed — range slider fallback stays visible
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Draw the waveform bars on a card's canvas. */
|
|
165
|
+
function drawWaveform(card: HTMLElement, progress: number) {
|
|
166
|
+
const peaks = cardPeaks.get(card);
|
|
167
|
+
const canvas = getCanvas(card);
|
|
168
|
+
if (!peaks || !canvas) return;
|
|
169
|
+
|
|
170
|
+
const dpr = window.devicePixelRatio || 1;
|
|
171
|
+
const rect = canvas.getBoundingClientRect();
|
|
172
|
+
const w = Math.round(rect.width * dpr);
|
|
173
|
+
const h = Math.round(rect.height * dpr);
|
|
174
|
+
if (w === 0 || h === 0) return;
|
|
175
|
+
|
|
176
|
+
if (canvas.width !== w || canvas.height !== h) {
|
|
177
|
+
canvas.width = w;
|
|
178
|
+
canvas.height = h;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ctx = canvas.getContext("2d");
|
|
182
|
+
if (!ctx) return;
|
|
183
|
+
ctx.clearRect(0, 0, w, h);
|
|
184
|
+
|
|
185
|
+
const count = peaks.length;
|
|
186
|
+
const step = w / count;
|
|
187
|
+
const barW = Math.max(1, Math.round(step * 0.6));
|
|
188
|
+
const minH = Math.round(2 * dpr);
|
|
189
|
+
const maxH = h * 0.85;
|
|
190
|
+
|
|
191
|
+
const color =
|
|
192
|
+
getComputedStyle(canvas).getPropertyValue("--site-text-primary").trim() ||
|
|
193
|
+
"#000";
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < count; i++) {
|
|
196
|
+
const x = Math.round(i * step + (step - barW) / 2);
|
|
197
|
+
const barH = Math.max(minH, Math.round(peaks[i] * maxH));
|
|
198
|
+
const y = Math.round((h - barH) / 2);
|
|
199
|
+
const played = (i + 0.5) / count <= progress;
|
|
200
|
+
|
|
201
|
+
ctx.globalAlpha = played ? 0.9 : 0.2;
|
|
202
|
+
ctx.fillStyle = color;
|
|
203
|
+
const r = Math.min(barW / 2, dpr);
|
|
204
|
+
ctx.beginPath();
|
|
205
|
+
ctx.roundRect(x, y, barW, barH, r);
|
|
206
|
+
ctx.fill();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ctx.globalAlpha = 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Sync & Tick ---
|
|
213
|
+
|
|
214
|
+
function syncUI(card: HTMLElement, audio: HTMLAudioElement) {
|
|
215
|
+
const { currentTime, duration } = audio;
|
|
216
|
+
const ok = isFinite(duration) && duration > 0;
|
|
217
|
+
const progress = ok ? currentTime / duration : 0;
|
|
218
|
+
|
|
219
|
+
if (!isSeeking) {
|
|
220
|
+
const range = getRange(card);
|
|
221
|
+
if (range && ok) {
|
|
222
|
+
range.value = String(Math.round(progress * 1000));
|
|
223
|
+
paintTrack(range, progress);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (cardPeaks.has(card)) {
|
|
227
|
+
drawWaveform(card, progress);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const timeEl = card.querySelector<HTMLElement>("[data-audio-time]");
|
|
231
|
+
if (timeEl) {
|
|
232
|
+
timeEl.textContent = ok
|
|
233
|
+
? `${fmt(currentTime)} / ${fmt(duration)}`
|
|
234
|
+
: fmt(currentTime);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function tick() {
|
|
240
|
+
if (!activeCard) return;
|
|
241
|
+
const audio = getAudio(activeCard);
|
|
242
|
+
if (!audio || audio.paused) return;
|
|
243
|
+
syncUI(activeCard, audio);
|
|
244
|
+
rafId = requestAnimationFrame(tick);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Player actions ---
|
|
248
|
+
|
|
249
|
+
function stopAll() {
|
|
250
|
+
if (activeCard) {
|
|
251
|
+
const prev = getAudio(activeCard);
|
|
252
|
+
if (prev && !prev.paused) prev.pause();
|
|
253
|
+
activeCard.classList.remove("is-playing");
|
|
254
|
+
activeCard = null;
|
|
255
|
+
}
|
|
256
|
+
cancelAnimationFrame(rafId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function togglePlay(card: HTMLElement) {
|
|
260
|
+
const audio = getAudio(card);
|
|
261
|
+
if (!audio) return;
|
|
262
|
+
|
|
263
|
+
if (activeCard && activeCard !== card) stopAll();
|
|
264
|
+
|
|
265
|
+
if (audio.paused) {
|
|
266
|
+
activeCard = card;
|
|
267
|
+
card.classList.add("is-playing");
|
|
268
|
+
try {
|
|
269
|
+
await audio.play();
|
|
270
|
+
} catch {
|
|
271
|
+
card.classList.remove("is-playing");
|
|
272
|
+
activeCard = null;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
rafId = requestAnimationFrame(tick);
|
|
276
|
+
loadWaveform(card);
|
|
277
|
+
} else {
|
|
278
|
+
audio.pause();
|
|
279
|
+
card.classList.remove("is-playing");
|
|
280
|
+
cancelAnimationFrame(rafId);
|
|
281
|
+
activeCard = null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Seek from range input value. */
|
|
286
|
+
function commitSeek(range: HTMLInputElement) {
|
|
287
|
+
const card = range.closest<HTMLElement>(".media-audio-card");
|
|
288
|
+
if (!card) return;
|
|
289
|
+
const audio = getAudio(card);
|
|
290
|
+
if (!audio) return;
|
|
291
|
+
|
|
292
|
+
const ratio = Number(range.value) / 1000;
|
|
293
|
+
const dur = audio.duration;
|
|
294
|
+
if (isFinite(dur) && dur > 0) {
|
|
295
|
+
audio.currentTime = ratio * dur;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Update waveform + time text during canvas drag (visual preview only). */
|
|
300
|
+
function previewCanvasSeek(canvas: HTMLCanvasElement, e: PointerEvent) {
|
|
301
|
+
const rect = canvas.getBoundingClientRect();
|
|
302
|
+
seekRatio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
303
|
+
|
|
304
|
+
const card = canvas.closest<HTMLElement>(".media-audio-card");
|
|
305
|
+
if (!card) return;
|
|
306
|
+
drawWaveform(card, seekRatio);
|
|
307
|
+
|
|
308
|
+
const audio = getAudio(card);
|
|
309
|
+
const timeEl = card.querySelector<HTMLElement>("[data-audio-time]");
|
|
310
|
+
if (audio && timeEl) {
|
|
311
|
+
const dur = audio.duration;
|
|
312
|
+
if (isFinite(dur) && dur > 0) {
|
|
313
|
+
timeEl.textContent = `${fmt(seekRatio * dur)} / ${fmt(dur)}`;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Commit canvas seek and start playback if paused. */
|
|
319
|
+
async function commitCanvasSeek(canvas: HTMLCanvasElement) {
|
|
320
|
+
const card = canvas.closest<HTMLElement>(".media-audio-card");
|
|
321
|
+
if (!card) return;
|
|
322
|
+
const audio = getAudio(card);
|
|
323
|
+
if (!audio) return;
|
|
324
|
+
|
|
325
|
+
if (audio.paused) {
|
|
326
|
+
// Not playing — start playback first (loads audio if preload="none"),
|
|
327
|
+
// then seek once duration is available.
|
|
328
|
+
if (activeCard && activeCard !== card) stopAll();
|
|
329
|
+
activeCard = card;
|
|
330
|
+
card.classList.add("is-playing");
|
|
331
|
+
try {
|
|
332
|
+
await audio.play();
|
|
333
|
+
} catch {
|
|
334
|
+
card.classList.remove("is-playing");
|
|
335
|
+
activeCard = null;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const dur = audio.duration;
|
|
339
|
+
if (isFinite(dur) && dur > 0) {
|
|
340
|
+
audio.currentTime = seekRatio * dur;
|
|
341
|
+
}
|
|
342
|
+
rafId = requestAnimationFrame(tick);
|
|
343
|
+
loadWaveform(card);
|
|
344
|
+
} else {
|
|
345
|
+
// Already playing — just seek
|
|
346
|
+
const dur = audio.duration;
|
|
347
|
+
if (isFinite(dur) && dur > 0) {
|
|
348
|
+
audio.currentTime = seekRatio * dur;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- Delegated event listeners ---
|
|
354
|
+
|
|
355
|
+
// Pointer tracking for both range input and waveform canvas
|
|
356
|
+
document.addEventListener(
|
|
357
|
+
"pointerdown",
|
|
358
|
+
(e: Event) => {
|
|
359
|
+
const target = e.target as HTMLElement;
|
|
360
|
+
if (target.matches("[data-audio-range]")) {
|
|
361
|
+
isSeeking = true;
|
|
362
|
+
seekingRange = target as HTMLInputElement;
|
|
363
|
+
} else if (target.matches("[data-audio-waveform]")) {
|
|
364
|
+
isSeeking = true;
|
|
365
|
+
seekingCanvas = target as HTMLCanvasElement;
|
|
366
|
+
previewCanvasSeek(target as HTMLCanvasElement, e as PointerEvent);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
true,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
document.addEventListener(
|
|
373
|
+
"pointermove",
|
|
374
|
+
(e: Event) => {
|
|
375
|
+
if (isSeeking && seekingCanvas) {
|
|
376
|
+
previewCanvasSeek(seekingCanvas, e as PointerEvent);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
true,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
document.addEventListener(
|
|
383
|
+
"pointerup",
|
|
384
|
+
() => {
|
|
385
|
+
if (isSeeking) {
|
|
386
|
+
if (seekingRange) {
|
|
387
|
+
commitSeek(seekingRange);
|
|
388
|
+
seekingRange = null;
|
|
389
|
+
} else if (seekingCanvas) {
|
|
390
|
+
commitCanvasSeek(seekingCanvas);
|
|
391
|
+
seekingCanvas = null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
isSeeking = false;
|
|
395
|
+
},
|
|
396
|
+
true,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
document.addEventListener(
|
|
400
|
+
"pointercancel",
|
|
401
|
+
() => {
|
|
402
|
+
seekingRange = null;
|
|
403
|
+
seekingCanvas = null;
|
|
404
|
+
isSeeking = false;
|
|
405
|
+
},
|
|
406
|
+
true,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
// Play / Pause
|
|
410
|
+
document.addEventListener("click", (e: Event) => {
|
|
411
|
+
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
|
|
412
|
+
"[data-audio-play]",
|
|
413
|
+
);
|
|
414
|
+
if (!btn) return;
|
|
415
|
+
e.preventDefault();
|
|
416
|
+
const card = btn.closest<HTMLElement>(".media-audio-card");
|
|
417
|
+
if (card) togglePlay(card);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Range input fallback — visual preview only, seek on pointerup
|
|
421
|
+
document.addEventListener(
|
|
422
|
+
"input",
|
|
423
|
+
(e: Event) => {
|
|
424
|
+
const range = e.target as HTMLInputElement;
|
|
425
|
+
if (!range.matches("[data-audio-range]")) return;
|
|
426
|
+
|
|
427
|
+
const card = range.closest<HTMLElement>(".media-audio-card");
|
|
428
|
+
if (!card) return;
|
|
429
|
+
|
|
430
|
+
const audio = getAudio(card);
|
|
431
|
+
if (!audio) return;
|
|
432
|
+
|
|
433
|
+
const ratio = Number(range.value) / 1000;
|
|
434
|
+
paintTrack(range, ratio);
|
|
435
|
+
|
|
436
|
+
const dur = audio.duration;
|
|
437
|
+
const timeEl = card.querySelector<HTMLElement>("[data-audio-time]");
|
|
438
|
+
if (timeEl && isFinite(dur) && dur > 0) {
|
|
439
|
+
timeEl.textContent = `${fmt(ratio * dur)} / ${fmt(dur)}`;
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
true,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
// Audio "ended" doesn't bubble — use capture
|
|
446
|
+
document.addEventListener(
|
|
447
|
+
"ended",
|
|
448
|
+
(e: Event) => {
|
|
449
|
+
const el = e.target as HTMLElement;
|
|
450
|
+
if (!el.closest) return;
|
|
451
|
+
const card = el.closest<HTMLElement>(".media-audio-card");
|
|
452
|
+
if (!card) return;
|
|
453
|
+
|
|
454
|
+
card.classList.remove("is-playing");
|
|
455
|
+
cancelAnimationFrame(rafId);
|
|
456
|
+
activeCard = null;
|
|
457
|
+
|
|
458
|
+
const range = getRange(card);
|
|
459
|
+
if (range) {
|
|
460
|
+
range.value = "0";
|
|
461
|
+
paintTrack(range, 0);
|
|
462
|
+
}
|
|
463
|
+
if (cardPeaks.has(card)) {
|
|
464
|
+
drawWaveform(card, 0);
|
|
465
|
+
}
|
|
466
|
+
const timeEl = card.querySelector<HTMLElement>("[data-audio-time]");
|
|
467
|
+
if (timeEl) timeEl.textContent = "0:00";
|
|
468
|
+
},
|
|
469
|
+
true,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
// --- Initialize pre-computed waveforms on page load ---
|
|
473
|
+
|
|
474
|
+
if (document.readyState === "loading") {
|
|
475
|
+
document.addEventListener("DOMContentLoaded", initPrecomputedWaveforms);
|
|
476
|
+
} else {
|
|
477
|
+
initPrecomputedWaveforms();
|
|
478
|
+
}
|