diff --git a/projects/core/src/combobox/combobox.test.lighthouse.ts b/projects/core/src/combobox/combobox.test.lighthouse.ts index 37f30dfc2a..b3db47d8ab 100644 --- a/projects/core/src/combobox/combobox.test.lighthouse.ts +++ b/projects/core/src/combobox/combobox.test.lighthouse.ts @@ -25,7 +25,7 @@ describe('combobox lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(36.9); + expect(report.payload.javascript.kb).toBeLessThan(37); }); test('combobox multi select with large dataset should meet lighthouse benchmarks', async () => { @@ -42,6 +42,6 @@ describe('combobox lighthouse report', () => { expect(report.scores.performance).toBeGreaterThanOrEqual(96); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(36.3); + expect(report.payload.javascript.kb).toBeLessThan(36.4); }); }); diff --git a/projects/core/src/internal/controllers/i18n.controller.test.ts b/projects/core/src/internal/controllers/i18n.controller.test.ts index 3a6b6799e8..d4e443c37f 100644 --- a/projects/core/src/internal/controllers/i18n.controller.test.ts +++ b/projects/core/src/internal/controllers/i18n.controller.test.ts @@ -76,6 +76,8 @@ describe('i18n.controller', () => { browseFiles: 'browse files', maxFileSize: 'max file size', currentTime: 'current time', + enableLoop: 'enable looping', + disableLoop: 'disable looping', enterFullscreen: 'enter full screen', exitFullscreen: 'exit full screen', muteMedia: 'mute media', @@ -147,6 +149,8 @@ describe('i18n.controller', () => { browseFiles: 'browse files', maxFileSize: 'max file size', currentTime: 'current time', + enableLoop: 'enable looping', + disableLoop: 'disable looping', enterFullscreen: 'enter full screen', exitFullscreen: 'exit full screen', muteMedia: 'mute media', diff --git a/projects/core/src/internal/services/i18n.service.test.ts b/projects/core/src/internal/services/i18n.service.test.ts index a58d73c860..f5105832bc 100644 --- a/projects/core/src/internal/services/i18n.service.test.ts +++ b/projects/core/src/internal/services/i18n.service.test.ts @@ -50,6 +50,8 @@ describe('I18nService', () => { maxFileSize: 'max file size', create: 'create', currentTime: 'current time', + enableLoop: 'enable looping', + disableLoop: 'disable looping', enterFullscreen: 'enter full screen', exitFullscreen: 'exit full screen', muteMedia: 'mute media', diff --git a/projects/core/src/internal/services/i18n.service.ts b/projects/core/src/internal/services/i18n.service.ts index f5407a5de2..d666d8b955 100644 --- a/projects/core/src/internal/services/i18n.service.ts +++ b/projects/core/src/internal/services/i18n.service.ts @@ -42,6 +42,8 @@ export interface I18nStrings { maxFileSize: string; create: string; currentTime: string; + enableLoop: string; + disableLoop: string; enterFullscreen: string; exitFullscreen: string; muteMedia: string; @@ -96,6 +98,8 @@ const i18nRegistry = { maxFileSize: 'max file size', create: 'create', currentTime: 'current time', + enableLoop: 'enable looping', + disableLoop: 'disable looping', enterFullscreen: 'enter full screen', exitFullscreen: 'exit full screen', muteMedia: 'mute media', diff --git a/projects/core/src/notification/notification.test.lighthouse.ts b/projects/core/src/notification/notification.test.lighthouse.ts index 2f0387419c..16c46bd9f7 100644 --- a/projects/core/src/notification/notification.test.lighthouse.ts +++ b/projects/core/src/notification/notification.test.lighthouse.ts @@ -17,6 +17,6 @@ describe('notification lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(25.9); + expect(report.payload.javascript.kb).toBeLessThan(26); }); }); diff --git a/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts b/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts index 58cdf5c4ae..d9c8d6c6ee 100644 --- a/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts +++ b/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('resize-handle lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(15.5); + expect(report.payload.javascript.kb).toBeLessThan(15.6); }); }); diff --git a/projects/core/src/time/time.test.lighthouse.ts b/projects/core/src/time/time.test.lighthouse.ts index 64d4df8ec8..a1308a892a 100644 --- a/projects/core/src/time/time.test.lighthouse.ts +++ b/projects/core/src/time/time.test.lighthouse.ts @@ -22,6 +22,6 @@ describe('time lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(25.9); + expect(report.payload.javascript.kb).toBeLessThan(26); }); }); diff --git a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts index 3c9aa2da90..177faca5ee 100644 --- a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts +++ b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts @@ -10,7 +10,7 @@ import noInvalidInvokerTriggers from './no-invalid-invoker-triggers.js'; const rule = noInvalidInvokerTriggers as unknown as JSRuleDefinition; const validElements = - 'nve-button, nve-icon-button, nve-menu-item, nve-sort-button, nve-tabs-item, nve-tag, nve-steps-item, nve-copy-button, nve-media-fullscreen-button, nve-media-mute-button, nve-media-pause-button, nve-media-playback-rate-select, nve-media-seek-button, nve-media-time-range, nve-media-volume-range'; + 'nve-button, nve-icon-button, nve-menu-item, nve-sort-button, nve-tabs-item, nve-tag, nve-steps-item, nve-copy-button, nve-media-fullscreen-button, nve-media-loop-button, nve-media-mute-button, nve-media-pause-button, nve-media-playback-rate-select, nve-media-seek-button, nve-media-time-range, nve-media-volume-range'; describe('noInvalidInvokerTriggers', () => { let tester: RuleTester; @@ -69,6 +69,7 @@ describe('noInvalidInvokerTriggers', () => { '', '', '', + '', '' ], invalid: [] diff --git a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts index 68627af4f0..a890ed5b02 100644 --- a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts +++ b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts @@ -26,6 +26,7 @@ const BUTTON_TYPE_ELEMENTS = [ 'nve-steps-item', 'nve-copy-button', 'nve-media-fullscreen-button', + 'nve-media-loop-button', 'nve-media-mute-button', 'nve-media-pause-button', 'nve-media-playback-rate-select', diff --git a/projects/media/.visual/media-loop-button.dark.png b/projects/media/.visual/media-loop-button.dark.png new file mode 100644 index 0000000000..5ea79e674e --- /dev/null +++ b/projects/media/.visual/media-loop-button.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bedf1979d7bdc83ac850c1d25e0e6400f1b7de3b73ff3949e8be1ee69c9d73a8 +size 1489 diff --git a/projects/media/.visual/media-loop-button.png b/projects/media/.visual/media-loop-button.png new file mode 100644 index 0000000000..408b924934 --- /dev/null +++ b/projects/media/.visual/media-loop-button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3f3ffc3922f291bb72f5feb0fa62acbd9927213b352932b2492e247a6a9ae0e +size 1453 diff --git a/projects/media/package.json b/projects/media/package.json index 192bc3054d..44b33c634e 100644 --- a/projects/media/package.json +++ b/projects/media/package.json @@ -77,6 +77,18 @@ "types": "./dist/fullscreen-button/index.d.ts", "default": "./dist/fullscreen-button/index.js" }, + "./loop-button": { + "types": "./dist/loop-button/index.d.ts", + "default": "./dist/loop-button/index.js" + }, + "./loop-button/define.js": { + "types": "./dist/loop-button/define.d.ts", + "default": "./dist/loop-button/define.js" + }, + "./loop-button/index.js": { + "types": "./dist/loop-button/index.d.ts", + "default": "./dist/loop-button/index.js" + }, "./mute-button": { "types": "./dist/mute-button/index.d.ts", "default": "./dist/mute-button/index.js" diff --git a/projects/media/src/controller/controller.examples.ts b/projects/media/src/controller/controller.examples.ts index 511bf0915c..74e65db664 100644 --- a/projects/media/src/controller/controller.examples.ts +++ b/projects/media/src/controller/controller.examples.ts @@ -27,6 +27,7 @@ export const Default = { + diff --git a/projects/media/src/controller/controller.loop.test.ts b/projects/media/src/controller/controller.loop.test.ts new file mode 100644 index 0000000000..e8c4bac570 --- /dev/null +++ b/projects/media/src/controller/controller.loop.test.ts @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFixture, elementIsStable, emulateClick, removeFixture } from '@internals/testing'; +import { mediaCommands, type MediaCommand, type MediaCommandEvent } from '../internal/media-command.js'; +import { mediaStateChange } from '../internal/media-state.js'; +import { MediaLoopButton } from '../loop-button/loop-button.js'; +import { MediaController } from './controller.js'; +import '../loop-button/define.js'; +import './define.js'; + +describe.each(['video', 'audio'] as const)(`${MediaController.metadata.tag} %s looping`, tag => { + let fixture: HTMLElement; + let controller: MediaController; + let media: HTMLMediaElement; + let button: MediaLoopButton; + + beforeEach(async () => { + fixture = await createFixture(html` + + ${tag === 'video' ? html`` : html``} + + + `); + controller = getElement(fixture, MediaController.metadata.tag); + media = getElement(controller, tag); + button = getElement(fixture, MediaLoopButton.metadata.tag); + await elementIsStable(controller); + await elementIsStable(button); + }); + + afterEach(() => { + vi.restoreAllMocks(); + removeFixture(fixture); + }); + + it('should expose the initial native loop setting', () => { + expect(media.loop).toBe(true); + expect(controller.mediaState.loop).toBe(true); + expect(Object.isFrozen(controller.mediaState)).toBe(true); + expect(controller.hasAttribute('loop')).toBe(true); + expect(button.pressed).toBe(true); + }); + + it('should apply explicit and toggle commands without changing playback', async () => { + const play = vi.spyOn(media, 'play'); + const pause = vi.spyOn(media, 'pause'); + const time = media.currentTime; + const listener = vi.fn(); + controller.addEventListener(mediaStateChange, listener); + dispatchCommand(controller, mediaCommands.disableLoop); + expect(media.loop).toBe(false); + expect(controller.mediaState.loop).toBe(false); + expect(controller.hasAttribute('loop')).toBe(false); + expect(listener).toHaveBeenCalledOnce(); + await elementIsStable(button); + expect(button.pressed).toBe(false); + expect(listener).toHaveBeenCalledOnce(); + + dispatchCommand(controller, mediaCommands.disableLoop); + expect(listener).toHaveBeenCalledOnce(); + dispatchCommand(controller, mediaCommands.enableLoop); + expect(media.loop).toBe(true); + dispatchCommand(controller, mediaCommands.toggleLoop); + expect(media.loop).toBe(false); + dispatchCommand(controller, mediaCommands.toggleLoop); + expect(media.loop).toBe(true); + await elementIsStable(button); + expect(button.pressed).toBe(true); + expect(listener).toHaveBeenCalledTimes(4); + expect(media.currentTime).toBe(time); + expect(play).not.toHaveBeenCalled(); + expect(pause).not.toHaveBeenCalled(); + }); + + it('should toggle native looping through the button', async () => { + await emulateClick(button); + await elementIsStable(button); + expect(media.loop).toBe(false); + expect(button.pressed).toBe(false); + await emulateClick(button); + await elementIsStable(button); + expect(media.loop).toBe(true); + expect(button.pressed).toBe(true); + }); + + it('should sync direct native property and attribute changes while paused', async () => { + expect(media.paused).toBe(true); + const listener = vi.fn(); + controller.addEventListener(mediaStateChange, listener); + media.loop = false; + await elementIsStable(button); + expect(controller.mediaState.loop).toBe(false); + expect(button.pressed).toBe(false); + expect(controller.hasAttribute('loop')).toBe(false); + expect(listener).toHaveBeenCalledOnce(); + + media.setAttribute('loop', ''); + await elementIsStable(button); + expect(controller.mediaState.loop).toBe(true); + expect(button.pressed).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + media.setAttribute('loop', ''); + media.setAttribute('preload', 'none'); + await elementIsStable(button); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('should read replacement media state and stop observing the previous media', async () => { + const previous = media; + const replacement = document.createElement(tag); + media.replaceWith(replacement); + await elementIsStable(controller); + await elementIsStable(button); + expect(controller.mediaState.loop).toBe(false); + expect(button.pressed).toBe(false); + + const listener = vi.fn(); + controller.addEventListener(mediaStateChange, listener); + previous.loop = false; + await elementIsStable(controller); + expect(listener).not.toHaveBeenCalled(); + replacement.loop = true; + await elementIsStable(button); + expect(controller.mediaState.loop).toBe(true); + expect(button.pressed).toBe(true); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('should release observers when disconnected and bind them on reconnection', async () => { + button.commandForElement = controller; + await elementIsStable(button); + controller.remove(); + expect(controller.mediaState.loop).toBe(false); + const listener = vi.fn(); + controller.addEventListener(mediaStateChange, listener); + media.loop = false; + media.loop = true; + const replacement = document.createElement(tag); + replacement.loop = true; + media.replaceWith(replacement); + await elementIsStable(button); + expect(listener).not.toHaveBeenCalled(); + fixture.prepend(controller); + await elementIsStable(controller); + await elementIsStable(button); + expect(controller.mediaState.loop).toBe(true); + expect(button.pressed).toBe(true); + replacement.loop = false; + await elementIsStable(button); + expect(controller.mediaState.loop).toBe(false); + expect(button.pressed).toBe(false); + }); + + it('should warn and ignore loop commands when media is missing', async () => { + media.remove(); + await elementIsStable(controller); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + dispatchCommand(controller, mediaCommands.enableLoop); + dispatchCommand(controller, mediaCommands.disableLoop); + dispatchCommand(controller, mediaCommands.toggleLoop); + expect(controller.mediaState.loop).toBe(false); + expect(warn).toHaveBeenCalledTimes(3); + expect(warn).toHaveBeenCalledWith('nve-media-controller missing media element'); + }); +}); + +function getElement(root: ParentNode, selector: string): T { + const element = root.querySelector(selector); + if (!element) { + throw new Error(`Missing element: ${selector}`); + } + return element; +} + +function dispatchCommand(controller: MediaController, command: MediaCommand) { + const event = new Event('command') as MediaCommandEvent; + Object.defineProperty(event, 'command', { value: command }); + controller.dispatchEvent(event); +} diff --git a/projects/media/src/controller/controller.ts b/projects/media/src/controller/controller.ts index 87c00b6735..f0d478d898 100644 --- a/projects/media/src/controller/controller.ts +++ b/projects/media/src/controller/controller.ts @@ -41,6 +41,9 @@ const mediaEventTypes = [ * @command --mute - Mute the media element. * @command --unmute - Unmute the media element. * @command --toggle-mute - Toggle media muted state. + * @command --enable-loop - Enable continuous media looping. + * @command --disable-loop - Disable media looping. + * @command --toggle-loop - Toggle media looping. * @command --seek - Move playback to the source valueAsNumber. * @command --seek-start - Move playback to zero seconds. * @command --seek-end - Move playback to the finite duration. @@ -68,6 +71,8 @@ export class MediaController extends LitElement { #media: HTMLMediaElement | null = null; + #loopObserver: MutationObserver | null = null; + #currentTime = 0; #fullscreen = false; @@ -81,6 +86,9 @@ export class MediaController extends LitElement { [mediaCommands.mute, () => this.#setMuted(true)], [mediaCommands.unmute, () => this.#setMuted(false)], [mediaCommands.toggleMuted, () => this.#toggleMuted()], + [mediaCommands.enableLoop, () => this.#setLoop(true)], + [mediaCommands.disableLoop, () => this.#setLoop(false)], + [mediaCommands.toggleLoop, () => this.#toggleLoop()], [mediaCommands.seek, source => this.#seekToSourceValue(source)], [mediaCommands.seekToStart, () => this.#setCurrentTime(0)], [mediaCommands.seekToEnd, () => this.#seekToEnd()], @@ -114,6 +122,9 @@ export class MediaController extends LitElement { super.connectedCallback(); this.addEventListener('command', this.#onCommand as EventListener); globalThis.document?.addEventListener('fullscreenchange', this.#syncFullscreen); + if (this.hasUpdated) { + this.#syncMediaSlot(); + } } disconnectedCallback() { @@ -138,6 +149,10 @@ export class MediaController extends LitElement { }; #syncMediaSlot = () => { + if (!this.isConnected) { + return; + } + const slot = this.renderRoot.querySelector('slot'); const media = (slot?.assignedElements({ flatten: true }) ?? []).filter(isSupportedMediaElement); if (media.length > 1) { @@ -153,8 +168,13 @@ export class MediaController extends LitElement { } this.#media && mediaEventTypes.forEach(type => this.#media?.removeEventListener(type, this.#syncMediaState)); + this.#loopObserver?.disconnect(); this.#media = media; this.#media && mediaEventTypes.forEach(type => this.#media?.addEventListener(type, this.#syncMediaState)); + if (media) { + this.#loopObserver ??= new MutationObserver(this.#syncMediaState); + this.#loopObserver.observe(media, { attributes: true, attributeFilter: ['loop'] }); + } this.#syncMediaState(); } @@ -178,6 +198,7 @@ export class MediaController extends LitElement { setBooleanAttribute(this, 'ended', state.ended); setBooleanAttribute(this, 'seeking', state.seeking); setBooleanAttribute(this, 'fullscreen', state.fullscreen); + setBooleanAttribute(this, 'loop', state.loop); setNumberAttribute(this, 'current-time', state.currentTime); setNumberAttribute(this, 'duration', state.duration); setNumberAttribute(this, 'volume', state.volume); @@ -219,6 +240,22 @@ export class MediaController extends LitElement { } } + #setLoop(loop: boolean) { + const media = this.#getMedia(); + if (media) { + media.loop = loop; + this.#syncMediaState(); + } + } + + #toggleLoop() { + const media = this.#getMedia(); + if (media) { + media.loop = !media.loop; + this.#syncMediaState(); + } + } + #seekToSourceValue(source: MediaCommandSource | null) { const value = getSourceNumber(source); Number.isFinite(value) ? this.#setCurrentTime(value) : this.#warnInvalidCommandValue(mediaCommands.seek); @@ -318,6 +355,7 @@ function getMediaState(media: HTMLMediaElement | null) { return { paused: true, muted: false, + loop: false, ended: false, seeking: false, currentTime: 0, @@ -331,6 +369,7 @@ function getMediaState(media: HTMLMediaElement | null) { buffered: getMediaTimeSpans(media.buffered), paused: media.paused, muted: media.muted, + loop: media.loop, ended: media.ended, seeking: media.seeking, currentTime: getFiniteNumber(media.currentTime, 0), diff --git a/projects/media/src/internal/button-form-control-usage.test.ts b/projects/media/src/internal/button-form-control-usage.test.ts index 227f7fe81f..7fa6937ead 100644 --- a/projects/media/src/internal/button-form-control-usage.test.ts +++ b/projects/media/src/internal/button-form-control-usage.test.ts @@ -6,10 +6,12 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing'; import type { ButtonFormControlMixinInstance } from '@nvidia-elements/forms/mixins'; import { MediaFullscreenButton } from '../fullscreen-button/fullscreen-button.js'; +import { MediaLoopButton } from '../loop-button/loop-button.js'; import { MediaMuteButton } from '../mute-button/mute-button.js'; import { MediaPauseButton } from '../pause-button/pause-button.js'; import { MediaSeekButton } from '../seek-button/seek-button.js'; import '../fullscreen-button/define.js'; +import '../loop-button/define.js'; import '../mute-button/define.js'; import '../pause-button/define.js'; import '../seek-button/define.js'; @@ -30,6 +32,11 @@ const usages: ButtonUsage[] = [ template: html``, submitTemplate: html`` }, + { + tag: MediaLoopButton.metadata.tag, + template: html``, + submitTemplate: html`` + }, { tag: MediaMuteButton.metadata.tag, checkbox: true, diff --git a/projects/media/src/internal/media-command.ts b/projects/media/src/internal/media-command.ts index 117555162b..190aa289b1 100644 --- a/projects/media/src/internal/media-command.ts +++ b/projects/media/src/internal/media-command.ts @@ -8,6 +8,9 @@ export const mediaCommands = { mute: '--mute', unmute: '--unmute', toggleMuted: '--toggle-mute', + enableLoop: '--enable-loop', + disableLoop: '--disable-loop', + toggleLoop: '--toggle-loop', seek: '--seek', seekToStart: '--seek-start', seekToEnd: '--seek-end', diff --git a/projects/media/src/internal/media-state.test.ts b/projects/media/src/internal/media-state.test.ts index 04f2506d4d..9c9db1143b 100644 --- a/projects/media/src/internal/media-state.test.ts +++ b/projects/media/src/internal/media-state.test.ts @@ -5,6 +5,11 @@ import { describe, expect, it } from 'vitest'; import { createMediaState, getTargetMediaState, isMediaState, mediaStatesEqual } from './media-state.js'; describe(createMediaState.name, () => { + it('should default loop state to false', () => { + expect(createMediaState().loop).toBe(false); + expect(createMediaState({ loop: true }).loop).toBe(true); + }); + it('should copy and freeze buffered time spans', () => { const span = { start: 0, end: 10 }; const buffered = [span]; @@ -19,6 +24,12 @@ describe(createMediaState.name, () => { }); describe(isMediaState.name, () => { + it('should require a boolean loop state', () => { + expect(isMediaState({ ...createMediaState(), loop: undefined })).toBe(false); + expect(isMediaState({ ...createMediaState(), loop: 'true' })).toBe(false); + expect(isMediaState(createMediaState({ loop: true }))).toBe(true); + }); + it('should validate ordered and disjoint buffered time spans', () => { expect( isMediaState( @@ -60,6 +71,11 @@ describe(getTargetMediaState.name, () => { }); describe(mediaStatesEqual.name, () => { + it('should detect loop-only changes', () => { + expect(mediaStatesEqual(createMediaState(), createMediaState({ loop: true }))).toBe(false); + expect(mediaStatesEqual(createMediaState({ loop: true }), createMediaState({ loop: true }))).toBe(true); + }); + it('should compare buffered time spans by value', () => { const state = createMediaState({ buffered: [ diff --git a/projects/media/src/internal/media-state.ts b/projects/media/src/internal/media-state.ts index 3836265a0c..27001590fa 100644 --- a/projects/media/src/internal/media-state.ts +++ b/projects/media/src/internal/media-state.ts @@ -14,6 +14,7 @@ export interface MediaState { readonly duration: number; readonly ended: boolean; readonly fullscreen: boolean; + readonly loop: boolean; readonly muted: boolean; readonly paused: boolean; readonly playbackRate: number; @@ -25,7 +26,7 @@ export type MediaStateChangeEvent = CustomEvent; export const mediaStateChange = 'media-state-change'; -const booleanStateKeys = ['ended', 'fullscreen', 'muted', 'paused', 'seeking'] as const; +const booleanStateKeys = ['ended', 'fullscreen', 'loop', 'muted', 'paused', 'seeking'] as const; const numberStateKeys = ['currentTime', 'duration', 'playbackRate', 'volume'] as const; @@ -37,6 +38,7 @@ const defaultMediaState: MediaState = { duration: 0, ended: false, fullscreen: false, + loop: false, muted: false, paused: true, playbackRate: 1, @@ -61,15 +63,8 @@ export function isMediaState(value: unknown): value is MediaState { export function mediaStatesEqual(a: MediaState, b: MediaState) { return ( mediaTimeSpansEqual(a.buffered, b.buffered) && - a.currentTime === b.currentTime && - a.duration === b.duration && - a.ended === b.ended && - a.fullscreen === b.fullscreen && - a.muted === b.muted && - a.paused === b.paused && - a.playbackRate === b.playbackRate && - a.seeking === b.seeking && - a.volume === b.volume + booleanStateKeys.every(key => a[key] === b[key]) && + numberStateKeys.every(key => a[key] === b[key]) ); } diff --git a/projects/media/src/loop-button/define.ts b/projects/media/src/loop-button/define.ts new file mode 100644 index 0000000000..fe1c544320 --- /dev/null +++ b/projects/media/src/loop-button/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { MediaLoopButton } from '@nvidia-elements/media/loop-button'; + +define(MediaLoopButton); + +declare global { + interface HTMLElementTagNameMap { + 'nve-media-loop-button': MediaLoopButton; + } +} diff --git a/projects/media/src/loop-button/index.ts b/projects/media/src/loop-button/index.ts new file mode 100644 index 0000000000..f93201b5c5 --- /dev/null +++ b/projects/media/src/loop-button/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './loop-button.js'; diff --git a/projects/media/src/loop-button/loop-button.css b/projects/media/src/loop-button/loop-button.css new file mode 100644 index 0000000000..4fc0cb99ef --- /dev/null +++ b/projects/media/src/loop-button/loop-button.css @@ -0,0 +1,2 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ diff --git a/projects/media/src/loop-button/loop-button.examples.ts b/projects/media/src/loop-button/loop-button.examples.ts new file mode 100644 index 0000000000..7fce28db56 --- /dev/null +++ b/projects/media/src/loop-button/loop-button.examples.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; + +export default { + title: 'Media/LoopButton', + component: 'nve-media-loop-button' +}; + +/** + * @summary Loop button connected to a video controller. Use with playback controls to repeat a recording continuously until the user disables looping. + */ +export const Default = { + render: () => html` + + +
+ + +
+
+ ` +}; + +/** + * @summary Video with looping enabled in native markup. Use when a recording should repeat from the first playback and the loop button should show its current state. + */ +export const InitiallyLooping = { + render: () => html` + + +
+ + +
+
+ ` +}; diff --git a/projects/media/src/loop-button/loop-button.test.axe.ts b/projects/media/src/loop-button/loop-button.test.axe.ts new file mode 100644 index 0000000000..384940d091 --- /dev/null +++ b/projects/media/src/loop-button/loop-button.test.axe.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { MediaLoopButton } from './loop-button.js'; +import './define.js'; + +describe(MediaLoopButton.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + + `); + const element = fixture.querySelector(MediaLoopButton.metadata.tag) as MediaLoopButton; + await elementIsStable(element); + + const results = await runAxe([MediaLoopButton.metadata.tag]); + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/media/src/loop-button/loop-button.test.lighthouse.ts b/projects/media/src/loop-button/loop-button.test.lighthouse.ts new file mode 100644 index 0000000000..d6f4a1586e --- /dev/null +++ b/projects/media/src/loop-button/loop-button.test.lighthouse.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('media loop button lighthouse report', () => { + test('media loop button should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-media-loop-button', /* html */` + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(21.5); + }); +}); diff --git a/projects/media/src/loop-button/loop-button.test.ssr.ts b/projects/media/src/loop-button/loop-button.test.ssr.ts new file mode 100644 index 0000000000..4b53a4d514 --- /dev/null +++ b/projects/media/src/loop-button/loop-button.test.ssr.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { MediaLoopButton } from './loop-button.js'; +import './define.js'; + +describe(MediaLoopButton.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(MediaLoopButton.metadata.tag)).toBe(true); + }); +}); diff --git a/projects/media/src/loop-button/loop-button.test.ts b/projects/media/src/loop-button/loop-button.test.ts new file mode 100644 index 0000000000..dadc629bc2 --- /dev/null +++ b/projects/media/src/loop-button/loop-button.test.ts @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing'; +import { mediaCommands } from '../internal/media-command.js'; +import { createMediaState, mediaStateChange, type MediaState } from '../internal/media-state.js'; +import { MediaLoopButton } from './loop-button.js'; +import './define.js'; + +type MediaStateTarget = HTMLElement & { mediaState: MediaState }; + +describe(MediaLoopButton.metadata.tag, () => { + let fixture: HTMLElement; + let button: MediaLoopButton; + let target: MediaStateTarget; + + beforeEach(async () => { + fixture = await createFixture(html` + +
+ `); + button = getElement(fixture, MediaLoopButton.metadata.tag); + target = getElement(fixture, '#target'); + setMediaState(target, { loop: false }); + await elementIsStable(button); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define a command button with unpressed state', () => { + expect(customElements.get(MediaLoopButton.metadata.tag)).toBeDefined(); + expect(button.type).toBe('button'); + expect(button.command).toBe(mediaCommands.toggleLoop); + expect(button.pressed).toBe(false); + expect(button._internals.ariaPressed).toBe('false'); + expect(button._internals.role).toBe('button'); + }); + + it('should dispatch the loop command without optimistically changing state', async () => { + const event = untilEvent(target, 'command'); + await emulateClick(button); + const command = await event; + expect(command.command).toBe(mediaCommands.toggleLoop); + expect(command.source).toBe(button); + expect(button.pressed).toBe(false); + }); + + it('should preserve explicit commands', async () => { + button.command = mediaCommands.enableLoop; + await elementIsStable(button); + const event = untilEvent(target, 'command'); + await emulateClick(button); + expect((await event).command).toBe(mediaCommands.enableLoop); + }); + + it('should not submit its form by default', async () => { + const form = document.createElement('form'); + fixture.append(form); + form.append(button); + const submit = vi.fn((event: SubmitEvent) => event.preventDefault()); + form.addEventListener('submit', submit); + await elementIsStable(button); + await emulateClick(button); + expect(submit).not.toHaveBeenCalled(); + expect([...new FormData(form)]).toEqual([]); + }); + + it('should sync pressed, ARIA, and icon state without emitting interaction events', async () => { + const input = vi.fn(); + const change = vi.fn(); + const command = vi.fn(); + button.addEventListener('input', input); + button.addEventListener('change', change); + target.addEventListener('command', command); + expect(getElement(button.renderRoot, 'nve-icon').getAttribute('name')).toBe('looping-off'); + + setMediaState(target, { loop: true }); + await elementIsStable(button); + expect(button.pressed).toBe(true); + expect(button.hasAttribute('pressed')).toBe(true); + expect(button.matches(':state(pressed)')).toBe(true); + expect(button._internals.ariaPressed).toBe('true'); + expect(getElement(button.renderRoot, 'nve-icon').getAttribute('name')).toBe('looping'); + + setMediaState(target, { loop: false }); + await elementIsStable(button); + expect(button.pressed).toBe(false); + expect(button.hasAttribute('pressed')).toBe(false); + expect(button.matches(':state(pressed)')).toBe(false); + expect(button._internals.ariaPressed).toBe('false'); + expect(input).not.toHaveBeenCalled(); + expect(change).not.toHaveBeenCalled(); + expect(command).not.toHaveBeenCalled(); + }); + + it('should read initial state and detach from its previous command target', async () => { + const next = document.createElement('div'); + const nextTarget = Object.assign(next, { mediaState: createMediaState({ loop: true }) }); + fixture.append(nextTarget); + button.commandForElement = nextTarget; + await elementIsStable(button); + expect(button.pressed).toBe(true); + + setMediaState(target, { loop: false }); + await elementIsStable(button); + expect(button.pressed).toBe(true); + + button.commandForElement = null; + button.removeAttribute('commandfor'); + await elementIsStable(button); + expect(button.pressed).toBe(false); + setMediaState(nextTarget, { loop: true }); + await elementIsStable(button); + expect(button.pressed).toBe(false); + }); + + it('should clear stale state and subscribe again after reconnection', async () => { + setMediaState(target, { loop: true }); + await elementIsStable(button); + button.remove(); + expect(button.pressed).toBe(false); + setMediaState(target, { loop: false }); + fixture.append(button); + await elementIsStable(button); + setMediaState(target, { loop: true }); + await elementIsStable(button); + expect(button.pressed).toBe(true); + }); + + it('should ignore invalid controller snapshots', async () => { + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: { loop: true } })); + await elementIsStable(button); + expect(button.pressed).toBe(false); + }); + + it.each([ + [mediaCommands.toggleLoop, 'enable looping', 'repeat recording'], + [mediaCommands.enableLoop, 'enable looping', 'repeat recording'], + [mediaCommands.disableLoop, 'disable looping', 'stop repeating'] + ])('should use a stable localized name for %s in both states', async (command, defaultName, localizedName) => { + button.command = command; + await elementIsStable(button); + expect(button._internals.ariaLabel).toBe(defaultName); + button.i18n = { enableLoop: 'repeat recording', disableLoop: 'stop repeating' }; + await elementIsStable(button); + expect(button._internals.ariaLabel).toBe(localizedName); + expect(button._internals.ariaPressed).toBe('false'); + setMediaState(target, { loop: true }); + await elementIsStable(button); + expect(button._internals.ariaLabel).toBe(localizedName); + expect(button._internals.ariaPressed).toBe('true'); + setMediaState(target, { loop: false }); + await elementIsStable(button); + expect(button._internals.ariaLabel).toBe(localizedName); + expect(button._internals.ariaPressed).toBe('false'); + button.i18n = { enableLoop: undefined, disableLoop: undefined }; + await elementIsStable(button); + expect(button._internals.ariaLabel).toBe(null); + }); + + it('should preserve an explicit accessible name', async () => { + button.ariaLabel = 'repeat this clip'; + setMediaState(target, { loop: true }); + await elementIsStable(button); + await expect.element(button).toHaveAccessibleName('repeat this clip'); + }); + + it('should preserve authored pressed markup and custom icon content', async () => { + const standalone = await createFixture(html` + repeat + `); + try { + const control = getElement(standalone, MediaLoopButton.metadata.tag); + await elementIsStable(control); + expect(control.pressed).toBe(true); + expect(control._internals.ariaPressed).toBe('true'); + const slot = getElement(control.renderRoot, 'slot'); + expect(slot.assignedElements()[0]?.textContent).toBe('repeat'); + } finally { + removeFixture(standalone); + } + }); + + it.each(['Enter', ' '])('should dispatch a command on the %s key', async key => { + button.focus(); + const event = untilEvent(target, 'command'); + await userEvent.keyboard(key === 'Enter' ? '{Enter}' : ' '); + expect((await event).command).toBe(mediaCommands.toggleLoop); + }); +}); + +function getElement(root: ParentNode, selector: string): T { + const element = root.querySelector(selector); + if (!element) { + throw new Error(`Missing element: ${selector}`); + } + return element; +} + +function setMediaState(target: MediaStateTarget, state: Partial) { + target.mediaState = createMediaState(state); + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState })); +} diff --git a/projects/media/src/loop-button/loop-button.test.visual.ts b/projects/media/src/loop-button/loop-button.test.visual.ts new file mode 100644 index 0000000000..0735d94f7d --- /dev/null +++ b/projects/media/src/loop-button/loop-button.test.visual.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('media loop button visual', () => { + test('media loop button should match visual baseline', async () => { + const report = await visualRunner.render('media-loop-button', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('media loop button should match visual baseline dark theme', async () => { + const report = await visualRunner.render('media-loop-button.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ + + +
+ `; +} diff --git a/projects/media/src/loop-button/loop-button.ts b/projects/media/src/loop-button/loop-button.ts new file mode 100644 index 0000000000..780cbdd628 --- /dev/null +++ b/projects/media/src/loop-button/loop-button.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { property } from 'lit/decorators/property.js'; +import type { PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; +import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins'; +import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { Icon } from '@nvidia-elements/core/icon'; +import { MediaStateController } from '../internal/controllers/media-state.controller.js'; +import { mediaCommands } from '../internal/media-command.js'; +import type { MediaState } from '../internal/media-state.js'; +import mediaButtonStyles from '../internal/media-button.css?inline'; +import styles from './loop-button.css?inline'; + +/** + * @element nve-media-loop-button + * @description Requests continuous looping changes for the controlled media. + * @documentation https://nvidia.github.io/elements/docs/media/loop-button/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/media/loop-button + * @slot - Optional custom icon content. + * @cssprop --background + * @cssprop --color + * @cssprop --border + * @cssprop --border-radius + * @cssprop --height + * @cssprop --width + * @cssprop --padding + * @csspart icon - The fallback icon element. + * @aria https://www.w3.org/WAI/ARIA/apg/patterns/button/ + * @stable false + */ +@scopedRegistry() +export class MediaLoopButton extends ButtonFormControlMixin(LitElement) { + static styles = useStyles([mediaButtonStyles, styles]); + + static elementDefinitions = { + [Icon.metadata.tag]: Icon + }; + + static readonly metadata = { + tag: 'nve-media-loop-button', + version: '0.0.0' + }; + + override command: string = mediaCommands.toggleLoop; + + @property({ type: Boolean, reflect: true, noAccessor: true }) declare pressed: boolean; + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating internal string values for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + #mediaStateController = new MediaStateController(this, mediaState => this.#syncPressedState(mediaState)); + + constructor() { + super(); + this.type = 'button'; + this.pressed = false; + } + + render() { + return html` + + `; + } + + get #iconName() { + return this.pressed ? 'looping' : 'looping-off'; + } + + get #defaultAriaLabel() { + return (this.command === mediaCommands.disableLoop ? this.i18n.disableLoop : this.i18n.enableLoop) ?? null; + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + this.#syncDefaultAriaLabel(); + } + + #syncDefaultAriaLabel() { + attachInternals(this); + this._internals.ariaLabel = this.#defaultAriaLabel; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.#syncPressedState(null); + } + + #syncPressedState = (state: MediaState | null) => { + this.pressed = state?.loop ?? false; // eslint-disable-line local/stateless-property -- Loop button mirrors the target controller state. + }; +} diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index b44cc996f8..08617b8716 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -322,6 +322,7 @@ export const renderDocsNav = data => /* html */ ` Controller Pause Button Playback Rate Select + Loop Button Mute Button Seek Button Time Range diff --git a/projects/site/src/docs/elements/index.11ty.js b/projects/site/src/docs/elements/index.11ty.js index 84b93d6c4b..bdc4a3e4b0 100644 --- a/projects/site/src/docs/elements/index.11ty.js +++ b/projects/site/src/docs/elements/index.11ty.js @@ -237,6 +237,7 @@ const previewOverrides = { 'nve-monaco-input': renderMonacoMock('nve-monaco-input'), 'nve-monaco-problems': renderMonacoMock('nve-monaco-problems'), 'nve-media-fullscreen-button': /* html */ ``, + 'nve-media-loop-button': /* html */ ``, 'nve-media-mute-button': /* html */ ``, 'nve-media-pause-button': /* html */ ``, 'nve-media-playback-rate-select': /* html */ ``, diff --git a/projects/site/src/docs/media/controller.md b/projects/site/src/docs/media/controller.md index 5ecc8a99d1..7e1be74af9 100644 --- a/projects/site/src/docs/media/controller.md +++ b/projects/site/src/docs/media/controller.md @@ -31,7 +31,7 @@ ## State -Read the latest immutable state from `mediaState`. The state includes `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `currentTime`, `duration`, `volume`, `playbackRate`, and `buffered`. +Read the latest immutable state from `mediaState`. The state includes `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `loop`, `currentTime`, `duration`, `volume`, `playbackRate`, and `buffered`. The `buffered` value contains ordered, disjoint time spans in seconds. The controller copies these spans from the native media element instead of exposing the browser-owned `TimeRanges` object. @@ -49,6 +49,6 @@ console.log(controller.mediaState); controller.addEventListener('media-state-change', event => console.log(event.detail)); ``` -The controller continues to reflect `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `current-time`, `duration`, `volume`, and `playback-rate` for styling and inspection. Buffered spans remain available through `mediaState` and `media-state-change`. The native media element still emits events such as `play`, `pause`, `progress`, `timeupdate`, `durationchange`, `volumechange`, and `ratechange`. +The controller continues to reflect `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `loop`, `current-time`, `duration`, `volume`, and `playback-rate` for styling and inspection. Buffered spans remain available through `mediaState` and `media-state-change`. The native media element still emits events such as `play`, `pause`, `progress`, `timeupdate`, `durationchange`, `volumechange`, and `ratechange`. The controller does not include `src`, `poster`, `autoplay`, track, or caption APIs. Use native media markup for media ownership and compose the Elements controls around it. diff --git a/projects/site/src/docs/media/loop-button.md b/projects/site/src/docs/media/loop-button.md new file mode 100644 index 0000000000..a692f0e66f --- /dev/null +++ b/projects/site/src/docs/media/loop-button.md @@ -0,0 +1,30 @@ +--- +{ + title: 'Media Loop Button', + layout: 'docs.11ty.js', + tag: 'nve-media-loop-button', + hideExamplesTab: true +} +--- + +## Installation + +{% install 'nve-media-loop-button' %} + +## Default + +{% example '@nvidia-elements/media/loop-button/loop-button.examples.json', 'Default' %} + +## Initial State + +Set `loop` on the native `video` or `audio` element to enable continuous replay before the first playback. The controller reads that setting and keeps the button in sync with changes to the native `loop` property or attribute. + +{% example '@nvidia-elements/media/loop-button/loop-button.examples.json', 'InitiallyLooping' %} + +## Commands + +The controller supports `--enable-loop`, `--disable-loop`, and `--toggle-loop`. Use the button's `command` attribute to request a specific setting instead of toggling. + +```html + +```