source: Klonkt/src/assets/js/mod/admin-audio.js@ 5300652

main
Last change on this file since 5300652 was 5300652, checked in by Robin <roboburr@…>, 4 weeks ago

Stap 2: "Ben jij dit?" in Beheer -> Audio (shaer-mbz)

Een paneel bovenaan het audioscherm: zoek op je artiestennaam (voorgevuld met
wat er al in de site staat), kies je eigen pagina, klaar. Drie talen.

DE TOELICHTING IS HET HELE PUNT. Elke kandidaat toont MusicBrainz'
disambiguation naast soort, land en jaren -- er zijn drie bands die Nirvana
heten, en met alleen een naam kiest iemand de verkeerde. Wij kiezen NIET voor
de artiest, ook niet als er precies een treffer is: een verkeerd geraden MBID
zet jouw naam onder andermans werk.

ONTKOPPELEN KAN. Om dezelfde reden: zo'n vergissing hoort met een klik terug
te draaien te zijn, niet met een databasebewerking.

Alleen een echte MBID komt de kolom in; de route weigert een URL of een handle.
De naam bewaren we ernaast zodat het scherm kan tonen WAT er hangt zonder
ervoor te netwerken -- en zodat een verkeerde koppeling opvalt.

TWEE DINGEN UIT DE HUISREGELS. De kandidaten gaan met textContent het scherm op
en niet als HTML: dat is tekst uit een vreemd register. En het blok hangt in
run() en niet op moduleniveau -- deze modules krijgen bij elke paginawissel
opnieuw init(), en een blok dat maar een keer per sessie draait is precies wat
shaer-5s1 opleverde. Ik had hem er eerst buiten gezet.

Als iemand niet in MusicBrainz staat zegt het scherm dat hij zich daar kan
aanmelden -- en erbij dat dat alleen dáár kan, niet vanuit Klonkt. Dat is geen
bescheidenheid maar hun API: artiesten zijn er niet mee aan te maken.

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

  • Property mode set to 100644
