diff --git a/blocks/video/video.css b/blocks/video/video.css new file mode 100644 index 0000000..1c2f67a --- /dev/null +++ b/blocks/video/video.css @@ -0,0 +1,76 @@ +.video { + display: block; + margin: 0 auto; + position: relative; + width: min(100%, calc((100dvh - var(--header-height) - (2 * var(--space-m))) * 16 / 9)); + aspect-ratio: 16 / 9; +} + +.video iframe { + width: 100%; + height: 100%; + border: none; + border-radius: 0; + transition: border-radius 0.8s; +} + +.video:has(.placeholder) iframe { + border-radius: var(--radius-m); +} + +.video .placeholder { + position: absolute; + inset: 0; + margin: 0; + border-radius: var(--radius-m); + cursor: pointer; + transition: box-shadow 0.2s; +} + +.video .placeholder:hover, +.video .placeholder:focus-visible { + box-shadow: var(--shadow-m); +} + +.video .placeholder img { + width: 100%; + height: 100%; + border-radius: var(--radius-m); + object-fit: cover; +} + +.video .placeholder::before, +.video .placeholder::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 80px; + height: 80px; + background-color: currentcolor; + transition: transform 0.2s; +} + +.video .placeholder:hover::before, +.video .placeholder:hover::after, +.video .placeholder:focus-visible::before, +.video .placeholder:focus-visible::after { + transform: translate(-50%, -50%) scale(1.2); +} + +.video[data-source="youtube"] .placeholder::before { + z-index: 1; + background-color: #f00; + mask-image: url('./youtube.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +.video[data-source="youtube"] .placeholder::after { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: white; +} diff --git a/blocks/video/video.js b/blocks/video/video.js new file mode 100644 index 0000000..06df1ab --- /dev/null +++ b/blocks/video/video.js @@ -0,0 +1,112 @@ +/** + * Returns the video provider name for a supported URL. + * @param {string} url - the video page URL to inspect + * @returns {string|null} provider name (e.g. 'youtube') + */ +function detectType(url) { + const { hostname } = new URL(url); + if (hostname === 'youtu.be' || hostname.endsWith('youtube.com')) return 'youtube'; + return null; +} + +/** + * Builds a YouTube iframe. + * @param {string} url - the video page or short URL to parse + * @returns {HTMLIFrameElement|null} configured embed iframe + */ +function createYouTubeEmbed(url) { + const { hostname, pathname, searchParams } = new URL(url); + const id = hostname === 'youtu.be' ? pathname.slice(1) : searchParams.get('v'); + if (!id) return null; + + const iframe = document.createElement('iframe'); + iframe.src = `https://www.youtube.com/embed/${id}`; + iframe.setAttribute('allowfullscreen', ''); + iframe.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); + iframe.title = 'YouTube video'; + return iframe; +} + +/** + * Builds an embed iframe for the given provider type and URL. + * @param {string} type - provider name returned by detectType + * @param {string} url - the video URL to embed + * @returns {HTMLIFrameElement|null} embed iframe + */ +function createEmbed(type, url) { + if (type === 'youtube') return createYouTubeEmbed(url); + return null; +} + +/** + * Builds a thumbnail overlay. + * @param {HTMLElement} block - block element containing the authored thumbnail image + * @param {HTMLIFrameElement} embed - video iframe + * @returns {HTMLElement|null} the placeholder figure + */ +function createPlaceholder(block, embed) { + const image = block.querySelector('picture, img'); + if (!image) return null; + + const figure = document.createElement('figure'); + figure.classList.add('placeholder'); + figure.setAttribute('role', 'button'); + figure.setAttribute('tabindex', '0'); + figure.append(image); + + function play() { + const src = new URL(embed.src); + src.searchParams.set('autoplay', 1); + embed.src = src.href; + figure.remove(); + } + + figure.addEventListener('click', play); + figure.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + play(); + } + }); + + return figure; +} + +/** + * Removes the block and its section wrapper from the DOM. + * @param {HTMLElement} block - the block element to remove + */ +function removeBlock(block) { + const wrapper = block.closest('.video-wrapper'); + if (wrapper) wrapper.remove(); +} + +export default function decorate(block) { + const link = block.querySelector('a[href]'); + if (!link) { removeBlock(block); return; } + + const type = detectType(link.href); + if (!type) { removeBlock(block); return; } + block.dataset.source = type; + + const embed = createEmbed(type, link.href); + if (!embed) { removeBlock(block); return; } + + const placeholder = createPlaceholder(block, embed); + + block.textContent = ''; + + if (placeholder) { + block.append(embed, placeholder); + return; + } + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + block.append(embed); + observer.disconnect(); + }); + }, { rootMargin: '0px' }); + observer.observe(block); +} diff --git a/blocks/video/youtube.svg b/blocks/video/youtube.svg new file mode 100644 index 0000000..b65fdbc --- /dev/null +++ b/blocks/video/youtube.svg @@ -0,0 +1,4 @@ + + + +