| 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 |
|
|---|
| 7 | import { 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).
|
|---|
| 11 | const doc = makeSweeper();
|
|---|
| 12 | let T = {};
|
|---|
| 13 |
|
|---|
| 14 | export function init() {
|
|---|
| 15 | doc.sweep();
|
|---|
| 16 | T = pageData();
|
|---|
| 17 | run();
|
|---|
| 18 | }
|
|---|
| 19 |
|
|---|
| 20 | function 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 | }
|
|---|