mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
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:
commit
5b2dd3b8b0
425 files changed
+59216
-3432
No files matched your search
@@ -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';
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+22
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+23
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+77
@@ -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>
|
||||
+95
@@ -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;
|
||||
}
|
||||
}
|
||||
+205
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+23
@@ -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) {
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+23
@@ -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) {
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+189
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+324
@@ -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> {
|
||||
|
||||
+213
@@ -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}`);
|
||||
}
|
||||
+3
-1
@@ -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
|
||||
|
||||
+191
@@ -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()"
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user