Ignore:
File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/views/pages/admin-audio.ejs

    r6ee289a r2ab99c0  
    573573<%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    574574
    575 <%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
    576 <%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %>
     575<script>
     576(function() {
     577
     578  // ── Live filename display for custom file inputs ──────────────
     579  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
     580    const nameEl = input.parentElement.querySelector('.ax-file-name');
     581    if (!nameEl) return;
     582    input.addEventListener('change', () => {
     583      if (input.files && input.files[0]) {
     584        nameEl.textContent = input.files[0].name;
     585        nameEl.removeAttribute('data-empty');
     586      } else {
     587        nameEl.textContent = '<%= t('aaud.no_file') %>';
     588        nameEl.setAttribute('data-empty', '');
     589      }
     590    });
     591  });
     592
     593  // ── Inline track preview (routed through the global mini-player) ──
     594  // Each .ax-track-play button is a thin wrapper around the global
     595  // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
     596  // .is-playing) is synced from the player's own audio element so it
     597  // stays accurate even when the user uses the mini-player's controls.
     598  (function setupTrackPreview() {
     599    const buttons = document.querySelectorAll('.ax-track-play');
     600    if (!buttons.length) return;
     601
     602    function setIcon(btn, playing) {
     603      const icon = btn.querySelector('.ax-track-play-icon');
     604      if (icon) icon.textContent = playing ? '⏸' : '▶';
     605      btn.classList.toggle('is-playing', playing);
     606      btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>');
     607    }
     608
     609    // Resync ALL preview buttons against the current audio element state.
     610    // Called on every play/pause/ended event so admins always see the right
     611    // icon — including the case where they hit pause on the mini-player
     612    // bar instead of the row's button.
     613    function resyncAll() {
     614      const audio = document.getElementById('audio-element');
     615      const player = window.pcmsAudioPlayer;
     616      const playing = audio && !audio.paused && !audio.ended;
     617      // audio.src is now a blob: URL (Spotify-style playback), so compare
     618      // against the player's logical track URL, not the element src.
     619      const cur = player && player.currentTrack();
     620      const curUrl = cur ? cur.url : '';
     621      buttons.forEach(b => {
     622        const isThisOne = playing && curUrl === b.dataset.streamUrl;
     623        setIcon(b, isThisOne);
     624      });
     625    }
     626
     627    buttons.forEach(btn => {
     628      btn.addEventListener('click', e => {
     629        e.preventDefault();
     630        const player = window.pcmsAudioPlayer;
     631        if (!player) {
     632          console.warn('[admin-audio] miniplayer not available');
     633          return;
     634        }
     635        const url = btn.dataset.streamUrl;
     636        if (!url) return;
     637
     638        // Build track metadata from the row's DOM so the mini-player shows
     639        // useful info (title/artist/album/cover) without an extra API call.
     640        const row = btn.closest('.ax-track');
     641        const titleEl  = row && row.querySelector('[data-cell="title"]');
     642        const artistEl = row && row.querySelector('[data-cell="artist"]');
     643        const albumEl  = row && row.querySelector('[data-cell="album"]');
     644        const coverImg = row && row.querySelector('img[data-cover-thumb]');
     645        const track = {
     646          url,
     647          title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
     648          artist: artistEl ? artistEl.textContent.trim() : '',
     649          album:  albumEl  ? albumEl.textContent.trim()  : '',
     650          cover:  coverImg ? coverImg.src                : '',
     651        };
     652
     653        // If this exact track is already current, toggle pause/play instead
     654        // of restarting from zero. Compare logical URLs (audio.src is a blob:).
     655        const cur = player.currentTrack();
     656        if (cur && cur.url === url) {
     657          if (player.isPlaying()) player.pause();
     658          else                    player.play();
     659          return;
     660        }
     661
     662        player.setQueue([track], 0);
     663      });
     664    });
     665
     666    // Hook the global audio element's events to keep the row buttons synced.
     667    // We attach lazily after the player has built its DOM. The script in
     668    // shell.ejs runs at page-load so #audio-element exists by the time
     669    // this IIFE fires (script tag is below the body content).
     670    const audio = document.getElementById('audio-element');
     671    if (audio) {
     672      ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
     673        audio.addEventListener(ev, resyncAll);
     674      });
     675      // Initial state on page load (e.g. user navigated back while a track
     676      // was already playing — buttons should reflect that).
     677      resyncAll();
     678    }
     679  })();
     680
     681
     682  // ── Bulk upload (drag-drop + sequential transcoding) ─────────
     683  // Files dropped or picked are queued (not uploaded immediately) so the
     684  // user can review the list, set shared metadata, then hit "Start upload".
     685  // The loop POSTs one file at a time to /admin/audio/upload with
     686  // Accept: application/json so the server returns structured per-file
     687  // results instead of redirecting.
     688  const dropzone   = document.getElementById('audio-dropzone');
     689  const fileInput  = document.getElementById('audio-files');
     690  const queueEl    = document.getElementById('upload-queue');
     691  const actionsEl  = document.getElementById('upload-actions');
     692  const startBtn   = document.getElementById('start-upload-btn');
     693  const clearBtn   = document.getElementById('clear-queue-btn');
     694  const artistInput= document.getElementById('batch-artist');
     695  const albumInput = document.getElementById('batch-album');
     696  const coverInput = document.getElementById('batch-cover');
     697
     698  /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
     699  const queue = [];
     700
     701  function fmtBytes(n) {
     702    if (n < 1024) return n + ' B';
     703    if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
     704    return (n / 1024 / 1024).toFixed(1) + ' MB';
     705  }
     706
     707  function setItemStatus(item, status, label) {
     708    const labels = {
     709      queued:      '<%= t('aaud.st_queued') %>',
     710      uploading:   '<%= t('aaud.st_uploading') %>',
     711      transcoding: '<%= t('aaud.st_transcoding') %>',
     712      done:        '✓ <%= t('aaud.st_done') %>',
     713      error:       '✗ <%= t('aaud.st_error') %>',
     714    };
     715    item.status = status;
     716    const badge = item.el.querySelector('.ax-queue-status');
     717    badge.className = 'ax-queue-status ax-queue-status--' + status;
     718    badge.textContent = label || labels[status] || status;
     719    // Restyle the row
     720    item.el.classList.remove(
     721      'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
     722    );
     723    if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
     724    else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
     725    else if (status === 'error') item.el.classList.add('ax-queue-item--error');
     726  }
     727
     728  function addFiles(files) {
     729    let added = 0;
     730    for (const file of files) {
     731      if (!file.type.startsWith('audio/') &&
     732          !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
     733        // Silently skip non-audio drops; keeps the UX uncluttered.
     734        continue;
     735      }
     736      const li = document.createElement('li');
     737      li.className = 'ax-queue-item';
     738      li.innerHTML =
     739        '<div class="ax-queue-name"></div>' +
     740        '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>';
     741      // Use textContent to avoid HTML-injection if a filename contains markup.
     742      li.querySelector('.ax-queue-name').textContent = file.name;
     743      // Append size hint inline
     744      const size = document.createElement('span');
     745      size.className = 'ax-queue-size';
     746      size.textContent = fmtBytes(file.size);
     747      li.querySelector('.ax-queue-name').appendChild(size);
     748      queueEl.appendChild(li);
     749      queue.push({ file, el: li, status: 'queued' });
     750      added++;
     751    }
     752    if (added) {
     753      queueEl.hidden = false;
     754      actionsEl.hidden = false;
     755    }
     756  }
     757
     758  // ── Drop-zone events ─────────────────────────────────────────
     759  // Page-level guard: a dropped file outside the zone would otherwise
     760  // make the browser navigate to it (e.g. opening the audio inline), which
     761  // discards typed metadata. We swallow drops anywhere unless the dropzone
     762  // explicitly handles them.
     763  ['dragover', 'drop'].forEach(ev => {
     764    window.addEventListener(ev, e => {
     765      // Allow drops INSIDE the dropzone — its own listener handles those.
     766      if (dropzone.contains(e.target)) return;
     767      e.preventDefault();
     768    });
     769  });
     770
     771  ['dragenter', 'dragover'].forEach(ev => {
     772    dropzone.addEventListener(ev, e => {
     773      e.preventDefault();
     774      dropzone.classList.add('is-dragover');
     775    });
     776  });
     777  ['dragleave', 'drop'].forEach(ev => {
     778    dropzone.addEventListener(ev, e => {
     779      e.preventDefault();
     780      dropzone.classList.remove('is-dragover');
     781    });
     782  });
     783  dropzone.addEventListener('drop', e => {
     784    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
     785  });
     786  fileInput.addEventListener('change', () => {
     787    addFiles(fileInput.files);
     788    // Reset so the same file can be picked again later if user wants
     789    fileInput.value = '';
     790  });
     791
     792  // ── Clear queue button ───────────────────────────────────────
     793  clearBtn.addEventListener('click', () => {
     794    // Only remove items that aren't currently uploading (anything queued
     795    // or already finished). An in-progress upload finishes, then its row
     796    // would also disappear once we re-render — but we keep it simple and
     797    // just refuse to clear during an active run.
     798    if (startBtn.disabled) return;
     799    queue.length = 0;
     800    queueEl.innerHTML = '';
     801    queueEl.hidden = true;
     802    actionsEl.hidden = true;
     803  });
     804
     805  // ── Sequential upload loop ───────────────────────────────────
     806  startBtn.addEventListener('click', async () => {
     807    if (startBtn.disabled) return;
     808    startBtn.disabled = true;
     809    clearBtn.disabled = true;
     810    dropzone.style.pointerEvents = 'none';
     811    dropzone.style.opacity = '0.5';
     812
     813    const sharedArtist = artistInput.value.trim();
     814    const sharedAlbum  = albumInput.value.trim();
     815    const sharedCover  = coverInput.files && coverInput.files[0];
     816
     817    // Process queued items one at a time. We iterate via index so that
     818    // if more files get dropped during the run they ALSO get processed
     819    // (queue.push above mutates the same array we're iterating).
     820    for (let i = 0; i < queue.length; i++) {
     821      const item = queue[i];
     822      if (item.status !== 'queued') continue;
     823      try {
     824        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
     825      } catch (err) {
     826        console.error('upload failed for', item.file.name, err);
     827        setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>'));
     828      }
     829    }
     830
     831    startBtn.disabled = false;
     832    clearBtn.disabled = false;
     833    dropzone.style.pointerEvents = '';
     834    dropzone.style.opacity = '';
     835
     836    // Reload the page so the new tracks appear in the list below.
     837    // Could also fetch them and inject, but a full reload is simpler and
     838    // ensures position indexes / album-grouping are correct.
     839    const anyDone = queue.some(q => q.status === 'done');
     840    if (anyDone) {
     841      setTimeout(() => location.reload(), 700);
     842    }
     843  });
     844
     845  async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
     846    setItemStatus(item, 'uploading');
     847
     848    const fd = new FormData();
     849    fd.append('audio', item.file);
     850    if (sharedArtist) fd.append('artist', sharedArtist);
     851    if (sharedAlbum)  fd.append('album',  sharedAlbum);
     852    if (sharedCover)  fd.append('cover',  sharedCover);
     853    // Title is intentionally omitted — server uses filename fallback.
     854
     855    // We can't reliably distinguish "still uploading bytes" from
     856    // "uploading done, ffmpeg running" without progress events, but the
     857    // status flips to "Converteren…" once the request is past upload phase.
     858    // We approximate this by waiting until the response arrives — by then
     859    // both phases are complete on the server side. For a smoother feel we
     860    // briefly show "transcoding" near the end of the request lifecycle.
     861    const transcodeHint = setTimeout(() => {
     862      if (item.status === 'uploading') setItemStatus(item, 'transcoding');
     863    }, 1500);
     864
     865    try {
     866      const res = await fetch('/admin/audio/upload', {
     867        method: 'POST',
     868        headers: { 'Accept': 'application/json' },
     869        body: fd,
     870        credentials: 'same-origin',
     871      });
     872      clearTimeout(transcodeHint);
     873
     874      // Server responds with JSON for our Accept header. If it didn't
     875      // (e.g. session expired and got an HTML login page), surface that.
     876      let data;
     877      try { data = await res.json(); }
     878      catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); }
     879
     880      if (!res.ok || !data.ok) {
     881        throw new Error(data.error || ('HTTP ' + res.status));
     882      }
     883
     884      setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>'));
     885    } catch (err) {
     886      clearTimeout(transcodeHint);
     887      throw err;
     888    }
     889  }
     890
     891  // ── Click-to-copy embed codes ─────────────────────────────────
     892  document.querySelectorAll('[data-copy]').forEach(el => {
     893    el.addEventListener('click', async () => {
     894      const text = el.dataset.copy;
     895      try {
     896        await navigator.clipboard.writeText(text);
     897        el.classList.add('is-copied');
     898        const original = el.textContent;
     899        el.textContent = '✓ <%= t('aaud.copied') %>';
     900        setTimeout(() => {
     901          el.classList.remove('is-copied');
     902          el.textContent = original;
     903        }, 1200);
     904      } catch (_) { /* fall through — selection still works */ }
     905    });
     906  });
     907
     908  // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
     909  const addLinkBtn = document.getElementById('add-link-track-btn');
     910  if (addLinkBtn) {
     911    addLinkBtn.addEventListener('click', async () => {
     912      addLinkBtn.disabled = true;
     913      try {
     914        const r = await fetch('/admin/audio/create-link', {
     915          method: 'POST', credentials: 'same-origin',
     916          headers: { 'Content-Type': 'application/json' },
     917          body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }),
     918        });
     919        const j = await r.json();
     920        if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>');
     921        if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
     922        window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
     923      } catch (err) {
     924        alert('<%= t('aaud.create_failed') %>: ' + err.message);
     925      } finally {
     926        addLinkBtn.disabled = false;
     927      }
     928    });
     929  }
     930
     931  // ── Wire all "Edit" buttons to the track-editor modal ─────────
     932  // After save we patch the row in-place rather than reloading,
     933  // so the user keeps their scroll position on long lists.
     934  document.querySelectorAll('[data-track-edit]').forEach(btn => {
     935    btn.addEventListener('click', () => {
     936      if (typeof window.openTrackEditor !== 'function') {
     937        alert('<%= t('aaud.editor_not_loaded') %>');
     938        return;
     939      }
     940      const id = btn.dataset.id;
     941      window.openTrackEditor({
     942        id,
     943        onSaved: (track) => {
     944          const row = document.querySelector('li[data-track-id="' + id + '"]');
     945          if (!row) return;
     946          // Update visible cells
     947          const titleEl  = row.querySelector('[data-cell="title"]');
     948          const artistEl = row.querySelector('[data-cell="artist"]');
     949          const albumEl  = row.querySelector('[data-cell="album"]');
     950          if (titleEl)  titleEl.textContent  = track.title  || '<%= t('aaud.untitled') %>';
     951          if (artistEl) artistEl.textContent = track.artist || '—';
     952          if (albumEl)  {
     953            albumEl.textContent = track.album || '';
     954            if (track.album) albumEl.removeAttribute('hidden');
     955            else albumEl.setAttribute('hidden', '');
     956          }
     957          // Update cover thumb (replace element if type changed)
     958          const oldThumb = row.querySelector('[data-cover-thumb]');
     959          if (oldThumb) {
     960            const parent = oldThumb.parentElement;
     961            if (track.cover_url) {
     962              const img = document.createElement('img');
     963              img.className = 'ax-track-cover';
     964              img.src = track.cover_url;
     965              img.alt = '';
     966              img.dataset.coverThumb = '';
     967              parent.replaceChild(img, oldThumb);
     968            } else {
     969              const sp = document.createElement('span');
     970              sp.className = 'ax-track-cover ax-track-cover-empty';
     971              sp.textContent = '♫';
     972              sp.dataset.coverThumb = '';
     973              parent.replaceChild(sp, oldThumb);
     974            }
     975          }
     976        },
     977      });
     978    });
     979  });
     980
     981  // Download-voor-email toggle — AJAX (geen pagina-reload meer).
     982  document.querySelectorAll('[data-track-dl]').forEach(btn => {
     983    btn.addEventListener('click', async () => {
     984      if (btn.disabled) return;
     985      const want = btn.dataset.on === '1' ? 0 : 1;
     986      btn.disabled = true;
     987      try {
     988        const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
     989          method: 'POST',
     990          headers: { 'Content-Type': 'application/json' },
     991          body: JSON.stringify({ downloadable: !!want }),
     992        });
     993        if (!res.ok) throw new Error('HTTP ' + res.status);
     994        btn.dataset.on = String(want);
     995        btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
     996        btn.style.opacity = want ? '1' : '.5';
     997        btn.title = want
     998          ? '<%= t('aaud.dl_on') %>'
     999          : '<%= t('aaud.dl_off') %>';
     1000      } catch (e) {
     1001        alert('<%= t('aaud.change_failed') %>: ' + (e.message || e));
     1002      } finally {
     1003        btn.disabled = false;
     1004      }
     1005    });
     1006  });
     1007})();
     1008</script>
Note: See TracChangeset for help on using the changeset viewer.