Merge remote-tracking branch 'origin/master' into claude/dash-clearkey-shaka

# Conflicts:
#	CLAUDE.md
#	libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
This commit is contained in:
4gray committed 2026-07-24 18:44:22 +02:00
commit 5b2dd3b8b0
425 files changed
+59216 -3432

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
export * from './lib/channel-list-container/channel-list-container.component';
export * from './lib/channel-list-container/channel-details-dialog/channel-details-dialog.component';
export * from './lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component';
export * from './lib/channel-list-container/channel-list-item/channel-list-item.component';
export * from './lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component';
export * from './lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component';
@@ -110,6 +110,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -16,6 +16,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import {
@@ -28,6 +29,7 @@ import {
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
const ALL_CHANNELS_SORT_STORAGE_KEY = 'm3u-all-channels-sort-mode';
@@ -51,6 +53,8 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util';
})
export class AllChannelsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
@@ -202,4 +206,22 @@ export class AllChannelsViewComponent {
width: 'calc(100vw - 32px)',
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
}
@@ -227,6 +227,12 @@
</mat-dialog-content>
<mat-dialog-actions class="channel-dialog__actions">
@if (supportsEpgMapping) {
<button mat-stroked-button (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
{{ 'CHANNELS.MAP_EPG' | translate }}
</button>
}
<button mat-stroked-button mat-dialog-close class="channel-dialog__close">
{{ 'CLOSE' | translate }}
</button>
@@ -1,11 +1,18 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { getM3uArchiveDays, isM3uCatchupPlaybackSupported } from '@iptvnator/shared/m3u-utils';
import { Channel } from '@iptvnator/shared/interfaces';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
interface ChannelDetailField {
readonly empty?: boolean;
@@ -35,6 +42,9 @@ interface HeroStat {
})
export class ChannelDetailsDialogComponent {
readonly channel = inject<Channel>(MAT_DIALOG_DATA);
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly archiveDays = getM3uArchiveDays(this.channel);
readonly catchupAvailable = this.archiveDays > 0;
@@ -182,4 +192,16 @@ export class ChannelDetailsDialogComponent {
return { labelKey, monospace, value: normalized };
}
openEpgMapping(): void {
const channelKey = resolveChannelEpgLookupKey(this.channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: this.channel.name ?? channelKey,
});
}
}
@@ -0,0 +1,77 @@
<h2 mat-dialog-title>{{ 'EPG_MAPPING_DIALOG.TITLE' | translate }}</h2>
<mat-dialog-content>
<p class="epg-mapping-subtitle">
{{ 'EPG_MAPPING_DIALOG.SUBTITLE' | translate : { channelName: data.channelName } }}
</p>
<div class="epg-mapping-current">
<span class="epg-mapping-current__label">
{{ 'EPG_MAPPING_DIALOG.CURRENT_MAPPING' | translate }}:
</span>
<span class="epg-mapping-current__value">
{{ currentMapping() || ('EPG_MAPPING_DIALOG.NO_MAPPING' | translate) }}
</span>
</div>
<mat-form-field class="epg-mapping-search" appearance="outline">
<mat-label>{{ 'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate }}</mat-label>
<input
matInput
[ngModel]="searchTerm()"
(ngModelChange)="onSearchInput($event)"
[placeholder]="'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate"
/>
<mat-icon matPrefix>search</mat-icon>
@if (loading()) {
<mat-spinner matSuffix diameter="20" />
}
@if (!hasSearchTerm()) {
<mat-hint>
{{
'EPG_MAPPING_DIALOG.SEARCH_HINT'
| translate: { min: searchMinChars }
}}
</mat-hint>
}
</mat-form-field>
@if (hasSearchTerm()) {
<div class="epg-mapping-results">
@for (channel of results(); track channel.id) {
<button
class="epg-mapping-result"
[class.epg-mapping-result--selected]="selectedId() === channel.id"
(click)="selectChannel(channel.id)"
>
<span class="epg-mapping-result__name">{{ channel.displayName }}</span>
<span class="epg-mapping-result__id">{{ channel.id }}</span>
</button>
} @empty {
<div class="epg-mapping-empty">
<mat-icon>search_off</mat-icon>
<span>{{ 'EPG_MAPPING_DIALOG.NO_RESULTS' | translate }}</span>
</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (currentMapping()) {
<button mat-button color="warn" (click)="removeMapping()">
{{ 'EPG_MAPPING_DIALOG.REMOVE' | translate }}
</button>
}
<button mat-button (click)="close()">
{{ 'EPG_MAPPING_DIALOG.CLOSE' | translate }}
</button>
<button
mat-raised-button
color="primary"
[disabled]="!selectedId()"
(click)="save()"
>
{{ 'EPG_MAPPING_DIALOG.SAVE' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,95 @@
.epg-mapping-subtitle {
margin: 0 0 16px;
color: var(--mat-sys-on-surface-variant);
font-size: 14px;
}
.epg-mapping-current {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 8px 12px;
border-radius: 8px;
background: var(--mat-sys-surface-container-high);
&__label {
font-size: 12px;
color: var(--mat-sys-on-surface-variant);
}
&__value {
font-size: 14px;
font-weight: 500;
font-family: monospace;
}
}
.epg-mapping-search {
width: 100%;
margin-bottom: 8px;
}
.epg-mapping-results {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 300px;
overflow-y: auto;
}
.epg-mapping-result {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
cursor: pointer;
text-align: left;
width: 100%;
color: inherit;
transition: background 0.15s, border-color 0.15s;
&:hover {
background: var(--mat-sys-surface-container-high);
}
&--selected {
border-color: var(--mat-sys-primary);
background: color-mix(
in srgb,
var(--mat-sys-primary) 10%,
transparent
);
}
&__name {
font-size: 14px;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
&__id {
font-size: 11px;
color: var(--mat-sys-on-surface-variant);
font-family: monospace;
}
}
.epg-mapping-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 40px;
width: 40px;
height: 40px;
}
}
@@ -0,0 +1,205 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { debounceTime, from, Subject, switchMap } from 'rxjs';
export interface EpgMappingDialogData {
channelKey: string;
channelName: string;
/** Owning playlist for portal channels; omitted for M3U lookup keys. */
playlistId?: string | null;
}
export interface EpgMappingDialogResult {
channelKey: string;
epgChannelId: string;
}
const SEARCH_MIN_CHARS = 2;
@Component({
selector: 'app-epg-mapping-dialog',
templateUrl: './epg-mapping-dialog.component.html',
styleUrl: './epg-mapping-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
TranslatePipe,
],
})
export class EpgMappingDialogComponent {
readonly data = inject<EpgMappingDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
MatDialogRef<EpgMappingDialogComponent, EpgMappingDialogResult>
);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly epgService = inject(EpgService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
readonly searchMinChars = SEARCH_MIN_CHARS;
readonly searchTerm = signal('');
readonly loading = signal(false);
readonly results = signal<
Array<{ id: string; displayName: string; iconUrl: string | null }>
>([]);
readonly selectedId = signal<string | null>(null);
readonly currentMapping = signal<string | null>(null);
private readonly search$ = new Subject<string>();
/** Open the dialog with the shared sizing used by every caller. */
static open(
dialog: MatDialog,
data: EpgMappingDialogData
): MatDialogRef<EpgMappingDialogComponent, EpgMappingDialogResult> {
return dialog.open(EpgMappingDialogComponent, {
data,
width: '500px',
maxHeight: '90vh',
});
}
constructor() {
// Fetch the current mapping (if any) on open — callers don't know it.
this.loadCurrentMapping();
this.search$
.pipe(
debounceTime(300),
switchMap((term) => {
if (term.trim().length < SEARCH_MIN_CHARS) {
return from(Promise.resolve([]));
}
this.loading.set(true);
const promise = this.epgBridge.searchEpgChannels(term);
return from(promise ?? Promise.resolve([]));
}),
takeUntilDestroyed()
)
.subscribe({
next: (items) => {
this.results.set(items ?? []);
this.loading.set(false);
},
error: () => {
this.results.set([]);
this.loading.set(false);
},
});
}
readonly hasSearchTerm = computed(
() => this.searchTerm().trim().length >= SEARCH_MIN_CHARS
);
onSearchInput(value: string): void {
this.searchTerm.set(value);
this.search$.next(value);
}
selectChannel(id: string): void {
this.selectedId.set(id);
}
save(): void {
const epgChannelId = this.selectedId();
if (!epgChannelId) return;
this.epgBridge
.setEpgMapping(
this.data.channelKey,
epgChannelId,
this.data.playlistId ?? undefined
)
.then((result) => {
if (!result?.success) {
this.notify('EPG_MAPPING_DIALOG.SAVE_FAILED');
return;
}
this.epgService.clearCache();
this.notify('EPG_MAPPING_DIALOG.SAVED');
this.dialogRef.close({
channelKey: this.data.channelKey,
epgChannelId,
});
});
}
removeMapping(): void {
this.epgBridge.deleteEpgMapping(this.data.channelKey).then((result) => {
if (!result?.success) {
this.notify('EPG_MAPPING_DIALOG.SAVE_FAILED');
return;
}
this.epgService.clearCache();
this.notify('EPG_MAPPING_DIALOG.REMOVED');
this.dialogRef.close();
});
}
close(): void {
this.dialogRef.close();
}
/**
* Resolve the stored mapping and enrich it with the EPG channel's
* display name so the user sees "BBC One (bbc.one.uk)" instead of a
* bare ID.
*/
private loadCurrentMapping(): void {
this.epgBridge.getEpgMapping(this.data.channelKey).then(async (m) => {
const mappedId = m?.epgChannelId ?? null;
if (!mappedId) {
this.currentMapping.set(null);
return;
}
this.currentMapping.set(mappedId);
try {
const matches =
(await this.epgBridge.searchEpgChannels(mappedId, 10)) ??
[];
const exact = matches.find((r) => r.id === mappedId);
if (exact?.displayName && exact.displayName !== mappedId) {
this.currentMapping.set(
`${exact.displayName} (${mappedId})`
);
}
} catch {
// Display name enrichment is cosmetic — keep the bare ID.
}
});
}
private notify(key: string): void {
this.snackBar.open(this.translate.instant(key), undefined, {
duration: 2000,
});
}
}
@@ -69,6 +69,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -17,9 +17,11 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -39,6 +41,8 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
})
export class FavoritesViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
@@ -152,6 +156,25 @@ export class FavoritesViewComponent {
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
@@ -331,6 +331,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -19,6 +19,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
@@ -30,6 +31,7 @@ import {
sortPlaylistChannelItems,
} from '../channel-list-sort.util';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
import { ResizableDirective } from '../../resizable/resizable.directive';
@@ -72,6 +74,8 @@ interface FilteredGroupView {
})
export class GroupsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly contextMenuTrigger =
@@ -490,6 +494,25 @@ export class GroupsViewComponent {
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
@@ -62,6 +62,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -12,7 +12,10 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import {
@@ -40,6 +43,8 @@ export interface RecentViewItem {
})
export class RecentViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
@@ -133,6 +138,25 @@ export class RecentViewComponent {
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
@@ -173,6 +173,25 @@
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (
isEpisodePaused(episode)
) {
<button
mat-icon-button
class="download-btn download-btn--paused"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME'
| translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (
isEpisodeDownloading(episode)
) {
@@ -413,6 +432,22 @@
>
<mat-icon>folder_open</mat-icon>
</button>
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="action-btn"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
@@ -394,6 +394,14 @@ app-season-tabs {
color: rgba(255, 255, 255, 0.5) !important;
cursor: not-allowed;
}
.download-btn--paused {
color: #fbbf24 !important;
&:hover {
background: color-mix(in srgb, #fbbf24 20%, transparent);
}
}
}
&:hover {
@@ -414,6 +414,35 @@ export class SeasonContainerComponent implements OnInit {
);
}
isEpisodePaused(episode: XtreamSerieEpisode): boolean {
if (!this.playlistId()) {
return false;
}
this.downloadsService.downloads();
return this.downloadsService.isPaused(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
}
async resumeEpisodeDownload(
event: Event,
episode: XtreamSerieEpisode
): Promise<void> {
event.stopPropagation();
if (!this.playlistId()) {
return;
}
await this.downloadsService.resumeDownloadByContent(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
}
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.playlistId()) {
@@ -64,10 +64,19 @@ describe('epg-archive.util', () => {
).toBe(false);
});
it('denies when the programme is not in the past', () => {
it('allows start-over on the currently-airing programme', () => {
expect(
canCatchUpProgramme('now', inWindowStart, true, 7, NOW)
).toBe(true);
});
it('denies start-over when archive playback is unavailable', () => {
expect(
canCatchUpProgramme('now', inWindowStart, false, 7, NOW)
).toBe(false);
});
it('denies future programmes', () => {
expect(
canCatchUpProgramme('future', NOW + 60_000, true, 7, NOW)
).toBe(false);
@@ -91,8 +100,8 @@ describe('epg-archive.util', () => {
expect(epgDialogActionFor('now', false)).toBe('live');
});
it('prefers live over timeshift for an on-air programme', () => {
expect(epgDialogActionFor('now', true)).toBe('live');
it('prefers start-over timeshift for a catch-up-able on-air programme', () => {
expect(epgDialogActionFor('now', true)).toBe('timeshift');
});
it('maps a catch-up-able past programme to timeshift', () => {
@@ -21,7 +21,8 @@ export function isWithinArchiveWindow(
return startMs >= nowMs - archiveDays * DAY_MS;
}
/** Whether a past programme is playable from the catch-up archive. */
/** Whether a programme is playable from the catch-up archive —
* both past programmes and the currently-airing programme (start-over). */
export function canCatchUpProgramme(
when: TimelineWhen,
startMs: number,
@@ -31,7 +32,7 @@ export function canCatchUpProgramme(
): boolean {
return (
archivePlaybackAvailable &&
when === 'past' &&
(when === 'past' || when === 'now') &&
isWithinArchiveWindow(startMs, archiveDays, nowMs)
);
}
@@ -41,7 +42,7 @@ export function epgDialogActionFor(
when: TimelineWhen,
canCatchUp: boolean
): EpgItemDialogAction | null {
if (when === 'now') {
if (when === 'now' && !canCatchUp) {
return 'live';
}
if (canCatchUp) {
@@ -109,7 +109,7 @@ function toRenderBlock(
nowFillPercent: nowFillFor(block, nowMs),
canCatchUp:
archivePlaybackAvailable &&
block.when === 'past' &&
(block.when === 'past' || block.when === 'now') &&
block.startMs >= archiveWindowStartMs,
};
}
@@ -80,6 +80,15 @@
<span class="epg-timeline__tag is-now">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
@if (item.canCatchUp) {
<button
type="button"
class="epg-timeline__watch"
(click)="watchClick.emit(item.block); $event.stopPropagation()"
>
<mat-icon>replay</mat-icon>
</button>
}
} @else if (isPlaying(item)) {
<span class="epg-timeline__tag is-arch">{{
'EPG.TIMELINE.FROM_ARCHIVE' | translate
@@ -167,6 +167,9 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
position: relative;
z-index: 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.epg-timeline__block-time {
@@ -316,15 +316,15 @@ export class EpgTimelineComponent {
onBlockClick(block: TimelineBlock): void {
this.selectedKey.set(block.key);
if (block.when === 'now') {
this.returnToLive.emit();
return;
}
if (this.canCatchUp(block)) {
this.programActivated.emit({
program: block.program,
type: 'timeshift',
});
return;
}
if (block.when === 'now') {
this.returnToLive.emit();
}
}
@@ -10,6 +10,7 @@
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[mediaTitle]="mediaTitle()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -16,6 +16,7 @@ import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -52,6 +53,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -0,0 +1,65 @@
<div class="embedded-mpv-dock-panel" (keydown)="onPanelKeydown($event)">
<button
mat-icon-button
type="button"
class="embedded-mpv-dock-panel__back"
data-test-id="embedded-mpv-dock-panel-back"
(click)="closed.emit()"
[attr.aria-label]="backLabel()"
[matTooltip]="backLabel()"
matTooltipPosition="above"
>
<mat-icon>arrow_back</mat-icon>
</button>
<span class="embedded-mpv-dock-panel__title">{{ panel().title }}</span>
<div class="embedded-mpv-dock-panel__ribbon-shell">
<div
#ribbon
class="embedded-mpv-dock-panel__ribbon"
role="menu"
aria-orientation="horizontal"
[attr.aria-label]="panel().title"
(scroll)="onRibbonScroll()"
(wheel)="onRibbonWheel($event)"
>
@for (
chip of panel().chips;
track chip.id;
let index = $index
) {
<button
#chip
type="button"
role="menuitemradio"
class="embedded-mpv-dock-panel__chip"
[class.embedded-mpv-dock-panel__chip--selected]="
chip.selected
"
[attr.aria-checked]="chip.selected"
[attr.tabindex]="chipTabIndex(chip.selected, index)"
[title]="chip.label"
(click)="chipSelected.emit(chip.id)"
>
@if (chip.selected) {
<mat-icon aria-hidden="true">check</mat-icon>
}
<span class="embedded-mpv-dock-panel__chip-label">{{
chip.label
}}</span>
</button>
}
</div>
<div
class="embedded-mpv-dock-panel__fade embedded-mpv-dock-panel__fade--start"
[class.embedded-mpv-dock-panel__fade--visible]="canScrollStart()"
aria-hidden="true"
></div>
<div
class="embedded-mpv-dock-panel__fade embedded-mpv-dock-panel__fade--end"
[class.embedded-mpv-dock-panel__fade--visible]="canScrollEnd()"
aria-hidden="true"
></div>
</div>
</div>
@@ -0,0 +1,166 @@
:host {
display: block;
min-width: 0;
}
.embedded-mpv-dock-panel {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
animation: embedded-mpv-dock-panel-in 160ms ease-out;
}
@keyframes embedded-mpv-dock-panel-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.embedded-mpv-dock-panel__back {
flex: 0 0 auto;
}
.embedded-mpv-dock-panel__title {
flex: 0 0 auto;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
color: color-mix(in srgb, var(--mat-sys-on-surface) 70%, transparent);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.embedded-mpv-dock-panel__ribbon-shell {
position: relative;
flex: 1 1 auto;
min-width: 0;
}
.embedded-mpv-dock-panel__ribbon {
display: flex;
align-items: center;
gap: 8px;
padding: 2px;
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.embedded-mpv-dock-panel__ribbon::-webkit-scrollbar {
display: none;
}
.embedded-mpv-dock-panel__chip {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
max-width: 220px;
height: 34px;
padding: 0 14px;
color: var(--mat-sys-on-surface);
background: transparent;
border: 1px solid var(--embedded-mpv-border);
border-radius: 999px;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
white-space: nowrap;
}
.embedded-mpv-dock-panel__chip:hover,
.embedded-mpv-dock-panel__chip:focus-visible {
background: color-mix(in srgb, var(--embedded-mpv-accent) 14%, transparent);
}
.embedded-mpv-dock-panel__chip:focus-visible {
outline: 2px solid var(--embedded-mpv-accent);
outline-offset: 1px;
}
.embedded-mpv-dock-panel__chip--selected {
background: color-mix(in srgb, var(--embedded-mpv-accent) 18%, transparent);
border-color: color-mix(
in srgb,
var(--embedded-mpv-accent) 55%,
transparent
);
}
.embedded-mpv-dock-panel__chip mat-icon {
flex: 0 0 auto;
width: 16px;
height: 16px;
font-size: 16px;
color: var(--embedded-mpv-accent);
}
.embedded-mpv-dock-panel__chip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.embedded-mpv-dock-panel__fade {
position: absolute;
top: 0;
bottom: 0;
width: 36px;
opacity: 0;
pointer-events: none;
transition: opacity 140ms ease-out;
}
.embedded-mpv-dock-panel__fade--visible {
opacity: 1;
}
.embedded-mpv-dock-panel__fade--start {
inset-inline-start: 0;
background: linear-gradient(
to right,
var(--embedded-mpv-glass),
transparent
);
}
.embedded-mpv-dock-panel__fade--end {
inset-inline-end: 0;
background: linear-gradient(
to left,
var(--embedded-mpv-glass),
transparent
);
}
:host-context([dir='rtl']) .embedded-mpv-dock-panel__fade--start {
background: linear-gradient(
to left,
var(--embedded-mpv-glass),
transparent
);
}
:host-context([dir='rtl']) .embedded-mpv-dock-panel__fade--end {
background: linear-gradient(
to right,
var(--embedded-mpv-glass),
transparent
);
}
@media (max-width: 720px) {
.embedded-mpv-dock-panel__title {
display: none;
}
}
@@ -0,0 +1,189 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
import { EmbeddedMpvDockPanelView } from './embedded-mpv-dock-panels';
const PANEL: EmbeddedMpvDockPanelView = {
kind: 'audio',
title: 'Audio tracks',
chips: Array.from({ length: 12 }, (_, index) => ({
id: String(index + 1),
label: `Track ${index + 1} with a fairly long descriptive name`,
selected: index === 3,
})),
};
describe('EmbeddedMpvDockPanelComponent', () => {
let fixture: ComponentFixture<EmbeddedMpvDockPanelComponent>;
const chipButtons = (): HTMLButtonElement[] =>
fixture.debugElement
.queryAll(By.css('.embedded-mpv-dock-panel__chip'))
.map((chip) => chip.nativeElement);
const panelRoot = (): HTMLElement =>
fixture.debugElement.query(By.css('.embedded-mpv-dock-panel'))
.nativeElement;
const ribbon = (): HTMLElement =>
fixture.debugElement.query(By.css('.embedded-mpv-dock-panel__ribbon'))
.nativeElement;
const dispatchPanelKey = (key: string): KeyboardEvent => {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
(document.activeElement ?? panelRoot()).dispatchEvent(event);
return event;
};
const flushMicrotasks = () => new Promise<void>(queueMicrotask);
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EmbeddedMpvDockPanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(EmbeddedMpvDockPanelComponent);
fixture.componentRef.setInput('panel', PANEL);
fixture.componentRef.setInput('backLabel', 'Back');
fixture.detectChanges();
await flushMicrotasks();
});
afterEach(() => {
fixture.destroy();
});
it('renders chips with tooltips, roving tabindex, and a selected marker', () => {
const chips = chipButtons();
expect(chips).toHaveLength(12);
expect(chips[3].getAttribute('aria-checked')).toBe('true');
expect(chips[3].tabIndex).toBe(0);
expect(chips[0].tabIndex).toBe(-1);
expect(chips[5].getAttribute('title')).toBe(PANEL.chips[5].label);
expect(chips[3].querySelector('mat-icon')).not.toBeNull();
expect(chips[0].querySelector('mat-icon')).toBeNull();
});
it('focuses the selected chip when the panel opens', () => {
expect(document.activeElement).toBe(chipButtons()[3]);
});
it('walks chips with arrow keys, Home, and End', () => {
const chips = chipButtons();
dispatchPanelKey('ArrowRight');
expect(document.activeElement).toBe(chips[4]);
dispatchPanelKey('ArrowLeft');
dispatchPanelKey('ArrowLeft');
expect(document.activeElement).toBe(chips[2]);
dispatchPanelKey('End');
expect(document.activeElement).toBe(chips[11]);
dispatchPanelKey('ArrowRight');
expect(document.activeElement).toBe(chips[11]);
dispatchPanelKey('Home');
expect(document.activeElement).toBe(chips[0]);
dispatchPanelKey('ArrowLeft');
expect(document.activeElement).toBe(chips[0]);
});
it('claims arrow keys so document-level shortcuts never see them', () => {
const documentKeydown = jest.fn();
document.addEventListener('keydown', documentKeydown);
const horizontal = dispatchPanelKey('ArrowRight');
const vertical = dispatchPanelKey('ArrowUp');
const escape = dispatchPanelKey('Escape');
expect(horizontal.defaultPrevented).toBe(true);
expect(vertical.defaultPrevented).toBe(true);
expect(escape.defaultPrevented).toBe(false);
const seenKeys = documentKeydown.mock.calls.map(
([event]: [KeyboardEvent]) => event.key
);
expect(seenKeys).toEqual(['Escape']);
document.removeEventListener('keydown', documentKeydown);
});
it('lets Space/Enter activate a chip without leaking to global shortcuts', () => {
// The global shortcut handler's Space case calls preventDefault() +
// togglePaused(); preventing the keydown default would suppress the
// chip button's native activation. The panel must stop Space/Enter
// from reaching the document without cancelling their default action.
const documentKeydown = jest.fn();
document.addEventListener('keydown', documentKeydown);
const space = dispatchPanelKey(' ');
const enter = dispatchPanelKey('Enter');
// Not defaultPrevented → the focused chip button still activates.
expect(space.defaultPrevented).toBe(false);
expect(enter.defaultPrevented).toBe(false);
// stopPropagation → the global handler never sees them.
expect(documentKeydown).not.toHaveBeenCalled();
document.removeEventListener('keydown', documentKeydown);
});
it('maps vertical wheel deltas to horizontal ribbon scrolling', () => {
const ribbonEl = ribbon();
ribbonEl.scrollLeft = 0;
const wheel = new WheelEvent('wheel', {
deltaY: 120,
deltaX: 0,
cancelable: true,
});
ribbonEl.dispatchEvent(wheel);
expect(ribbonEl.scrollLeft).toBe(120);
expect(wheel.defaultPrevented).toBe(true);
const horizontalSwipe = new WheelEvent('wheel', {
deltaY: 2,
deltaX: 40,
cancelable: true,
});
ribbonEl.dispatchEvent(horizontalSwipe);
expect(ribbonEl.scrollLeft).toBe(120);
expect(horizontalSwipe.defaultPrevented).toBe(false);
});
it('emits chipSelected and closed', () => {
const selected = jest.fn();
const closed = jest.fn();
fixture.componentInstance.chipSelected.subscribe(selected);
fixture.componentInstance.closed.subscribe(closed);
chipButtons()[7].click();
expect(selected).toHaveBeenCalledWith('8');
fixture.debugElement
.query(By.css('[data-test-id="embedded-mpv-dock-panel-back"]'))
.nativeElement.click();
expect(closed).toHaveBeenCalledTimes(1);
});
it('keeps the first chip tabbable when no chip is selected', () => {
fixture.componentRef.setInput('panel', {
kind: 'aspect',
title: 'Aspect ratio',
chips: PANEL.chips.map((chip) => ({ ...chip, selected: false })),
});
fixture.detectChanges();
const chips = chipButtons();
expect(chips[0].tabIndex).toBe(0);
expect(chips[1].tabIndex).toBe(-1);
});
});
@@ -0,0 +1,213 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
effect,
inject,
input,
output,
signal,
untracked,
viewChild,
viewChildren,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import type {
EmbeddedMpvDockPanelKind,
EmbeddedMpvDockPanelView,
} from './embedded-mpv-dock-panels';
/**
* Horizontal chip panel that morphs the native-view embedded MPV dock row:
* back button + title on the inline-start side, then a scrollable chip
* ribbon. It lives inside the fixed-height controls strip, so opening it
* never changes the native MPV view bounds (no video re-letterboxing).
*/
@Component({
selector: 'app-embedded-mpv-dock-panel',
templateUrl: './embedded-mpv-dock-panel.component.html',
styleUrl: './embedded-mpv-dock-panel.component.scss',
imports: [MatButtonModule, MatIconModule, MatTooltipModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmbeddedMpvDockPanelComponent implements OnDestroy {
readonly panel = input.required<EmbeddedMpvDockPanelView>();
readonly backLabel = input.required<string>();
readonly chipSelected = output<string>();
readonly closed = output<void>();
readonly canScrollStart = signal(false);
readonly canScrollEnd = signal(false);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly ribbon =
viewChild<ElementRef<HTMLDivElement>>('ribbon');
private readonly chips =
viewChildren<ElementRef<HTMLButtonElement>>('chip');
private lastRevealedKind: EmbeddedMpvDockPanelKind | null = null;
private destroyed = false;
constructor() {
effect(() => {
const panel = this.panel();
const kind = panel.kind;
const hasChips = panel.chips.length > 0;
untracked(() => {
const isNewPanel = kind !== this.lastRevealedKind;
this.lastRevealedKind = kind;
queueMicrotask(() => {
if (this.destroyed) {
return;
}
this.updateScrollState();
if (isNewPanel && hasChips) {
this.focusAndRevealSelectedChip();
}
});
});
});
}
ngOnDestroy(): void {
this.destroyed = true;
}
chipTabIndex(selected: boolean, index: number): number {
if (selected) {
return 0;
}
const hasSelected = this.panel().chips.some((chip) => chip.selected);
return !hasSelected && index === 0 ? 0 : -1;
}
onRibbonScroll(): void {
this.updateScrollState();
}
onRibbonWheel(event: WheelEvent): void {
// Vertical wheel drives horizontal ribbon scrolling; dominant
// horizontal deltas (trackpad swipes) keep native scrolling.
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
return;
}
const ribbonEl = this.ribbon()?.nativeElement;
if (!ribbonEl) {
return;
}
event.preventDefault();
ribbonEl.scrollLeft += event.deltaY * (this.isRtl() ? -1 : 1);
this.updateScrollState();
}
onPanelKeydown(event: KeyboardEvent): void {
const key = event.key;
if (key === ' ' || key === 'Enter') {
// Let the focused chip button activate natively (Space/Enter →
// click → chipSelected), but stop the keydown before it reaches
// the global shortcut handler, whose Space case would otherwise
// preventDefault() the activation and toggle playback instead.
// No preventDefault here — that would re-suppress the click.
event.stopPropagation();
return;
}
if (key === 'ArrowUp' || key === 'ArrowDown') {
// The panel owns the keyboard: never let the global volume
// shortcuts fire while a chip panel is open.
event.preventDefault();
event.stopPropagation();
return;
}
if (
key !== 'ArrowLeft' &&
key !== 'ArrowRight' &&
key !== 'Home' &&
key !== 'End'
) {
return;
}
event.preventDefault();
event.stopPropagation();
const chips = this.chips().map((chip) => chip.nativeElement);
if (!chips.length) {
return;
}
chips[this.nextChipIndex(key, chips)]?.focus();
}
private nextChipIndex(key: string, chips: HTMLButtonElement[]): number {
if (key === 'Home') {
return 0;
}
if (key === 'End') {
return chips.length - 1;
}
const forward = (key === 'ArrowRight') !== this.isRtl();
const currentIndex = chips.indexOf(
document.activeElement as HTMLButtonElement
);
if (currentIndex === -1) {
return forward ? 0 : chips.length - 1;
}
return Math.max(
0,
Math.min(chips.length - 1, currentIndex + (forward ? 1 : -1))
);
}
private focusAndRevealSelectedChip(): void {
const chips = this.chips().map((chip) => chip.nativeElement);
const selected =
chips.find((chip) => chip.getAttribute('aria-checked') === 'true') ??
chips[0];
if (!selected) {
return;
}
try {
selected.focus({ preventScroll: true });
} catch {
selected.focus();
}
this.centerChipInRibbon(selected);
}
private centerChipInRibbon(chip: HTMLElement): void {
const ribbonEl = this.ribbon()?.nativeElement;
if (!ribbonEl) {
return;
}
const ribbonRect = ribbonEl.getBoundingClientRect();
const chipRect = chip.getBoundingClientRect();
// Physical-axis math keeps this correct in RTL; the browser clamps
// scrollLeft to the valid range on both directions.
ribbonEl.scrollLeft +=
chipRect.left +
chipRect.width / 2 -
(ribbonRect.left + ribbonRect.width / 2);
this.updateScrollState();
}
private updateScrollState(): void {
const ribbonEl = this.ribbon()?.nativeElement;
if (!ribbonEl) {
return;
}
const maxScroll = Math.max(
0,
ribbonEl.scrollWidth - ribbonEl.clientWidth
);
const offset = Math.abs(ribbonEl.scrollLeft);
this.canScrollStart.set(maxScroll > 1 && offset > 1);
this.canScrollEnd.set(maxScroll > 1 && offset < maxScroll - 1);
}
private isRtl(): boolean {
return (
getComputedStyle(this.elementRef.nativeElement).direction === 'rtl'
);
}
}
@@ -0,0 +1,205 @@
import { Signal, computed } from '@angular/core';
import { EmbeddedMpvAudioTrack } from '@iptvnator/shared/interfaces';
import { ASPECT_PRESETS, SPEED_PRESETS } from './embedded-mpv-format.utils';
import type { EmbeddedMpvMenuState } from './embedded-mpv-ui-state';
export type EmbeddedMpvDockPanelKind =
| 'audio'
| 'subtitle'
| 'speed'
| 'aspect';
export interface EmbeddedMpvDockChip {
readonly id: string;
readonly label: string;
readonly selected: boolean;
}
export interface EmbeddedMpvDockPanelView {
readonly kind: EmbeddedMpvDockPanelKind;
readonly title: string;
readonly chips: readonly EmbeddedMpvDockChip[];
}
/** Chip id representing the "subtitles off" (-1) selection. */
export const SUBTITLES_OFF_CHIP_ID = 'off';
export interface EmbeddedMpvDockPanelDeps {
readonly menus: EmbeddedMpvMenuState;
readonly audioTracks: Signal<EmbeddedMpvAudioTrack[]>;
readonly subtitleTracks: Signal<EmbeddedMpvAudioTrack[]>;
readonly selectedSubtitleTrackId: Signal<number | null>;
readonly playbackSpeed: Signal<number>;
readonly aspectOverride: Signal<string>;
readonly translateLabel: (key: string) => string;
readonly audioTrackLabel: (
track: EmbeddedMpvAudioTrack,
index: number
) => string;
readonly subtitleTrackLabel: (
track: EmbeddedMpvAudioTrack,
index: number
) => string;
readonly aspectLabel: (aspect: string) => string;
readonly selectAudioTrack: (trackId: number) => void;
readonly selectSubtitleTrack: (trackId: number) => void;
readonly selectSpeed: (speed: number) => void;
readonly selectAspect: (aspect: string) => void;
readonly closePanels: () => void;
readonly playerRoot: () => HTMLElement | null;
readonly revealControls: () => void;
}
/**
* View model for the horizontal in-dock chip panels of the native-view
* embedded MPV dock (audio, subtitle, speed, aspect). The panels morph the
* controls row inside its fixed-height strip, so opening one never changes
* the native MPV view bounds — unlike the removed popover-era bottom cutout.
*/
export class EmbeddedMpvDockPanelState {
readonly active: Signal<EmbeddedMpvDockPanelView | null>;
private openerKind: EmbeddedMpvDockPanelKind | null = null;
constructor(private readonly deps: EmbeddedMpvDockPanelDeps) {
this.active = computed(() => this.buildActivePanel());
}
toggle(kind: EmbeddedMpvDockPanelKind): void {
// Always remember the button that opened a panel so focus returns to
// it when the panel closes. restoreOpenerFocus() only acts when focus
// has fallen to document.body, so re-toggling a panel with focus still
// on its button never steals focus.
this.openerKind = kind;
this.deps.menus.toggle(kind);
this.deps.revealControls();
}
/**
* Call whenever `menus.dockPanelOpen()` changes. When a panel closes it
* takes keyboard focus down with it (the dock row re-renders, so the
* pre-open button instance no longer exists); focus is handed to the
* freshly rendered toggle button of the menu that was open.
*/
handlePanelOpenChange(panelOpen: boolean): void {
if (panelOpen) {
return;
}
const kind = this.openerKind;
this.openerKind = null;
if (!kind) {
return;
}
queueMicrotask(() => this.restoreOpenerFocus(kind));
}
select(chipId: string): void {
const panel = this.active();
if (!panel) {
return;
}
switch (panel.kind) {
case 'audio':
this.deps.selectAudioTrack(Number(chipId));
return;
case 'subtitle':
this.deps.selectSubtitleTrack(
chipId === SUBTITLES_OFF_CHIP_ID ? -1 : Number(chipId)
);
return;
case 'speed':
this.deps.selectSpeed(Number(chipId));
return;
case 'aspect':
this.deps.selectAspect(chipId);
return;
}
}
close(): void {
this.deps.closePanels();
}
private restoreOpenerFocus(kind: EmbeddedMpvDockPanelKind): void {
if (
document.activeElement !== document.body &&
document.activeElement !== null
) {
return;
}
this.deps
.playerRoot()
?.querySelector<HTMLElement>(
`[data-embedded-mpv-menu-button="${kind}"]`
)
?.focus();
}
private buildActivePanel(): EmbeddedMpvDockPanelView | null {
const { menus } = this.deps;
if (menus.audioOpen()) {
return {
kind: 'audio',
title: this.deps.translateLabel(
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
),
chips: this.deps
.audioTracks()
.map((track, index) => ({
id: String(track.id),
label: this.deps.audioTrackLabel(track, index),
selected: track.selected === true,
})),
};
}
if (menus.subtitleOpen()) {
return {
kind: 'subtitle',
title: this.deps.translateLabel('EMBEDDED_MPV.PLAYER.SUBTITLES'),
chips: [
{
id: SUBTITLES_OFF_CHIP_ID,
label: this.deps.translateLabel(
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
),
selected: this.deps.selectedSubtitleTrackId() === null,
},
...this.deps.subtitleTracks().map((track, index) => ({
id: String(track.id),
label: this.deps.subtitleTrackLabel(track, index),
selected: track.selected === true,
})),
],
};
}
if (menus.speedOpen()) {
const speed = this.deps.playbackSpeed();
return {
kind: 'speed',
title: this.deps.translateLabel(
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
),
chips: SPEED_PRESETS.map((preset) => ({
id: String(preset.value),
label: preset.label,
selected: preset.value === speed,
})),
};
}
if (menus.aspectOpen()) {
const aspect = this.deps.aspectOverride();
return {
kind: 'aspect',
title: this.deps.translateLabel(
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
),
chips: ASPECT_PRESETS.map((preset) => ({
id: preset.value,
label: this.deps.aspectLabel(preset.value),
selected: preset.value === aspect,
})),
};
}
return null;
}
}
@@ -59,7 +59,10 @@ describe('embedded MPV format utilities', () => {
expect(volumeLabel(0.755)).toBe('Volume 76%');
});
it('rounds host bounds and keeps minimum native view dimensions', () => {
it('preserves fractional host edges and keeps minimum native view dimensions', () => {
// Rounding happens once in the main process, after CSS→native
// scaling — pre-rounded edges would drift by up to 1px per scale
// factor on scaled displays.
const host = {
getBoundingClientRect: () => ({
left: 10.4,
@@ -70,8 +73,8 @@ describe('embedded MPV format utilities', () => {
} as HTMLElement;
expect(measureBounds(host)).toEqual({
x: 10,
y: 21,
x: 10.4,
y: 20.6,
width: 1,
height: 1,
});
@@ -10,16 +10,6 @@ export const HIDDEN_BOUNDS: EmbeddedMpvBounds = Object.freeze({
height: 1,
}) as EmbeddedMpvBounds;
/**
* Vertical pixels to subtract from the MPV view's height when a control
* popover (volume, audio, subtitle, speed, aspect) is open above the
* controls strip. The native NSView paints over the WebContents, so we
* shrink it from the bottom to expose the popover region in DOM. Sized to
* cover the tallest popover (audio/subtitle list capped at ~240 px plus
* title + padding); video keeps playing in the upper region.
*/
export const MENU_OPEN_BOTTOM_CUTOUT_PX = 300;
export const SPEED_PRESETS: ReadonlyArray<{ value: number; label: string }> = [
{ value: 0.5, label: '0.5×' },
{ value: 0.75, label: '0.75×' },
@@ -120,12 +110,19 @@ export function persistVolume(value: number): void {
localStorage.setItem('volume', String(value));
}
/**
* Measures the host element in CSS pixels without rounding. The main process
* converts these bounds to native units (page zoom × display scale) and
* rounds exactly once, after scaling — pre-rounding here would bake up to
* ±0.5px of CSS error that the scale factor then amplifies into visible
* off-by-one seams (e.g. a 10.49px edge at 200% renders at 21px, not 20px).
*/
export function measureBounds(host: HTMLElement): EmbeddedMpvBounds {
const rect = host.getBoundingClientRect();
return {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
x: rect.left,
y: rect.top,
width: Math.max(1, rect.width),
height: Math.max(1, rect.height),
};
}
@@ -17,7 +17,6 @@ export interface EmbeddedMpvLegacyInteractionsDeps {
readonly statusLabel: () => string;
readonly togglePaused: () => void | Promise<void>;
readonly toggleFullscreen: () => void | Promise<void>;
readonly triggerBoundsSync: () => void;
}
export class EmbeddedMpvLegacyInteractions {
@@ -165,8 +164,9 @@ export class EmbeddedMpvLegacyInteractions {
if (!this.deps.isAvailable() || !this.deps.menus.anyOpen()) {
return;
}
// Menus live inside the fixed-height dock strip, so closing them
// needs no bounds resync — the native MPV view never moved.
this.deps.menus.closeAll();
this.deps.triggerBoundsSync();
this.scheduleControlsHide();
}
@@ -0,0 +1,324 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { HIDDEN_BOUNDS } from './embedded-mpv-format.utils';
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
import { EmbeddedMpvPlayerComponent } from './embedded-mpv-player.component';
import {
EmbeddedMpvBoundsProvider,
EmbeddedMpvSessionController,
} from './embedded-mpv-session-controller';
@Component({
imports: [EmbeddedMpvPlayerComponent],
template: `<app-embedded-mpv-player [playback]="playback" />`,
})
class DockPanelsHostComponent {
playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.test/movie/42.mp4',
title: 'Movie',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'movie',
},
};
}
const HOST_RECT = { left: 4, top: 8, width: 1280, height: 720 };
const FULL_BOUNDS = { x: 4, y: 8, width: 1280, height: 720 };
const HOST_STUB = {
getBoundingClientRect: () => HOST_RECT,
} as unknown as HTMLElement;
describe('EmbeddedMpvPlayerComponent dock panels', () => {
let fixture: ComponentFixture<DockPanelsHostComponent>;
let player: EmbeddedMpvPlayerComponent;
let controller: EmbeddedMpvSessionController;
let boundsProviderSpy: jest.SpyInstance;
const overlayActive = signal(false);
const boundsProvider = (): EmbeddedMpvBoundsProvider =>
boundsProviderSpy.mock.calls[0][0];
const query = (selector: string) =>
fixture.debugElement.query(By.css(selector));
const queryAll = (selector: string) =>
fixture.debugElement.queryAll(By.css(selector));
const configureReadyController = () => {
controller.support.set({
supported: true,
platform: 'darwin',
engine: 'native',
capabilities: {
subtitles: true,
playbackSpeed: true,
aspectOverride: true,
screenshot: false,
recording: false,
},
});
controller.session.set({
id: 'session-1',
title: 'Movie',
streamUrl: 'https://example.test/movie/42.mp4',
status: 'playing',
positionSeconds: 30,
durationSeconds: 120,
volume: 1,
audioTracks: [
{
id: 1,
language: 'eng',
selected: true,
defaultTrack: true,
},
{ id: 2, language: 'deu', selected: false },
],
selectedAudioTrackId: 1,
subtitleTracks: [
{ id: 21, language: 'eng', selected: false },
{ id: 22, language: 'deu', selected: false },
],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-07-19T12:00:00Z',
updatedAt: '2026-07-19T12:00:00Z',
} satisfies EmbeddedMpvSession);
fixture.detectChanges();
};
beforeEach(async () => {
overlayActive.set(false);
boundsProviderSpy = jest.spyOn(
EmbeddedMpvSessionController.prototype,
'setBoundsProvider'
);
await TestBed.configureTestingModule({
imports: [DockPanelsHostComponent, TranslateModule.forRoot()],
providers: [
{
provide: EmbeddedMpvOverlayVisibilityService,
useValue: { overlayActive },
},
],
}).compileComponents();
fixture = TestBed.createComponent(DockPanelsHostComponent);
fixture.detectChanges();
const playerDebugElement = fixture.debugElement.query(
By.directive(EmbeddedMpvPlayerComponent)
);
player = playerDebugElement.componentInstance;
controller = playerDebugElement.injector.get(
EmbeddedMpvSessionController
);
configureReadyController();
});
afterEach(() => {
fixture.destroy();
boundsProviderSpy.mockRestore();
});
it('keeps full host bounds while every control menu is open', () => {
const provider = boundsProvider();
expect(provider(HOST_STUB)).toEqual(FULL_BOUNDS);
for (const menu of [
'volume',
'audio',
'subtitle',
'speed',
'aspect',
] as const) {
player.menus.open(menu);
// Core regression: menus render inside the fixed dock strip, so
// the native MPV view must never shrink (no bottom cutout).
expect(provider(HOST_STUB)).toEqual(FULL_BOUNDS);
}
});
it('still hides the native view while a modal overlay is active', () => {
overlayActive.set(true);
expect(boundsProvider()(HOST_STUB)).toEqual(HIDDEN_BOUNDS);
overlayActive.set(false);
expect(boundsProvider()(HOST_STUB)).toEqual(FULL_BOUNDS);
});
it('morphs the dock row into a horizontal audio panel with menu roles', () => {
query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click();
fixture.detectChanges();
expect(query('.embedded-mpv-player__transport')).toBeNull();
expect(query('app-embedded-mpv-dock-panel')).not.toBeNull();
const ribbon = query('.embedded-mpv-dock-panel__ribbon');
expect(ribbon.attributes['role']).toBe('menu');
expect(ribbon.attributes['aria-orientation']).toBe('horizontal');
const chips = queryAll('.embedded-mpv-dock-panel__chip');
expect(chips).toHaveLength(2);
expect(chips[0].attributes['role']).toBe('menuitemradio');
expect(chips[0].nativeElement.getAttribute('aria-checked')).toBe(
'true'
);
expect(chips[0].nativeElement.tabIndex).toBe(0);
expect(chips[1].nativeElement.getAttribute('aria-checked')).toBe(
'false'
);
expect(chips[1].nativeElement.tabIndex).toBe(-1);
expect(chips[1].nativeElement.getAttribute('title')).toContain('deu');
});
it('selects an audio chip, closes the panel, and restores the row', async () => {
const setAudioTrack = jest
.spyOn(controller, 'setAudioTrack')
.mockResolvedValue(undefined);
query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click();
fixture.detectChanges();
queryAll('.embedded-mpv-dock-panel__chip')[1].nativeElement.click();
await fixture.whenStable();
fixture.detectChanges();
expect(setAudioTrack).toHaveBeenCalledWith(2);
expect(player.menus.audioOpen()).toBe(false);
expect(query('app-embedded-mpv-dock-panel')).toBeNull();
expect(query('.embedded-mpv-player__transport')).not.toBeNull();
});
it('renders the subtitles-off chip first and maps it to track -1', async () => {
const setSubtitleTrack = jest
.spyOn(controller, 'setSubtitleTrack')
.mockResolvedValue(undefined);
query(
'[data-embedded-mpv-menu-button="subtitle"]'
).nativeElement.click();
fixture.detectChanges();
const chips = queryAll('.embedded-mpv-dock-panel__chip');
expect(chips).toHaveLength(3);
expect(chips[0].nativeElement.getAttribute('aria-checked')).toBe(
'true'
);
chips[0].nativeElement.click();
await fixture.whenStable();
expect(setSubtitleTrack).toHaveBeenCalledWith(-1);
query(
'[data-embedded-mpv-menu-button="subtitle"]'
).nativeElement.click();
fixture.detectChanges();
queryAll('.embedded-mpv-dock-panel__chip')[2].nativeElement.click();
await fixture.whenStable();
expect(setSubtitleTrack).toHaveBeenCalledWith(22);
});
it('selects speed and aspect presets from horizontal chip rows', async () => {
const setSpeed = jest
.spyOn(controller, 'setSpeed')
.mockResolvedValue(undefined);
const setAspect = jest
.spyOn(controller, 'setAspect')
.mockResolvedValue(undefined);
query('[data-embedded-mpv-menu-button="speed"]').nativeElement.click();
fixture.detectChanges();
expect(queryAll('.embedded-mpv-dock-panel__chip')).toHaveLength(6);
queryAll('.embedded-mpv-dock-panel__chip')[4].nativeElement.click();
await fixture.whenStable();
fixture.detectChanges();
expect(setSpeed).toHaveBeenCalledWith(1.5);
query('[data-embedded-mpv-menu-button="aspect"]').nativeElement.click();
fixture.detectChanges();
expect(queryAll('.embedded-mpv-dock-panel__chip')).toHaveLength(5);
queryAll('.embedded-mpv-dock-panel__chip')[1].nativeElement.click();
await fixture.whenStable();
expect(setAspect).toHaveBeenCalledWith('16:9');
});
it('expands volume inline without morphing the row', () => {
player.menus.open('volume');
fixture.detectChanges();
expect(query('.embedded-mpv-player__volume-inline')).not.toBeNull();
expect(query('.embedded-mpv-player__transport')).not.toBeNull();
expect(query('app-embedded-mpv-dock-panel')).toBeNull();
const slider = query('.embedded-mpv-player__slider--volume')
.nativeElement as HTMLInputElement;
slider.value = '0.4';
slider.dispatchEvent(new Event('input', { bubbles: true }));
fixture.detectChanges();
expect(player.volume()).toBe(0.4);
});
it('closes an open panel with Escape', () => {
query('[data-embedded-mpv-menu-button="speed"]').nativeElement.click();
fixture.detectChanges();
expect(player.menus.speedOpen()).toBe(true);
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
fixture.detectChanges();
expect(player.menus.anyOpen()).toBe(false);
expect(query('.embedded-mpv-player__transport')).not.toBeNull();
});
it('blocks seek and volume arrow shortcuts while a chip panel is open', () => {
const seekBy = jest
.spyOn(controller, 'seekBy')
.mockResolvedValue(true);
const volumeBefore = player.volume();
query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click();
fixture.detectChanges();
for (const key of [
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
]) {
document.dispatchEvent(
new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
})
);
}
expect(seekBy).not.toHaveBeenCalled();
expect(player.volume()).toBe(volumeBefore);
player.menus.closeAll();
fixture.detectChanges();
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
})
);
expect(seekBy).toHaveBeenCalledWith(5);
});
});
@@ -98,6 +98,7 @@
<app-player-controls
[controller]="sharedControls"
[playerSurface]="playerSurface()"
[mediaTitle]="mediaTitle()"
[showControls]="showControls()"
[shortcutsEnabled]="sharedShortcutsEnabled()"
(previousEpisodeRequested)="requestPreviousEpisode()"
@@ -111,229 +112,249 @@
[class.embedded-mpv-player__controls--visible]="
controlsAreVisible()
"
[class.embedded-mpv-player__controls--panel]="
dockPanels.active() !== null
"
>
<div class="embedded-mpv-player__transport">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="isLoading()"
(click)="togglePaused()"
[attr.aria-label]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
[matTooltip]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isPaused() ? 'play_arrow' : 'pause'
}}</mat-icon>
</button>
@if (showSeriesNavigation()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-previous-episode"
[disabled]="isLoading() || !canPreviousEpisode()"
(click)="requestPreviousEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-next-episode"
[disabled]="isLoading() || !canNextEpisode()"
(click)="requestNextEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(-10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>replay_10</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>forward_10</mat-icon>
</button>
</div>
<div class="embedded-mpv-player__timeline">
<input
class="embedded-mpv-player__slider"
[style.--slider-progress.%]="
canSeek()
? (timelineValue() /
((session()?.durationSeconds ?? 0) || 1)) *
100
: 0
"
type="range"
min="0"
[max]="session()?.durationSeconds ?? 0"
[value]="timelineValue()"
[disabled]="!canSeek()"
[attr.aria-valuetext]="formatTime(timelineValue())"
(input)="onTimelineInput($event)"
(change)="onTimelineCommit($event)"
@if (dockPanels.active(); as dockPanel) {
<app-embedded-mpv-dock-panel
[panel]="dockPanel"
[backLabel]="dockPanelBackLabel()"
(chipSelected)="dockPanels.select($event)"
(closed)="dockPanels.close()"
/>
<div class="embedded-mpv-player__time">
<span>{{ formatTime(timelineValue()) }}</span>
@if (canSeek()) {
<span>{{
formatTime(session()?.durationSeconds)
}}</span>
} @else if (isLivePlayback()) {
<span
class="embedded-mpv-player__live-badge"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.LIVE_STREAM' | translate
"
>
<span
class="embedded-mpv-player__live-dot"
aria-hidden="true"
></span>
LIVE
</span>
} @else {
<span>--:--</span>
}
</div>
@if (recordingStatusText(); as recordingStatus) {
<div
class="embedded-mpv-player__recording-status"
[class.embedded-mpv-player__recording-status--active]="
isRecording()
} @else {
<div class="embedded-mpv-player__transport">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="isLoading()"
(click)="togglePaused()"
[attr.aria-label]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
[attr.aria-live]="isRecording() ? 'off' : 'polite'"
[matTooltip]="
(isPaused()
? 'EMBEDDED_MPV.PLAYER.PLAY'
: 'EMBEDDED_MPV.PLAYER.PAUSE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isRecording()
? 'fiber_manual_record'
: 'check_circle'
isPaused() ? 'play_arrow' : 'pause'
}}</mat-icon>
<span>{{ recordingStatus }}</span>
</div>
}
</div>
<div class="embedded-mpv-player__actions">
<div
class="embedded-mpv-player__popover-anchor"
(pointerenter)="onVolumeHoverEnter()"
(pointerleave)="onVolumeHoverLeave()"
(focusin)="onVolumeHoverEnter()"
(focusout)="onVolumeHoverLeave()"
(wheel)="onVolumeWheel($event)"
>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleMute()"
[attr.aria-label]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
[matTooltip]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
@if (menus.volumeOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-popover"
role="dialog"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.VOLUME' | translate
"
>
<input
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
[style.--slider-progress.%]="volume() * 100"
type="range"
min="0"
max="1"
step="0.01"
[value]="volume()"
[attr.aria-label]="volumeLabel()"
[attr.aria-valuetext]="volumeLabel()"
(input)="onVolumeInput($event)"
/>
<span class="embedded-mpv-player__volume-value">
{{ volumeLabel() }}
</span>
</div>
}
</div>
@if (hasAudioTracks()) {
<div class="embedded-mpv-player__popover-anchor">
@if (showSeriesNavigation()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-previous-episode"
[disabled]="isLoading() || !canPreviousEpisode()"
(click)="requestPreviousEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.PREVIOUS_EPISODE'
| translate
"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-next-episode"
[disabled]="isLoading() || !canNextEpisode()"
(click)="requestNextEpisode()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.NEXT_EPISODE' | translate
"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(-10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.BACK_10_SECONDS' | translate
"
matTooltipPosition="above"
>
<mat-icon>replay_10</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canSeek()"
(click)="seekBy(10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
"
matTooltipPosition="above"
>
<mat-icon>forward_10</mat-icon>
</button>
</div>
<div class="embedded-mpv-player__timeline">
<input
class="embedded-mpv-player__slider"
[style.--slider-progress.%]="
canSeek()
? (timelineValue() /
((session()?.durationSeconds ?? 0) ||
1)) *
100
: 0
"
type="range"
min="0"
[max]="session()?.durationSeconds ?? 0"
[value]="timelineValue()"
[disabled]="!canSeek()"
[attr.aria-valuetext]="formatTime(timelineValue())"
(input)="onTimelineInput($event)"
(change)="onTimelineCommit($event)"
/>
<div class="embedded-mpv-player__time">
<span>{{ formatTime(timelineValue()) }}</span>
@if (canSeek()) {
<span>{{
formatTime(session()?.durationSeconds)
}}</span>
} @else if (isLivePlayback()) {
<span
class="embedded-mpv-player__live-badge"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.LIVE_STREAM'
| translate
"
>
<span
class="embedded-mpv-player__live-dot"
aria-hidden="true"
></span>
LIVE
</span>
} @else {
<span>--:--</span>
}
</div>
@if (recordingStatusText(); as recordingStatus) {
<div
class="embedded-mpv-player__recording-status"
[class.embedded-mpv-player__recording-status--active]="
isRecording()
"
[attr.aria-live]="isRecording() ? 'off' : 'polite'"
>
<mat-icon>{{
isRecording()
? 'fiber_manual_record'
: 'check_circle'
}}</mat-icon>
<span>{{ recordingStatus }}</span>
</div>
}
</div>
<div class="embedded-mpv-player__actions">
<div
class="embedded-mpv-player__volume-group"
(pointerenter)="onVolumeHoverEnter()"
(pointerleave)="onVolumeHoverLeave()"
(focusin)="onVolumeHoverEnter()"
(focusout)="onVolumeHoverLeave()"
(wheel)="onVolumeWheel($event)"
>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleMute()"
[attr.aria-label]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
[matTooltip]="
(volume() <= 0
? 'EMBEDDED_MPV.PLAYER.UNMUTE'
: 'EMBEDDED_MPV.PLAYER.MUTE'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
@if (menus.volumeOpen()) {
<div
class="embedded-mpv-player__volume-inline"
role="group"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.VOLUME' | translate
"
>
<input
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
[style.--slider-progress.%]="
volume() * 100
"
type="range"
min="0"
max="1"
step="0.01"
[value]="volume()"
[attr.aria-label]="volumeLabel()"
[attr.aria-valuetext]="volumeLabel()"
(input)="onVolumeInput($event)"
/>
<span
class="embedded-mpv-player__volume-value"
>{{ volumePercent() }}</span
>
</div>
}
</div>
@if (hasAudioTracks()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="audio"
(click)="toggleAudioMenu()"
[attr.aria-expanded]="menus.audioOpen()"
[attr.aria-label]="
@@ -346,59 +367,14 @@
>
<mat-icon>graphic_eq</mat-icon>
</button>
@if (menus.audioOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
| translate
"
>
<div class="embedded-mpv-player__menu-title">
{{
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
>
@for (
track of audioTracks();
track track.id;
let index = $index
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
track.selected
"
(click)="selectAudioTrack(track.id)"
>
<span>{{
trackLabel(track, index)
}}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
}
@if (capabilities().subtitles && hasSubtitleTracks()) {
<div class="embedded-mpv-player__popover-anchor">
@if (capabilities().subtitles && hasSubtitleTracks()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="subtitle"
(click)="toggleSubtitleMenu()"
[attr.aria-expanded]="menus.subtitleOpen()"
[attr.aria-label]="
@@ -415,82 +391,14 @@
: 'subtitles'
}}</mat-icon>
</button>
@if (menus.subtitleOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
"
>
<div class="embedded-mpv-player__menu-title">
{{
'EMBEDDED_MPV.PLAYER.SUBTITLES'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
>
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
selectedSubtitleTrackId() === null
"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
selectedSubtitleTrackId() === null
"
(click)="selectSubtitleTrack(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
| translate
}}</span>
@if (
selectedSubtitleTrackId() === null
) {
<mat-icon>check</mat-icon>
}
</button>
@for (
track of subtitleTracks();
track track.id;
let index = $index
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
track.selected
"
(click)="
selectSubtitleTrack(track.id)
"
>
<span>{{
subtitleLabel(track, index)
}}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
}
@if (capabilities().playbackSpeed) {
<div class="embedded-mpv-player__popover-anchor">
@if (capabilities().playbackSpeed) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="speed"
(click)="toggleSpeedMenu()"
[attr.aria-expanded]="menus.speedOpen()"
[attr.aria-label]="
@@ -512,60 +420,14 @@
>
<mat-icon>speed</mat-icon>
</button>
@if (menus.speedOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate
"
>
<div class="embedded-mpv-player__menu-title">
{{
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
>
@for (
preset of speedPresets;
track preset.value
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value === playbackSpeed()
"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
preset.value === playbackSpeed()
"
(click)="selectSpeed(preset.value)"
>
<span>{{ preset.label }}</span>
@if (
preset.value === playbackSpeed()
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
}
@if (capabilities().aspectOverride) {
<div class="embedded-mpv-player__popover-anchor">
@if (capabilities().aspectOverride) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-embedded-mpv-menu-button="aspect"
(click)="toggleAspectMenu()"
[attr.aria-expanded]="menus.aspectOpen()"
[attr.aria-label]="
@@ -587,105 +449,57 @@
>
<mat-icon>aspect_ratio</mat-icon>
</button>
@if (menus.aspectOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate
"
>
<div class="embedded-mpv-player__menu-title">
{{
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO'
| translate
}}
</div>
<div
class="embedded-mpv-player__audio-track-list"
>
@for (
preset of aspectPresets;
track preset.value
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value ===
aspectOverride()
"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
preset.value ===
aspectOverride()
"
(click)="selectAspect(preset.value)"
>
<span>{{ preset.label }}</span>
@if (
preset.value ===
aspectOverride()
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
}
@if (canRecord()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button embedded-mpv-player__record-button"
[class.embedded-mpv-player__record-button--active]="
isRecording()
"
[disabled]="isLoading()"
(click)="toggleRecording()"
[attr.aria-pressed]="isRecording()"
[attr.aria-label]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
[matTooltip]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
matTooltipPosition="above"
>
<mat-icon>{{
isRecording()
? 'stop_circle'
: 'fiber_manual_record'
}}</mat-icon>
</button>
}
@if (canRecord()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button embedded-mpv-player__record-button"
[class.embedded-mpv-player__record-button--active]="
isRecording()
"
[disabled]="isLoading()"
(click)="toggleRecording()"
[attr.aria-pressed]="isRecording()"
[attr.aria-label]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
[matTooltip]="
(isRecording()
? 'EMBEDDED_MPV.PLAYER.STOP_RECORDING'
: 'EMBEDDED_MPV.PLAYER.START_RECORDING'
) | translate
"
class="embedded-mpv-player__control-button"
[disabled]="!canFullscreen()"
(click)="toggleFullscreen()"
[attr.aria-label]="fullscreenLabel()"
[matTooltip]="fullscreenLabel()"
matTooltipPosition="above"
>
<mat-icon>{{
isRecording()
? 'stop_circle'
: 'fiber_manual_record'
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
}}</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
[disabled]="!canFullscreen()"
(click)="toggleFullscreen()"
[attr.aria-label]="fullscreenLabel()"
[matTooltip]="fullscreenLabel()"
matTooltipPosition="above"
>
<mat-icon>{{
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
}}</mat-icon>
</button>
</div>
</div>
}
</div>
}
</div>
@@ -294,17 +294,29 @@
display: flex;
align-items: center;
gap: 8px;
// Let the actions cell shrink below its content so the inline volume
// (a scroll container) clips its own slider instead of pushing the
// fullscreen button past the overflow:hidden player edge on narrow
// sidebar-constrained widths (viewport wider than the 720px breakpoint).
min-width: 0;
}
.embedded-mpv-player__popover-anchor {
position: relative;
.embedded-mpv-player__volume-group {
display: flex;
align-items: center;
min-width: 0;
}
.embedded-mpv-player__control-button {
position: relative;
}
/* A dock panel morphs the whole controls row into a single full-width
cell; the strip keeps its fixed height so MPV bounds stay untouched. */
.embedded-mpv-player__controls--panel {
grid-template-columns: minmax(0, 1fr);
}
.embedded-mpv-player__record-button--active {
color: var(--mat-sys-error) !important;
}
@@ -398,115 +410,40 @@
box-shadow: none;
}
.embedded-mpv-player__control-panel {
min-width: 0;
color: var(--mat-sys-on-surface);
background: var(--embedded-mpv-glass);
border: 1px solid var(--embedded-mpv-border);
border-radius: 14px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
/* Volume expands inline next to the mute icon, inside the actions row —
never above the strip, so it needs no compositor workarounds. */
.embedded-mpv-player__volume-inline {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
animation: embedded-mpv-volume-in 160ms ease-out;
}
.embedded-mpv-player__volume-popover {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
display: grid;
grid-template-columns: minmax(160px, 200px);
gap: 4px;
padding: 12px 14px 10px;
z-index: 3;
@keyframes embedded-mpv-volume-in {
from {
max-width: 0;
opacity: 0;
}
to {
max-width: 200px;
opacity: 1;
}
}
.embedded-mpv-player__volume-popover::before {
/* Hover-bridge so the cursor can move from the button to the popover
without crossing a dead zone. */
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -8px;
height: 8px;
}
.embedded-mpv-player__volume-popover .embedded-mpv-player__slider--volume {
width: 100%;
.embedded-mpv-player__volume-inline .embedded-mpv-player__slider--volume {
width: 132px;
}
.embedded-mpv-player__volume-value {
color: color-mix(in srgb, var(--mat-sys-on-surface) 80%, transparent);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
text-align: right;
min-width: 34px;
text-align: end;
white-space: nowrap;
}
.embedded-mpv-player__audio-popover {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
min-width: 220px;
max-width: min(420px, 80vw);
padding: 10px 12px;
z-index: 3;
}
.embedded-mpv-player__menu-title {
padding: 0 2px 6px;
color: color-mix(in srgb, var(--mat-sys-on-surface) 70%, transparent);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.embedded-mpv-player__audio-track-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent)
transparent;
}
.embedded-mpv-player__audio-track {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
color: var(--mat-sys-on-surface);
background: transparent;
border: 0;
border-radius: 10px;
cursor: pointer;
font: inherit;
text-align: left;
}
.embedded-mpv-player__audio-track span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.embedded-mpv-player__audio-track:hover,
.embedded-mpv-player__audio-track:focus-visible,
.embedded-mpv-player__audio-track--selected {
background: color-mix(in srgb, var(--embedded-mpv-accent) 16%, transparent);
}
.embedded-mpv-player__audio-track mat-icon {
color: var(--embedded-mpv-accent);
font-size: 18px;
width: 18px;
height: 18px;
}
.embedded-mpv-player :is(button[mat-icon-button]) {
color: var(--mat-sys-on-surface);
}
@@ -531,9 +468,7 @@
min-width: 0;
}
.embedded-mpv-player__volume-popover,
.embedded-mpv-player__audio-popover {
right: 0;
left: auto;
.embedded-mpv-player__volume-inline .embedded-mpv-player__slider--volume {
width: 96px;
}
}
@@ -24,8 +24,11 @@ import {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
import type { PlayerMediaTitle } from '../player-controls/player-controls.model';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
import { EmbeddedMpvDockPanelState } from './embedded-mpv-dock-panels';
import { EmbeddedMpvLegacyInteractions } from './embedded-mpv-legacy-interactions';
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
@@ -37,7 +40,6 @@ import {
import {
ASPECT_PRESETS,
HIDDEN_BOUNDS,
MENU_OPEN_BOTTOM_CUTOUT_PX,
SPEED_PRESETS,
aspectLabel,
audioTrackLabel,
@@ -57,6 +59,7 @@ const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
templateUrl: './embedded-mpv-player.component.html',
styleUrl: './embedded-mpv-player.component.scss',
imports: [
EmbeddedMpvDockPanelComponent,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
@@ -75,6 +78,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly showControls = input(true);
readonly recordingFolder = input('');
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -108,6 +112,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
private readonly shortcuts = new EmbeddedMpvShortcuts();
readonly menus = new EmbeddedMpvMenuState();
readonly feedback = new EmbeddedMpvFeedback();
readonly dockPanels: EmbeddedMpvDockPanelState;
readonly viewport = viewChild<ElementRef<HTMLDivElement>>('viewport');
readonly playerRoot = viewChild<ElementRef<HTMLDivElement>>('playerRoot');
@@ -230,6 +235,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
percent: Math.round(this.volume() * 100),
});
});
readonly volumePercent = computed(
() => `${Math.round(this.volume() * 100)}%`
);
readonly dockPanelBackLabel = computed(() => {
this.translationsTick();
return this.translate.instant('EMBEDDED_MPV.PLAYER.BACK');
});
/**
* Non-null while the user drags the timeline: the slider and time label
* preview this value locally and the single seek IPC call is deferred to
@@ -328,10 +340,34 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
statusLabel: this.statusLabel,
togglePaused: () => this.togglePaused(),
toggleFullscreen: () => this.toggleFullscreen(),
triggerBoundsSync: () => this.controller.triggerBoundsSync(),
});
this.legacyInteractions.attach();
this.dockPanels = new EmbeddedMpvDockPanelState({
menus: this.menus,
audioTracks: this.audioTracks,
subtitleTracks: this.subtitleTracks,
selectedSubtitleTrackId: this.selectedSubtitleTrackId,
playbackSpeed: this.playbackSpeed,
aspectOverride: this.aspectOverride,
translateLabel: (key) => {
this.translationsTick();
return this.translate.instant(key);
},
audioTrackLabel: (track, index) => this.trackLabel(track, index),
subtitleTrackLabel: (track, index) =>
this.subtitleLabel(track, index),
aspectLabel: (aspect) => this.aspectLabel(aspect),
selectAudioTrack: (trackId) => void this.selectAudioTrack(trackId),
selectSubtitleTrack: (trackId) =>
void this.selectSubtitleTrack(trackId),
selectSpeed: (speed) => void this.selectSpeed(speed),
selectAspect: (aspect) => void this.selectAspect(aspect),
closePanels: () => this.legacyInteractions.closePopovers(),
playerRoot: () => this.playerRoot()?.nativeElement ?? null,
revealControls: () => this.legacyInteractions.revealControls(),
});
this.sharedControls.configure({
playback: this.playback,
seriesNavigation: this.seriesNavigation,
@@ -347,32 +383,26 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.controller.setBoundsProvider((host) => {
// The frame-copy engine paints into an ordinary DOM canvas:
// dialogs and popovers stack above it natively, so the
// hide-offscreen and popover-cutout compositor workarounds
// must not shrink its render size.
// dialogs and overlays stack above it natively, so the
// hide-offscreen compositor workaround must not shrink its
// render size.
if (this.isFrameCopyEngine()) {
return measureBounds(host);
}
if (this.overlayVisibility.overlayActive()) {
return HIDDEN_BOUNDS;
}
const rect = measureBounds(host);
if (this.menus.anyOpen()) {
return {
...rect,
height: Math.max(
1,
rect.height - MENU_OPEN_BOTTOM_CUTOUT_PX
),
};
}
return rect;
// Control menus render as horizontal panels inside the
// fixed-height dock strip below the video host, so open menus
// never require shrinking the native MPV view.
return measureBounds(host);
});
this.shortcuts.attach({
isAvailable: () =>
this.legacyInteractions.isAvailable() &&
!this.overlayVisibility.overlayActive(),
arrowKeysBlocked: () => this.menus.dockPanelOpen(),
onEscape: () => this.legacyInteractions.closePopovers(),
togglePaused: () => void this.togglePaused(),
toggleFullscreen: () => void this.toggleFullscreen(),
@@ -411,10 +441,14 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
effect(() => {
this.overlayVisibility.overlayActive();
this.menus.anyOpen();
this.controller.triggerBoundsSync();
});
effect(() => {
const panelOpen = this.menus.dockPanelOpen();
untracked(() => this.dockPanels.handlePanelOpenChange(panelOpen));
});
effect(() => {
const session = this.session();
if (!session) {
@@ -601,20 +635,16 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
}
toggleAudioMenu(): void {
this.menus.toggle('audio');
this.legacyInteractions.revealControls();
this.dockPanels.toggle('audio');
}
toggleSubtitleMenu(): void {
this.menus.toggle('subtitle');
this.legacyInteractions.revealControls();
this.dockPanels.toggle('subtitle');
}
toggleSpeedMenu(): void {
this.menus.toggle('speed');
this.legacyInteractions.revealControls();
this.dockPanels.toggle('speed');
}
toggleAspectMenu(): void {
this.menus.toggle('aspect');
this.legacyInteractions.revealControls();
this.dockPanels.toggle('aspect');
}
async selectAudioTrack(trackId: number): Promise<void> {
@@ -0,0 +1,213 @@
import { TestBed } from '@angular/core/testing';
import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
/**
* Moving the window to a display with a different scale (or changing the
* page zoom) rescales the CSS→native mapping the backend applies to bounds,
* without necessarily resizing the host element. The controller watches
* devicePixelRatio through a re-armed matchMedia query and re-syncs bounds
* when it changes (#1145).
*/
describe('EmbeddedMpvSessionController devicePixelRatio watch', () => {
class FakeMediaQueryList {
private readonly listeners = new Set<() => void>();
constructor(readonly media: string) {}
addEventListener(_type: 'change', listener: () => void): void {
this.listeners.add(listener);
}
removeEventListener(_type: 'change', listener: () => void): void {
this.listeners.delete(listener);
}
fire(): void {
for (const listener of [...this.listeners]) {
listener();
}
}
get listenerCount(): number {
return this.listeners.size;
}
}
let electron: {
platform: string;
getEmbeddedMpvSupport: jest.Mock;
prepareEmbeddedMpv: jest.Mock;
createEmbeddedMpvSession: jest.Mock;
loadEmbeddedMpvPlayback: jest.Mock;
disposeEmbeddedMpvSession: jest.Mock;
setEmbeddedMpvBounds: jest.Mock;
onEmbeddedMpvSessionUpdate: jest.Mock;
};
let mediaQueries: FakeMediaQueryList[];
beforeEach(() => {
electron = {
platform: 'win32',
getEmbeddedMpvSupport: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'win32' }),
prepareEmbeddedMpv: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'win32' }),
createEmbeddedMpvSession: jest
.fn()
.mockResolvedValue(createSession()),
loadEmbeddedMpvPlayback: jest.fn().mockResolvedValue(undefined),
disposeEmbeddedMpvSession: jest.fn().mockResolvedValue(undefined),
setEmbeddedMpvBounds: jest.fn().mockResolvedValue(undefined),
onEmbeddedMpvSessionUpdate: jest.fn(() => jest.fn()),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: electron,
});
mediaQueries = [];
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (media: string) => {
const query = new FakeMediaQueryList(media);
mediaQueries.push(query);
return query;
},
});
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1,
});
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: class MockResizeObserver {
observe = jest.fn();
disconnect = jest.fn();
},
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: (handle: number) => window.clearTimeout(handle),
});
TestBed.configureTestingModule({
providers: [EmbeddedMpvSessionController],
});
});
afterEach(() => {
TestBed.resetTestingModule();
delete (window as unknown as { electron?: unknown }).electron;
delete (window as unknown as { matchMedia?: unknown }).matchMedia;
jest.restoreAllMocks();
});
it('re-syncs bounds and re-arms the query when devicePixelRatio changes', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
expect(mediaQueries.length).toBe(1);
expect(mediaQueries[0].media).toBe('(resolution: 1dppx)');
electron.setEmbeddedMpvBounds.mockClear();
// Simulate a move to a 150%-scaled display.
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1.5,
});
mediaQueries[0].fire();
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'bounds re-sync after dPR change'
);
// The stale query is released and a new one tracks the new ratio.
expect(mediaQueries[0].listenerCount).toBe(0);
expect(mediaQueries.length).toBe(2);
expect(mediaQueries[1].media).toBe('(resolution: 1.5dppx)');
// A second display change must fire through the re-armed query.
electron.setEmbeddedMpvBounds.mockClear();
mediaQueries[1].fire();
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'bounds re-sync after second dPR change'
);
teardown();
expect(mediaQueries[mediaQueries.length - 1].listenerCount).toBe(0);
});
});
function createHost(): HTMLElement {
return {
getBoundingClientRect: () => ({
left: 10,
top: 20,
width: 640,
height: 360,
}),
} as HTMLElement;
}
function createPlayback(): ResolvedPortalPlayback {
return {
streamUrl: 'https://example.com/movie.mp4',
title: 'Example Movie',
};
}
function createSession(): EmbeddedMpvSession {
return {
id: 'mpv-1',
title: 'Example Movie',
streamUrl: 'https://example.com/movie.mp4',
status: 'playing',
positionSeconds: 0,
durationSeconds: null,
volume: 0.5,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-07-19T00:00:00.000Z',
updatedAt: '2026-07-19T00:00:01.000Z',
};
}
async function waitFor(
condition: () => boolean,
description: string
): Promise<void> {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
if (condition()) {
return;
}
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
throw new Error(`Timed out waiting for ${description}`);
}
@@ -141,8 +141,10 @@ describe('EmbeddedMpvSessionController', () => {
);
expect(electron.prepareEmbeddedMpv).toHaveBeenCalled();
// Fractional CSS edges stay unrounded: the main process rounds once,
// after converting them to native units.
expect(electron.createEmbeddedMpvSession).toHaveBeenCalledWith(
{ x: 11, y: 21, width: 640, height: 360 },
{ x: 10.6, y: 20.5, width: 640, height: 360 },
'Example Movie',
0.7
);
@@ -151,6 +151,31 @@ export class EmbeddedMpvSessionController {
window.addEventListener('resize', scheduleBoundsSync);
window.addEventListener('scroll', scheduleBoundsSync, true);
// Page zoom and monitor DPI rescale the CSS→native-pixel mapping the
// backend applies to these bounds. Moving the window to a display
// with a different scale can keep the CSS layout identical (no
// resize, no ResizeObserver), so watch devicePixelRatio through a
// re-armed matchMedia query and re-sync when it changes.
let detachDprWatch: (() => void) | null = null;
const watchDevicePixelRatio = () => {
detachDprWatch?.();
detachDprWatch = null;
const query = window.matchMedia?.(
`(resolution: ${window.devicePixelRatio}dppx)`
);
if (!query) {
return;
}
const onChange = () => {
watchDevicePixelRatio();
scheduleBoundsSync();
};
query.addEventListener('change', onChange);
detachDprWatch = () =>
query.removeEventListener('change', onChange);
};
watchDevicePixelRatio();
const create = async () => {
this.session.set(createLoadingSession(playback, initialVolume));
await waitForStartupPaint();
@@ -237,6 +262,8 @@ export class EmbeddedMpvSessionController {
resizeObserver.disconnect();
window.removeEventListener('resize', scheduleBoundsSync);
window.removeEventListener('scroll', scheduleBoundsSync, true);
detachDprWatch?.();
detachDprWatch = null;
if (this.activeBoundsSync === scheduleBoundsSync) {
this.activeBoundsSync = null;
@@ -48,6 +48,23 @@ describe('EmbeddedMpvShortcuts', () => {
expect(handlers.toggleMute).toHaveBeenCalledTimes(1);
});
it('suspends arrow shortcuts while a dock chip panel owns the keyboard', () => {
shortcuts.detach();
shortcuts.attach({ ...handlers, arrowKeysBlocked: () => true });
expect(dispatchKey('ArrowLeft')).toBe(false);
expect(dispatchKey('ArrowRight')).toBe(false);
expect(dispatchKey('ArrowUp')).toBe(false);
expect(dispatchKey('ArrowDown')).toBe(false);
expect(dispatchKey(' ')).toBe(true);
expect(dispatchKey('m')).toBe(true);
expect(handlers.seekBy).not.toHaveBeenCalled();
expect(handlers.adjustVolume).not.toHaveBeenCalled();
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
expect(handlers.toggleMute).toHaveBeenCalledTimes(1);
});
it('always allows escape to close popovers even when playback is unavailable', () => {
handlers.isAvailable.mockReturnValue(false);
@@ -1,5 +1,10 @@
export interface EmbeddedMpvShortcutHandlers {
isAvailable: () => boolean;
/**
* While true, arrow keys stop seeking/adjusting volume — an open dock
* chip panel owns them for chip navigation instead.
*/
arrowKeysBlocked?: () => boolean;
onEscape: () => void;
togglePaused: () => void;
toggleFullscreen: () => void;
@@ -41,6 +46,8 @@ export class EmbeddedMpvShortcuts {
return;
}
const arrowsBlocked = handlers.arrowKeysBlocked?.() === true;
switch (event.key) {
case ' ':
case 'k':
@@ -54,18 +61,30 @@ export class EmbeddedMpvShortcuts {
handlers.toggleFullscreen();
return;
case 'ArrowLeft':
if (arrowsBlocked) {
return;
}
event.preventDefault();
handlers.seekBy(-5);
return;
case 'ArrowRight':
if (arrowsBlocked) {
return;
}
event.preventDefault();
handlers.seekBy(5);
return;
case 'ArrowUp':
if (arrowsBlocked) {
return;
}
event.preventDefault();
handlers.adjustVolume(0.05);
return;
case 'ArrowDown':
if (arrowsBlocked) {
return;
}
event.preventDefault();
handlers.adjustVolume(-0.05);
return;
@@ -19,6 +19,22 @@ describe('EmbeddedMpvMenuState', () => {
expect(menus.audioOpen()).toBe(false);
expect(menus.anyOpen()).toBe(false);
});
it('reports dockPanelOpen for chip panels but not the volume slider', () => {
const menus = new EmbeddedMpvMenuState();
menus.open('volume');
expect(menus.anyOpen()).toBe(true);
expect(menus.dockPanelOpen()).toBe(false);
for (const menu of ['audio', 'subtitle', 'speed', 'aspect'] as const) {
menus.open(menu);
expect(menus.dockPanelOpen()).toBe(true);
}
menus.closeAll();
expect(menus.dockPanelOpen()).toBe(false);
});
});
describe('EmbeddedMpvFeedback', () => {
@@ -8,8 +8,10 @@ export type EmbeddedMpvMenu =
| 'aspect';
/**
* Tracks which menu/popover is currently open and exposes individual signals
* the template binds to. Only one menu can be open at a time.
* Tracks which menu is currently open and exposes individual signals the
* template binds to. Only one menu can be open at a time. Menus render as
* horizontal panels inside the fixed-height controls strip, so open state
* never affects the native MPV view bounds.
*/
export class EmbeddedMpvMenuState {
readonly volumeOpen = signal(false);
@@ -27,6 +29,19 @@ export class EmbeddedMpvMenuState {
this.aspectOpen()
);
/**
* True while a chip panel morphs the dock row (audio, subtitle, speed,
* aspect — not the inline volume slider). While open, arrow keys walk
* the chips instead of seeking or changing the volume.
*/
readonly dockPanelOpen = computed(
() =>
this.audioOpen() ||
this.subtitleOpen() ||
this.speedOpen() ||
this.aspectOpen()
);
toggle(menu: EmbeddedMpvMenu): void {
const target = this.signalFor(menu);
const next = !target();
@@ -10,6 +10,7 @@
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[mediaTitle]="mediaTitle()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -24,6 +24,7 @@ import {
} from '../playback-diagnostics/playback-diagnostics.util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -71,6 +72,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
@@ -0,0 +1,51 @@
import { Signal, computed, signal } from '@angular/core';
import type { PlayerControlsState } from './player-controls.model';
/**
* Owns the scrub state and timeline projections for the controls bar: the
* current slider value (scrub position while dragging, playback position
* otherwise), the bounded duration, and the progress percentage.
*/
export class ControlsTimeline {
readonly scrubPosition = signal<number | null>(null);
constructor(private readonly state: Signal<PlayerControlsState>) {}
readonly duration = computed(() => {
const duration = this.state().durationSeconds;
return typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: 0;
});
readonly value = computed(
() =>
this.normalize(
this.scrubPosition() ?? this.state().positionSeconds
) ?? 0
);
readonly progress = computed(() => {
const duration = this.duration();
return this.state().canSeek && duration > 0
? (this.value() / duration) * 100
: 0;
});
readEventValue(event: Event): number | null {
return this.normalize(Number((event.target as HTMLInputElement).value));
}
private normalize(value: number): number | null {
if (!Number.isFinite(value)) {
return null;
}
const duration = this.state().durationSeconds;
const upperBound =
typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: Number.POSITIVE_INFINITY;
return Math.min(Math.max(0, value), upperBound);
}
}
@@ -6,6 +6,7 @@ export * from './controls-feedback';
export * from './controls-fullscreen';
export * from './controls-shortcuts';
export * from './controls-surface';
export * from './controls-timeline';
export * from './controls-view-model';
export * from './controls-visibility';
export * from './controls-volume';
@@ -9,6 +9,23 @@
</div>
}
@if (fullscreenMediaTitle(); as mediaTitle) {
<div
class="player-controls__title"
[class.player-controls__title--visible]="controlsAreVisible()"
data-test-id="player-controls-media-title"
>
<div class="player-controls__title-primary">
{{ mediaTitle.primary }}
</div>
@if (mediaTitle.secondary) {
<div class="player-controls__title-secondary">
{{ mediaTitle.secondary }}
</div>
}
</div>
}
@if (showControls()) {
<div
#controlsBar
@@ -0,0 +1,191 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
describe('PlayerControlsComponent fullscreen media title', () => {
let fixture: ComponentFixture<PlayerControlsComponent>;
let component: PlayerControlsComponent;
let fake: ReturnType<typeof createFakeController>;
let surface: HTMLElement;
let fullscreenElement: Element | null;
const setState = (overrides: Partial<PlayerControlsState>) =>
fake.state.set({ ...createEmptyControlsState(), ...overrides });
const enterFullscreen = () => {
fullscreenElement = surface;
document.dispatchEvent(new Event('fullscreenchange'));
fixture.detectChanges();
};
const queryTitle = (): HTMLElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="player-controls-media-title"]'
);
beforeEach(async () => {
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
surface = document.createElement('div');
document.body.appendChild(surface);
fullscreenElement = null;
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('controller', fake.controller);
fixture.componentRef.setInput('playerSurface', surface);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
surface.remove();
jest.useRealTimers();
});
it('stays hidden outside fullscreen even when a media title is set', () => {
fixture.componentRef.setInput('mediaTitle', {
primary: 'Some Movie',
secondary: null,
});
fixture.detectChanges();
expect(queryTitle()).toBeNull();
});
it('shows both title lines in fullscreen and hides them again on exit', () => {
fixture.componentRef.setInput('mediaTitle', {
primary: 'Breaking Code',
secondary: 'S01E02',
});
fixture.detectChanges();
enterFullscreen();
const title = queryTitle();
expect(title).not.toBeNull();
expect(
title?.querySelector('.player-controls__title-primary')?.textContent
).toContain('Breaking Code');
expect(
title?.querySelector('.player-controls__title-secondary')
?.textContent
).toContain('S01E02');
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
fixture.detectChanges();
expect(queryTitle()).toBeNull();
});
it('renders a single line when no secondary text is provided', () => {
fixture.componentRef.setInput('mediaTitle', {
primary: 'Channel One',
secondary: null,
});
fixture.detectChanges();
enterFullscreen();
const title = queryTitle();
expect(title).not.toBeNull();
expect(
title?.querySelector('.player-controls__title-secondary')
).toBeNull();
});
it('stays hidden for a blank primary title and when controls are disabled', () => {
fixture.componentRef.setInput('mediaTitle', {
primary: ' ',
secondary: 'S01E02',
});
fixture.detectChanges();
enterFullscreen();
expect(queryTitle()).toBeNull();
fixture.componentRef.setInput('mediaTitle', {
primary: 'Breaking Code',
secondary: null,
});
fixture.componentRef.setInput('showControls', false);
fixture.detectChanges();
expect(queryTitle()).toBeNull();
});
it('follows the auto-hide visibility of the controls bar', () => {
jest.useFakeTimers();
fixture.componentRef.setInput('mediaTitle', {
primary: 'Breaking Code',
secondary: 'S01E02',
});
fixture.detectChanges();
enterFullscreen();
expect(
queryTitle()?.classList.contains(
'player-controls__title--visible'
)
).toBe(true);
setState({ status: 'playing' });
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(component.controlsAreVisible()).toBe(false);
expect(
queryTitle()?.classList.contains(
'player-controls__title--visible'
)
).toBe(false);
surface.dispatchEvent(new MouseEvent('pointermove'));
fixture.detectChanges();
expect(
queryTitle()?.classList.contains(
'player-controls__title--visible'
)
).toBe(true);
});
});
@@ -72,6 +72,59 @@
}
}
.player-controls__title {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 4px;
// Generous bottom padding lets the scrim fall below the text and fade out.
padding: 20px 24px 40px;
opacity: 0;
// Purely informative: never intercept clicks meant for the video surface.
pointer-events: none;
transform: translateY(-8px);
// Mirror of the bottom bar's scrim so the title reads over bright video.
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.35) 55%,
transparent 100%
);
transition:
opacity 180ms ease-out,
transform 200ms ease-out;
}
.player-controls__title--visible {
opacity: 1;
transform: translateY(0);
}
.player-controls__title-primary {
overflow: hidden;
color: var(--pc-text);
font-size: 1.15rem;
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.player-controls__title-secondary {
overflow: hidden;
color: var(--pc-text-dim);
font-size: 0.85rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.player-controls__bar {
position: absolute;
right: 0;
@@ -21,11 +21,15 @@ import { ControlsMenuSelection } from './controls-menu-selection';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsShortcuts } from './controls-shortcuts';
import { ControlsSurface } from './controls-surface';
import { ControlsTimeline } from './controls-timeline';
import { ControlsVisibility } from './controls-visibility';
import { createControlsViewModel } from './controls-view-model';
import { ControlsVolume } from './controls-volume';
import { formatTime, speedLabel } from './controls-format.utils';
import type { PlayerController } from './player-controls.model';
import type {
PlayerController,
PlayerMediaTitle,
} from './player-controls.model';
@Component({
selector: 'app-player-controls',
@@ -45,6 +49,7 @@ export class PlayerControlsComponent implements OnDestroy {
readonly playerSurface = input<HTMLElement | null>(null);
readonly showControls = input(true);
readonly shortcutsEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly barHovered = signal(false);
@@ -86,29 +91,25 @@ export class PlayerControlsComponent implements OnDestroy {
readonly state = computed(() => this.controller().state());
readonly capabilities = computed(() => this.controller().capabilities());
private readonly controllerVolume = computed(() => this.state().volume);
readonly scrubPosition = signal<number | null>(null);
readonly timelineDuration = computed(() => {
const duration = this.state().durationSeconds;
return typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: 0;
});
readonly timelineValue = computed(
() =>
this.normalizeTimelineValue(
this.scrubPosition() ?? this.state().positionSeconds
) ?? 0
);
readonly timelineProgress = computed(() => {
const duration = this.timelineDuration();
return this.state().canSeek && duration > 0
? (this.timelineValue() / duration) * 100
: 0;
});
private readonly timeline = new ControlsTimeline(this.state);
readonly scrubPosition = this.timeline.scrubPosition;
readonly timelineDuration = this.timeline.duration;
readonly timelineValue = this.timeline.value;
readonly timelineProgress = this.timeline.progress;
readonly displayVolume = this.volume.value;
readonly isFullscreen = this.fullscreen.isFullscreen;
// The page around the player already names the content; the overlay only
// fills that gap in fullscreen, where no other chrome is visible.
readonly fullscreenMediaTitle = computed<PlayerMediaTitle | null>(() => {
if (!this.showControls() || !this.isFullscreen()) {
return null;
}
const mediaTitle = this.mediaTitle();
return mediaTitle?.primary?.trim() ? mediaTitle : null;
});
private readonly vm = createControlsViewModel({
state: this.state,
capabilities: this.capabilities,
@@ -246,11 +247,11 @@ export class PlayerControlsComponent implements OnDestroy {
}
onTimelineInput(event: Event): void {
this.reveal();
this.scrubPosition.set(this.readTimelineValue(event));
this.scrubPosition.set(this.timeline.readEventValue(event));
}
onTimelineCommit(event: Event): void {
this.reveal();
const target = this.readTimelineValue(event);
const target = this.timeline.readEventValue(event);
this.scrubPosition.set(null);
if (
target === null ||
@@ -331,24 +332,6 @@ export class PlayerControlsComponent implements OnDestroy {
this.volume.adjust(delta);
this.reveal();
}
private readTimelineValue(event: Event): number | null {
return this.normalizeTimelineValue(
Number((event.target as HTMLInputElement).value)
);
}
private normalizeTimelineValue(value: number): number | null {
if (!Number.isFinite(value)) {
return null;
}
const duration = this.state().durationSeconds;
const upperBound =
typeof duration === 'number' && Number.isFinite(duration)
? Math.max(0, duration)
: Number.POSITIVE_INFINITY;
return Math.min(Math.max(0, value), upperBound);
}
private closePopovers(): void {
if (!this.menus.anyOpen()) {
return;
@@ -28,6 +28,18 @@ export interface PlayerTrack {
selected: boolean;
}
/**
* Content title lines rendered over the player while the controls are visible
* in fullscreen. Hosts pass display-ready strings; the controls never derive
* them from playback data.
*/
export interface PlayerMediaTitle {
/** Movie title, channel name, or series name. */
primary: string;
/** Optional second line, e.g. the "S01E03" label for series episodes. */
secondary?: string | null;
}
export interface PlayerPreset<T> {
value: T;
label: string;
@@ -50,9 +50,17 @@
</header>
<div class="player-shell__viewport">
@if (ambientEnabled()) {
<div
class="player-shell__ambient"
aria-hidden="true"
[style.--ambient-image]="ambientImageStyle()"
></div>
}
<app-web-player-view
[streamUrl]="streamUrl()"
[title]="title()"
[mediaTitle]="playerMediaTitle()"
[playback]="playback()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
@@ -77,8 +77,20 @@
justify-content: flex-end;
}
// The viewport is the "theater stage": it spans the full content width and,
// on wide-short windows, becomes wider than 16:9. The player is centered and
// letterboxed inside it, so the leftover is always the black stage (or the
// ambient fill) — never a stray strip of app background.
.player-shell__viewport {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
// Definite width is load-bearing: with `width: auto`, `max-height` would
// transfer through `aspect-ratio` into a max-width and re-clamp the stage
// to 16:9 — recreating the app-surface gap on wide-short windows.
width: 100%;
min-height: 320px;
max-height: min(70vh, 720px);
aspect-ratio: 16 / 9;
@@ -86,10 +98,31 @@
background: #000;
}
// Player: largest 16:9 box that fits the stage height, horizontally centered.
// The stage is never narrower than 16:9, so height-driven sizing never overflows.
.player-shell__viewport > app-web-player-view {
position: relative;
z-index: 1;
display: block;
width: 100%;
flex: 0 0 auto;
height: 100%;
width: auto;
max-width: 100%;
aspect-ratio: 16 / 9;
}
// "Ambient mode": a blurred, dimmed copy of the poster fills the whole stage
// behind the player, turning the letterbox margins into atmosphere.
.player-shell__ambient {
position: absolute;
inset: 0;
z-index: 0;
background-image: var(--ambient-image);
background-position: center;
background-size: cover;
filter: blur(34px) brightness(0.5) saturate(1.15);
transform: scale(1.18);
pointer-events: none;
}
:host-context(.dark-theme) .player-shell {
@@ -21,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
@@ -123,6 +124,78 @@ describe('PortalInlinePlayerComponent', () => {
expect(events).toEqual(['ended', 'previous', 'next']);
});
describe('playerMediaTitle', () => {
it('uses the series title with the episode label for episode playback', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/series/1002.mp4',
title: 'Episode 2',
});
fixture.componentRef.setInput('episodeMetadata', {
label: 'S01E02',
title: 'Episode 2',
seasonNumber: 1,
episodeNumber: 2,
});
fixture.componentRef.setInput('seriesTitle', 'Breaking Code');
fixture.detectChanges();
expect(component.playerMediaTitle()).toEqual({
primary: 'Breaking Code',
secondary: 'S01E02',
});
const webPlayer = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(webPlayer.mediaTitle()).toEqual({
primary: 'Breaking Code',
secondary: 'S01E02',
});
});
it('falls back to the playback title when no series title is provided', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/series/1002.mp4',
title: 'Episode 2',
});
fixture.componentRef.setInput('episodeMetadata', {
label: 'S01E02',
seasonNumber: 1,
episodeNumber: 2,
});
fixture.detectChanges();
expect(component.playerMediaTitle()).toEqual({
primary: 'Episode 2',
secondary: 'S01E02',
});
});
it('is a single line for movie playback and ignores the series title', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/vod/1.mp4',
title: 'Some Movie',
});
fixture.componentRef.setInput('seriesTitle', 'Unrelated Series');
fixture.detectChanges();
expect(component.playerMediaTitle()).toEqual({
primary: 'Some Movie',
secondary: null,
});
});
it('is null without a playback title', () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.test/vod/1.mp4',
title: '',
});
fixture.detectChanges();
expect(component.playerMediaTitle()).toBeNull();
});
});
it('emits backClicked (not closed) from the back button in the now-playing bar', () => {
let backCount = 0;
let closedCount = 0;
@@ -202,4 +275,115 @@ describe('PortalInlinePlayerComponent', () => {
expect(component.title()).toBe('US | Some Movie');
});
});
describe('ambient background fill', () => {
async function setup(
ambientEnabled: boolean,
player = 'videojs'
): Promise<void> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [
PortalInlinePlayerComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: SettingsStore,
useValue: {
player: signal(player),
playerAmbientMode: signal(ambientEnabled),
stripCountryPrefix: signal(false),
},
},
],
})
.overrideComponent(PortalInlinePlayerComponent, {
remove: { imports: [WebPlayerViewComponent] },
add: { imports: [StubWebPlayerViewComponent] },
})
.compileComponents();
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
}
const ambientEl = () =>
fixture.nativeElement.querySelector('.player-shell__ambient');
it('renders a poster-backed ambient layer for VOD when enabled', async () => {
await setup(true);
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mp4',
title: 'Some Movie',
thumbnail: 'https://cdn.example.com/poster.jpg',
});
fixture.detectChanges();
const layer = ambientEl() as HTMLElement | null;
expect(component.ambientEnabled()).toBe(true);
expect(layer).toBeTruthy();
expect(layer?.style.getPropertyValue('--ambient-image')).toBe(
'url("https://cdn.example.com/poster.jpg")'
);
});
it('does not render the ambient layer when the setting is off', async () => {
await setup(false);
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mp4',
title: 'Some Movie',
thumbnail: 'https://cdn.example.com/poster.jpg',
});
fixture.detectChanges();
expect(component.ambientEnabled()).toBe(false);
expect(ambientEl()).toBeNull();
});
it('skips the ambient layer for live channels and without a poster', async () => {
await setup(true);
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'CNN',
thumbnail: 'https://cdn.example.com/logo.png',
isLive: true,
});
fixture.detectChanges();
expect(ambientEl()).toBeNull();
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mp4',
title: 'No Poster Movie',
});
fixture.detectChanges();
expect(ambientEl()).toBeNull();
});
it('keeps the ambient layer off for non-web engines like embedded MPV', async () => {
await setup(true, 'embedded-mpv');
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mp4',
title: 'Some Movie',
thumbnail: 'https://cdn.example.com/poster.jpg',
});
fixture.detectChanges();
expect(component.ambientEnabled()).toBe(false);
expect(ambientEl()).toBeNull();
});
it('rejects non-http(s) poster URLs to avoid CSS breakout', async () => {
await setup(true);
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mp4',
title: 'Sneaky',
thumbnail: 'javascript:alert(1)',
});
fixture.detectChanges();
expect(component.ambientImageStyle()).toBeNull();
expect(ambientEl()).toBeNull();
});
});
});
@@ -14,10 +14,12 @@ import { TranslateModule } from '@ngx-translate/core';
import {
PlayerContentInfo,
ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component';
import type {
SeriesEpisodeMetadata,
@@ -46,6 +48,9 @@ export class PortalInlinePlayerComponent {
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly episodeMetadata = input<SeriesEpisodeMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
/** Series name for the fullscreen title overlay. Xtream episode playback
* carries the episode title, so the series name must come from the host. */
readonly seriesTitle = input<string | null>(null);
private readonly settingsStore = inject(SettingsStore);
// Strip only live-channel titles — VOD/series titles ("Mission:
// Impossible - Fallout") must never lose their leading segment.
@@ -58,6 +63,44 @@ export class PortalInlinePlayerComponent {
);
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
readonly startTime = computed(() => this.playback()?.startTime ?? 0);
/**
* Poster used for the "Ambient mode" fill behind the player. Live channels
* carry logos rather than posters, so they are excluded.
*/
private readonly ambientImageUrl = computed<string | null>(() => {
const playback = this.playback();
if (!playback || playback.isLive) {
return null;
}
return playback.thumbnail ?? null;
});
/** Safe `url(...)` value, or null when the poster URL is not a plain http/data URL. */
readonly ambientImageStyle = computed<string | null>(() => {
const url = this.ambientImageUrl();
if (!url || !/^(https?:|data:)/i.test(url)) {
return null;
}
const safe = url.replace(/"/g, '%22').replace(/\\/g, '%5C');
return `url("${safe}")`;
});
readonly ambientEnabled = computed<boolean>(() => {
// Web players only — mirrors the settings UI, which offers the toggle
// for HTML5, Video.js, and ArtPlayer. Embedded MPV composites a native
// video layer, so an extra DOM layer behind it stays out of the mix.
const player = this.settingsStore.player?.();
const isWebPlayer =
player === VideoPlayer.VideoJs ||
player === VideoPlayer.Html5Player ||
player === VideoPlayer.ArtPlayer;
return (
isWebPlayer &&
this.settingsStore.playerAmbientMode?.() === true &&
!!this.ambientImageStyle()
);
});
readonly contentInfo = computed<PlayerContentInfo | undefined>(
() => this.playback()?.contentInfo
);
@@ -72,6 +115,17 @@ export class PortalInlinePlayerComponent {
? `${metadata.label} - ${metadata.title}`
: metadata.label;
});
readonly playerMediaTitle = computed<PlayerMediaTitle | null>(() => {
const metadata = this.episodeMetadata();
const primary = (
(metadata ? this.seriesTitle() : null) || this.title()
)?.trim();
if (!primary) {
return null;
}
return { primary, secondary: metadata?.label ?? null };
});
readonly closed = output<void>();
/** Back arrow in the now-playing bar: route-level back, not just close. */
@@ -13,6 +13,7 @@
<app-player-controls
[controller]="controlsAdapter"
[playerSurface]="playerRoot"
[mediaTitle]="mediaTitle()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -25,6 +25,7 @@ import {
} from '../playback-diagnostics/playback-diagnostics.util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -77,6 +78,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
@@ -37,7 +37,7 @@
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
@if (meta.tmdbCast?.length) {
<div class="details__cast-strip">
@for (member of meta.tmdbCast; track member.name) {
@for (member of meta.tmdbCast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -75,10 +75,43 @@
}
</div>
}
@if (meta.director) {
@if (meta.director || meta.tmdbDirectors?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="value">{{ meta.director }}</span>
@if (meta.tmdbDirectors?.length) {
<div class="details__cast-strip">
@for (member of meta.tmdbDirectors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="member.name"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ meta.director }}</span>
}
</div>
}
@if (meta.ratingKinopoisk) {
@@ -161,6 +194,14 @@
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isPausedDownload()) {
<button
class="download-btn download-btn--paused"
(click)="resumePausedDownload()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'DOWNLOADS.RESUME' | translate }}
</button>
} @else if (isDownloading()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
@@ -26,6 +26,7 @@ import {
} from '@iptvnator/services';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
import { createVodDownloadState } from './vod-download-state.util';
/**
* Unified VOD details component for both Xtream and Stalker portals.
@@ -189,23 +190,13 @@ export class VodDetailsComponent {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
});
/** Whether VOD is already downloaded */
readonly isDownloaded = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloaded(vodId, item.playlistId, 'vod');
});
/** Whether VOD is currently downloading */
readonly isDownloading = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
});
private readonly downloadState = createVodDownloadState(
this.downloadsService,
this.item
);
readonly isDownloaded = this.downloadState.isDownloaded;
readonly isDownloading = this.downloadState.isDownloading;
readonly isPausedDownload = this.downloadState.isPausedDownload;
readonly matchedExternalPlayback = computed(() => {
const session = this.externalPlayback();
@@ -343,6 +334,16 @@ export class VodDetailsComponent {
this.downloadRequested.emit(this.item());
}
/** Resume the paused download of this VOD */
async resumePausedDownload(): Promise<void> {
const item = this.item();
await this.downloadsService.resumeDownloadByContent(
getVodNumericId(item),
item.playlistId,
'vod'
);
}
onInlineTimeUpdate(event: {
currentTime: number;
duration: number;
@@ -0,0 +1,34 @@
import { computed, Signal } from '@angular/core';
import { DownloadsService } from '@iptvnator/services';
import { getVodNumericId, VodDetailsItem } from '@iptvnator/shared/interfaces';
/**
* Download-state signals for a VOD detail view. Reading
* `downloadsService.downloads()` inside each computed creates the reactive
* dependency on the download list.
*/
export function createVodDownloadState(
downloadsService: DownloadsService,
item: Signal<VodDetailsItem>
) {
const query = (
check: (vodId: number, playlistId: string) => boolean
): Signal<boolean> =>
computed(() => {
const currentItem = item();
downloadsService.downloads();
return check(getVodNumericId(currentItem), currentItem.playlistId);
});
return {
isDownloaded: query((vodId, playlistId) =>
downloadsService.isDownloaded(vodId, playlistId, 'vod')
),
isDownloading: query((vodId, playlistId) =>
downloadsService.isDownloading(vodId, playlistId, 'vod')
),
isPausedDownload: query((vodId, playlistId) =>
downloadsService.isPaused(vodId, playlistId, 'vod')
),
};
}
@@ -0,0 +1,138 @@
import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
getLikelyBrowserUnsupportedCodecLabels,
} from '../playback-diagnostics/playback-diagnostics.util';
export type PlaybackDiagnosticDetail = {
readonly labelKey: string;
readonly value: string;
};
export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
return `${getDiagnosticTranslationBase(issue)}.TITLE`;
}
export function getDiagnosticDescriptionKey(
issue: PlaybackDiagnostic,
supportsManagedExternalPlayers: boolean
): string {
if (
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
!supportsManagedExternalPlayers
) {
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION';
}
return `${getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
}
export function getDiagnosticMeta(issue: PlaybackDiagnostic): string {
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
if (codecs) {
return codecs;
}
return issue.container || issue.mimeType || '';
}
export function getDiagnosticCodecHint(issue: PlaybackDiagnostic): string {
return getLikelyBrowserUnsupportedCodecLabels(issue).join(', ');
}
export function getDiagnosticDetails(
issue: PlaybackDiagnostic
): readonly PlaybackDiagnosticDetail[] {
return [
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
value: issue.code,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: formatPlayer(issue.player),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: formatDiagnosticSource(issue.source),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
value: issue.container,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE',
value: issue.mimeType ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
value: issue.videoCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
value: issue.audioCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE',
value: issue.nativeErrorCode?.toString() ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value: issue.nativeErrorMessage ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value: issue.details ?? '',
},
].filter(({ value }) => value.trim().length > 0);
}
function getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
switch (issue.code) {
case PlaybackDiagnosticCode.UnsupportedContainer:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER';
case PlaybackDiagnosticCode.UnsupportedCodec:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC';
case PlaybackDiagnosticCode.MediaDecodeError:
return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR';
case PlaybackDiagnosticCode.NetworkError:
return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR';
case PlaybackDiagnosticCode.BrowserAccessError:
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR';
case PlaybackDiagnosticCode.DrmOrEncryption:
return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION';
case PlaybackDiagnosticCode.UnknownPlaybackError:
default:
return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR';
}
}
function formatPlayer(player: PlaybackDiagnostic['player']): string {
switch (player) {
case 'videojs':
return 'Video.js';
case 'html5':
return 'HTML5';
case 'artplayer':
return 'ArtPlayer';
default:
return '';
}
}
function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string {
switch (source) {
case 'hls':
return 'HLS.js';
case 'mpegts':
return 'mpegts.js';
case 'native':
return 'Native media element';
case 'shaka':
return 'Shaka Player';
case 'source':
return 'Stream metadata';
default:
return source;
}
}
@@ -2,6 +2,7 @@
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
@@ -20,6 +21,7 @@
@defer (on immediate) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -39,6 +41,7 @@
@defer (on immediate) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -57,6 +60,7 @@
} @else if (selectedPlayer() === 'embedded-mpv') {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
@@ -68,6 +72,7 @@
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
@@ -35,6 +35,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({ selector: 'app-vjs-player', template: '' })
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
@@ -50,6 +51,7 @@ class StubVjsPlayerComponent {
@Component({ selector: 'app-html-video-player', template: '' })
class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -66,6 +68,7 @@ class StubHtmlVideoPlayerComponent {
@Component({ selector: 'app-art-player', template: '' })
class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -82,6 +85,7 @@ class StubArtPlayerComponent {
@Component({ selector: 'app-embedded-mpv-player', template: '' })
class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
@@ -35,6 +35,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
})
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
@@ -53,6 +54,7 @@ class StubVjsPlayerComponent {
})
class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -72,6 +74,7 @@ class StubHtmlVideoPlayerComponent {
})
class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -91,6 +94,7 @@ class StubArtPlayerComponent {
})
class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
@@ -165,6 +169,47 @@ describe('WebPlayerViewComponent', () => {
expect(fixture.nativeElement.classList).toContain('web-player-view');
});
describe('resolvedMediaTitle', () => {
it('prefers an explicit media title over the playback title', async () => {
fixture.componentRef.setInput('mediaTitle', {
primary: 'Breaking Code',
secondary: 'S01E02',
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(component.resolvedMediaTitle()).toEqual({
primary: 'Breaking Code',
secondary: 'S01E02',
});
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
expect(player.mediaTitle()).toEqual({
primary: 'Breaking Code',
secondary: 'S01E02',
});
});
it('falls back to the playback title as a single line', () => {
fixture.detectChanges();
expect(component.resolvedMediaTitle()).toEqual({
primary: 'Example Movie',
secondary: null,
});
});
it('is null when the title falls back to the raw stream URL', () => {
fixture.componentRef.setInput('title', '');
fixture.detectChanges();
expect(component.resolvedMediaTitle()).toBeNull();
});
});
it('renders diagnostics and emits MPV fallback requests when managed external players are available', () => {
const requests: unknown[] = [];
runtimeCapabilities.supportsManagedExternalPlayers = true;
@@ -219,6 +264,33 @@ describe('WebPlayerViewComponent', () => {
]);
});
it('uses the Matroska mime type for MKV paths', () => {
const streamUrl = 'https://example.com/archive/movie.mkv';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/matroska',
},
]);
});
it('uses the Matroska mime type for query-declared MKV streams', () => {
const streamUrl =
'https://example.com/play?container=mkv&token=signed';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/matroska',
},
]);
});
it('treats web script playback URLs without declared media extension as MPEG-TS', () => {
const streamUrl = 'https://example.com/live.php?stream=123&token=x';
@@ -30,23 +30,24 @@ import { ArtPlayerComponent } from '../art-player/art-player.component';
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import {
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WEB_PLAYER_SHARED_CONTROLS_ENABLED,
} from '../player-controls';
import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
type PlaybackFallbackRequest,
getLikelyBrowserUnsupportedCodecLabels,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
type PlaybackDiagnosticDetail = {
readonly labelKey: string;
readonly value: string;
};
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './web-player-view-diagnostics.utils';
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
@@ -93,6 +94,9 @@ export class WebPlayerViewComponent {
showCaptions = input<boolean>(false);
playerOverride = input<VideoPlayer | null>(null);
seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
/** Display-ready title lines for the fullscreen overlay; hosts with richer
* context (e.g. series name + episode label) pass it explicitly. */
mediaTitle = input<PlayerMediaTitle | null>(null);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
@@ -157,9 +161,21 @@ export class WebPlayerViewComponent {
this.settings()?.player ??
VideoPlayer.VideoJs
);
readonly recordingFolder = computed(
() => this.settings()?.recordingFolder ?? ''
);
readonly resolvedMediaTitle = computed<PlayerMediaTitle | null>(() => {
const explicit = this.mediaTitle();
if (explicit?.primary?.trim()) {
return explicit;
}
const playback = this.resolvedPlayback();
const title = playback.title?.trim();
// resolvedPlayback() falls back to the stream URL as title; a raw URL
// is not a watchable overlay title.
if (!title || title === playback.streamUrl) {
return null;
}
return { primary: title, secondary: null };
});
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
constructor() {
effect(() => {
@@ -180,7 +196,9 @@ export class WebPlayerViewComponent {
? 'application/x-mpegURL'
: extension === 'ts' || !extension
? 'video/mp2t'
: 'video/mp4';
: extension === 'mkv'
? 'video/matroska'
: 'video/mp4';
this.vjsOptions = {
isLive,
@@ -260,99 +278,16 @@ export class WebPlayerViewComponent {
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
}
getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
return `${this.getDiagnosticTranslationBase(issue)}.TITLE`;
}
readonly getDiagnosticTitleKey = getDiagnosticTitleKey;
readonly getDiagnosticMeta = getDiagnosticMeta;
readonly getDiagnosticCodecHint = getDiagnosticCodecHint;
readonly getDiagnosticDetails = getDiagnosticDetails;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
if (
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
!this.runtime.supportsManagedExternalPlayers
) {
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION';
}
return `${this.getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
}
getDiagnosticMeta(issue: PlaybackDiagnostic): string {
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
if (codecs) {
return codecs;
}
return issue.container || issue.mimeType || '';
}
getDiagnosticCodecHint(issue: PlaybackDiagnostic): string {
return getLikelyBrowserUnsupportedCodecLabels(issue).join(', ');
}
getDiagnosticDetails(
issue: PlaybackDiagnostic
): readonly PlaybackDiagnosticDetail[] {
return [
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
value: issue.code,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: this.formatPlayer(issue.player),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: this.formatDiagnosticSource(issue.source),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
value: issue.container,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE',
value: issue.mimeType ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
value: issue.videoCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
value: issue.audioCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE',
value: issue.nativeErrorCode?.toString() ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value: issue.nativeErrorMessage ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value: issue.details ?? '',
},
].filter(({ value }) => value.trim().length > 0);
}
private getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
switch (issue.code) {
case PlaybackDiagnosticCode.UnsupportedContainer:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER';
case PlaybackDiagnosticCode.UnsupportedCodec:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC';
case PlaybackDiagnosticCode.MediaDecodeError:
return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR';
case PlaybackDiagnosticCode.NetworkError:
return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR';
case PlaybackDiagnosticCode.BrowserAccessError:
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR';
case PlaybackDiagnosticCode.DrmOrEncryption:
return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION';
case PlaybackDiagnosticCode.UnknownPlaybackError:
default:
return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR';
}
return getDiagnosticDescriptionKey(
issue,
this.runtime.supportsManagedExternalPlayers
);
}
private getHeaderValue(
@@ -368,26 +303,4 @@ export class WebPlayerViewComponent {
);
return matchingKey ? headers[matchingKey] : undefined;
}
private formatPlayer(player: PlaybackDiagnostic['player']): string {
const labels: Record<string, string> = {
videojs: 'Video.js',
html5: 'HTML5',
artplayer: 'ArtPlayer',
};
return (player && labels[player]) || '';
}
private formatDiagnosticSource(
source: PlaybackDiagnostic['source']
): string {
const labels: Record<string, string> = {
hls: 'HLS.js',
mpegts: 'mpegts.js',
native: 'Native media element',
shaka: 'Shaka Player',
source: 'Stream metadata',
};
return labels[source] ?? source;
}
}
@@ -165,6 +165,8 @@
}
@if (item.character) {
· {{ item.character }}
} @else if (item.crewJob) {
· {{ crewJobKey(item.crewJob) | translate }}
}
</span>
</button>
@@ -52,6 +52,13 @@ export class ActorViewComponent {
readonly filterMode = signal<'all' | 'available'>('all');
/** Translated label key for a crew-only credit ("Director"/"Creator") */
crewJobKey(job: 'Director' | 'Creator'): string {
return job === 'Creator'
? 'XTREAM.CREW_JOB_CREATOR'
: 'XTREAM.CREW_JOB_DIRECTOR';
}
readonly visibleItems = computed(() =>
this.showAvailabilityFilter() && this.filterMode() === 'available'
? this.items().filter((item) => item.available)
+10
View File
@@ -95,6 +95,16 @@
background: rgba(255, 255, 255, 0.04);
}
}
&--paused {
background: rgba(251, 191, 36, 0.14);
border-color: rgba(251, 191, 36, 0.4);
color: #fbbf24;
&:hover {
background: rgba(251, 191, 36, 0.22);
}
}
}
@mixin mobile-action-buttons {