fix(remote-control): apply volume to built-in M3U players

Closes #1061
This commit is contained in:
4gray authored and GitHub committed 2026-06-14 13:12:45 +02:00
1 parent adf37e7504
commit bc4a5958dc
14 files changed
+528 -97

No files matched your search

@@ -47,6 +47,7 @@ export type M3uTestChannel = {
groupTitle?: string;
logo?: string;
name: string;
radio?: boolean;
tvgId?: string;
tvgName?: string;
url: string;
@@ -616,6 +617,7 @@ export function buildM3uContent(channels: M3uTestChannel[]): string {
channel.tvgName ? `tvg-name="${channel.tvgName}"` : '',
channel.logo ? `tvg-logo="${channel.logo}"` : '',
channel.groupTitle ? `group-title="${channel.groupTitle}"` : '',
channel.radio ? 'radio="true"' : '',
]
.filter(Boolean)
.join(' ');
@@ -648,6 +650,7 @@ export function parseM3uFixture(filePath: string): M3uTestChannel[] {
groupTitle?: string;
logo?: string;
name: string;
radio?: boolean;
tvgId?: string;
tvgName?: string;
}
@@ -665,6 +668,7 @@ export function parseM3uFixture(filePath: string): M3uTestChannel[] {
line.match(/group-title="([^"]*)"/)?.[1]?.trim() ?? '',
logo: line.match(/tvg-logo="([^"]*)"/)?.[1]?.trim() ?? '',
name: line.split(',').at(-1)?.trim() ?? '',
radio: /radio="true"/i.test(line),
tvgId: line.match(/tvg-id="([^"]*)"/)?.[1]?.trim() ?? '',
tvgName: line.match(/tvg-name="([^"]*)"/)?.[1]?.trim() ?? '',
};
@@ -0,0 +1,273 @@
import { APIRequestContext, Page } from '@playwright/test';
import { AddressInfo, createServer as createNetServer } from 'net';
import {
channelItemByTitle,
closeElectronApp,
enableRemoteControl,
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openSettings,
saveSettings,
test,
waitForM3uCatalog,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
type RemoteControlStatus = {
channelName?: string;
isLiveView: boolean;
muted?: boolean;
portal: 'm3u' | 'xtream' | 'stalker' | 'unknown';
supportsVolume?: boolean;
volume?: number;
};
test.describe('Electron Remote Control', () => {
test('@remote-control @m3u @electron applies remote volume commands to the selected built-in video player', async ({
dataDir,
request,
}) => {
const remotePort = await reserveFreePort();
const channelName = 'Remote ArtPlayer Channel';
const playlistFile = writeTemporaryM3uFile(
dataDir,
'remote-control-video.m3u',
[
{
groupTitle: 'Remote',
name: channelName,
url: 'https://example.channels/remote-art-player.m3u8',
},
]
);
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await selectSettingsOption(
app.mainWindow,
'select-video-player',
'artplayer'
);
await enableRemoteControl(app.mainWindow, remotePort);
await saveSettings(app.mainWindow);
await waitForRemoteControlServer(request, remotePort);
await goToDashboard(app.mainWindow);
await importM3uPlaylistFromNativeDialog(app, playlistFile);
await waitForM3uCatalog(app.mainWindow);
await channelItemByTitle(app.mainWindow, channelName)
.first()
.click();
const playerVideo = app.mainWindow
.locator('app-art-player video')
.first();
await expect(playerVideo).toHaveCount(1, { timeout: 20000 });
await waitForRemoteStatus(request, remotePort, (status) => {
return (
status.portal === 'm3u' &&
status.channelName === channelName &&
status.supportsVolume === true &&
roundVolume(status.volume) === 1
);
});
await postRemoteCommand(request, remotePort, '/volume/down');
await waitForRemoteStatus(request, remotePort, (status) => {
return (
status.portal === 'm3u' &&
status.channelName === channelName &&
status.supportsVolume === true &&
roundVolume(status.volume) === 0.9 &&
status.muted === false
);
});
await expect
.poll(() =>
readMediaVolume(app.mainWindow, 'app-art-player video')
)
.toBe(0.9);
} finally {
await closeElectronApp(app);
}
});
test('@remote-control @m3u @electron applies remote volume commands to radio audio playback', async ({
dataDir,
request,
}) => {
const remotePort = await reserveFreePort();
const channelName = 'Remote Radio Channel';
const playlistFile = writeTemporaryM3uFile(
dataDir,
'remote-control-radio.m3u',
[
{
groupTitle: 'Radio',
name: channelName,
radio: true,
url: 'https://example.channels/remote-radio-stream.mp3',
},
]
);
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await enableRemoteControl(app.mainWindow, remotePort);
await saveSettings(app.mainWindow);
await waitForRemoteControlServer(request, remotePort);
await goToDashboard(app.mainWindow);
await importM3uPlaylistFromNativeDialog(app, playlistFile);
await waitForM3uCatalog(app.mainWindow);
await channelItemByTitle(app.mainWindow, channelName)
.first()
.click();
const audio = app.mainWindow
.locator('app-audio-player audio')
.first();
await expect(audio).toHaveCount(1, { timeout: 20000 });
await waitForRemoteStatus(request, remotePort, (status) => {
return (
status.portal === 'm3u' &&
status.channelName === channelName &&
status.supportsVolume === true &&
roundVolume(status.volume) === 1
);
});
await postRemoteCommand(request, remotePort, '/volume/down');
await waitForRemoteStatus(request, remotePort, (status) => {
return (
status.portal === 'm3u' &&
status.channelName === channelName &&
status.supportsVolume === true &&
roundVolume(status.volume) === 0.9 &&
status.muted === false
);
});
await expect
.poll(() =>
readMediaVolume(app.mainWindow, 'app-audio-player audio')
)
.toBe(0.9);
} finally {
await closeElectronApp(app);
}
});
});
async function selectSettingsOption(
page: Page,
selectTestId: string,
optionTestId: string
): Promise<void> {
await page.getByTestId(selectTestId).click();
await page.getByTestId(optionTestId).click();
}
async function reserveFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createNetServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address() as AddressInfo | null;
if (!address) {
server.close();
reject(new Error('Could not reserve a remote control port.'));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function waitForRemoteControlServer(
request: APIRequestContext,
port: number
): Promise<void> {
await waitForRemoteStatus(
request,
port,
(status) => status.portal === 'unknown' && status.isLiveView === false
);
}
async function waitForRemoteStatus(
request: APIRequestContext,
port: number,
predicate: (status: RemoteControlStatus) => boolean
): Promise<RemoteControlStatus> {
let latestStatus: RemoteControlStatus | null = null;
await expect
.poll(
async () => {
latestStatus = await getRemoteStatus(request, port);
return latestStatus ? predicate(latestStatus) : false;
},
{ timeout: 20000 }
)
.toBe(true);
return latestStatus as RemoteControlStatus;
}
async function getRemoteStatus(
request: APIRequestContext,
port: number
): Promise<RemoteControlStatus | null> {
try {
const response = await request.get(remoteControlUrl(port, '/status'), {
timeout: 1000,
});
if (!response.ok()) {
return null;
}
return (await response.json()) as RemoteControlStatus;
} catch {
return null;
}
}
async function postRemoteCommand(
request: APIRequestContext,
port: number,
path: string
): Promise<void> {
const response = await request.post(remoteControlUrl(port, path), {
data: {},
});
expect(response.ok()).toBe(true);
}
function remoteControlUrl(port: number, path: string): string {
return `http://127.0.0.1:${port}/api/remote-control${path}`;
}
async function readMediaVolume(page: Page, selector: string): Promise<number> {
return page.locator(selector).first().evaluate((element) => {
return Number((element as HTMLMediaElement).volume.toFixed(2));
});
}
function roundVolume(volume: number | undefined): number | null {
if (volume === undefined) {
return null;
}
return Number(volume.toFixed(2));
}
+3 -1
View File
@@ -131,6 +131,8 @@ Implemented behavior:
- up/down in 0.1 increments
- toggle mute with last non-zero volume restore
- persists to `localStorage`
- propagates to built-in inline players: Video.js, HTML5, ArtPlayer, and radio `AudioPlayerComponent`
- does not control external MPV/VLC sessions or the experimental Embedded MPV player
- Publishes status snapshots via `updateRemoteControlStatus(...)`:
- `portal: 'm3u'`
- `isLiveView: true`
@@ -230,7 +232,7 @@ Implemented UI behavior:
| Channel up/down | Yes | Yes | Yes |
| Number select | Yes | Yes | Yes |
| Status publish | Yes | Yes | Yes |
| Volume command handling | Yes | No | No |
| Volume command handling | Yes, for built-in inline M3U players | No | No |
| `supportsVolume` in status | true | false | false |
## Known limitations
@@ -70,6 +70,8 @@
[channelName]="
activeChannel.name || activeChannel?.tvg?.name || ''
"
[volume]="volume()"
(volumeChange)="onInlineVolumeChange($event)"
/>
} @else {
<app-web-player-view
@@ -77,7 +79,7 @@
[title]="embeddedPlayback()?.title ?? ''"
[playback]="embeddedPlayback()"
[playerOverride]="playerSettings.player ?? null"
[volume]="volume"
[volume]="volume()"
[showCaptions]="!!playerSettings.showCaptions"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
@@ -8,6 +8,7 @@ import {
signal,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -115,6 +116,8 @@ class StubAudioPlayerComponent {
readonly url = input('');
readonly icon = input('');
readonly channelName = input('');
readonly volume = input<number | null>(null);
readonly volumeChange = output<number>();
}
@Component({
@@ -558,6 +561,48 @@ describe('VideoPlayerComponent', () => {
).toBe(false);
});
it('passes remote volume changes to the radio audio player', () => {
const radioChannel = {
...sampleChannel,
radio: 'true',
} as Channel;
syncStoreState(radioChannel);
fixture.detectChanges();
(
component as unknown as {
handleRemoteControlCommand(command: {
type: 'volume-down';
}): void;
}
).handleRemoteControlCommand({ type: 'volume-down' });
fixture.detectChanges();
const audioPlayer = fixture.debugElement.query(
By.directive(StubAudioPlayerComponent)
).componentInstance as StubAudioPlayerComponent;
expect(audioPlayer.volume()).toBe(0.9);
});
it('keeps parent volume state in sync with radio player controls', () => {
const radioChannel = {
...sampleChannel,
radio: 'true',
} as Channel;
syncStoreState(radioChannel);
fixture.detectChanges();
const audioPlayer = fixture.debugElement.query(
By.directive(StubAudioPlayerComponent)
).componentInstance as StubAudioPlayerComponent;
audioPlayer.volumeChange.emit(0.35);
fixture.detectChanges();
expect(audioPlayer.volume()).toBe(0.35);
expect(localStorage.getItem('volume')).toBe('0.35');
});
it('restores the collapsed live EPG panel state for inline playback', () => {
fixture.destroy();
localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'collapsed');
@@ -274,13 +274,13 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
showChannelNumberOverlay = false;
private channelNumberTimeout?: number;
volume = 1;
readonly volume = signal(1);
constructor() {
// Initialize volume from localStorage in constructor
const savedVolume = localStorage.getItem('volume');
if (savedVolume !== null) {
this.volume = Number(savedVolume);
this.volume.set(Number(savedVolume));
}
// React to settings changes
@@ -434,8 +434,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
epgStart: currentEpgProgram?.start,
epgEnd: currentEpgProgram?.stop,
supportsVolume: true,
volume: this.volume,
muted: this.volume === 0,
volume: this.volume(),
muted: this.volume() === 0,
});
});
}
@@ -788,14 +788,14 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
if (command.type === 'volume-up') {
this.setVolume(this.volume + 0.1);
this.setVolume(this.volume() + 0.1);
} else if (command.type === 'volume-down') {
this.setVolume(this.volume - 0.1);
this.setVolume(this.volume() - 0.1);
} else if (command.type === 'volume-toggle-mute') {
if (this.volume === 0) {
if (this.volume() === 0) {
this.setVolume(this.lastKnownVolume || 1);
} else {
this.lastKnownVolume = this.volume;
this.lastKnownVolume = this.volume();
this.setVolume(0);
}
}
@@ -803,7 +803,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
private setVolume(next: number): void {
const clamped = Math.max(0, Math.min(1, Number(next.toFixed(2))));
this.volume = clamped;
this.volume.set(clamped);
if (clamped > 0) {
this.lastKnownVolume = clamped;
}
@@ -815,12 +815,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
portal: 'm3u',
isLiveView: true,
supportsVolume: true,
volume: this.volume,
muted: this.volume === 0,
volume: this.volume(),
muted: this.volume() === 0,
});
}
}
onInlineVolumeChange(volume: number): void {
this.setVolume(volume);
}
private get remoteControlBridge(): Window['electron'] | undefined {
return this.runtime.supportsRemoteControl ? window.electron : undefined;
}
@@ -0,0 +1,46 @@
import Artplayer from 'artplayer';
import Hls from 'hls.js';
type AudioTrackSelector = {
html: string | HTMLElement;
default?: boolean;
};
const AUDIO_TRACK_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" fill="white">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>`;
export function addHlsAudioTrackSettings(
player: Artplayer,
hls: Hls
): void {
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
const tracks = hls.audioTracks;
if (!tracks || tracks.length <= 1) return;
player.setting.add({
html: 'Audio',
icon: AUDIO_TRACK_ICON,
width: 220,
tooltip: tracks[hls.audioTrack]?.name || '',
selector: tracks.map((track, index) => ({
html: track.name || track.lang || `Track ${index + 1}`,
default: index === hls.audioTrack,
})),
onSelect: function (this: Artplayer, item: AudioTrackSelector) {
const selectedLabel =
typeof item.html === 'string'
? item.html
: (item.html.textContent ?? '');
const selectedIndex = tracks.findIndex(
(track, index) =>
(track.name || track.lang || `Track ${index + 1}`) ===
selectedLabel
);
if (selectedIndex >= 0) {
hls.audioTrack = selectedIndex;
}
},
});
});
}
@@ -0,0 +1,9 @@
<div class="art-player-shell">
<div #artplayer class="artplayer-container"></div>
<app-series-playback-navigation-controls
[navigation]="seriesNavigation"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
</div>
@@ -0,0 +1,18 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.art-player-shell {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.artplayer-container {
width: 100%;
height: 100%;
}
@@ -16,8 +16,10 @@ class MockArtplayer {
readonly destroy = jest.fn();
readonly currentTime = 0;
readonly duration = 0;
volume: number;
constructor(readonly options: Record<string, unknown>) {
this.volume = Number(options['volume'] ?? 1);
artPlayerInstances.push(this);
}
}
@@ -208,6 +210,20 @@ describe('ArtPlayerComponent', () => {
expect(artPlayerInstances[0].options['isLive']).toBe(true);
});
it('applies volume input changes without recreating the player', () => {
createComponent({
url: 'https://example.com/live/channel.m3u8',
name: 'HLS Live',
});
const player = artPlayerInstances[0];
fixture.componentRef.setInput('volume', 0.37);
fixture.detectChanges();
expect(player.volume).toBe(0.37);
expect(artPlayerInstances).toHaveLength(1);
});
it('emits a playback issue when mpegts.js reports an unsupported codec', () => {
createComponent({
url: 'https://example.com/live/channel.ts',
@@ -14,6 +14,7 @@ import Artplayer from 'artplayer';
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
import mpegts from 'mpegts.js';
import { Channel } from '@iptvnator/shared/interfaces';
import { addHlsAudioTrackSettings } from './art-player-audio-tracks';
import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
@@ -27,47 +28,13 @@ import {
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
type AudioTrackSelector = {
html: string | HTMLElement;
default?: boolean;
};
Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000;
@Component({
selector: 'app-art-player',
imports: [SeriesPlaybackNavigationControlsComponent],
template: `
<div class="art-player-shell">
<div #artplayer class="artplayer-container"></div>
<app-series-playback-navigation-controls
[navigation]="seriesNavigation"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
</div>
`,
styles: [
`
:host {
display: block;
width: 100%;
height: 100%;
}
.art-player-shell {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.artplayer-container {
width: 100%;
height: 100%;
}
`,
],
templateUrl: './art-player.component.html',
styleUrls: ['./art-player.component.scss'],
})
export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
@Input() channel!: Channel;
@@ -122,6 +89,9 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
this.destroyPlayer();
this.initPlayer();
}
if (changes['volume'] && this.player) {
this.applyVolume(changes['volume'].currentValue);
}
}
private destroyPlayer(): void {
@@ -170,7 +140,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
this.player = new Artplayer({
container: el,
url: this.channel.url + (this.channel.epgParams || ''),
volume: this.volume,
volume: this.clampVolume(this.volume),
isLive: isLive,
autoplay: true,
type: this.getVideoType(this.channel.url),
@@ -204,7 +174,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
});
this.hls.loadSource(url);
this.hls.attachMedia(video);
this.setupHlsAudioTracks();
addHlsAudioTrackSettings(this.player, this.hls);
} else if (
video.canPlayType('application/vnd.apple.mpegurl')
) {
@@ -279,6 +249,19 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
});
}
private applyVolume(value: number): void {
this.player.volume = this.clampVolume(value);
}
private clampVolume(value: number): number {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return 1;
}
return Math.max(0, Math.min(1, numericValue));
}
private handleHlsManifestParsed(
url: string,
data: ManifestParsedData
@@ -318,50 +301,6 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
);
}
/**
* Listens for HLS.js audio tracks and adds a settings menu entry
* to ArtPlayer for switching between available audio tracks.
*/
private setupHlsAudioTracks(): void {
if (!this.hls) return;
const hls = this.hls;
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
const tracks = hls.audioTracks;
if (!tracks || tracks.length <= 1) return;
const audioTrackSetting = {
html: 'Audio',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" fill="white">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>`,
width: 220,
tooltip: tracks[hls.audioTrack]?.name || '',
selector: tracks.map((track, index) => ({
html: track.name || track.lang || `Track ${index + 1}`,
default: index === hls.audioTrack,
})),
onSelect: function (this: Artplayer, item: AudioTrackSelector) {
const selectedLabel =
typeof item.html === 'string'
? item.html
: (item.html.textContent ?? '');
const selectedIndex = tracks.findIndex(
(t, i) =>
(t.name || t.lang || `Track ${i + 1}`) ===
selectedLabel
);
if (selectedIndex >= 0) {
hls.audioTrack = selectedIndex;
}
},
};
this.player.setting.add(audioTrackSetting);
});
}
private getVideoType(url: string): string {
const extension = getPlaybackMediaExtensionFromUrl(url);
switch (extension) {
@@ -72,6 +72,28 @@ describe('AudioPlayerComponent', () => {
expect(localStorage.getItem('volume')).toBe('0');
});
it('applies external volume input changes to the current audio element', () => {
const audio = createComponent();
fixture.componentRef.setInput('volume', 0.25);
fixture.detectChanges();
expect(component.volume()).toBe(0.25);
expect(audio.volume).toBe(0.25);
expect(localStorage.getItem('volume')).toBe('0.25');
expect(loadSpy).toHaveBeenCalledTimes(1);
});
it('emits local volume changes so parent playback state stays current', () => {
createComponent();
const emitted: number[] = [];
component.volumeChange.subscribe((volume) => emitted.push(volume));
component.setVolume(0.3);
expect(emitted).toEqual([0.3]);
});
it('handles volume and mute keyboard shortcuts without focusing inputs', () => {
createComponent();
component.setVolume(0.5);
@@ -9,6 +9,7 @@ import {
input,
output,
signal,
untracked,
viewChild,
} from '@angular/core';
import { extractDominantColor } from './extract-color';
@@ -171,8 +172,10 @@ export class AudioPlayerComponent {
readonly icon = input<string>('');
readonly url = input.required<string>();
readonly channelName = input<string>('');
readonly externalVolume = input<number | null>(null, { alias: 'volume' });
readonly dispatchAdjacentChannelAction = input(true);
readonly channelSwitchRequested = output<'next' | 'previous'>();
readonly volumeChange = output<number>();
readonly playState = signal<'play' | 'paused'>('paused');
readonly volume = signal(1);
@@ -200,12 +203,19 @@ export class AudioPlayerComponent {
this.volume.set(Math.max(0, Math.min(1, saved)));
}
effect(() => {
const volume = this.externalVolume();
if (volume === null) return;
this.setVolume(volume, { emitChange: false });
});
effect(() => {
const url = this.url();
const audio = this.audioRef()?.nativeElement;
if (!audio || !url) return;
audio.src = url;
audio.volume = this.volume();
audio.volume = untracked(() => this.volume());
audio.load();
this.logoError.set(false);
this.play();
@@ -264,12 +274,18 @@ export class AudioPlayerComponent {
this.playState.set('paused');
}
setVolume(value: number) {
setVolume(
value: number,
options: { emitChange?: boolean } = {}
) {
const clamped = Math.round(Math.max(0, Math.min(1, value)) * 100) / 100;
this.volume.set(clamped);
const audio = this.audioRef()?.nativeElement;
if (audio) audio.volume = clamped;
localStorage.setItem('volume', String(clamped));
if (options.emitChange !== false) {
this.volumeChange.emit(clamped);
}
}
mute() {
@@ -444,6 +444,41 @@ describe('WebPlayerViewComponent', () => {
expect(events).toEqual(['ended', 'previous', 'next']);
});
it.each([
{
player: VideoPlayer.VideoJs,
directive: StubVjsPlayerComponent,
},
{
player: VideoPlayer.Html5Player,
directive: StubHtmlVideoPlayerComponent,
},
{
player: VideoPlayer.ArtPlayer,
directive: StubArtPlayerComponent,
},
])(
'passes volume to $player inline player',
async ({ player, directive }) => {
fixture.componentRef.setInput('playerOverride', player);
fixture.componentRef.setInput('volume', 0.42);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const playerElement = fixture.debugElement.query(
By.directive(directive)
);
expect(playerElement).not.toBeNull();
const playerInstance = playerElement.componentInstance as {
volume: () => number;
};
expect(playerInstance.volume()).toBe(0.42);
}
);
it.each([
{
player: VideoPlayer.VideoJs,