Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions projects/core/src/combobox/combobox.test.lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions projects/core/src/internal/services/i18n.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions projects/core/src/internal/services/i18n.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface I18nStrings {
maxFileSize: string;
create: string;
currentTime: string;
enableLoop: string;
disableLoop: string;
enterFullscreen: string;
exitFullscreen: string;
muteMedia: string;
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion projects/core/src/time/time.test.lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,6 +69,7 @@ describe('noInvalidInvokerTriggers', () => {
'<nve-media-seek-button commandfor="my-player"></nve-media-seek-button>',
'<nve-media-time-range commandfor="my-player"></nve-media-time-range>',
'<nve-media-volume-range commandfor="my-player"></nve-media-volume-range>',
'<nve-media-loop-button commandfor="my-player"></nve-media-loop-button>',
'<nve-media-fullscreen-button commandfor="my-player"></nve-media-fullscreen-button>'
],
invalid: []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions projects/media/.visual/media-loop-button.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions projects/media/.visual/media-loop-button.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions projects/media/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions projects/media/src/controller/controller.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const Default = {
<nve-media-mute-button commandfor="example-media" name="muted" value="true" style="margin-left: auto"></nve-media-mute-button>
<nve-media-volume-range commandfor="example-media" name="volume" style="max-width: 100px"></nve-media-volume-range>
<nve-media-playback-rate-select commandfor="example-media" name="playbackRate"></nve-media-playback-rate-select>
<nve-media-loop-button commandfor="example-media"></nve-media-loop-button>
<nve-media-fullscreen-button commandfor="example-media"></nve-media-fullscreen-button>
</div>
</div>
Expand Down
182 changes: 182 additions & 0 deletions projects/media/src/controller/controller.loop.test.ts
Original file line number Diff line number Diff line change
@@ -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`
<nve-media-controller id="loop-controller">
${tag === 'video' ? html`<video loop></video>` : html`<audio loop></audio>`}
</nve-media-controller>
<nve-media-loop-button commandfor="loop-controller"></nve-media-loop-button>
`);
controller = getElement<MediaController>(fixture, MediaController.metadata.tag);
media = getElement<HTMLMediaElement>(controller, tag);
button = getElement<MediaLoopButton>(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<T extends Element>(root: ParentNode, selector: string): T {
const element = root.querySelector<T>(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);
}
Loading
Loading