Index: src/assets/js/embed-player.js
===================================================================
--- src/assets/js/embed-player.js	(revision d97c58c1d15ec3570a575b49ec2da4ca41691ed1)
+++ src/assets/js/embed-player.js	(revision 995b1006d95895db0cadf87fe794b03472df628d)
@@ -113,6 +113,6 @@
   function fallbackIframe(provider, ref, url) {
     if (provider === 'youtube') {
-      const id = ytId(ref, url);
-      return { src: 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + '?autoplay=1&rel=0', ratio: true, fs: true };
+      const src = ytEmbedSrc(ref, url, 'autoplay=1&rel=0');
+      if (src) return { src, ratio: true, fs: true };
     }
     if (provider === 'soundcloud') {
@@ -134,9 +134,39 @@
     return m + ':' + (s < 10 ? '0' : '') + s;
   }
+  // Three ref shapes, the same ones AudioEmbedService.detectProvider produces
+  // and the same ones the Klonkt hub uses, so a ref travels between them
+  // unchanged:  "<video>" | "<video>?list=<L>" | "list:<L>"
+  //
+  // ytId answers only "which VIDEO", because that is what a poster thumbnail
+  // needs; for a bare playlist there is no video and it returns null.
   function ytId(ref, url) {
-    if (ref && /^[A-Za-z0-9_-]{11}$/.test(ref)) return ref;
+    const r = String(ref || '');
+    if (r.indexOf('list:') === 0) return null;              // a playlist has no single video
+    const bare = r.split('?list=')[0];
+    if (/^[A-Za-z0-9_-]{11}$/.test(bare)) return bare;
     const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
     if (m) return m[1];
     return null;  // no blind slice — an invalid ref returns nothing rather than a broken id
+  }
+  /** The playlist id of a ref (or of the URL it came from), else null. */
+  function ytList(ref, url) {
+    const r = String(ref || '');
+    if (r.indexOf('list:') === 0) return r.slice(5) || null;
+    const i = r.indexOf('?list=');
+    if (i > 0) return r.slice(i + 6) || null;
+    const m = (url || '').match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/);
+    return m ? m[1] : null;
+  }
+  /** The /embed/ URL for a ref. A bare playlist embeds as `videoseries`. */
+  function ytEmbedSrc(ref, url, query) {
+    const base = 'https://www.youtube-nocookie.com/embed/';
+    const id = ytId(ref, url);
+    const list = ytList(ref, url);
+    let src;
+    if (!id && list) src = base + 'videoseries?list=' + encodeURIComponent(list);
+    else if (id && list) src = base + encodeURIComponent(id) + '?list=' + encodeURIComponent(list);
+    else if (id) src = base + encodeURIComponent(id);
+    else return null;
+    return src + (src.indexOf('?') > 0 ? '&' : '?') + (query || '');
   }
   // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs).
