From 1420af378c78ed6dae5272b6acbf9c86140660cb Mon Sep 17 00:00:00 2001 From: Kam Date: Wed, 10 Jun 2026 23:47:18 +0300 Subject: [PATCH] fix(docs-infra): use a facade for docs-video to fix Firefox embeds Follow-up to #69205. After switching adev's COEP to `credentialless`, the cross-origin YouTube iframe in `` loads in Chromium and Safari but not Firefox, whose `credentialless` policy does not extend to nested frames. The result was a COEP error screen instead of the player. Render `` as a lightweight thumbnail facade instead of embedding the iframe directly. The thumbnail is a cross-origin subresource, so it loads under `credentialless` in every browser. `DocViewer` then upgrades the facade to the inline player on hydration in browsers that can load the embed (Chromium, Safari), preserving the previous behavior there. On Firefox the facade stays a plain link that opens the video on YouTube (with autoplay), which replaces the error screen. The thumbnail uses `maxresdefault` and falls back to `hqdefault` when a video has no max-resolution image. --- .../docs-viewer/docs-viewer.component.ts | 35 ++++++++++++++- .../shared/marked/extensions/docs-video.mts | 34 +++++++++++---- .../test/docs-video/docs-video.spec.mts | 17 +++++--- adev/shared-docs/styles/docs/_video.scss | 43 +++++++++++++++++++ 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts index b9c22eb174cb..aa8caacbe85a 100644 --- a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts +++ b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts @@ -31,7 +31,7 @@ import {Router} from '@angular/router'; import {fromEvent} from 'rxjs'; import {Snippet} from '../../../interfaces'; import {NavigationState, TOC_SKIP_CONTENT_MARKER} from '../../../services'; -import {handleHrefClickEventWithRouter} from '../../../utils'; +import {handleHrefClickEventWithRouter, isFirefox} from '../../../utils'; import {IconComponent} from '../../icon/icon.component'; import {TableOfContents} from '../../table-of-contents/table-of-contents.component'; @@ -124,6 +124,7 @@ export class DocViewer { // In case when content contains tabs, create tabs component and move // content in a tab into tab panel. this.constructTabs(contentContainer); + this.setupVideoFacades(contentContainer); } // Display Breadcrumb component if the `` element exists @@ -412,4 +413,36 @@ export class DocViewer { tabGroup.parentElement!.replaceChild(tabGroupRef.location.nativeElement, tabGroup); } } + + private setupVideoFacades(element: HTMLElement): void { + const facades = element.querySelectorAll('a.docs-video-facade'); + + for (const facade of Array.from(facades)) { + const src = facade.getAttribute('data-video-src'); + if (!src) { + continue; + } + + if (isFirefox) { + const thumbnail = facade.querySelector('img.docs-video-thumbnail'); + thumbnail?.addEventListener( + 'error', + () => { + thumbnail.src = thumbnail.src.replace('maxresdefault', 'hqdefault'); + }, + {once: true}, + ); + continue; + } + + const iframe = this.document.createElement('iframe'); + iframe.className = 'docs-video'; + iframe.src = src; + iframe.title = facade.getAttribute('data-video-title') ?? 'Video player'; + iframe.setAttribute('allow', 'accelerometer; encrypted-media; gyroscope; picture-in-picture'); + iframe.setAttribute('allowfullscreen', ''); + iframe.setAttribute('credentialless', ''); + facade.replaceWith(iframe); + } + } } diff --git a/adev/shared-docs/pipeline/shared/marked/extensions/docs-video.mts b/adev/shared-docs/pipeline/shared/marked/extensions/docs-video.mts index 610f06123268..0c8a4f0d4cf6 100644 --- a/adev/shared-docs/pipeline/shared/marked/extensions/docs-video.mts +++ b/adev/shared-docs/pipeline/shared/marked/extensions/docs-video.mts @@ -53,17 +53,33 @@ export const docsVideoExtension = { ); } + const videoId = /\/embed\/([^/?#]+)/.exec(token.src)?.[1]; + const watchUrl = videoId ? `https://www.youtube.com/watch?v=${videoId}&autoplay=1` : token.src; + const thumbnail = videoId ? `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg` : ''; + const label = token.title ? `Play video: ${token.title}` : 'Play video'; + return ` `; }, diff --git a/adev/shared-docs/pipeline/shared/marked/test/docs-video/docs-video.spec.mts b/adev/shared-docs/pipeline/shared/marked/test/docs-video/docs-video.spec.mts index af4cb94e879f..35444ce2537e 100644 --- a/adev/shared-docs/pipeline/shared/marked/test/docs-video/docs-video.spec.mts +++ b/adev/shared-docs/pipeline/shared/marked/test/docs-video/docs-video.spec.mts @@ -20,15 +20,20 @@ describe('markdown to html', () => { markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, rendererContext)); }); - it('should create an iframe in a container', () => { + it('should create a video facade in a container', () => { const videoContainerEl = markdownDocument.querySelector('.docs-video-container')!; - const iframeEl = videoContainerEl.children[0]; + const facadeEl = videoContainerEl.children[0]; expect(videoContainerEl.children.length).toBe(1); - expect(iframeEl.nodeName).toBe('IFRAME'); - expect(iframeEl.getAttribute('src')).toBeTruthy(); - expect(iframeEl.classList.contains('docs-video')).toBeTrue(); - expect(iframeEl.getAttribute('title')).toBeTruthy(); + expect(facadeEl.nodeName).toBe('A'); + expect(facadeEl.classList.contains('docs-video-facade')).toBeTrue(); + expect(facadeEl.getAttribute('href')).toContain('youtube.com/watch?v='); + expect(facadeEl.getAttribute('data-video-src')).toBeTruthy(); + + const thumbnailEl = facadeEl.querySelector('img.docs-video-thumbnail'); + expect(thumbnailEl?.getAttribute('src')).toContain('i.ytimg.com'); + + expect(facadeEl.querySelector('.docs-video-play-button')).toBeTruthy(); }); }); diff --git a/adev/shared-docs/styles/docs/_video.scss b/adev/shared-docs/styles/docs/_video.scss index c20a6bb8e2b9..bdc1537c44dc 100644 --- a/adev/shared-docs/styles/docs/_video.scss +++ b/adev/shared-docs/styles/docs/_video.scss @@ -7,5 +7,48 @@ overflow: hidden; aspect-ratio: 16 / 9; } + + .docs-video-facade { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 0.25rem; + overflow: hidden; + cursor: pointer; + } + + .docs-video-thumbnail { + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + display: block; + } + + .docs-video-play-button { + position: absolute; + inset: 0; + margin: auto; + width: 68px; + height: 48px; + + svg { + width: 100%; + height: 100%; + } + + .docs-video-play-button-bg { + fill: #212121; + fill-opacity: 0.8; + transition: fill-opacity 0.2s ease; + } + } + + .docs-video-facade:hover .docs-video-play-button-bg, + .docs-video-facade:focus-visible .docs-video-play-button-bg { + fill: #f00; + fill-opacity: 1; + } } }