source: Klonkt/src/views/pages/admin-audio.ejs@ 57b1843

main
Last change on this file since 57b1843 was d727e92, checked in by roboburr <roboburr@…>, 3 months ago

feat(audio): tracks without audio (link-only) — list with open-in links, no upload

You can now add a "Track without audio" in Admin → Audio (button) — only
title/artist + the Spotify/YouTube/SoundCloud links, no file. Such tracks
appear in albums & playlists (and standalone track embeds) in the list
with the open-in icons but WITHOUT a play button, and stay outside the
playback queue (which matches on URL). Lookups (posts album/single +
PlaylistService) no longer filter out fileless tracks. New route POST
/admin/audio/create-link. buster audio.css?v=9.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 36.4 KB
Line 
1<div class="container ax-page">
2
3 <p><a href="/admin" class="btn">&larr; Beheer</a></p>
4 <header class="ax-header">
5 <div>
6 <h1>Audio tracks</h1>
7 <p class="ax-tagline">Site-level MP3s. Gebruik <code>[[track:&lt;id&gt;]]</code> in een post om een play-knop in te voegen.</p>
8 </div>
9 </header>
10
11 <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %>
12 <% if (error) { %><div class="ax-flash ax-flash-err"><%= error %></div><% } %>
13
14 <%# ── UPLOAD ──────────────────────────────────────────────── %>
15 <section class="ax-card">
16 <div class="ax-card-title">Upload</div>
17
18 <%# Bulk drag-drop zone. Multiple files at once, transcoded sequentially.
19 Title is auto-derived from each filename. Artist/album/cover are
20 SHARED across the batch (set once, applied to every track) — same
21 pattern as iTunes/Plex bulk-add. %>
22 <div class="ax-form">
23
24 <%# Shared metadata fields (apply to ALL files in the batch). %>
25 <div class="ax-row ax-row-2">
26 <label class="ax-field">
27 <span>Artiest <small>(toegepast op alle bestanden)</small></span>
28 <input type="text" id="batch-artist" placeholder="Optioneel">
29 </label>
30 <label class="ax-field">
31 <span>Album <small>(toegepast op alle bestanden)</small></span>
32 <input type="text" id="batch-album" placeholder="Optioneel">
33 </label>
34 </div>
35 <label class="ax-field ax-file">
36 <span>Cover <small>(optioneel, toegepast op alle bestanden — jpg/png/webp/gif, max 5 MB)</small></span>
37 <span class="ax-file-control">
38 <span class="ax-btn ax-btn-secondary ax-file-btn">🖼 Kies cover</span>
39 <span class="ax-file-name" data-empty>Geen bestand gekozen</span>
40 <input type="file" id="batch-cover" accept="image/jpeg,image/png,image/webp,image/gif">
41 </span>
42 </label>
43
44 <%# The drop-zone itself. Click anywhere to open file picker. %>
45 <label class="ax-dropzone" id="audio-dropzone" tabindex="0">
46 <span class="ax-dropzone-icon">🎵</span>
47 <strong class="ax-dropzone-title">Sleep audio hierheen</strong>
48 <span class="ax-dropzone-sub">of klik om bestanden te kiezen<br><small>mp3, m4a, ogg, opus, flac · max <%= maxBytesMb %> MB · wav max <%= maxWavMb %> MB</small></span>
49 <input type="file" id="audio-files" accept="audio/*" multiple>
50 </label>
51
52 <%# Per-file progress list. Populated by JS as files are queued. %>
53 <ul class="ax-upload-queue" id="upload-queue" hidden></ul>
54
55 <div class="ax-form-actions" id="upload-actions" hidden>
56 <button type="button" class="ax-btn ax-btn-primary" id="start-upload-btn">⬆ Start upload</button>
57 <button type="button" class="ax-btn" id="clear-queue-btn">Wis lijst</button>
58 </div>
59 </div>
60 </section>
61
62 <%# ── TRACK LIST ─────────────────────────────────────────── %>
63 <section class="ax-card">
64 <div class="ax-card-title" style="display:flex;align-items:center;gap:.5rem">
65 Tracks <span class="ax-count"><%= tracks.length %></span>
66 <button type="button" class="ax-btn ax-btn-secondary" id="add-link-track-btn"
67 style="margin-left:auto;font-size:.85rem" title="Een track zonder audiobestand — alleen titel + open-in links">
68 + Track zonder audio
69 </button>
70 </div>
71
72 <% if (!tracks.length) { %>
73 <div class="ax-empty">
74 <div class="ax-empty-icon">♫</div>
75 <p>Nog geen tracks. Upload er een hierboven.</p>
76 </div>
77 <% } else { %>
78 <ul class="ax-list">
79 <% tracks.forEach(function(t) { %>
80 <li class="ax-track" data-track-id="<%= t.id %>">
81 <% if (t.cover_url) { %>
82 <img class="ax-track-cover" src="<%= t.cover_url %>" alt="" data-cover-thumb>
83 <% } else { %>
84 <span class="ax-track-cover ax-track-cover-empty" data-cover-thumb>♫</span>
85 <% } %>
86
87 <div class="ax-track-meta">
88 <div class="ax-track-title" data-cell="title"><%= t.title || '(zonder titel)' %></div>
89 <div class="ax-track-sub">
90 <span data-cell="artist"><%= t.artist || '—' %></span>
91 <% if (t.album) { %><span class="ax-track-sep">·</span><span data-cell="album"><%= t.album %></span><% } else { %><span data-cell="album" hidden></span><% } %>
92 <span class="ax-track-sep">·</span><span><%= t.size ? Math.round(t.size/1024) + ' KB' : '—' %></span>
93 </div>
94 <code class="ax-embed" data-copy="[[track:<%= t.id %>]]" title="Klik om te kopiëren">[[track:<%= t.id %>]]</code>
95 </div>
96
97 <div class="ax-track-actions">
98 <% if (t.stream_url) { %>
99 <button type="button" class="ax-icon-btn ax-track-play"
100 data-stream-url="<%= t.stream_url %>"
101 data-track-id="<%= t.id %>"
102 aria-label="Afspelen" title="Afspelen">
103 <span class="ax-track-play-icon" aria-hidden="true">▶</span>
104 </button>
105 <% } %>
106 <button type="button" class="ax-icon-btn" data-track-edit data-id="<%= t.id %>" aria-label="Bewerken" title="Bewerken">✎</button>
107 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
108 <button type="button" class="ax-icon-btn" data-track-dl data-id="<%= t.id %>" data-on="<%= t.downloadable ? '1' : '0' %>"
109 aria-label="Download-voor-email" title="<%= t.downloadable ? 'Download-voor-email staat AAN — klik om uit te zetten' : 'Download-voor-email staat uit — klik om aan te zetten' %>"
110 style="<%= t.downloadable ? 'color:var(--accent,#6b8f71)' : 'opacity:.5' %>">⬇</button>
111 <% } %>
112 <form action="/admin/audio/<%= t.id %>/delete" method="post" onsubmit="return confirm('Track verwijderen?')" class="ax-track-delete">
113 <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="Verwijderen" title="Verwijderen">🗑</button>
114 </form>
115 </div>
116 </li>
117 <% }); %>
118 </ul>
119 <% } %>
120 </section>
121
122 <% if ((typeof premiumUnlocked === 'undefined' || premiumUnlocked) && typeof embedUrl !== 'undefined') { %>
123 <section class="ax-section" style="margin-top:1.5rem">
124 <h2 style="margin:0 0 .4rem">🔊 Embedbare speler</h2>
125 <p style="opacity:.75;font-size:.9rem;margin:0 0 .6rem">Plak deze code op je eigen website/blog om je muziek met deze speler in te sluiten:</p>
126 <textarea readonly rows="3" onclick="this.select()" style="width:100%;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;border:1px solid var(--rule);background:var(--paper-2);color:inherit;">&lt;iframe src="<%= embedUrl %>" width="100%" height="420" frameborder="0" loading="lazy" style="border-radius:12px"&gt;&lt;/iframe&gt;</textarea>
127 <p style="margin:.6rem 0 0"><a class="btn" href="<%= embedUrl %>" target="_blank">▶ Speler-voorbeeld openen</a></p>
128 </section>
129 <% } %>
130
131</div>
132
133<style>
134/* ─── Page wrapper ──────────────────────────────────────────────── */
135.ax-page {
136 max-width: 880px;
137 margin: 1.5rem auto 4rem;
138 padding: 0 1rem;
139}
140.ax-header {
141 display: flex; align-items: flex-start; gap: 0.75rem;
142 margin-bottom: 1.5rem;
143}
144.ax-header h1 {
145 font-family: var(--font-display, serif);
146 font-size: 1.75rem;
147 margin: 0 0 0.25rem;
148}
149.ax-tagline {
150 color: var(--ink-muted, var(--ink-soft));
151 margin: 0;
152 font-size: 0.9rem;
153 line-height: 1.5;
154}
155.ax-tagline code {
156 background: var(--paper-2);
157 padding: 0.1em 0.4em;
158 border-radius: 4px;
159 font-size: 0.9em;
160}
161.ax-back {
162 display: inline-flex; align-items: center; justify-content: center;
163 width: 40px; height: 40px;
164 border: 1px solid var(--rule);
165 border-radius: 8px;
166 background: var(--paper);
167 color: var(--ink);
168 text-decoration: none;
169 font-size: 1.1rem;
170 flex-shrink: 0;
171 transition: border-color 120ms;
172}
173.ax-back:hover { border-color: var(--accent); }
174
175/* ─── Flash messages ───────────────────────────────────────────── */
176.ax-flash {
177 padding: 0.75rem 1rem;
178 border-radius: 8px;
179 margin-bottom: 1rem;
180 font-size: 0.9rem;
181}
182.ax-flash-ok { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
183.ax-flash-err { background: rgba(200,60,60,.15); color: #c33; border: 1px solid rgba(200,60,60,.3); }
184
185/* ─── Card sections ────────────────────────────────────────────── */
186.ax-card {
187 background: var(--paper);
188 border: 1px solid var(--rule);
189 border-radius: 12px;
190 padding: 1.25rem;
191 margin-bottom: 1rem;
192}
193.ax-card-title {
194 font-family: var(--font-display, serif);
195 font-size: 1.15rem;
196 font-weight: 600;
197 margin-bottom: 1rem;
198 display: flex; align-items: baseline; gap: 0.5rem;
199}
200.ax-count {
201 font-family: var(--font-ui, system-ui);
202 font-size: 0.8rem;
203 color: var(--ink-muted, var(--ink-soft));
204 background: var(--paper-2);
205 padding: 0.1em 0.55em;
206 border-radius: 999px;
207 font-weight: 500;
208}
209
210/* ─── Form fields ──────────────────────────────────────────────── */
211.ax-form {
212 display: flex; flex-direction: column;
213 gap: 0.85rem;
214}
215.ax-row {
216 display: grid; gap: 0.85rem;
217}
218.ax-row-2 { grid-template-columns: 1fr 1fr; }
219@media (max-width: 600px) {
220 .ax-row-2 { grid-template-columns: 1fr; }
221}
222.ax-field {
223 display: flex; flex-direction: column;
224 gap: 0.35rem;
225 min-width: 0;
226}
227.ax-field > span {
228 font-size: 0.8rem;
229 font-weight: 600;
230 color: var(--ink-soft);
231 display: flex; align-items: baseline; gap: 0.4rem;
232 flex-wrap: wrap;
233}
234.ax-field > span small {
235 font-weight: 400; color: var(--ink-muted);
236 font-size: 0.95em;
237}
238.ax-field input[type="text"],
239.ax-field input[type="url"],
240.ax-field input[type="email"],
241.ax-field select,
242.ax-field textarea {
243 width: 100%;
244 box-sizing: border-box;
245 padding: 0.6rem 0.75rem;
246 border: 1px solid var(--rule);
247 border-radius: 6px;
248 background: var(--paper-2);
249 color: var(--ink);
250 font-family: var(--font-ui, system-ui), sans-serif;
251 font-size: 0.95rem;
252 -webkit-appearance: none; appearance: none;
253 transition: border-color 120ms;
254}
255.ax-field input:focus,
256.ax-field select:focus,
257.ax-field textarea:focus {
258 outline: 2px solid var(--accent);
259 outline-offset: -1px;
260 border-color: var(--accent);
261}
262
263/* ─── File inputs (custom-styled wrapper) ─────────────────────── */
264.ax-file-control {
265 display: flex; align-items: center; gap: 0.5rem;
266 flex-wrap: wrap;
267 position: relative;
268 min-height: 40px;
269}
270.ax-file-control input[type="file"] {
271 position: absolute; inset: 0;
272 opacity: 0; cursor: pointer;
273 z-index: 2;
274}
275.ax-file-btn {
276 pointer-events: none; /* let clicks pass through to the file input */
277}
278.ax-file-name {
279 font-size: 0.85rem;
280 color: var(--ink);
281 word-break: break-all;
282 flex: 1;
283 min-width: 0;
284}
285.ax-file-name[data-empty] { color: var(--ink-muted, var(--ink-soft)); }
286
287.ax-form-actions {
288 display: flex; gap: 0.5rem;
289 margin-top: 0.25rem;
290}
291.ax-form-actions[hidden] { display: none; }
292
293/* ─── Buttons ──────────────────────────────────────────────────── */
294.ax-btn {
295 display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
296 padding: 0.55rem 1rem;
297 border: 1px solid var(--rule);
298 background: var(--paper-2);
299 color: var(--ink);
300 font-family: var(--font-ui, system-ui), sans-serif;
301 font-size: 0.9rem; font-weight: 500;
302 text-decoration: none;
303 border-radius: 6px;
304 cursor: pointer;
305 min-height: 40px;
306 transition: background 120ms, border-color 120ms;
307 -webkit-tap-highlight-color: transparent;
308}
309.ax-btn:hover { border-color: var(--accent); }
310.ax-btn-secondary { background: var(--paper-2); }
311.ax-btn-primary {
312 background: var(--accent); color: white;
313 border-color: var(--accent);
314}
315.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
316
317/* Tiny icon-only button for row actions */
318.ax-icon-btn {
319 display: inline-flex; align-items: center; justify-content: center;
320 width: 40px; height: 40px;
321 border: 1px solid var(--rule);
322 background: var(--paper-2);
323 color: var(--ink);
324 border-radius: 8px;
325 cursor: pointer;
326 font-size: 1rem;
327 transition: background 120ms, border-color 120ms, color 120ms;
328}
329.ax-icon-btn:hover { border-color: var(--accent); }
330.ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
331
332.ax-track-play.is-playing {
333 border-color: var(--accent);
334 color: var(--accent);
335 background: color-mix(in srgb, var(--accent) 12%, var(--paper-2));
336}
337.ax-track-play-icon { font-size: 0.85rem; line-height: 1; }
338
339/* ─── Empty state ──────────────────────────────────────────────── */
340.ax-empty {
341 text-align: center;
342 padding: 2rem 1rem;
343 color: var(--ink-muted, var(--ink-soft));
344}
345.ax-empty-icon {
346 font-size: 2.5rem;
347 font-family: var(--font-display, serif);
348 color: var(--ink-soft);
349 opacity: 0.4;
350 margin-bottom: 0.5rem;
351}
352.ax-empty p { margin: 0; }
353
354/* ─── Track list (card per row) ────────────────────────────────── */
355.ax-list {
356 list-style: none; padding: 0; margin: 0;
357 display: flex; flex-direction: column;
358 gap: 0.6rem;
359}
360.ax-track {
361 display: grid;
362 grid-template-columns: 56px 1fr auto;
363 gap: 0.85rem;
364 align-items: center;
365 padding: 0.75rem;
366 background: var(--paper-2);
367 border: 1px solid var(--rule);
368 border-radius: 10px;
369 transition: border-color 120ms;
370}
371.ax-track:hover { border-color: var(--accent); }
372.ax-track-cover {
373 width: 56px; height: 56px;
374 border-radius: 6px;
375 object-fit: cover;
376 background: var(--paper);
377 display: flex; align-items: center; justify-content: center;
378 font-family: var(--font-display, serif);
379 font-size: 1.5rem;
380 color: var(--accent);
381 flex-shrink: 0;
382}
383.ax-track-cover-empty { /* span variant */ }
384.ax-track-meta {
385 min-width: 0; /* allow text-overflow on long titles */
386 display: flex; flex-direction: column; gap: 0.2rem;
387}
388.ax-track-title {
389 font-weight: 600;
390 font-size: 0.95rem;
391 color: var(--ink);
392 overflow: hidden;
393 text-overflow: ellipsis;
394 white-space: nowrap;
395}
396.ax-track-sub {
397 font-size: 0.8rem;
398 color: var(--ink-muted, var(--ink-soft));
399 display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
400}
401.ax-track-sep { opacity: 0.5; }
402.ax-embed {
403 display: inline-block;
404 background: var(--paper);
405 padding: 0.15rem 0.5rem;
406 border-radius: 4px;
407 font-family: var(--font-mono, ui-monospace, monospace);
408 font-size: 0.75rem;
409 color: var(--ink-soft);
410 cursor: pointer;
411 user-select: all;
412 margin-top: 0.15rem;
413 word-break: break-all;
414 align-self: flex-start;
415 transition: background 120ms;
416}
417.ax-embed:hover { background: var(--rule); }
418.ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
419
420.ax-track-actions {
421 display: flex; gap: 0.4rem;
422 flex-shrink: 0;
423}
424.ax-track-delete { display: inline; }
425
426/* ─── Mobile tweaks ───────────────────────────────────────────── */
427@media (max-width: 480px) {
428 .ax-track {
429 grid-template-columns: 48px 1fr;
430 grid-template-areas: "cover meta" "actions actions";
431 gap: 0.6rem;
432 }
433 .ax-track-cover { grid-area: cover; width: 48px; height: 48px; }
434 .ax-track-meta { grid-area: meta; }
435 .ax-track-actions {
436 grid-area: actions;
437 justify-content: flex-end;
438 border-top: 1px solid var(--rule);
439 padding-top: 0.5rem;
440 margin-top: 0.25rem;
441 }
442}
443
444/* ─── Bulk drop-zone ──────────────────────────────────────────── */
445.ax-dropzone {
446 display: flex; flex-direction: column; align-items: center; justify-content: center;
447 gap: 0.4rem;
448 padding: 2rem 1rem;
449 border: 2px dashed var(--rule);
450 border-radius: 12px;
451 background: var(--paper-2);
452 cursor: pointer;
453 text-align: center;
454 transition: border-color 150ms, background 150ms;
455 position: relative;
456 min-height: 160px;
457}
458.ax-dropzone:hover,
459.ax-dropzone:focus-within {
460 border-color: var(--accent);
461}
462.ax-dropzone.is-dragover {
463 border-color: var(--accent);
464 background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
465}
466.ax-dropzone input[type="file"] {
467 /* Cover the whole dropzone but invisible — clicks bubble through to it. */
468 position: absolute; inset: 0;
469 opacity: 0;
470 cursor: pointer;
471}
472.ax-dropzone-icon {
473 font-size: 2rem;
474 opacity: 0.5;
475 line-height: 1;
476}
477.ax-dropzone-title {
478 font-size: 1rem;
479 font-weight: 600;
480 color: var(--ink);
481}
482.ax-dropzone-sub {
483 font-size: 0.85rem;
484 color: var(--ink-muted, var(--ink-soft));
485 line-height: 1.4;
486}
487.ax-dropzone-sub small {
488 font-size: 0.75rem;
489 opacity: 0.8;
490}
491
492/* ─── Upload queue ────────────────────────────────────────────── */
493.ax-upload-queue {
494 list-style: none;
495 padding: 0; margin: 0.75rem 0 0;
496 display: flex; flex-direction: column;
497 gap: 0.4rem;
498}
499.ax-queue-item {
500 display: grid;
501 grid-template-columns: 1fr auto;
502 gap: 0.75rem;
503 align-items: center;
504 padding: 0.6rem 0.85rem;
505 background: var(--paper);
506 border: 1px solid var(--rule);
507 border-radius: 8px;
508 font-size: 0.875rem;
509 transition: border-color 150ms, opacity 150ms;
510}
511.ax-queue-item--active {
512 border-color: var(--accent);
513}
514.ax-queue-item--done {
515 border-color: rgba(40,160,90,0.4);
516 opacity: 0.85;
517}
518.ax-queue-item--error {
519 border-color: rgba(220,60,60,0.5);
520}
521.ax-queue-name {
522 font-weight: 500;
523 color: var(--ink);
524 overflow: hidden;
525 text-overflow: ellipsis;
526 white-space: nowrap;
527 min-width: 0;
528}
529.ax-queue-size {
530 font-size: 0.75rem;
531 color: var(--ink-muted, var(--ink-soft));
532 margin-left: 0.5rem;
533}
534.ax-queue-status {
535 font-size: 0.75rem;
536 font-weight: 500;
537 padding: 0.15em 0.6em;
538 border-radius: 999px;
539 background: var(--paper-2);
540 color: var(--ink-soft);
541 white-space: nowrap;
542 flex-shrink: 0;
543}
544.ax-queue-status--queued { color: var(--ink-muted, var(--ink-soft)); }
545.ax-queue-status--uploading,
546.ax-queue-status--transcoding {
547 color: var(--accent);
548 background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
549}
550.ax-queue-status--done {
551 color: #2a9d5e;
552 background: rgba(40,160,90,0.12);
553}
554.ax-queue-status--error {
555 color: #dc2626;
556 background: rgba(220,60,60,0.12);
557}
558</style>
559
560<%# Track editor modal (exposes window.openTrackEditor) %>
561<%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
562
563<script>
564(function() {
565
566 // ── Live filename display for custom file inputs ──────────────
567 document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
568 const nameEl = input.parentElement.querySelector('.ax-file-name');
569 if (!nameEl) return;
570 input.addEventListener('change', () => {
571 if (input.files && input.files[0]) {
572 nameEl.textContent = input.files[0].name;
573 nameEl.removeAttribute('data-empty');
574 } else {
575 nameEl.textContent = 'Geen bestand gekozen';
576 nameEl.setAttribute('data-empty', '');
577 }
578 });
579 });
580
581 // ── Inline track preview (routed through the global mini-player) ──
582 // Each .ax-track-play button is a thin wrapper around the global
583 // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
584 // .is-playing) is synced from the player's own audio element so it
585 // stays accurate even when the user uses the mini-player's controls.
586 (function setupTrackPreview() {
587 const buttons = document.querySelectorAll('.ax-track-play');
588 if (!buttons.length) return;
589
590 function setIcon(btn, playing) {
591 const icon = btn.querySelector('.ax-track-play-icon');
592 if (icon) icon.textContent = playing ? '⏸' : '▶';
593 btn.classList.toggle('is-playing', playing);
594 btn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
595 }
596
597 // Resync ALL preview buttons against the current audio element state.
598 // Called on every play/pause/ended event so admins always see the right
599 // icon — including the case where they hit pause on the mini-player
600 // bar instead of the row's button.
601 function resyncAll() {
602 const audio = document.getElementById('audio-element');
603 const player = window.pcmsAudioPlayer;
604 const playing = audio && !audio.paused && !audio.ended;
605 // audio.src is now a blob: URL (Spotify-style playback), so compare
606 // against the player's logical track URL, not the element src.
607 const cur = player && player.currentTrack();
608 const curUrl = cur ? cur.url : '';
609 buttons.forEach(b => {
610 const isThisOne = playing && curUrl === b.dataset.streamUrl;
611 setIcon(b, isThisOne);
612 });
613 }
614
615 buttons.forEach(btn => {
616 btn.addEventListener('click', e => {
617 e.preventDefault();
618 const player = window.pcmsAudioPlayer;
619 if (!player) {
620 console.warn('[admin-audio] miniplayer not available');
621 return;
622 }
623 const url = btn.dataset.streamUrl;
624 if (!url) return;
625
626 // Build track metadata from the row's DOM so the mini-player shows
627 // useful info (title/artist/album/cover) without an extra API call.
628 const row = btn.closest('.ax-track');
629 const titleEl = row && row.querySelector('[data-cell="title"]');
630 const artistEl = row && row.querySelector('[data-cell="artist"]');
631 const albumEl = row && row.querySelector('[data-cell="album"]');
632 const coverImg = row && row.querySelector('img[data-cover-thumb]');
633 const track = {
634 url,
635 title: titleEl ? titleEl.textContent.trim() : 'Track',
636 artist: artistEl ? artistEl.textContent.trim() : '',
637 album: albumEl ? albumEl.textContent.trim() : '',
638 cover: coverImg ? coverImg.src : '',
639 };
640
641 // If this exact track is already current, toggle pause/play instead
642 // of restarting from zero. Compare logical URLs (audio.src is a blob:).
643 const cur = player.currentTrack();
644 if (cur && cur.url === url) {
645 if (player.isPlaying()) player.pause();
646 else player.play();
647 return;
648 }
649
650 player.setQueue([track], 0);
651 });
652 });
653
654 // Hook the global audio element's events to keep the row buttons synced.
655 // We attach lazily after the player has built its DOM. The script in
656 // shell.ejs runs at page-load so #audio-element exists by the time
657 // this IIFE fires (script tag is below the body content).
658 const audio = document.getElementById('audio-element');
659 if (audio) {
660 ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
661 audio.addEventListener(ev, resyncAll);
662 });
663 // Initial state on page load (e.g. user navigated back while a track
664 // was already playing — buttons should reflect that).
665 resyncAll();
666 }
667 })();
668
669
670 // ── Bulk upload (drag-drop + sequential transcoding) ─────────
671 // Files dropped or picked are queued (not uploaded immediately) so the
672 // user can review the list, set shared metadata, then hit "Start upload".
673 // The loop POSTs one file at a time to /admin/audio/upload with
674 // Accept: application/json so the server returns structured per-file
675 // results instead of redirecting.
676 const dropzone = document.getElementById('audio-dropzone');
677 const fileInput = document.getElementById('audio-files');
678 const queueEl = document.getElementById('upload-queue');
679 const actionsEl = document.getElementById('upload-actions');
680 const startBtn = document.getElementById('start-upload-btn');
681 const clearBtn = document.getElementById('clear-queue-btn');
682 const artistInput= document.getElementById('batch-artist');
683 const albumInput = document.getElementById('batch-album');
684 const coverInput = document.getElementById('batch-cover');
685
686 /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
687 const queue = [];
688
689 function fmtBytes(n) {
690 if (n < 1024) return n + ' B';
691 if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
692 return (n / 1024 / 1024).toFixed(1) + ' MB';
693 }
694
695 function setItemStatus(item, status, label) {
696 const labels = {
697 queued: 'Wachten',
698 uploading: 'Uploaden…',
699 transcoding: 'Converteren…',
700 done: '✓ Klaar',
701 error: '✗ Fout',
702 };
703 item.status = status;
704 const badge = item.el.querySelector('.ax-queue-status');
705 badge.className = 'ax-queue-status ax-queue-status--' + status;
706 badge.textContent = label || labels[status] || status;
707 // Restyle the row
708 item.el.classList.remove(
709 'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
710 );
711 if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
712 else if (status === 'done') item.el.classList.add('ax-queue-item--done');
713 else if (status === 'error') item.el.classList.add('ax-queue-item--error');
714 }
715
716 function addFiles(files) {
717 let added = 0;
718 for (const file of files) {
719 if (!file.type.startsWith('audio/') &&
720 !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
721 // Silently skip non-audio drops; keeps the UX uncluttered.
722 continue;
723 }
724 const li = document.createElement('li');
725 li.className = 'ax-queue-item';
726 li.innerHTML =
727 '<div class="ax-queue-name"></div>' +
728 '<span class="ax-queue-status ax-queue-status--queued">Wachten</span>';
729 // Use textContent to avoid HTML-injection if a filename contains markup.
730 li.querySelector('.ax-queue-name').textContent = file.name;
731 // Append size hint inline
732 const size = document.createElement('span');
733 size.className = 'ax-queue-size';
734 size.textContent = fmtBytes(file.size);
735 li.querySelector('.ax-queue-name').appendChild(size);
736 queueEl.appendChild(li);
737 queue.push({ file, el: li, status: 'queued' });
738 added++;
739 }
740 if (added) {
741 queueEl.hidden = false;
742 actionsEl.hidden = false;
743 }
744 }
745
746 // ── Drop-zone events ─────────────────────────────────────────
747 // Page-level guard: a dropped file outside the zone would otherwise
748 // make the browser navigate to it (e.g. opening the audio inline), which
749 // discards typed metadata. We swallow drops anywhere unless the dropzone
750 // explicitly handles them.
751 ['dragover', 'drop'].forEach(ev => {
752 window.addEventListener(ev, e => {
753 // Allow drops INSIDE the dropzone — its own listener handles those.
754 if (dropzone.contains(e.target)) return;
755 e.preventDefault();
756 });
757 });
758
759 ['dragenter', 'dragover'].forEach(ev => {
760 dropzone.addEventListener(ev, e => {
761 e.preventDefault();
762 dropzone.classList.add('is-dragover');
763 });
764 });
765 ['dragleave', 'drop'].forEach(ev => {
766 dropzone.addEventListener(ev, e => {
767 e.preventDefault();
768 dropzone.classList.remove('is-dragover');
769 });
770 });
771 dropzone.addEventListener('drop', e => {
772 if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
773 });
774 fileInput.addEventListener('change', () => {
775 addFiles(fileInput.files);
776 // Reset so the same file can be picked again later if user wants
777 fileInput.value = '';
778 });
779
780 // ── Clear queue button ───────────────────────────────────────
781 clearBtn.addEventListener('click', () => {
782 // Only remove items that aren't currently uploading (anything queued
783 // or already finished). An in-progress upload finishes, then its row
784 // would also disappear once we re-render — but we keep it simple and
785 // just refuse to clear during an active run.
786 if (startBtn.disabled) return;
787 queue.length = 0;
788 queueEl.innerHTML = '';
789 queueEl.hidden = true;
790 actionsEl.hidden = true;
791 });
792
793 // ── Sequential upload loop ───────────────────────────────────
794 startBtn.addEventListener('click', async () => {
795 if (startBtn.disabled) return;
796 startBtn.disabled = true;
797 clearBtn.disabled = true;
798 dropzone.style.pointerEvents = 'none';
799 dropzone.style.opacity = '0.5';
800
801 const sharedArtist = artistInput.value.trim();
802 const sharedAlbum = albumInput.value.trim();
803 const sharedCover = coverInput.files && coverInput.files[0];
804
805 // Process queued items one at a time. We iterate via index so that
806 // if more files get dropped during the run they ALSO get processed
807 // (queue.push above mutates the same array we're iterating).
808 for (let i = 0; i < queue.length; i++) {
809 const item = queue[i];
810 if (item.status !== 'queued') continue;
811 try {
812 await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
813 } catch (err) {
814 console.error('upload failed for', item.file.name, err);
815 setItemStatus(item, 'error', '✗ ' + (err.message || 'Mislukt'));
816 }
817 }
818
819 startBtn.disabled = false;
820 clearBtn.disabled = false;
821 dropzone.style.pointerEvents = '';
822 dropzone.style.opacity = '';
823
824 // Reload the page so the new tracks appear in the list below.
825 // Could also fetch them and inject, but a full reload is simpler and
826 // ensures position indexes / album-grouping are correct.
827 const anyDone = queue.some(q => q.status === 'done');
828 if (anyDone) {
829 setTimeout(() => location.reload(), 700);
830 }
831 });
832
833 async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
834 setItemStatus(item, 'uploading');
835
836 const fd = new FormData();
837 fd.append('audio', item.file);
838 if (sharedArtist) fd.append('artist', sharedArtist);
839 if (sharedAlbum) fd.append('album', sharedAlbum);
840 if (sharedCover) fd.append('cover', sharedCover);
841 // Title is intentionally omitted — server uses filename fallback.
842
843 // We can't reliably distinguish "still uploading bytes" from
844 // "uploading done, ffmpeg running" without progress events, but the
845 // status flips to "Converteren…" once the request is past upload phase.
846 // We approximate this by waiting until the response arrives — by then
847 // both phases are complete on the server side. For a smoother feel we
848 // briefly show "transcoding" near the end of the request lifecycle.
849 const transcodeHint = setTimeout(() => {
850 if (item.status === 'uploading') setItemStatus(item, 'transcoding');
851 }, 1500);
852
853 try {
854 const res = await fetch('/admin/audio/upload', {
855 method: 'POST',
856 headers: { 'Accept': 'application/json' },
857 body: fd,
858 credentials: 'same-origin',
859 });
860 clearTimeout(transcodeHint);
861
862 // Server responds with JSON for our Accept header. If it didn't
863 // (e.g. session expired and got an HTML login page), surface that.
864 let data;
865 try { data = await res.json(); }
866 catch (_) { throw new Error('Onverwacht serverantwoord (' + res.status + ')'); }
867
868 if (!res.ok || !data.ok) {
869 throw new Error(data.error || ('HTTP ' + res.status));
870 }
871
872 setItemStatus(item, 'done', '✓ ' + (data.title || 'Klaar'));
873 } catch (err) {
874 clearTimeout(transcodeHint);
875 throw err;
876 }
877 }
878
879 // ── Click-to-copy embed codes ─────────────────────────────────
880 document.querySelectorAll('[data-copy]').forEach(el => {
881 el.addEventListener('click', async () => {
882 const text = el.dataset.copy;
883 try {
884 await navigator.clipboard.writeText(text);
885 el.classList.add('is-copied');
886 const original = el.textContent;
887 el.textContent = '✓ gekopieerd';
888 setTimeout(() => {
889 el.classList.remove('is-copied');
890 el.textContent = original;
891 }, 1200);
892 } catch (_) { /* fall through — selection still works */ }
893 });
894 });
895
896 // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
897 const addLinkBtn = document.getElementById('add-link-track-btn');
898 if (addLinkBtn) {
899 addLinkBtn.addEventListener('click', async () => {
900 addLinkBtn.disabled = true;
901 try {
902 const r = await fetch('/admin/audio/create-link', {
903 method: 'POST', credentials: 'same-origin',
904 headers: { 'Content-Type': 'application/json' },
905 body: JSON.stringify({ title: 'Nieuwe track' }),
906 });
907 const j = await r.json();
908 if (!r.ok || !j.ok) throw new Error(j.error || 'Aanmaken mislukt');
909 if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
910 window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
911 } catch (err) {
912 alert('Track aanmaken mislukt: ' + err.message);
913 } finally {
914 addLinkBtn.disabled = false;
915 }
916 });
917 }
918
919 // ── Wire all "Edit" buttons to the track-editor modal ─────────
920 // After save we patch the row in-place rather than reloading,
921 // so the user keeps their scroll position on long lists.
922 document.querySelectorAll('[data-track-edit]').forEach(btn => {
923 btn.addEventListener('click', () => {
924 if (typeof window.openTrackEditor !== 'function') {
925 alert('Track editor niet geladen');
926 return;
927 }
928 const id = btn.dataset.id;
929 window.openTrackEditor({
930 id,
931 onSaved: (track) => {
932 const row = document.querySelector('li[data-track-id="' + id + '"]');
933 if (!row) return;
934 // Update visible cells
935 const titleEl = row.querySelector('[data-cell="title"]');
936 const artistEl = row.querySelector('[data-cell="artist"]');
937 const albumEl = row.querySelector('[data-cell="album"]');
938 if (titleEl) titleEl.textContent = track.title || '(zonder titel)';
939 if (artistEl) artistEl.textContent = track.artist || '—';
940 if (albumEl) {
941 albumEl.textContent = track.album || '';
942 if (track.album) albumEl.removeAttribute('hidden');
943 else albumEl.setAttribute('hidden', '');
944 }
945 // Update cover thumb (replace element if type changed)
946 const oldThumb = row.querySelector('[data-cover-thumb]');
947 if (oldThumb) {
948 const parent = oldThumb.parentElement;
949 if (track.cover_url) {
950 const img = document.createElement('img');
951 img.className = 'ax-track-cover';
952 img.src = track.cover_url;
953 img.alt = '';
954 img.dataset.coverThumb = '';
955 parent.replaceChild(img, oldThumb);
956 } else {
957 const sp = document.createElement('span');
958 sp.className = 'ax-track-cover ax-track-cover-empty';
959 sp.textContent = '♫';
960 sp.dataset.coverThumb = '';
961 parent.replaceChild(sp, oldThumb);
962 }
963 }
964 },
965 });
966 });
967 });
968
969 // Download-voor-email toggle — AJAX (geen pagina-reload meer).
970 document.querySelectorAll('[data-track-dl]').forEach(btn => {
971 btn.addEventListener('click', async () => {
972 if (btn.disabled) return;
973 const want = btn.dataset.on === '1' ? 0 : 1;
974 btn.disabled = true;
975 try {
976 const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
977 method: 'POST',
978 headers: { 'Content-Type': 'application/json' },
979 body: JSON.stringify({ downloadable: !!want }),
980 });
981 if (!res.ok) throw new Error('HTTP ' + res.status);
982 btn.dataset.on = String(want);
983 btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
984 btn.style.opacity = want ? '1' : '.5';
985 btn.title = want
986 ? 'Download-voor-email staat AAN — klik om uit te zetten'
987 : 'Download-voor-email staat uit — klik om aan te zetten';
988 } catch (e) {
989 alert('Kon niet wijzigen: ' + (e.message || e));
990 } finally {
991 btn.disabled = false;
992 }
993 });
994 });
995})();
996</script>
Note: See TracBrowser for help on using the repository browser.