diff --git a/apps/e2e/fixtures/selectors.ts b/apps/e2e/fixtures/selectors.ts index 02252fff..d64ac418 100644 --- a/apps/e2e/fixtures/selectors.ts +++ b/apps/e2e/fixtures/selectors.ts @@ -69,7 +69,7 @@ export const SELECTORS = { // Sliders // HTML: , React: horizontal .media-slider inside .media-time-controls timeSlider: 'media-time-slider, .media-time-controls .media-slider', - volumeSlider: 'media-volume-slider, .media-slider[data-orientation="vertical"]', + volumeSlider: 'media-volume-slider, .media-popover--volume .media-slider', sliderThumb: 'media-slider-thumb, .media-slider__thumb', // Display elements @@ -81,11 +81,10 @@ export const SELECTORS = { '[data-type="duration"].media-time', '[data-type="remaining"].media-time', ].join(', '), - poster: 'media-poster, img[data-visible]', + poster: 'media-poster, img[data-loaded]', bufferingIndicator: 'media-buffering-indicator, .media-buffering-indicator', thumbnail: 'media-slider-thumbnail, .media-thumbnail__image', - // Popover & tooltip tooltip: 'media-tooltip, .media-tooltip', popover: 'media-popover, .media-popover', errorDialog: 'media-error-dialog, .media-error', diff --git a/apps/e2e/page-objects/player.ts b/apps/e2e/page-objects/player.ts index 2170d041..c598d02f 100644 --- a/apps/e2e/page-objects/player.ts +++ b/apps/e2e/page-objects/player.ts @@ -98,6 +98,14 @@ export class PlayerPage { return this.page.locator(SELECTORS.thumbnail).first(); } + get tooltip(): Locator { + return this.page.locator(SELECTORS.tooltip); + } + + get playTooltip(): Locator { + return this.tooltip.filter({ hasText: 'Play' }).first(); + } + get popover(): Locator { return this.page.locator(SELECTORS.popover).first(); } diff --git a/apps/e2e/tests/captions.spec.ts b/apps/e2e/tests/captions.spec.ts index f9d16f1e..407136a9 100644 --- a/apps/e2e/tests/captions.spec.ts +++ b/apps/e2e/tests/captions.spec.ts @@ -37,4 +37,24 @@ test.describe('Captions', () => { const options = page.locator(SELECTORS.activeMenuOptions); await expect(options).toHaveCount(2, { timeout: 5_000 }); }); + + test('captions button toggles captions', async ({ page }) => { + await page.evaluate(() => { + const video = document.querySelector('video') as HTMLVideoElement; + if (!video) return; + + const track = document.createElement('track'); + track.kind = 'subtitles'; + track.label = 'English'; + track.srclang = 'en'; + track.src = `data:text/vtt,${encodeURIComponent('WEBVTT\n\n00:00:00.000 --> 00:00:30.000\nTest caption')}`; + video.appendChild(track); + }); + + await player.showControls(); + await player.captionsButton.click(); + await expect(player.captionsButton).toHaveAttribute(DATA_ATTRS.active, ''); + await player.captionsButton.click(); + await expect(player.captionsButton).not.toHaveAttribute(DATA_ATTRS.active); + }); }); diff --git a/apps/e2e/tests/video-controls.spec.ts b/apps/e2e/tests/video-controls.spec.ts index 705ddaf9..49a7bee3 100644 --- a/apps/e2e/tests/video-controls.spec.ts +++ b/apps/e2e/tests/video-controls.spec.ts @@ -1,9 +1,89 @@ -import { expect, test } from '@playwright/test'; -import { ALL_VIDEO_PAGES, type PageEntry } from '../fixtures/media'; +import { expect, type Page, test } from '@playwright/test'; +import { ALL_VIDEO_PAGES, type PageEntry, VIDEO_PAGES } from '../fixtures/media'; import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; import { PlayerPage } from '../page-objects/player'; -for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly PageEntry[]) { +const UI_VIDEO_PAGES = VIDEO_PAGES.filter(({ media }) => media === 'video'); + +function getMediaVolume(page: Page): Promise { + return page.evaluate((selector) => { + const media = document.querySelector(selector) as HTMLMediaElement | null; + const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; + return actual?.volume ?? 1; + }, SELECTORS.media); +} + +async function mockPresentation(page: Page): Promise { + await page.addInitScript(() => { + let fullscreenElement: Element | null = null; + let pipElement: Element | null = null; + + Object.defineProperties(document, { + fullscreenElement: { configurable: true, get: () => fullscreenElement }, + fullscreenEnabled: { configurable: true, get: () => true }, + pictureInPictureElement: { configurable: true, get: () => pipElement }, + pictureInPictureEnabled: { configurable: true, get: () => true }, + }); + + Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { + configurable: true, + value: async function requestFullscreen(this: HTMLElement) { + fullscreenElement = this; + document.dispatchEvent(new Event('fullscreenchange')); + }, + }); + + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + value: async () => { + fullscreenElement = null; + document.dispatchEvent(new Event('fullscreenchange')); + }, + }); + + Object.defineProperty(HTMLVideoElement.prototype, 'requestPictureInPicture', { + configurable: true, + value: async function requestPictureInPicture(this: HTMLVideoElement) { + pipElement = this; + this.dispatchEvent(new Event('enterpictureinpicture')); + return {}; + }, + }); + + Object.defineProperties(HTMLVideoElement.prototype, { + webkitPresentationMode: { + configurable: true, + get: function webkitPresentationMode(this: HTMLVideoElement) { + return pipElement === this ? 'picture-in-picture' : 'inline'; + }, + }, + webkitSetPresentationMode: { + configurable: true, + value: function webkitSetPresentationMode(this: HTMLVideoElement, mode: string) { + const wasPip = pipElement === this; + pipElement = mode === 'picture-in-picture' ? this : null; + + if (!wasPip && pipElement === this) { + this.dispatchEvent(new Event('enterpictureinpicture')); + } else if (wasPip && pipElement !== this) { + this.dispatchEvent(new Event('leavepictureinpicture')); + } + }, + }, + }); + + Object.defineProperty(document, 'exitPictureInPicture', { + configurable: true, + value: async () => { + const video = pipElement; + pipElement = null; + video?.dispatchEvent(new Event('leavepictureinpicture')); + }, + }); + }); +} + +for (const { name, path, skipBrowsers } of ALL_VIDEO_PAGES as readonly PageEntry[]) { const rateMenu = !path.includes('/cdn-video') && !path.includes('/ejected'); test.describe(`Video Controls — ${name}`, () => { test.skip(({ browserName }) => { @@ -20,10 +100,6 @@ for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly Pa // --- Grouped: control presence & attributes (one navigation) --- test('all controls are present with correct attributes', async () => { - await expect(player.seekForward).toBeAttached(); - await expect(player.seekForward).toHaveAttribute(DATA_ATTRS.direction, 'forward'); - await expect(player.seekBackward).toBeAttached(); - await expect(player.seekBackward).toHaveAttribute(DATA_ATTRS.direction, 'backward'); await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.volumeLevel); await expect(player.fullscreenButton).toHaveAttribute(DATA_ATTRS.availability); await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.availability); @@ -47,15 +123,6 @@ for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly Pa await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); }); - // --- Seek --- - - test('seek forward advances playback', async () => { - test.skip(media === 'native-hls-video', 'seek before playback not yet supported'); - - await player.seekForward.click(); - await expect(player.playButton).toHaveAttribute(DATA_ATTRS.started, ''); - }); - // --- Time Slider --- test('time slider allows seeking', async ({ page }) => { @@ -102,18 +169,112 @@ for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly Pa // --- Poster --- - test('poster hides after playback starts', async ({ page }) => { + test('poster hides after playback starts', async () => { + await expect(player.poster).toBeAttached(); await player.play(); - const posterVisible = await page.evaluate((attr) => { - const htmlPoster = document.querySelector('media-poster'); - if (htmlPoster) return htmlPoster.hasAttribute(attr); + await expect(player.poster).not.toHaveAttribute(DATA_ATTRS.visible); + }); + }); +} - const reactPoster = document.querySelector(`img[${attr}]`); - return !!reactPoster; - }, DATA_ATTRS.visible); +for (const { name, path } of UI_VIDEO_PAGES) { + test.describe(`Video Controls — ${name} UI`, () => { + let player: PlayerPage; - expect(posterVisible).toBe(false); + test.beforeEach(async ({ page }) => { + await mockPresentation(page); + player = new PlayerPage(page); + await page.goto(path); + await player.waitForMediaReady(); + }); + + test('volume slider changes volume', async ({ page }) => { + await player.showControls(); + await player.muteButton.hover(); + + await expect(player.volumeSlider).toBeVisible(); + + const box = await player.volumeSlider.boundingBox(); + if (!box) throw new Error('Volume slider not visible'); + + await page.mouse.click(box.x + box.width / 2, box.y + box.height * 0.75); + + await expect.poll(() => getMediaVolume(page)).toBeLessThan(0.5); + }); + + test('buffering indicator follows waiting state', async ({ page }) => { + await player.play(); + await page.evaluate((selector) => { + const media = document.querySelector(selector) as HTMLMediaElement | null; + const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; + if (!actual) return; + + Object.defineProperties(actual, { + paused: { configurable: true, get: () => false }, + readyState: { configurable: true, get: () => HTMLMediaElement.HAVE_CURRENT_DATA }, + }); + actual.dispatchEvent(new Event('waiting')); + }, SELECTORS.media); + + await expect(player.bufferingIndicator).toHaveAttribute(DATA_ATTRS.visible, '', { timeout: 2_000 }); + + await page.evaluate((selector) => { + const media = document.querySelector(selector) as HTMLMediaElement | null; + const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; + if (!actual) return; + + Object.defineProperty(actual, 'readyState', { + configurable: true, + get: () => HTMLMediaElement.HAVE_ENOUGH_DATA, + }); + actual.dispatchEvent(new Event('playing')); + }, SELECTORS.media); + + await expect(player.bufferingIndicator).not.toHaveAttribute(DATA_ATTRS.visible); + }); + + test('play button shows its tooltip on hover', async ({ page }) => { + await player.showControls(); + await player.playButton.hover(); + + await expect(player.playTooltip).toHaveAttribute(DATA_ATTRS.open, '', { timeout: 2_000 }); + }); + + test('play button reflects ended playback', async ({ page }) => { + await page.evaluate((selector) => { + const media = document.querySelector(selector) as HTMLMediaElement | null; + const actual = (media?.querySelector?.('video') as HTMLMediaElement) ?? media; + if (!actual) return; + + Object.defineProperties(actual, { + ended: { configurable: true, get: () => true }, + paused: { configurable: true, get: () => true }, + }); + actual.dispatchEvent(new Event('ended')); + }, SELECTORS.media); + + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.ended, ''); + }); + + test('fullscreen button toggles fullscreen', async () => { + await expect(player.fullscreenButton).toHaveAttribute(DATA_ATTRS.availability, 'available'); + + await player.fullscreenButton.click(); + await expect(player.fullscreenButton).toHaveAttribute(DATA_ATTRS.fullscreen, ''); + + await player.fullscreenButton.click(); + await expect(player.fullscreenButton).not.toHaveAttribute(DATA_ATTRS.fullscreen); + }); + + test('PiP button toggles picture-in-picture', async () => { + await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.availability, 'available'); + + await player.pipButton.click(); + await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.pip, ''); + + await player.pipButton.click(); + await expect(player.pipButton).not.toHaveAttribute(DATA_ATTRS.pip); }); }); } diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-chromium.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-chromium.png index 6d719708..7a95b8d2 100644 Binary files a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-chromium.png and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-webkit.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-webkit.png index c2e2eba7..ec5d5f88 100644 Binary files a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-webkit.png and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-html-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-chromium.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-chromium.png index 6d719708..410ab338 100644 Binary files a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-chromium.png and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-chromium.png differ diff --git a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-webkit.png b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-webkit.png index 0dc7e485..ec5d5f88 100644 Binary files a/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-webkit.png and b/apps/e2e/tests/visual/audio-skin.spec.ts-snapshots/audio-react-default-vite-webkit.png differ diff --git a/apps/e2e/tests/visual/video-skin.spec.ts b/apps/e2e/tests/visual/video-skin.spec.ts index d17100f3..5007cf95 100644 --- a/apps/e2e/tests/visual/video-skin.spec.ts +++ b/apps/e2e/tests/visual/video-skin.spec.ts @@ -41,9 +41,8 @@ for (const { name, path } of VISUAL_PAGES) { await player.hoverTimeSlider(50); // Wait for thumbnail to finish loading (deterministic, no fixed timeout) - const thumbnail = page.locator(SELECTORS.thumbnail).first(); - await expect(thumbnail).toBeAttached({ timeout: 10_000 }); - await expect(thumbnail).not.toHaveAttribute(DATA_ATTRS.loading, { timeout: 10_000 }); + await expect(player.thumbnail).toBeAttached({ timeout: 10_000 }); + await expect(player.thumbnail).not.toHaveAttribute(DATA_ATTRS.loading, { timeout: 10_000 }); await expect(player.playerRoot).toHaveScreenshot(`video-${name.toLowerCase()}-storyboard.png`); }); diff --git a/packages/html/src/define/shared.css b/packages/html/src/define/shared.css index 5a0524b7..ddbfa53b 100644 --- a/packages/html/src/define/shared.css +++ b/packages/html/src/define/shared.css @@ -6,18 +6,6 @@ media-tooltip-group { display: contents; } -.media-sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - white-space: nowrap; - border: 0; - clip: rect(0, 0, 0, 0); -} - :host { /* `display:grid` fixes a weird issue with Safari when setting aspect-ratio */ display: grid; diff --git a/packages/skins/src/default/css/components/reset.css b/packages/skins/src/default/css/components/reset.css index cb3fcbd6..14dd57a2 100644 --- a/packages/skins/src/default/css/components/reset.css +++ b/packages/skins/src/default/css/components/reset.css @@ -1,3 +1,5 @@ +@import "../../../shared/css/sr-only.css"; + /* ========================================================================== Reset ========================================================================== */ diff --git a/packages/skins/src/minimal/css/components/reset.css b/packages/skins/src/minimal/css/components/reset.css index 6773da60..44ff3eba 100644 --- a/packages/skins/src/minimal/css/components/reset.css +++ b/packages/skins/src/minimal/css/components/reset.css @@ -1,3 +1,5 @@ +@import "../../../shared/css/sr-only.css"; + /* ========================================================================== Reset ========================================================================== */ diff --git a/packages/skins/src/shared/css/sr-only.css b/packages/skins/src/shared/css/sr-only.css new file mode 100644 index 00000000..c1004de5 --- /dev/null +++ b/packages/skins/src/shared/css/sr-only.css @@ -0,0 +1,11 @@ +.media-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + white-space: nowrap; + border: 0; + clip: rect(0, 0, 0, 0); +}