From bc4a5958dc5335f989856e55d47f2af54032506d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:12:45 +0200 Subject: [PATCH] fix(remote-control): apply volume to built-in M3U players Closes #1061 --- .../src/electron-test-fixtures.ts | 4 + .../src/remote-control.e2e.ts | 273 ++++++++++++++++++ docs/architecture/remote-control.md | 4 +- .../video-player/video-player.component.html | 4 +- .../video-player.component.spec.ts | 45 +++ .../video-player/video-player.component.ts | 26 +- .../lib/art-player/art-player-audio-tracks.ts | 46 +++ .../lib/art-player/art-player.component.html | 9 + .../lib/art-player/art-player.component.scss | 18 ++ .../art-player/art-player.component.spec.ts | 16 + .../lib/art-player/art-player.component.ts | 103 ++----- .../audio-player.component.spec.ts | 22 ++ .../audio-player/audio-player.component.ts | 20 +- .../web-player-view.component.spec.ts | 35 +++ 14 files changed, 528 insertions(+), 97 deletions(-) create mode 100644 apps/electron-backend-e2e/src/remote-control.e2e.ts create mode 100644 libs/ui/playback/src/lib/art-player/art-player-audio-tracks.ts create mode 100644 libs/ui/playback/src/lib/art-player/art-player.component.html create mode 100644 libs/ui/playback/src/lib/art-player/art-player.component.scss diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 62f365b24..a2edd6418 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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() ?? '', }; diff --git a/apps/electron-backend-e2e/src/remote-control.e2e.ts b/apps/electron-backend-e2e/src/remote-control.e2e.ts new file mode 100644 index 000000000..8807e2cc8 --- /dev/null +++ b/apps/electron-backend-e2e/src/remote-control.e2e.ts @@ -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 { + await page.getByTestId(selectTestId).click(); + await page.getByTestId(optionTestId).click(); +} + +async function reserveFreePort(): Promise { + 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 { + await waitForRemoteStatus( + request, + port, + (status) => status.portal === 'unknown' && status.isLiveView === false + ); +} + +async function waitForRemoteStatus( + request: APIRequestContext, + port: number, + predicate: (status: RemoteControlStatus) => boolean +): Promise { + 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 { + 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 { + 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 { + 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)); +} diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 313985185..60b1b4993 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -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 diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 9f28374bf..a5d66acc1 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -70,6 +70,8 @@ [channelName]=" activeChannel.name || activeChannel?.tvg?.name || '' " + [volume]="volume()" + (volumeChange)="onInlineVolumeChange($event)" /> } @else { + +`; + +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; + } + }, + }); + }); +} diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.html b/libs/ui/playback/src/lib/art-player/art-player.component.html new file mode 100644 index 000000000..b1d1d60c8 --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player.component.html @@ -0,0 +1,9 @@ +
+
+ + +
diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.scss b/libs/ui/playback/src/lib/art-player/art-player.component.scss new file mode 100644 index 000000000..f1436d05e --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player.component.scss @@ -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%; +} diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index bdf573870..4cdab391f 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -16,8 +16,10 @@ class MockArtplayer { readonly destroy = jest.fn(); readonly currentTime = 0; readonly duration = 0; + volume: number; constructor(readonly options: Record) { + 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', diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 3ad090bd6..f4ccd861d 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.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: ` -
-
- - -
- `, - 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: ` - - `, - 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) { diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts index 434cbf868..b82104e09 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts @@ -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); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index a54bd623e..c6e350106 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -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(''); readonly url = input.required(); readonly channelName = input(''); + readonly externalVolume = input(null, { alias: 'volume' }); readonly dispatchAdjacentChannelAction = input(true); readonly channelSwitchRequested = output<'next' | 'previous'>(); + readonly volumeChange = output(); 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() { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index cbd8638c9..9974ce522 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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,