@@ -166,7 +196,7 @@
     let html = '';
     if (provider === 'youtube') {
-      const id = ytId(ref, url);
-      html = id
-        ? '<div class="pcms-embed-ratio"><iframe src="https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + '?rel=0" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>'
+      const src = ytEmbedSrc(ref, url, 'rel=0');
+      html = src
+        ? '<div class="pcms-embed-ratio"><iframe src="' + escAttr(src) + '" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>'
         : '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">YouTube</a>';
     } else if (provider === 'soundcloud') {
@@ -215,6 +245,8 @@
     const isVideo = provider === 'youtube';
     const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
-    const poster = provider === 'youtube'
-      ? `https://i.ytimg.com/vi/${ytId(ref, url)}/hqdefault.jpg` : '';
+    // A bare playlist has no video id, and interpolating null gave
+    // i.ytimg.com/vi/null/hqdefault.jpg — a 404 painted as the poster.
+    const posterId = provider === 'youtube' ? ytId(ref, url) : null;
+    const poster = posterId ? `https://i.ytimg.com/vi/${posterId}/hqdefault.jpg` : '';
 
     el.innerHTML = ''
@@ -388,12 +420,19 @@
       const YT = await ytApi();
       const id = ytId(ref, url);
+      const list = ytList(ref, url);
       return new Promise((resolve, reject) => {
         let pollTimer = null;
+        let endTimer = null;       // see onStateChange: a list "ends" between items
         const player = new YT.Player(mountEl, {
-          videoId: id,
+          // With a video AND a list, `list` makes the player continue into the
+          // playlist after this song. Without a video it is the playlist
+          // itself, and then listType says so -- videoId must stay absent, or
+          // the API loads that one video and forgets the list.
+          ...(id ? { videoId: id } : {}),
           host: 'https://www.youtube-nocookie.com',
           playerVars: {
             controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0,
             disablekb: 1, iv_load_policy: 3, origin: window.location.origin,
+            ...(list ? (id ? { list } : { list, listType: 'playlist' }) : {}),
           },
           events: {
@@ -408,4 +447,5 @@
                 destroy() {
                   if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+                  if (endTimer) { clearTimeout(endTimer); endTimer = null; }
                   try { player.destroy(); } catch (e) {}
                 },
@@ -415,4 +455,7 @@
               // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued
               if (e.data === 1) {
+                // A next playlist item started, so the pending "it is over" was
+                // false alarm.
+                if (endTimer) { clearTimeout(endTimer); endTimer = null; }
                 hooks.onPlay();
                 if (!pollTimer) pollTimer = setInterval(() => {
@@ -423,6 +466,15 @@
                 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
               } else if (e.data === 0) {
-                hooks.onEnded();
                 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+                // INSIDE a playlist, YouTube reports "ended" between every two
+                // songs as well. Firing onEnded there hands the queue on after
+                // song one and cuts the album off. So on a list we wait, and
+                // only a silence that is not interrupted by the next song
+                // counts as the end. Same rule as the hub (2.5s).
+                if (list) {
+                  if (!endTimer) endTimer = setTimeout(() => { endTimer = null; hooks.onEnded(); }, 2500);
+                } else {
+                  hooks.onEnded();
+                }
               }
             },
Index: src/services/AudioEmbedService.js
===================================================================
--- src/services/AudioEmbedService.js	(revision d97c58c1d15ec3570a575b49ec2da4ca41691ed1)
+++ src/services/AudioEmbedService.js	(revision 995b1006d95895db0cadf87fe794b03472df628d)
@@ -68,9 +68,35 @@
     }
 
-    // YouTube — video id is always exactly 11 characters (aligns with the client-side
-    // ytId() in embed-player.js, which also expects {11}).
-    if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) {
-      const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i);
-      return { provider: 'youtube', id: match[1], url };
+    // YouTube — a video id is always exactly 11 characters (aligns with the
+    // client-side ytId() in embed-player.js, which also expects {11}).
+    //
+    // A link may carry a video, a playlist, or both, and until now we kept only
+    // the video and threw `list=` away -- so a link to an album played its first
+    // song and stopped. The ref now keeps whichever is there, in the same three
+    // shapes the Klonkt hub uses, so one ref travels between the two unchanged:
+    //
+    //   "<video>"           one video
+    //   "<video>?list=<L>"  that video, and on through the list
+    //   "list:<L>"          the whole playlist (YouTube's `videoseries`)
+    //
+    // `list` may sit before or after `v=` and is often entity-encoded (&amp;)
+    // in a baked href, hence the scan over the whole URL rather than a fixed
+    // order. A list id is 10-60 chars: longer and looser than a video id.
+    if (/(?:youtube(?:-nocookie)?\.com\/(?:watch\?|playlist\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i.test(url)) {
+      const vm = url.match(/(?:[?&](?:amp;)?v=|youtu\.be\/|\/embed\/|\/shorts\/|\/live\/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])/i);
+      const lm = url.match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/i);
+      // `videoseries` is a marker, not a video: a bare playlist embed URL reads
+      // /embed/videoseries?list=..., and taking that for an id gives a dead
+      // frame. It is EXACTLY eleven characters, so no length rule catches it --
+      // it has to be named. (Measured, not assumed: it slipped through a
+      // boundary check that looked like it covered this.)
+      const id = vm && vm[1] !== 'videoseries' ? vm[1] : null;
+      const list = lm ? lm[1] : null;
+      if (id || list) {
+        const ref = id ? (list ? `${id}?list=${list}` : id) : `list:${list}`;
+        // `id` stays exactly what it was for every caller that only wants a
+        // video; `list` and `ref` are additions.
+        return { provider: 'youtube', id, list, ref, url };
+      }
     }
 
@@ -122,6 +148,9 @@
       // iframe, so the embed appears in OUR brand style.
       case 'youtube':
-        return this.embedPlaceholder('youtube', config.id, 'video',
-          config.url || `https://youtu.be/${config.id}`);
+        // The ref carries the list when there is one; `id` alone would drop it
+        // and play a single song out of an album.
+        return this.embedPlaceholder('youtube', config.ref || config.id, 'video',
+          config.url || (config.id ? `https://youtu.be/${config.id}`
+                                   : `https://www.youtube.com/playlist?list=${config.list}`));
       case 'soundcloud':
         return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
@@ -224,6 +253,23 @@
   }
 
-  static youtubeIframe({ id }) {
-    const src = `https://www.youtube-nocookie.com/embed/${id}`;
+  /**
+   * The plain provider iframe. Takes the same ref shapes as the placeholder:
+   * "<video>", "<video>?list=<L>" and "list:<L>" -- a bare playlist embeds as
+   * `videoseries`. Kept in step with the placeholder path on purpose: this is
+   * the fallback, and a fallback that silently drops the playlist is the worst
+   * kind, because it looks like it worked.
+   */
+  static youtubeIframe({ id, ref }) {
+    const r = ref || id || '';
+    const base = 'https://www.youtube-nocookie.com/embed/';
+    let src;
+    if (r.startsWith('list:')) {
+      src = `${base}videoseries?list=${encodeURIComponent(r.slice(5))}`;
+    } else if (r.includes('?list=')) {
+      const [v, l] = r.split('?list=');
+      src = `${base}${encodeURIComponent(v)}?list=${encodeURIComponent(l)}`;
+    } else {
+      src = base + encodeURIComponent(r);
+    }
     return `
       <figure class="folio-embed folio-embed--youtube">
Index: test/youtube-playlist.test.js
===================================================================
--- test/youtube-playlist.test.js	(revision 995b1006d95895db0cadf87fe794b03472df628d)
+++ test/youtube-playlist.test.js	(revision 995b1006d95895db0cadf87fe794b03472df628d)
@@ -0,0 +1,79 @@
+// YouTube-playlists in een post (Bart, 17-8): dezelfde parsing als de Klonkt
+// hub, zodat een link naar een album ook als album speelt.
+//
+// Tot nu toe hield detectProvider alleen de video-id vast en gooide `list=`
+// weg, en een kale `youtube.com/playlist?list=…` herkende hij niet als YouTube.
+// Een link naar een album speelde daarmee het eerste nummer en stopte.
+//
+// De ref kent drie vormen, gelijk aan die van de hub, zodat één ref tussen de
+// twee heen en weer kan zonder vertaling:
+//   "<video>" | "<video>?list=<L>" | "list:<L>"
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const { default: AudioEmbedService } = await import('../src/services/AudioEmbedService.js');
+const det = (u) => AudioEmbedService.detectProvider(u);
+
+test('een gewone video blijft precies wat hij was', () => {
+  const r = det('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+  assert.equal(r.provider, 'youtube');
+  assert.equal(r.id, 'dQw4w9WgXcQ');
+  assert.equal(r.list, null);
+  assert.equal(r.ref, 'dQw4w9WgXcQ');
+});
+
+test('een video BINNEN een playlist houdt allebei', () => {
+  for (const u of [
+    'https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc123def456',
+    'https://www.youtube.com/watch?list=PLabc123def456&v=dQw4w9WgXcQ',   // andere volgorde
+    'https://www.youtube.com/watch?v=dQw4w9WgXcQ&amp;list=PLabc123def456', // uit een gebakken href
+    'https://youtu.be/dQw4w9WgXcQ?list=PLabc123def456',
+  ]) {
+    const r = det(u);
+    assert.equal(r.ref, 'dQw4w9WgXcQ?list=PLabc123def456', u);
+    assert.equal(r.id, 'dQw4w9WgXcQ', u);
+    assert.equal(r.list, 'PLabc123def456', u);
+  }
+});
+
+test('een kale playlist wordt herkend, en heeft geen video', () => {
+  const r = det('https://www.youtube.com/playlist?list=PLabc123def456');
+  assert.equal(r.provider, 'youtube');
+  assert.equal(r.id, null);
+  assert.equal(r.ref, 'list:PLabc123def456');
+});
+
+// Dit is de val: `videoseries` is EXACT elf tekens, net als een video-id. Geen
+// enkele lengteregel vangt hem; hij moet bij naam genoemd worden. Ik liep er bij
+// het bouwen zelf in, met een grenscontrole die eroverheen leek te gaan.
+test('videoseries is een markering, geen video-id', () => {
+  for (const u of [
+    'https://www.youtube.com/embed/videoseries?list=PLabc123def456',
+    'https://www.youtube-nocookie.com/embed/videoseries?list=PLabc123def456',  // wat een embed zelf uitzendt
+  ]) {
+    const r = det(u);
+    assert.ok(r, u);
+    assert.equal(r.id, null, 'videoseries mag nooit als video-id doorgaan: ' + u);
+    assert.equal(r.ref, 'list:PLabc123def456', u);
+  }
+});
+
+test('een link zonder video en zonder lijst levert niets op', () => {
+  assert.equal(det('https://www.youtube.com/watch?x=1'), null);
+  assert.equal(det('javascript:alert(1)//youtu.be/dQw4w9WgXcQ'), null, 'het schema-slot blijft dicht');
+});
+
+test('de placeholder draagt de ref, niet alleen de video', () => {
+  const html = AudioEmbedService.generateIframe('youtube', det('https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc123def456'));
+  assert.match(html, /data-embed-ref="dQw4w9WgXcQ\?list=PLabc123def456"/);
+  const kaal = AudioEmbedService.generateIframe('youtube', det('https://www.youtube.com/playlist?list=PLabc123def456'));
+  assert.match(kaal, /data-embed-ref="list:PLabc123def456"/);
+});
+
+test('de iframe-terugval laat de playlist niet stilletjes vallen', () => {
+  assert.match(AudioEmbedService.youtubeIframe({ ref: 'list:PLabc123def456' }),
+    /embed\/videoseries\?list=PLabc123def456/);
+  assert.match(AudioEmbedService.youtubeIframe({ ref: 'dQw4w9WgXcQ?list=PLabc123def456' }),
+    /embed\/dQw4w9WgXcQ\?list=PLabc123def456/);
+  assert.match(AudioEmbedService.youtubeIframe({ id: 'dQw4w9WgXcQ' }), /embed\/dQw4w9WgXcQ"/);
+});
