mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08: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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user