File size: 21.4 KB
Line 
1// Audio in beheer -- verplaatst uit inline script, shaer-bqr.
2//
3// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
4// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
5// link BINNEN de site binnenkomt (shaer-0i6).
6
7import { pageData, makeSweeper } from './lib.js';
8
9// Zie post-edit.js: init() per paginawissel, de veger haalt de window-
10// listeners van de vorige lichting weg (shaer-5s1).
11const doc = makeSweeper();
12let T = {};
13
14export function init() {
15 doc.sweep();
16 T = pageData();
17 run();
18}
19
20function run() {
21
22(function() {
23
24 // ── Live filename display for custom file inputs ──────────────
25 document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
26 const nameEl = input.parentElement.querySelector('.ax-file-name');
27 if (!nameEl) return;
28 input.addEventListener('change', () => {
29 if (input.files && input.files[0]) {
30 nameEl.textContent = input.files[0].name;
31 nameEl.removeAttribute('data-empty');
32 } else {
33 nameEl.textContent = (T.no_file || '');
34 nameEl.setAttribute('data-empty', '');
35 }
36 });
37 });
38
39 // ── Inline track preview (routed through the global mini-player) ──
40 // Each .ax-track-play button is a thin wrapper around the global
41 // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
42 // .is-playing) is synced from the player's own audio element so it
43 // stays accurate even when the user uses the mini-player's controls.
44 (function setupTrackPreview() {
45 const buttons = document.querySelectorAll('.ax-track-play');
46 if (!buttons.length) return;
47
48 function setIcon(btn, playing) {
49 const icon = btn.querySelector('.ax-track-play-icon');
50 if (icon) icon.textContent = playing ? '⏸' : '▶';
51 btn.classList.toggle('is-playing', playing);
52 btn.setAttribute('aria-label', playing ? (T.pause || '') : (T.play || ''));
53 }
54
55 // Resync ALL preview buttons against the current audio element state.
56 // Called on every play/pause/ended event so admins always see the right
57 // icon — including the case where they hit pause on the mini-player
58 // bar instead of the row's button.
59 function resyncAll() {
60 const audio = document.getElementById('audio-element');
61 const player = window.pcmsAudioPlayer;
62 const playing = audio && !audio.paused && !audio.ended;
63 // audio.src is now a blob: URL (Spotify-style playback), so compare
64 // against the player's logical track URL, not the element src.
65 const cur = player && player.currentTrack();
66 const curUrl = cur ? cur.url : '';
67 buttons.forEach(b => {
68 const isThisOne = playing && curUrl === b.dataset.streamUrl;
69 setIcon(b, isThisOne);
70 });
71 }
72
73 buttons.forEach(btn => {
74 btn.addEventListener('click', e => {
75 e.preventDefault();
76 const player = window.pcmsAudioPlayer;
77 if (!player) {
78 console.warn('[admin-audio] miniplayer not available');
79 return;
80 }
81 const url = btn.dataset.streamUrl;
82 if (!url) return;
83
84 // Build track metadata from the row's DOM so the mini-player shows
85 // useful info (title/artist/album/cover) without an extra API call.
86 const row = btn.closest('.ax-track');
87 const titleEl = row && row.querySelector('[data-cell="title"]');
88 const artistEl = row && row.querySelector('[data-cell="artist"]');
89 const albumEl = row && row.querySelector('[data-cell="album"]');
90 const coverImg = row && row.querySelector('img[data-cover-thumb]');
91 const track = {
92 url,
93 title: titleEl ? titleEl.textContent.trim() : 'Track',
94 artist: artistEl ? artistEl.textContent.trim() : '',
95 album: albumEl ? albumEl.textContent.trim() : '',
96 cover: coverImg ? coverImg.src : '',
97 };
98
99 // If this exact track is already current, toggle pause/play instead
100 // of restarting from zero. Compare logical URLs (audio.src is a blob:).
101 const cur = player.currentTrack();
102 if (cur && cur.url === url) {
103 if (player.isPlaying()) player.pause();
104 else player.play();
105 return;
106 }
107
108 player.setQueue([track], 0);
109 });
110 });
111
112 // Hook the global audio element's events to keep the row buttons synced.
113 // We attach lazily after the player has built its DOM. The script in
114 // shell.ejs runs at page-load so #audio-element exists by the time
115 // this IIFE fires (script tag is below the body content).
116 const audio = document.getElementById('audio-element');
117 if (audio) {
118 ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
119 audio.addEventListener(ev, resyncAll);
120 });
121 // Initial state on page load (e.g. user navigated back while a track
122 // was already playing — buttons should reflect that).
123 resyncAll();
124 }
125 })();
126
127
128 // ── Bulk upload (drag-drop + sequential transcoding) ─────────
129 // Files dropped or picked are queued (not uploaded immediately) so the
130 // user can review the list, set shared metadata, then hit "Start upload".
131 // The loop POSTs one file at a time to /admin/audio/upload with
132 // Accept: application/json so the server returns structured per-file
133 // results instead of redirecting.
134 const dropzone = document.getElementById('audio-dropzone');
135 const fileInput = document.getElementById('audio-files');
136 const queueEl = document.getElementById('upload-queue');
137 const actionsEl = document.getElementById('upload-actions');
138 const startBtn = document.getElementById('start-upload-btn');
139 const clearBtn = document.getElementById('clear-queue-btn');
140 const artistInput= document.getElementById('batch-artist');
141 const albumInput = document.getElementById('batch-album');
142 const coverInput = document.getElementById('batch-cover');
143
144 /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
145 const queue = [];
146
147 function fmtBytes(n) {
148 if (n < 1024) return n + ' B';
149 if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
150 return (n / 1024 / 1024).toFixed(1) + ' MB';
151 }
152
153 function setItemStatus(item, status, label) {
154 const labels = {
155 queued: (T.st_queued || ''),
156 uploading: (T.st_uploading || ''),
157 transcoding: (T.st_transcoding || ''),
158 done: '✓ ' + (T.st_done || ''),
159 error: '✗ ' + (T.st_error || ''),
160 };
161 item.status = status;
162 const badge = item.el.querySelector('.ax-queue-status');
163 badge.className = 'ax-queue-status ax-queue-status--' + status;
164 badge.textContent = label || labels[status] || status;
165 // Restyle the row
166 item.el.classList.remove(
167 'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
168 );
169 if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
170 else if (status === 'done') item.el.classList.add('ax-queue-item--done');
171 else if (status === 'error') item.el.classList.add('ax-queue-item--error');
172 }
173
174 function addFiles(files) {
175 let added = 0;
176 for (const file of files) {
177 if (!file.type.startsWith('audio/') &&
178 !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
179 // Silently skip non-audio drops; keeps the UX uncluttered.
180 continue;
181 }
182 const li = document.createElement('li');
183 li.className = 'ax-queue-item';
184 li.innerHTML =
185 '<div class="ax-queue-name"></div>' +
186 '<span class="ax-queue-status ax-queue-status--queued">' + (T.st_queued || '') + '</span>';
187 // Use textContent to avoid HTML-injection if a filename contains markup.
188 li.querySelector('.ax-queue-name').textContent = file.name;
189 // Append size hint inline
190 const size = document.createElement('span');
191 size.className = 'ax-queue-size';
192 size.textContent = fmtBytes(file.size);
193 li.querySelector('.ax-queue-name').appendChild(size);
194 queueEl.appendChild(li);
195 queue.push({ file, el: li, status: 'queued' });
196 added++;
197 }
198 if (added) {
199 queueEl.hidden = false;
200 actionsEl.hidden = false;
201 }
202 }
203
204 // ── Drop-zone events ─────────────────────────────────────────
205 // Page-level guard: a dropped file outside the zone would otherwise
206 // make the browser navigate to it (e.g. opening the audio inline), which
207 // discards typed metadata. We swallow drops anywhere unless the dropzone
208 // explicitly handles them.
209 ['dragover', 'drop'].forEach(ev => {
210 doc.on(window, ev, e => {
211 // Allow drops INSIDE the dropzone — its own listener handles those.
212 if (dropzone.contains(e.target)) return;
213 e.preventDefault();
214 });
215 });
216
217 ['dragenter', 'dragover'].forEach(ev => {
218 dropzone.addEventListener(ev, e => {
219 e.preventDefault();
220 dropzone.classList.add('is-dragover');
221 });
222 });
223 ['dragleave', 'drop'].forEach(ev => {
224 dropzone.addEventListener(ev, e => {
225 e.preventDefault();
226 dropzone.classList.remove('is-dragover');
227 });
228 });
229 dropzone.addEventListener('drop', e => {
230 if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
231 });
232 fileInput.addEventListener('change', () => {
233 addFiles(fileInput.files);
234 // Reset so the same file can be picked again later if user wants
235 fileInput.value = '';
236 });
237
238 // ── Clear queue button ───────────────────────────────────────
239 clearBtn.addEventListener('click', () => {
240 // Only remove items that aren't currently uploading (anything queued
241 // or already finished). An in-progress upload finishes, then its row
242 // would also disappear once we re-render — but we keep it simple and
243 // just refuse to clear during an active run.
244 if (startBtn.disabled) return;
245 queue.length = 0;
246 queueEl.innerHTML = '';
247 queueEl.hidden = true;
248 actionsEl.hidden = true;
249 });
250
251 // ── Sequential upload loop ───────────────────────────────────
252 startBtn.addEventListener('click', async () => {
253 if (startBtn.disabled) return;
254 startBtn.disabled = true;
255 clearBtn.disabled = true;
256 dropzone.style.pointerEvents = 'none';
257 dropzone.style.opacity = '0.5';
258
259 const sharedArtist = artistInput.value.trim();
260 const sharedAlbum = albumInput.value.trim();
261 const sharedCover = coverInput.files && coverInput.files[0];
262
263 // Process queued items one at a time. We iterate via index so that
264 // if more files get dropped during the run they ALSO get processed
265 // (queue.push above mutates the same array we're iterating).
266 for (let i = 0; i < queue.length; i++) {
267 const item = queue[i];
268 if (item.status !== 'queued') continue;
269 try {
270 await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
271 } catch (err) {
272 console.error('upload failed for', item.file.name, err);
273 setItemStatus(item, 'error', '✗ ' + (err.message || (T.failed || '')));
274 }
275 }
276
277 startBtn.disabled = false;
278 clearBtn.disabled = false;
279 dropzone.style.pointerEvents = '';
280 dropzone.style.opacity = '';
281
282 // Reload the page so the new tracks appear in the list below.
283 // Could also fetch them and inject, but a full reload is simpler and
284 // ensures position indexes / album-grouping are correct.
285 const anyDone = queue.some(q => q.status === 'done');
286 if (anyDone) {
287 setTimeout(() => location.reload(), 700);
288 }
289 });
290
291 async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
292 setItemStatus(item, 'uploading');
293
294 const fd = new FormData();
295 fd.append('audio', item.file);
296 if (sharedArtist) fd.append('artist', sharedArtist);
297 if (sharedAlbum) fd.append('album', sharedAlbum);
298 if (sharedCover) fd.append('cover', sharedCover);
299 // Title is intentionally omitted — server uses filename fallback.
300
301 // We can't reliably distinguish "still uploading bytes" from
302 // "uploading done, ffmpeg running" without progress events, but the
303 // status flips to "Converteren…" once the request is past upload phase.
304 // We approximate this by waiting until the response arrives — by then
305 // both phases are complete on the server side. For a smoother feel we
306 // briefly show "transcoding" near the end of the request lifecycle.
307 const transcodeHint = setTimeout(() => {
308 if (item.status === 'uploading') setItemStatus(item, 'transcoding');
309 }, 1500);
310
311 try {
312 const res = await fetch('/admin/audio/upload', {
313 method: 'POST',
314 headers: { 'Accept': 'application/json' },
315 body: fd,
316 credentials: 'same-origin',
317 });
318 clearTimeout(transcodeHint);
319
320 // Server responds with JSON for our Accept header. If it didn't
321 // (e.g. session expired and got an HTML login page), surface that.
322 let data;
323 try { data = await res.json(); }
324 catch (_) { throw new Error((T.err_unexpected || '') + ' (' + res.status + ')'); }
325
326 if (!res.ok || !data.ok) {
327 throw new Error(data.error || ('HTTP ' + res.status));
328 }
329
330 setItemStatus(item, 'done', '✓ ' + (data.title || (T.st_done || '')));
331 } catch (err) {
332 clearTimeout(transcodeHint);
333 throw err;
334 }
335 }
336
337 // ── Click-to-copy embed codes ─────────────────────────────────
338 document.querySelectorAll('[data-copy]').forEach(el => {
339 el.addEventListener('click', async () => {
340 const text = el.dataset.copy;
341 try {
342 await navigator.clipboard.writeText(text);
343 el.classList.add('is-copied');
344 const original = el.textContent;
345 el.textContent = '✓ ' + (T.copied || '');
346 setTimeout(() => {
347 el.classList.remove('is-copied');
348 el.textContent = original;
349 }, 1200);
350 } catch (_) { /* fall through — selection still works */ }
351 });
352 });
353
354 // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
355 const addLinkBtn = document.getElementById('add-link-track-btn');
356 if (addLinkBtn) {
357 addLinkBtn.addEventListener('click', async () => {
358 addLinkBtn.disabled = true;
359 try {
360 const r = await fetch('/admin/audio/create-link', {
361 method: 'POST', credentials: 'same-origin',
362 headers: { 'Content-Type': 'application/json' },
363 body: JSON.stringify({ title: (T.new_track || '') }),
364 });
365 const j = await r.json();
366 if (!r.ok || !j.ok) throw new Error(j.error || (T.create_failed || ''));
367 if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
368 window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
369 } catch (err) {
370 alert((T.create_failed || '') + ': ' + err.message);
371 } finally {
372 addLinkBtn.disabled = false;
373 }
374 });
375 }
376
377 // ── Wire all "Edit" buttons to the track-editor modal ─────────
378 // After save we patch the row in-place rather than reloading,
379 // so the user keeps their scroll position on long lists.
380 document.querySelectorAll('[data-track-edit]').forEach(btn => {
381 btn.addEventListener('click', () => {
382 if (typeof window.openTrackEditor !== 'function') {
383 alert((T.editor_not_loaded || ''));
384 return;
385 }
386 const id = btn.dataset.id;
387 window.openTrackEditor({
388 id,
389 onSaved: (track) => {
390 const row = document.querySelector('li[data-track-id="' + id + '"]');
391 if (!row) return;
392 // Update visible cells
393 const titleEl = row.querySelector('[data-cell="title"]');
394 const artistEl = row.querySelector('[data-cell="artist"]');
395 const albumEl = row.querySelector('[data-cell="album"]');
396 if (titleEl) titleEl.textContent = track.title || (T.untitled || '');
397 if (artistEl) artistEl.textContent = track.artist || '—';
398 if (albumEl) {
399 albumEl.textContent = track.album || '';
400 if (track.album) albumEl.removeAttribute('hidden');
401 else albumEl.setAttribute('hidden', '');
402 }
403 // Update cover thumb (replace element if type changed)
404 const oldThumb = row.querySelector('[data-cover-thumb]');
405 if (oldThumb) {
406 const parent = oldThumb.parentElement;
407 if (track.cover_url) {
408 const img = document.createElement('img');
409 img.className = 'ax-track-cover';
410 img.src = track.cover_url;
411 img.alt = '';
412 img.dataset.coverThumb = '';
413 parent.replaceChild(img, oldThumb);
414 } else {
415 const sp = document.createElement('span');
416 sp.className = 'ax-track-cover ax-track-cover-empty';
417 sp.textContent = '♫';
418 sp.dataset.coverThumb = '';
419 parent.replaceChild(sp, oldThumb);
420 }
421 }
422 },
423 });
424 });
425 });
426
427 // Download-voor-email toggle — AJAX (geen pagina-reload meer).
428 document.querySelectorAll('[data-track-dl]').forEach(btn => {
429 btn.addEventListener('click', async () => {
430 if (btn.disabled) return;
431 const want = btn.dataset.on === '1' ? 0 : 1;
432 btn.disabled = true;
433 try {
434 const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
435 method: 'POST',
436 headers: { 'Content-Type': 'application/json' },
437 body: JSON.stringify({ downloadable: !!want }),
438 });
439 if (!res.ok) throw new Error('HTTP ' + res.status);
440 btn.dataset.on = String(want);
441 btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
442 btn.style.opacity = want ? '1' : '.5';
443 btn.title = want
444 ? (T.dl_on || '')
445 : (T.dl_off || '');
446 } catch (e) {
447 alert((T.change_failed || '') + ': ' + (e.message || e));
448 } finally {
449 btn.disabled = false;
450 }
451 });
452 });
453})();
454
455 // MusicBrainz-paneel (shaer-mbz). Binnen run(), niet op moduleniveau:
456 // deze modules krijgen bij elke paginawissel opnieuw init(), en een blok dat
457 // maar een keer per sessie draait is precies wat shaer-5s1 opleverde.
458 wireMusicBrainz();
459}
460
461// ── MusicBrainz: ben jij dit? (shaer-mbz, stap 2) ─────────────────
462//
463// De kandidaten komen van de server, want MusicBrainz eist een verzoek per
464// seconde per APPLICATIE en een User-Agent met contact -- allebei niet vanuit
465// een browser af te dwingen.
466//
467// WIJ KIEZEN NIET. Ook niet als er precies een treffer is: een verkeerd
468// geraden MBID zet jouw naam onder andermans werk. De knop staat er, de klik
469// is van de artiest.
470function wireMusicBrainz() {
471 const knop = document.getElementById('mb-zoek-btn');
472 const veld = document.getElementById('mb-q');
473 const uit = document.getElementById('mb-uit');
474 if (!knop || !veld || !uit || knop.__wired) return;
475 knop.__wired = true;
476
477 const el = (tag, cls, tekst) => {
478 const e = document.createElement(tag);
479 if (cls) e.className = cls;
480 if (tekst != null) e.textContent = tekst; // textContent: nooit HTML uit een vreemd register
481 return e;
482 };
483
484 async function zoek() {
485 const q = (veld.value || '').trim();
486 if (!q) return;
487 uit.hidden = false;
488 uit.replaceChildren(el('p', 'ax-hint', T.aaud_mb_busy || 'Zoeken…'));
489 knop.disabled = true;
490 try {
491 const r = await fetch(`api/musicbrainz?q=${encodeURIComponent(q)}`, { credentials: 'same-origin' });
492 const j = await r.json();
493 toon((j && j.kandidaten) || []);
494 } catch {
495 uit.replaceChildren(el('p', 'ax-hint', T.aaud_mb_fail || 'MusicBrainz is even niet bereikbaar.'));
496 } finally {
497 knop.disabled = false;
498 }
499 }
500
501 function toon(kandidaten) {
502 if (!kandidaten.length) {
503 uit.replaceChildren(el('p', 'ax-hint', T.aaud_mb_none || 'Niets gevonden.'));
504 return;
505 }
506 const lijst = el('ul', 'mb-lijst');
507 for (const k of kandidaten) {
508 const li = el('li', 'mb-kandidaat');
509 li.appendChild(el('strong', null, k.naam));
510 // De toelichting is het hele punt: er zijn drie bands die Nirvana heten,
511 // en zonder dit veld kiest iemand de verkeerde.
512 const bij = [k.toelichting, k.soort, k.land, k.jaren].filter(Boolean).join(' · ');
513 if (bij) li.appendChild(el('small', 'mb-bij', bij));
514 const open = el('a', 'mb-open', T.aaud_mb_open || 'Bekijk op MusicBrainz');
515 open.href = k.url; open.target = '_blank'; open.rel = 'noopener';
516 li.appendChild(open);
517 li.appendChild(kiesForm(k));
518 lijst.appendChild(li);
519 }
520 uit.replaceChildren(lijst);
521 }
522
523 function kiesForm(k) {
524 const f = document.createElement('form');
525 f.method = 'POST';
526 f.action = 'musicbrainz/link';
527 for (const [naam, waarde] of [['_csrf', csrf()], ['mbid', k.mbid], ['naam', k.naam]]) {
528 const i = document.createElement('input');
529 i.type = 'hidden'; i.name = naam; i.value = waarde || '';
530 f.appendChild(i);
531 }
532 const b = el('button', 'ax-btn', T.aaud_mb_pick || 'Dit ben ik');
533 b.type = 'submit';
534 f.appendChild(b);
535 return f;
536 }
537
538 // Het token staat al in elk formulier op deze pagina; er is er geen apart voor.
539 const csrf = () => (document.querySelector('input[name="_csrf"]') || {}).value || '';
540
541 knop.addEventListener('click', zoek);
542 veld.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); zoek(); } });
543}
Note: See TracBrowser for help on using the repository browser.