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

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

Het paneel hoort bij wie je bent, niet bij je bestanden (Robins correctie)

Verhuisd van Beheer -> Audio naar het profielscherm van de site-eigenaar, en
daar staat het NAAST de aliassen -- want dat is dezelfde vraag. Een alias zegt
wie je elders in de fediverse bent, een MBID zegt wie je in het muziekregister
bent. Bij de audiobestanden ging het over beheer van spullen; hier gaat het over
identiteit, en daar hoort het thuis.

DE VERHUIZING VERANDERDE DE VORM, en dat is de moeite waard om te weten. Het
profielscherm IS een formulier, en een formulier in een formulier bestaat niet
in HTML. Dus geen eigen opslaan-knop meer: de keuze zet een verborgen veld en
gaat mee met de Opslaan van de pagina, precies zoals de profielfoto. Dat leest
ook beter -- een halve wijziging die je nog kunt herzien voor je opslaat.

Ontkoppelen is nu het veld leegmaken, geen aparte route. Minder oppervlak.

Het zoek-endpoint ging mee naar /admin/sites/:slug/api/musicbrainz. Een endpoint
onder /admin/audio laten staan voor een scherm in /admin/sites is precies zo'n
naad die later niemand meer kan plaatsen. De teksten heten nu asite.mb_* in
plaats van aaud.mb_*, in alle drie de talen, met een placeholder erbij.

Wat NIET veranderde: de zoekopdracht draait server-side (hun regel geldt per
applicatie), wij kiezen niet voor de artiest, en alleen een echte MBID komt de
kolom in.

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

  • Property mode set to 100644
File size: 18.0 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}
Note: See TracBrowser for help on using the repository browser.