- Timestamp:
- 08/17/2026 07:32:49 AM (3 weeks ago)
- Branches:
- main
- Children:
- e854ace
- Parents:
- d97c58c
- git-author:
- Robin <roboburr@…> (08/17/2026 07:30:41 AM)
- git-committer:
- Robin <roboburr@…> (08/17/2026 07:32:49 AM)
- Location:
- src
- Files:
-
- 2 edited
-
assets/js/embed-player.js (modified) (8 diffs)
-
services/AudioEmbedService.js (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/js/embed-player.js
rd97c58c r995b100 113 113 function fallbackIframe(provider, ref, url) { 114 114 if (provider === 'youtube') { 115 const id = ytId(ref, url);116 return { src: 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + '?autoplay=1&rel=0', ratio: true, fs: true };115 const src = ytEmbedSrc(ref, url, 'autoplay=1&rel=0'); 116 if (src) return { src, ratio: true, fs: true }; 117 117 } 118 118 if (provider === 'soundcloud') { … … 134 134 return m + ':' + (s < 10 ? '0' : '') + s; 135 135 } 136 // Three ref shapes, the same ones AudioEmbedService.detectProvider produces 137 // and the same ones the Klonkt hub uses, so a ref travels between them 138 // unchanged: "<video>" | "<video>?list=<L>" | "list:<L>" 139 // 140 // ytId answers only "which VIDEO", because that is what a poster thumbnail 141 // needs; for a bare playlist there is no video and it returns null. 136 142 function ytId(ref, url) { 137 if (ref && /^[A-Za-z0-9_-]{11}$/.test(ref)) return ref; 143 const r = String(ref || ''); 144 if (r.indexOf('list:') === 0) return null; // a playlist has no single video 145 const bare = r.split('?list=')[0]; 146 if (/^[A-Za-z0-9_-]{11}$/.test(bare)) return bare; 138 147 const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/); 139 148 if (m) return m[1]; 140 149 return null; // no blind slice — an invalid ref returns nothing rather than a broken id 150 } 151 /** The playlist id of a ref (or of the URL it came from), else null. */ 152 function ytList(ref, url) { 153 const r = String(ref || ''); 154 if (r.indexOf('list:') === 0) return r.slice(5) || null; 155 const i = r.indexOf('?list='); 156 if (i > 0) return r.slice(i + 6) || null; 157 const m = (url || '').match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/); 158 return m ? m[1] : null; 159 } 160 /** The /embed/ URL for a ref. A bare playlist embeds as `videoseries`. */ 161 function ytEmbedSrc(ref, url, query) { 162 const base = 'https://www.youtube-nocookie.com/embed/'; 163 const id = ytId(ref, url); 164 const list = ytList(ref, url); 165 let src; 166 if (!id && list) src = base + 'videoseries?list=' + encodeURIComponent(list); 167 else if (id && list) src = base + encodeURIComponent(id) + '?list=' + encodeURIComponent(list); 168 else if (id) src = base + encodeURIComponent(id); 169 else return null; 170 return src + (src.indexOf('?') > 0 ? '&' : '?') + (query || ''); 141 171 } 142 172 // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs). … … 166 196 let html = ''; 167 197 if (provider === 'youtube') { 168 const id = ytId(ref, url);169 html = id170 ? '<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>'198 const src = ytEmbedSrc(ref, url, 'rel=0'); 199 html = src 200 ? '<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>' 171 201 : '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">YouTube</a>'; 172 202 } else if (provider === 'soundcloud') { … … 215 245 const isVideo = provider === 'youtube'; 216 246 const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls 217 const poster = provider === 'youtube' 218 ? `https://i.ytimg.com/vi/${ytId(ref, url)}/hqdefault.jpg` : ''; 247 // A bare playlist has no video id, and interpolating null gave 248 // i.ytimg.com/vi/null/hqdefault.jpg — a 404 painted as the poster. 249 const posterId = provider === 'youtube' ? ytId(ref, url) : null; 250 const poster = posterId ? `https://i.ytimg.com/vi/${posterId}/hqdefault.jpg` : ''; 219 251 220 252 el.innerHTML = '' … … 388 420 const YT = await ytApi(); 389 421 const id = ytId(ref, url); 422 const list = ytList(ref, url); 390 423 return new Promise((resolve, reject) => { 391 424 let pollTimer = null; 425 let endTimer = null; // see onStateChange: a list "ends" between items 392 426 const player = new YT.Player(mountEl, { 393 videoId: id, 427 // With a video AND a list, `list` makes the player continue into the 428 // playlist after this song. Without a video it is the playlist 429 // itself, and then listType says so -- videoId must stay absent, or 430 // the API loads that one video and forgets the list. 431 ...(id ? { videoId: id } : {}), 394 432 host: 'https://www.youtube-nocookie.com', 395 433 playerVars: { 396 434 controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0, 397 435 disablekb: 1, iv_load_policy: 3, origin: window.location.origin, 436 ...(list ? (id ? { list } : { list, listType: 'playlist' }) : {}), 398 437 }, 399 438 events: { … … 408 447 destroy() { 409 448 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } 449 if (endTimer) { clearTimeout(endTimer); endTimer = null; } 410 450 try { player.destroy(); } catch (e) {} 411 451 }, … … 415 455 // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued 416 456 if (e.data === 1) { 457 // A next playlist item started, so the pending "it is over" was 458 // false alarm. 459 if (endTimer) { clearTimeout(endTimer); endTimer = null; } 417 460 hooks.onPlay(); 418 461 if (!pollTimer) pollTimer = setInterval(() => { … … 423 466 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } 424 467 } else if (e.data === 0) { 425 hooks.onEnded();426 468 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } 469 // INSIDE a playlist, YouTube reports "ended" between every two 470 // songs as well. Firing onEnded there hands the queue on after 471 // song one and cuts the album off. So on a list we wait, and 472 // only a silence that is not interrupted by the next song 473 // counts as the end. Same rule as the hub (2.5s). 474 if (list) { 475 if (!endTimer) endTimer = setTimeout(() => { endTimer = null; hooks.onEnded(); }, 2500); 476 } else { 477 hooks.onEnded(); 478 } 427 479 } 428 480 }, -
src/services/AudioEmbedService.js
rd97c58c r995b100 68 68 } 69 69 70 // YouTube — video id is always exactly 11 characters (aligns with the client-side 71 // ytId() in embed-player.js, which also expects {11}). 72 if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) { 73 const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i); 74 return { provider: 'youtube', id: match[1], url }; 70 // YouTube — a video id is always exactly 11 characters (aligns with the 71 // client-side ytId() in embed-player.js, which also expects {11}). 72 // 73 // A link may carry a video, a playlist, or both, and until now we kept only 74 // the video and threw `list=` away -- so a link to an album played its first 75 // song and stopped. The ref now keeps whichever is there, in the same three 76 // shapes the Klonkt hub uses, so one ref travels between the two unchanged: 77 // 78 // "<video>" one video 79 // "<video>?list=<L>" that video, and on through the list 80 // "list:<L>" the whole playlist (YouTube's `videoseries`) 81 // 82 // `list` may sit before or after `v=` and is often entity-encoded (&) 83 // in a baked href, hence the scan over the whole URL rather than a fixed 84 // order. A list id is 10-60 chars: longer and looser than a video id. 85 if (/(?:youtube(?:-nocookie)?\.com\/(?:watch\?|playlist\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i.test(url)) { 86 const vm = url.match(/(?:[?&](?:amp;)?v=|youtu\.be\/|\/embed\/|\/shorts\/|\/live\/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])/i); 87 const lm = url.match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/i); 88 // `videoseries` is a marker, not a video: a bare playlist embed URL reads 89 // /embed/videoseries?list=..., and taking that for an id gives a dead 90 // frame. It is EXACTLY eleven characters, so no length rule catches it -- 91 // it has to be named. (Measured, not assumed: it slipped through a 92 // boundary check that looked like it covered this.) 93 const id = vm && vm[1] !== 'videoseries' ? vm[1] : null; 94 const list = lm ? lm[1] : null; 95 if (id || list) { 96 const ref = id ? (list ? `${id}?list=${list}` : id) : `list:${list}`; 97 // `id` stays exactly what it was for every caller that only wants a 98 // video; `list` and `ref` are additions. 99 return { provider: 'youtube', id, list, ref, url }; 100 } 75 101 } 76 102 … … 122 148 // iframe, so the embed appears in OUR brand style. 123 149 case 'youtube': 124 return this.embedPlaceholder('youtube', config.id, 'video', 125 config.url || `https://youtu.be/${config.id}`); 150 // The ref carries the list when there is one; `id` alone would drop it 151 // and play a single song out of an album. 152 return this.embedPlaceholder('youtube', config.ref || config.id, 'video', 153 config.url || (config.id ? `https://youtu.be/${config.id}` 154 : `https://www.youtube.com/playlist?list=${config.list}`)); 126 155 case 'soundcloud': 127 156 return this.embedPlaceholder('soundcloud', config.url, 'track', config.url); … … 224 253 } 225 254 226 static youtubeIframe({ id }) { 227 const src = `https://www.youtube-nocookie.com/embed/${id}`; 255 /** 256 * The plain provider iframe. Takes the same ref shapes as the placeholder: 257 * "<video>", "<video>?list=<L>" and "list:<L>" -- a bare playlist embeds as 258 * `videoseries`. Kept in step with the placeholder path on purpose: this is 259 * the fallback, and a fallback that silently drops the playlist is the worst 260 * kind, because it looks like it worked. 261 */ 262 static youtubeIframe({ id, ref }) { 263 const r = ref || id || ''; 264 const base = 'https://www.youtube-nocookie.com/embed/'; 265 let src; 266 if (r.startsWith('list:')) { 267 src = `${base}videoseries?list=${encodeURIComponent(r.slice(5))}`; 268 } else if (r.includes('?list=')) { 269 const [v, l] = r.split('?list='); 270 src = `${base}${encodeURIComponent(v)}?list=${encodeURIComponent(l)}`; 271 } else { 272 src = base + encodeURIComponent(r); 273 } 228 274 return ` 229 275 <figure class="folio-embed folio-embed--youtube">
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)