diff --git a/apps/e2e/tests/visual/video-skin.spec.ts b/apps/e2e/tests/visual/video-skin.spec.ts index 5ac53861..4502e548 100644 --- a/apps/e2e/tests/visual/video-skin.spec.ts +++ b/apps/e2e/tests/visual/video-skin.spec.ts @@ -50,6 +50,87 @@ for (const { name, path } of VISUAL_PAGES) { }); } +// --- Portrait media layout --- + +test.describe('Visual — HTML Portrait Layout', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/pages/html-video-mp4.html', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => customElements.get('video-skin')); + await page.evaluate(() => { + const root = document.getElementById('root'); + if (!root) return; + + root.innerHTML = ` + + + + + + `; + }); + }); + + test('keeps the authored skin aspect ratio', async ({ page }) => { + const box = await page.evaluate(() => { + const container = document.querySelector('video-skin')?.shadowRoot?.querySelector('media-container'); + const rect = container?.getBoundingClientRect(); + + return rect ? { height: rect.height, width: rect.width } : null; + }); + + expect(box).not.toBeNull(); + expect(box!.width / box!.height).toBeCloseTo(16 / 9, 1); + }); + + test('caps portrait thumbnails to the configured max height', async ({ page }) => { + const src = `data:image/svg+xml,${encodeURIComponent( + '' + )}`; + + await page.evaluate((url) => { + const thumbnail = document + .querySelector('video-skin') + ?.shadowRoot?.querySelector('media-slider-thumbnail') as HTMLElement & { + thumbnails?: Array<{ url: string; startTime: number; width: number; height: number }>; + }; + + if (!thumbnail) return; + thumbnail.thumbnails = [{ url, startTime: 0, width: 270, height: 480 }]; + }, src); + + await page.waitForFunction(() => { + const thumbnail = document.querySelector('video-skin')?.shadowRoot?.querySelector('media-slider-thumbnail'); + + return ( + thumbnail && + !thumbnail.hasAttribute('data-hidden') && + !thumbnail.hasAttribute('data-loading') && + parseFloat(getComputedStyle(thumbnail).height) > 0 + ); + }); + + const size = await page.evaluate(() => { + const thumbnail = document.querySelector('video-skin')!.shadowRoot!.querySelector('media-slider-thumbnail')!; + const style = getComputedStyle(thumbnail); + const probe = document.createElement('div'); + probe.style.height = style.getPropertyValue('--media-slider-thumbnail-max-height'); + document.body.append(probe); + + const configuredMaxHeight = parseFloat(getComputedStyle(probe).height); + probe.remove(); + + return { + height: parseFloat(style.height), + configuredMaxHeight, + maxHeight: parseFloat(style.maxHeight), + }; + }); + + expect(size.maxHeight).toBeCloseTo(size.configuredMaxHeight, 0); + expect(size.height).toBeLessThanOrEqual(size.maxHeight); + }); +}); + // --- Captions snapshot (dedicated page with subtitle track baked in) --- test.describe('Visual — Captions', () => { diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index c3511b90..eedd5ab9 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -31,6 +31,12 @@ export const SOURCES = { type: 'hls', subType: 'mp4', }, + 'hls-6': { + label: 'HLS - Tailwind (portrait)', + url: 'https://stream.mux.com/vth873zxidmhBVVRWBKcPTxnSQ302QqUm.m3u8', + type: 'hls', + subType: 'mp4', + }, 'hls-multi-audio': { label: 'HLS - Multi-language audio', url: 'https://stream.mux.com/s41JYeqIpBMBzE4OzxDyGR2yrp2hD1CQ6gJN9SlVGDQ.m3u8', diff --git a/packages/html/src/define/shared.css b/packages/html/src/define/shared.css index 50795d15..ddbfa53b 100644 --- a/packages/html/src/define/shared.css +++ b/packages/html/src/define/shared.css @@ -12,6 +12,11 @@ media-tooltip-group { width: 100%; } +media-container { + min-width: 0; + min-height: 0; +} + /* Hide volume popover when volume control is unsupported (e.g., iOS Safari). */ .media-popover--volume:has(media-volume-slider[data-availability="unsupported"]) { display: none; diff --git a/packages/skins/src/default/css/video.css b/packages/skins/src/default/css/video.css index 0c80c76a..abbbbab6 100644 --- a/packages/skins/src/default/css/video.css +++ b/packages/skins/src/default/css/video.css @@ -248,6 +248,7 @@ .media-default-skin--video .media-slider__thumbnail { --media-slider-thumbnail-max-width: 11rem; + --media-slider-thumbnail-max-height: 8rem; --media-slider-thumbnail-padding: -1.125rem; /** Inset is the difference between the container width and the slider (100%) width. @@ -285,10 +286,7 @@ & .media-thumbnail__image { max-width: var(--media-slider-thumbnail-max-width); - } - - &:has(.media-thumbnail__image[data-loading]) { - max-height: 6rem; + max-height: var(--media-slider-thumbnail-max-height); } } .media-default-skin--video .media-slider[data-pointing] .media-slider__thumbnail:has([role="img"]:not([data-hidden])) { diff --git a/packages/skins/src/default/tailwind/video.tailwind.ts b/packages/skins/src/default/tailwind/video.tailwind.ts index dce6ff16..3b6493f3 100644 --- a/packages/skins/src/default/tailwind/video.tailwind.ts +++ b/packages/skins/src/default/tailwind/video.tailwind.ts @@ -155,16 +155,22 @@ export const thumbnail = { root: cn( baseThumbnail.root, surface, - '[--media-slider-thumbnail-max-width:11rem] [--media-slider-thumbnail-padding:-1.125rem] [--media-slider-thumbnail-inset:calc((100cqi-100%)/2)]', + '[--media-slider-thumbnail-max-width:11rem]', + '[--media-slider-thumbnail-max-height:8rem]', + '[--media-slider-thumbnail-padding:-1.125rem]', + '[--media-slider-thumbnail-inset:calc((100cqi-100%)/2)]', 'absolute [left:clamp(calc(var(--media-slider-thumbnail-max-width)/2+var(--media-slider-thumbnail-padding)-var(--media-slider-thumbnail-inset)),var(--media-slider-pointer),calc(100%-var(--media-slider-thumbnail-max-width)/2-var(--media-slider-thumbnail-padding)+var(--media-slider-thumbnail-inset)))] bottom-[calc(100%+1.2rem)] -translate-x-1/2', 'opacity-0 scale-80 blur-sm origin-bottom', 'transition-[scale,opacity,filter] duration-150', 'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:opacity-100', 'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:scale-100', - 'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:blur-none', - 'has-[[role=img][data-loading]]:max-h-24' + 'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:blur-none' + ), + image: cn( + baseThumbnail.image, + 'max-w-(--media-slider-thumbnail-max-width)', + 'max-h-(--media-slider-thumbnail-max-height)' ), - image: cn(baseThumbnail.image, 'max-w-(--media-slider-thumbnail-max-width)'), }; /* ========================================================================== diff --git a/packages/skins/src/minimal/css/video.css b/packages/skins/src/minimal/css/video.css index 65ea4e29..89a845e8 100644 --- a/packages/skins/src/minimal/css/video.css +++ b/packages/skins/src/minimal/css/video.css @@ -267,6 +267,7 @@ .media-minimal-skin--video .media-slider__thumbnail { --media-slider-thumbnail-max-width: 11rem; + --media-slider-thumbnail-max-height: 8rem; --media-slider-thumbnail-padding: -0.5rem; /** Inset is the difference between the container width and the slider (100%) width. @@ -317,10 +318,7 @@ & .media-thumbnail__image { max-width: var(--media-slider-thumbnail-max-width); - } - - &:has(.media-thumbnail__image[data-loading]) { - max-height: 6rem; + max-height: var(--media-slider-thumbnail-max-height); } } .media-minimal-skin--video .media-slider[data-pointing] .media-slider__thumbnail:has([role="img"]:not([data-hidden])) { diff --git a/packages/skins/src/minimal/tailwind/video.tailwind.ts b/packages/skins/src/minimal/tailwind/video.tailwind.ts index 01d9613c..c7e8e32a 100644 --- a/packages/skins/src/minimal/tailwind/video.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/video.tailwind.ts @@ -163,22 +163,28 @@ export const thumbnail = { ...baseThumbnail, root: cn( baseThumbnail.root, - '[--media-slider-thumbnail-max-width:11rem] [--media-slider-thumbnail-padding:-0.5rem] [--media-slider-thumbnail-inset:calc(100cqi-100%)]', + '[--media-slider-thumbnail-max-width:11rem]', + '[--media-slider-thumbnail-max-height:8rem]', + '[--media-slider-thumbnail-padding:-0.5rem]', + '[--media-slider-thumbnail-inset:calc(100cqi-100%)]', 'absolute [left:clamp(calc(var(--media-slider-thumbnail-max-width)/2+var(--media-slider-thumbnail-padding)),var(--media-slider-pointer),calc(100%-var(--media-slider-thumbnail-max-width)/2-var(--media-slider-thumbnail-padding)+var(--media-slider-thumbnail-inset)))] bottom-full -translate-x-1/2', '@2xl/media-root:[left:var(--media-slider-pointer)]', 'opacity-0 scale-80 blur-sm origin-bottom', 'transition-[scale,opacity,filter] duration-150', 'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:opacity-100', 'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:scale-100', - 'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:blur-none', - 'has-[[role=img][data-loading]]:max-h-24' + 'has-[[role=img]:not([data-hidden])]:group-data-pointing/slider:blur-none' ), imageWrapper: cn( baseThumbnail.imageWrapper, 'after:absolute after:inset-0 after:rounded-[inherit]', 'after:ring-1 after:ring-black/5 after:shadow-sm after:shadow-black/20' ), - image: cn(baseThumbnail.image, 'max-w-(--media-slider-thumbnail-max-width)'), + image: cn( + baseThumbnail.image, + 'max-w-(--media-slider-thumbnail-max-width)', + 'max-h-(--media-slider-thumbnail-max-height)' + ), }; /* ==========================================================================