Merge remote-tracking branch 'origin/master' into claude/channel-history-playback-check-0b3bd9

# Conflicts:
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts
This commit is contained in:
4gray committed 2026-09-27 20:55:48 +02:00
commit 02ed4e589c
378 files changed
+16812 -1098

No files matched your search

+2
View File
@@ -15,6 +15,7 @@ export * from './lib/portal-detail-shell/detail-template.directives';
export * from './lib/portal-detail-shell/portal-detail-shell.component';
export * from './lib/view-in-portal-action/view-in-portal-action.component';
export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
export * from './lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/resizable/resizable.directive';
export * from './lib/season-container/season-container.component';
@@ -31,3 +32,4 @@ export * from './lib/watched-badge/watched-badge.component';
export * from './lib/window-controls/window-controls.component';
export * from './lib/channel-scroll-focus/channel-scroll-focus.directive';
export * from './lib/category-lock-menu/category-lock-menu.component';
@@ -0,0 +1,49 @@
import { Component, viewChild } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { CategoryLockMenuComponent } from './category-lock-menu.component';
@Component({
imports: [CategoryLockMenuComponent],
template: `<app-category-lock-menu
(toggleRequested)="requested.push($event)"
/>`,
})
class HostComponent {
readonly menu = viewChild.required(CategoryLockMenuComponent);
readonly requested: boolean[] = [];
}
describe('CategoryLockMenuComponent', () => {
it('opens at the pointer and requests the opposite lock state', async () => {
await TestBed.configureTestingModule({
imports: [
HostComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const host = fixture.componentInstance;
const event = new MouseEvent('contextmenu', {
clientX: 40,
clientY: 60,
cancelable: true,
});
host.menu().open(event, true);
await Promise.resolve();
fixture.detectChanges();
expect(event.defaultPrevented).toBe(true);
expect(host.menu().position()).toEqual({ x: '40px', y: '60px' });
const item = document.querySelector<HTMLButtonElement>(
'[data-test-id="category-lock-menu-toggle"]'
);
expect(item?.textContent).toContain('PARENTAL_LOCK.UNLOCK_CATEGORY');
item?.click();
expect(host.requested).toEqual([false]);
});
});
@@ -0,0 +1,85 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
/**
* Right-click accelerator of the parental lock: one positioned context menu
* with a single "Lock … / Unlock …" item. Hosts (the portal category rail,
* the M3U groups rail) call `open()` from a `contextmenu` event and persist
* the emitted target state themselves — the menu knows nothing about
* playlists or lock stores. Bulk actions and the full list stay in the
* "Manage categories" dialog; this only shortens the single-category case.
*/
@Component({
selector: 'app-category-lock-menu',
imports: [MatIconModule, MatMenuModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
aria-hidden="true"
class="category-lock-menu__anchor"
[style.left]="position().x"
[style.top]="position().y"
[matMenuTriggerFor]="menu"
#trigger="matMenuTrigger"
></div>
<mat-menu #menu="matMenu">
<button
mat-menu-item
type="button"
data-test-id="category-lock-menu-toggle"
(click)="toggleRequested.emit(!locked())"
>
<mat-icon>{{ locked() ? 'lock_open' : 'lock' }}</mat-icon>
<span>{{
(locked() ? unlockLabelKey() : lockLabelKey()) | translate
}}</span>
</button>
</mat-menu>
`,
styles: `
:host {
display: contents;
}
.category-lock-menu__anchor {
position: fixed;
width: 0;
height: 0;
pointer-events: none;
}
`,
})
export class CategoryLockMenuComponent {
readonly lockLabelKey = input('PARENTAL_LOCK.LOCK_CATEGORY');
readonly unlockLabelKey = input('PARENTAL_LOCK.UNLOCK_CATEGORY');
/** The desired state: `true` = lock the row, `false` = unlock it. */
readonly toggleRequested = output<boolean>();
readonly locked = signal(false);
readonly position = signal({ x: '0px', y: '0px' });
private readonly trigger = viewChild.required<MatMenuTrigger>('trigger');
/** Opens the menu at the pointer for a row whose lock state is `locked`. */
open(event: MouseEvent, locked: boolean): void {
event.preventDefault();
event.stopPropagation();
this.locked.set(locked);
this.position.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.trigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => this.trigger().openMenu());
}
}
@@ -3,7 +3,7 @@
[view]="activeView()"
[showEpg]="shouldShowEpg()"
/>
} @else if (displayedChannels().length) {
} @else if (showChannelViews()) {
@if (
!compact() &&
viewTitle() &&
@@ -62,11 +62,17 @@
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
[hiddenGroupTitles]="hiddenGroupTitles()"
[playlistId]="activePlaylist()?._id ?? null"
[lockedGroupTitles]="lockedGroupTitles()"
[withheldGroupCount]="withheldGroupCount()"
[managementGroups]="managementGroups()"
[sidebarWidth]="sidebarWidth()"
(channelSelected)="onChannelSelected($event)"
(channelPlaybackRequested)="onChannelPlaybackRequested($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(hiddenGroupTitlesChanged)="onHiddenGroupTitlesChanged($event)"
(lockedGroupTitlesChanged)="onLockedGroupTitlesChanged($event)"
(groupLockToggled)="onGroupLockToggled($event)"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
@@ -33,7 +33,11 @@
height: 64px;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0)
),
rgba(127, 127, 127, 0.1);
display: flex;
align-items: center;
@@ -6,6 +6,8 @@ import {
NavigationEnd,
Router,
} from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs';
@@ -13,6 +15,7 @@ import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import {
ParentalLockService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
@@ -126,6 +129,11 @@ describe('ChannelListContainerComponent', () => {
await TestBed.configureTestingModule({
imports: [ChannelListContainerComponent],
providers: [
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
{
provide: EpgService,
useValue: epgService,
@@ -194,6 +202,69 @@ describe('ChannelListContainerComponent', () => {
fixture = TestBed.createComponent(ChannelListContainerComponent);
});
it('keeps the groups view (and its unlock row) when every group is withheld', () => {
const withheld = signal(0);
Object.defineProperty(fixture.componentInstance, 'withheldGroupCount', {
value: withheld,
});
fixture.componentRef.setInput('activeView', 'groups');
fixture.detectChanges();
const component = fixture.componentInstance;
// No channels and nothing withheld: the generic empty state.
expect(component.showChannelViews()).toBe(false);
withheld.set(3);
expect(component.showChannelViews()).toBe(true);
// Other views have no unlock row to show.
fixture.componentRef.setInput('activeView', 'all');
expect(component.showChannelViews()).toBe(false);
});
it('drops a group lock toggle whose playlist changed while the PIN was asked', async () => {
const parentalLock = TestBed.inject(ParentalLockService);
const setM3uLocks = jest
.spyOn(parentalLock, 'setM3uLocks')
.mockResolvedValue(true);
jest.spyOn(parentalLock, 'requestUnlock').mockImplementation(
async () => {
activePlaylistSignal.set({
_id: 'playlist-2',
title: 'Playlist Two',
count: 0,
importDate: '2026-04-11T00:00:00.000Z',
} as PlaylistMeta);
return true;
}
);
await fixture.componentInstance.onGroupLockToggled({
groupKey: 'News',
locked: false,
});
expect(setM3uLocks).not.toHaveBeenCalled();
});
it('saves a group lock toggle under the playlist it was requested for', async () => {
const parentalLock = TestBed.inject(ParentalLockService);
const setM3uLocks = jest
.spyOn(parentalLock, 'setM3uLocks')
.mockResolvedValue(true);
jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true);
await fixture.componentInstance.onGroupLockToggled({
groupKey: 'News',
locked: true,
});
expect(setM3uLocks).toHaveBeenCalledWith(
'playlist-1',
expect.any(Function)
);
});
it('does not clear the shared channel list on destroy', () => {
fixture.detectChanges();
@@ -297,7 +368,9 @@ describe('ChannelListContainerComponent', () => {
});
epgService.getCurrentProgramsForChannels
.mockReturnValueOnce(first.asObservable())
.mockReturnValueOnce(of(new Map([['guide-news', program('Fresh')]])));
.mockReturnValueOnce(
of(new Map([['guide-news', program('Fresh')]]))
);
fixture.detectChanges();
const channels = [
@@ -13,13 +13,14 @@ import {
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
@@ -45,11 +46,13 @@ import {
Subscription,
} from 'rxjs';
import {
ParentalLockService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
ALL_CATEGORIES_WITHHELD,
foldSearchText,
Channel,
EpgProgram,
@@ -64,6 +67,7 @@ import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
import { GroupsViewComponent } from './groups-view/groups-view.component';
import type { GroupManagementDialogGroup } from './groups-view/group-management-dialog/group-management-dialog.component';
import {
RecentViewComponent,
RecentViewItem,
@@ -116,6 +120,9 @@ const EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS = 2000;
export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly epgService = inject(EpgService);
private readonly playlistsService = inject(PlaylistsService);
private readonly parentalLock = inject(ParentalLockService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly router = inject(Router);
@@ -126,7 +133,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Route-aware playlist ID for recent-item mutations */
private readonly resolvedPlaylistId =
this.playlistContext.resolvedPlaylistId;
private readonly activePlaylist = this.playlistContext.activePlaylist;
protected readonly activePlaylist = this.playlistContext.activePlaylist;
/** Map of channel ID to current EPG program */
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
@@ -316,9 +323,88 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
return playlist.hiddenGroupTitles ?? [];
});
/** Displayed channels - filters out unfavorited channels in global favorites view */
/** The M3U playlist the parental lock keys its group locks by. */
private readonly lockPlaylistId = computed(() => {
const playlist = this.activePlaylist();
if (!playlist || playlist.serverUrl || playlist.macAddress) {
return null;
}
return playlist._id;
});
/** Locked group titles for the management dialog; `null` while the lock is off. */
readonly lockedGroupTitles = computed<string[] | null>(() => {
this.parentalLock.version();
const playlistId = this.lockPlaylistId();
if (!playlistId || !this.parentalLock.enabled()) {
return null;
}
return this.parentalLock.lockedGroupTitles(playlistId);
});
/**
* Groups of this playlist the lock currently withholds; the groups rail
* shows one "N locked · Enter PIN to show" row for them, as the portal
* category rail does.
*/
readonly withheldGroupCount = computed(() => {
const withheld = this.withheldGroupTitles();
if (withheld.size === 0) {
return 0;
}
return this.managementGroups().filter((group) =>
withheld.has(group.key)
).length;
});
/**
* Whether the channel views render instead of the generic empty state.
* With every M3U group locked the filtered list is empty, yet the groups
* view must still render: it carries the "N locked · Enter PIN to show"
* row, the only in-context way back.
*/
readonly showChannelViews = computed(
() =>
this.displayedChannels().length > 0 ||
(this.activeView() === 'groups' && this.withheldGroupCount() > 0)
);
/** Group titles currently withheld: locked AND the lock is active. */
private readonly withheldGroupTitles = computed(() => {
this.parentalLock.version();
const playlistId = this.lockPlaylistId();
if (!playlistId || !this.parentalLock.active()) {
return new Set<string>();
}
if (this.parentalLock.withholdsEverything?.()) {
return ALL_CATEGORIES_WITHHELD;
}
return new Set(this.parentalLock.lockedGroupTitles(playlistId));
});
/**
* Every channel of the playlist minus the withheld groups. All views,
* the fullscreen panel and numeric zapping derive from this list, so a
* locked group cannot resurface through any of them.
*/
private readonly visibleChannelList = computed(() => {
const withheld = this.withheldGroupTitles();
const channels = this.channelListSignal();
if (withheld.size === 0) {
return channels;
}
return channels.filter(
(channel) => !withheld.has(channel.group?.title ?? '')
);
});
private readonly visibleChannelList$ = toObservable(
this.visibleChannelList
);
/** Every group with its size, locked ones included, for the management dialog. */
readonly managementGroups = computed<GroupManagementDialogGroup[]>(() =>
Object.entries(groupChannelsByTitle(this.channelListSignal())).map(
([key, channels]) => ({ key, count: channels.length })
)
);
/** Displayed channels - the playlist minus the parental lock's withheld groups */
readonly displayedChannels = computed(() => {
return this.channelListSignal();
return this.visibleChannelList();
});
/** Object with channels sorted by groups */
@@ -327,7 +413,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
);
readonly recentChannelItems = computed<RecentViewItem[]>(() => {
const channels = this.channelListSignal();
const channels = this.visibleChannelList();
const recentItems = this.recentItems();
const channelsByUrl = new Map(
channels.map((channel) => [channel.url, channel] as const)
@@ -382,7 +468,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** List with favorites */
favorites$ = combineLatest([
this.store.select(selectFavorites),
this.channelList$,
this.visibleChannelList$,
]).pipe(
map(([favoriteChannelIds, channelList]) => {
const channelsByUrl = mapChannelsByFirstUrl(channelList);
@@ -531,6 +617,62 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
);
}
async onLockedGroupTitlesChanged(
lockedGroupTitles: string[]
): Promise<void> {
await this.saveGroupLocks(lockedGroupTitles);
}
/**
* One group from the right-click menu, behind the PIN, applied inside
* the store's queue. The playlist is captured before the prompt and must
* still be the open one afterwards: two playlists can share a group
* name, and the edit must not land on the other one.
*/
async onGroupLockToggled(change: {
groupKey: string;
locked: boolean;
}): Promise<void> {
const playlistId = this.lockPlaylistId();
if (
!playlistId ||
!(await this.parentalLock.requestUnlock()) ||
this.lockPlaylistId() !== playlistId
) {
return;
}
await this.saveGroupLocks(
(current) =>
change.locked
? [...new Set([...current, change.groupKey])]
: current.filter((title) => title !== change.groupKey),
playlistId
);
}
private async saveGroupLocks(
groupTitles: string[] | ((current: readonly string[]) => string[]),
playlistId = this.lockPlaylistId()
): Promise<void> {
if (!playlistId) {
return;
}
// The dialog has closed by now, so a failed write (storage full or
// unavailable, app-state IPC failure) must be reported here, or the
// user relocks believing the chosen groups are protected.
const saved = await this.parentalLock.setM3uLocks(
playlistId,
groupTitles
);
if (!saved) {
this.snackBar.open(
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
}
onHiddenGroupTitlesChanged(hiddenGroupTitles: string[]): void {
const playlist = this.activePlaylist();
@@ -5,6 +5,12 @@
<div class="selection-info">
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
@if (showLocks) {
<span class="selection-info__locks">
· {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
<div class="bulk-actions">
<button
@@ -66,6 +72,33 @@
}
</span>
<span class="item-count">({{ group.count }})</span>
@if (showLocks) {
<button
type="button"
mat-icon-button
class="lock-toggle"
[class.lock-toggle--locked]="group.locked"
[attr.aria-pressed]="group.locked"
[attr.data-test-id]="'group-lock-' + group.key"
[matTooltip]="
(group.locked
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
[attr.aria-label]="
(group.locked
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
(click)="toggleLock(group, $event)"
>
<mat-icon>{{
group.locked ? 'lock' : 'lock_open'
}}</mat-icon>
</button>
}
</div>
} @empty {
<div class="empty-message">
@@ -148,3 +148,27 @@
color: var(--mat-sys-on-surface-variant);
font-size: 0.8125rem;
}
.selection-info__locks {
margin-left: 4px;
}
.lock-toggle {
width: 28px;
height: 28px;
min-width: 28px;
padding: 0;
line-height: 28px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&--locked {
color: var(--mat-sys-primary);
}
}
@@ -0,0 +1,75 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import {
GroupManagementDialogComponent,
GroupManagementDialogData,
} from './group-management-dialog.component';
describe('GroupManagementDialogComponent', () => {
const dialogRef = { close: jest.fn() };
const parentalLock = { active: signal(false) };
async function create(
data: GroupManagementDialogData
): Promise<ComponentFixture<GroupManagementDialogComponent>> {
await TestBed.configureTestingModule({
imports: [
GroupManagementDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: ParentalLockService, useValue: parentalLock },
],
}).compileComponents();
const fixture = TestBed.createComponent(GroupManagementDialogComponent);
fixture.detectChanges();
return fixture;
}
const groups = [
{ key: 'News', count: 3 },
{ key: 'Adult', count: 2 },
];
beforeEach(() => {
jest.clearAllMocks();
parentalLock.active.set(false);
});
it('closes without a result when the session relocks while locks are shown', async () => {
const fixture = await create({
groups,
hiddenGroupTitles: [],
lockedGroupTitles: ['Adult'],
});
expect(dialogRef.close).not.toHaveBeenCalled();
parentalLock.active.set(true);
fixture.detectChanges();
expect(dialogRef.close).toHaveBeenCalledWith(undefined);
});
it('keeps the plain hide/show editor open while nothing relocks', async () => {
const fixture = await create({ groups, hiddenGroupTitles: ['News'] });
fixture.detectChanges();
expect(dialogRef.close).not.toHaveBeenCalled();
expect(fixture.componentInstance.showLocks).toBe(false);
});
it('closes the editor without lock toggles too on a relock: it lists every group', async () => {
// Opened without toggles because the lock store was unreadable.
const fixture = await create({ groups, hiddenGroupTitles: ['News'] });
parentalLock.active.set(true);
fixture.detectChanges();
expect(dialogRef.close).toHaveBeenCalledWith(undefined);
});
});
@@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
@@ -14,7 +15,9 @@ import {
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import { foldSearchText } from '@iptvnator/shared/interfaces';
export interface GroupManagementDialogGroup {
@@ -25,10 +28,19 @@ export interface GroupManagementDialogGroup {
export interface GroupManagementDialogData {
readonly groups: GroupManagementDialogGroup[];
readonly hiddenGroupTitles: string[];
/** Parental lock: present only while the lock feature is enabled. */
readonly lockedGroupTitles?: string[];
}
export interface GroupManagementDialogResult {
readonly hiddenGroupTitles: string[];
/** Absent when the dialog offered no lock toggles. */
readonly lockedGroupTitles?: string[];
}
interface GroupWithSelection extends GroupManagementDialogGroup {
readonly selected: boolean;
readonly locked: boolean;
}
@Component({
@@ -38,6 +50,7 @@ interface GroupWithSelection extends GroupManagementDialogGroup {
MatButtonModule,
MatCheckboxModule,
MatIconModule,
MatTooltipModule,
TitleCasePipe,
TranslatePipe,
],
@@ -47,17 +60,26 @@ interface GroupWithSelection extends GroupManagementDialogGroup {
})
export class GroupManagementDialogComponent {
private readonly dialogRef = inject(
MatDialogRef<GroupManagementDialogComponent, string[] | undefined>
MatDialogRef<
GroupManagementDialogComponent,
GroupManagementDialogResult | undefined
>
);
readonly data = inject<GroupManagementDialogData>(MAT_DIALOG_DATA);
private readonly parentalLock = inject(ParentalLockService);
readonly showLocks = this.data.lockedGroupTitles !== undefined;
readonly searchTerm = signal('');
readonly groups = signal<GroupWithSelection[]>(
this.data.groups.map((group) => ({
...group,
selected: !this.data.hiddenGroupTitles.includes(group.key),
locked: (this.data.lockedGroupTitles ?? []).includes(group.key),
}))
);
readonly lockedCount = computed(
() => this.groups().filter((group) => group.locked).length
);
readonly filteredGroups = computed(() => {
const term = foldSearchText(this.searchTerm().trim());
@@ -81,6 +103,17 @@ export class GroupManagementDialogComponent {
this.groups().every((group) => group.selected)
);
constructor() {
// A relock while the editor is open: the locked group names it
// lists (with or without lock toggles — every group is listed) and
// the lock list it can rewrite are behind the PIN.
effect(() => {
if (this.parentalLock.active()) {
this.dialogRef.close(undefined);
}
});
}
clearSearch(): void {
this.searchTerm.set('');
}
@@ -95,6 +128,17 @@ export class GroupManagementDialogComponent {
);
}
toggleLock(group: GroupWithSelection, event?: Event): void {
event?.stopPropagation();
this.groups.update((groups) =>
groups.map((current) =>
current.key === group.key
? { ...current, locked: !current.locked }
: current
)
);
}
selectAll(): void {
this.groups.update((groups) =>
groups.map((group) => ({ ...group, selected: true }))
@@ -112,7 +156,16 @@ export class GroupManagementDialogComponent {
.filter((group) => !group.selected)
.map((group) => group.key);
this.dialogRef.close(hiddenGroupTitles);
this.dialogRef.close({
hiddenGroupTitles,
...(this.showLocks
? {
lockedGroupTitles: this.groups()
.filter((group) => group.locked)
.map((group) => group.key),
}
: {}),
});
}
cancel(): void {
@@ -1,4 +1,4 @@
@if (!hasAnyGroups()) {
@if (!hasAnyGroups() && withheldGroupCount() === 0) {
<div class="groups-view-empty-state">
<mat-icon class="empty-state-icon">{{
searchTerm() ? 'search_off' : 'folder_off'
@@ -127,6 +127,9 @@
: null
"
(click)="selectGroup(group.key)"
(contextmenu)="
onGroupContextMenu(group.key, $event)
"
>
<span class="nav-item-label">
@if (group.key) {
@@ -167,6 +170,29 @@
}
</div>
}
@if (withheldGroupCount() > 0) {
<button
type="button"
class="groups-locked-row"
data-test-id="groups-locked-row"
(click)="requestParentalUnlock()"
[attr.aria-label]="
'PARENTAL_LOCK.UNLOCK_TO_SHOW' | translate
"
>
<mat-icon>lock</mat-icon>
<span class="groups-locked-row__label">
{{
'PARENTAL_LOCK.LOCKED_CATEGORIES_ROW'
| translate
: { count: withheldGroupCount() }
}}
</span>
<span class="groups-locked-row__hint">
{{ 'PARENTAL_LOCK.UNLOCK_TO_SHOW' | translate }}
</span>
</button>
}
</div>
</div>
</aside>
@@ -354,3 +380,10 @@
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
</mat-menu>
<app-category-lock-menu
#groupLockMenu
lockLabelKey="PARENTAL_LOCK.LOCK_GROUP"
unlockLabelKey="PARENTAL_LOCK.UNLOCK_GROUP"
(toggleRequested)="onGroupLockToggle($event)"
/>
@@ -327,3 +327,50 @@
padding-inline-end: 0;
}
}
// Same affordance as the portal category rail's locked row: the parent
// sees that groups are withheld and can unlock from here.
.groups-locked-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin-top: 4px;
padding: 8px 12px;
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover,
&:focus-visible {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
outline: none;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
&__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__hint {
font-size: 0.6875rem;
opacity: 0.8;
white-space: nowrap;
}
}
@@ -4,7 +4,8 @@ import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { ParentalLockService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { GroupManagementDialogComponent } from './group-management-dialog/group-management-dialog.component';
@@ -46,6 +47,12 @@ describe('GroupsViewComponent', () => {
let fixture: ComponentFixture<GroupsViewComponent>;
let component: GroupsViewComponent;
let dialog: { open: jest.Mock };
let parentalLock: {
requestUnlock: jest.Mock;
ensureLocksReadable: jest.Mock;
lockedGroupTitles: jest.Mock;
active: jest.Mock;
};
const sportsCenter = createChannel(
'sports-1',
@@ -102,6 +109,13 @@ describe('GroupsViewComponent', () => {
beforeEach(async () => {
localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY);
parentalLock = {
requestUnlock: jest.fn().mockResolvedValue(true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
lockedGroupTitles: jest.fn(() => ['News']),
active: jest.fn(() => false),
};
dialog = {
open: jest.fn(),
};
@@ -117,6 +131,10 @@ describe('GroupsViewComponent', () => {
provide: MatDialog,
useValue: dialog,
},
{
provide: ParentalLockService,
useValue: parentalLock,
},
],
}).compileComponents();
@@ -426,9 +444,7 @@ describe('GroupsViewComponent', () => {
const rail = fixture.nativeElement.querySelector(
'aside.groups-nav-panel'
) as HTMLElement;
expect(rail.classList.contains('groups-nav-panel--compact')).toBe(
true
);
expect(rail.classList.contains('groups-nav-panel--compact')).toBe(true);
expect(rail.style.width).toBe('148px');
expect(localStorage.getItem('m3u-groups-nav-width')).toBe('320');
@@ -500,14 +516,14 @@ describe('GroupsViewComponent', () => {
).toEqual(['Movie Classic']);
});
it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', () => {
it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', async () => {
const hiddenGroupTitlesChanged = jest.fn();
component.hiddenGroupTitlesChanged.subscribe(hiddenGroupTitlesChanged);
dialog.open.mockReturnValue({
afterClosed: () => of(['News', 'Sports']),
afterClosed: () => of({ hiddenGroupTitles: ['News', 'Sports'] }),
});
component.openGroupManagement();
await component.openGroupManagement();
expect(dialog.open).toHaveBeenCalledWith(
GroupManagementDialogComponent,
@@ -531,6 +547,121 @@ describe('GroupsViewComponent', () => {
]);
});
it('opens the manage-groups dialog without lock toggles while the lock store is unreadable', async () => {
fixture.componentRef.setInput('playlistId', 'playlist-a');
fixture.componentRef.setInput('lockedGroupTitles', []);
fixture.detectChanges();
parentalLock.ensureLocksReadable.mockResolvedValue(false);
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
await component.openGroupManagement();
const data = dialog.open.mock.calls[0][1].data;
expect(data.lockedGroupTitles).toBeUndefined();
});
it('opens no manage-groups dialog when the session relocked while the store was read', async () => {
fixture.componentRef.setInput('playlistId', 'playlist-a');
fixture.componentRef.setInput('lockedGroupTitles', []);
fixture.detectChanges();
parentalLock.ensureLocksReadable.mockImplementation(async () => {
parentalLock.active.mockReturnValue(true);
return true;
});
await component.openGroupManagement();
expect(dialog.open).not.toHaveBeenCalled();
});
it('drops the manage-groups result once another playlist is open', async () => {
const hiddenGroupTitlesChanged = jest.fn();
const lockedGroupTitlesChanged = jest.fn();
component.hiddenGroupTitlesChanged.subscribe(hiddenGroupTitlesChanged);
component.lockedGroupTitlesChanged.subscribe(lockedGroupTitlesChanged);
fixture.componentRef.setInput('playlistId', 'playlist-a');
fixture.componentRef.setInput('lockedGroupTitles', ['News']);
fixture.detectChanges();
const closed = new Subject<unknown>();
dialog.open.mockReturnValue({ afterClosed: () => closed });
await component.openGroupManagement();
fixture.componentRef.setInput('playlistId', 'playlist-b');
closed.next({ hiddenGroupTitles: [], lockedGroupTitles: [] });
expect(hiddenGroupTitlesChanged).not.toHaveBeenCalled();
expect(lockedGroupTitlesChanged).not.toHaveBeenCalled();
});
it('emits a single-group toggle from the right-click menu (the host asks for the PIN)', () => {
const groupLockToggled = jest.fn();
component.groupLockToggled.subscribe(groupLockToggled);
fixture.componentRef.setInput('lockedGroupTitles', ['News']);
fixture.detectChanges();
const event = new MouseEvent('contextmenu', { cancelable: true });
component.onGroupContextMenu('Sports', event);
expect(event.defaultPrevented).toBe(true);
component.onGroupLockToggle(true);
expect(groupLockToggled).toHaveBeenLastCalledWith({
groupKey: 'Sports',
locked: true,
});
component.onGroupContextMenu('News', event);
component.onGroupLockToggle(false);
expect(groupLockToggled).toHaveBeenLastCalledWith({
groupKey: 'News',
locked: false,
});
expect(parentalLock.requestUnlock).not.toHaveBeenCalled();
});
it('shows the locked-groups row while groups are withheld and unlocks from it', async () => {
const row = () =>
fixture.nativeElement.querySelector(
'[data-test-id="groups-locked-row"]'
) as HTMLButtonElement | null;
expect(row()).toBeNull();
fixture.componentRef.setInput('withheldGroupCount', 2);
fixture.detectChanges();
expect(row()).not.toBeNull();
row()?.click();
expect(parentalLock.requestUnlock).toHaveBeenCalled();
});
it('keeps the rail and its unlock row when every group is withheld', () => {
fixture.componentRef.setInput('groupedChannels', {});
fixture.componentRef.setInput('withheldGroupCount', 3);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.groups-view-empty-state')
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="groups-locked-row"]'
)
).not.toBeNull();
});
it('offers no group lock menu while the feature is off', () => {
const event = new MouseEvent('contextmenu', { cancelable: true });
component.onGroupContextMenu('Sports', event);
expect(event.defaultPrevented).toBe(false);
});
it('keeps the manage-groups dialog closed when the parental PIN is refused', async () => {
// The dialog names every locked group and can rewrite the locks.
parentalLock.requestUnlock.mockResolvedValueOnce(false);
await component.openGroupManagement();
expect(dialog.open).not.toHaveBeenCalled();
});
it('drops the selected-group header in compact mode but keeps the groups rail header', () => {
fixture.componentRef.setInput('showHeader', false);
fixture.detectChanges();
@@ -22,7 +22,7 @@ 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 { SettingsStore } from '@iptvnator/services';
import { ParentalLockService, SettingsStore } from '@iptvnator/services';
import {
foldSearchText,
Channel,
@@ -41,6 +41,7 @@ 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 { CategoryLockMenuComponent } from '../../category-lock-menu/category-lock-menu.component';
import { ResizableDirective } from '../../resizable/resizable.directive';
import {
GroupManagementDialogComponent,
@@ -70,6 +71,7 @@ interface FilteredGroupView {
styleUrls: ['./groups-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CategoryLockMenuComponent,
ChannelScrollFocusDirective,
ChannelListItemComponent,
MatButtonModule,
@@ -84,6 +86,7 @@ interface FilteredGroupView {
})
export class GroupsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly parentalLock = inject(ParentalLockService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly settingsStore = inject(SettingsStore);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
@@ -144,6 +147,24 @@ export class GroupsViewComponent {
/** Set of favorite channel URLs */
readonly favoriteIds = input<Set<string>>(new Set());
readonly hiddenGroupTitles = input<string[]>([]);
/**
* Parental lock. `lockedGroupTitles` is `null` while the lock feature is
* off (no toggles in the dialog); `managementGroups` lists EVERY group
* of the playlist, locked ones included, because `groupedChannels`
* arrives with the withheld groups already removed.
*/
/**
* The playlist the groups belong to. The management dialog's result is
* applied only while it is still the open one: the host saves hidden
* and locked groups under the CURRENT playlist.
*/
readonly playlistId = input<string | null>(null);
readonly lockedGroupTitles = input<string[] | null>(null);
readonly managementGroups = input<GroupManagementDialogGroup[] | null>(
null
);
/** Groups the active lock withholds; > 0 renders the unlock row. */
readonly withheldGroupCount = input(0);
/** Current outer sidebar width */
readonly sidebarWidth = input<number | null>(null);
@@ -164,6 +185,9 @@ export class GroupsViewComponent {
/** Emits when the groups rail resize ends */
readonly sidebarWidthRequestEnded = output<number>();
readonly hiddenGroupTitlesChanged = output<string[]>();
readonly lockedGroupTitlesChanged = output<string[]>();
/** Right-click toggle of ONE group; the host applies it in the lock store's queue. */
readonly groupLockToggled = output<{ groupKey: string; locked: boolean }>();
/** Emits when the user clicks the inline collapse toggle in the groups header */
readonly sidebarToggleRequested = output<void>();
@@ -176,6 +200,10 @@ export class GroupsViewComponent {
*/
readonly selectedGroupChange = output<string | null>();
private readonly groupLockMenu =
viewChild<CategoryLockMenuComponent>('groupLockMenu');
private groupLockKey: string | null = null;
readonly isGroupSearchOpen = signal(false);
readonly localGroupSearchTerm = signal('');
readonly selectedGroupKey = signal<string | null>(null);
@@ -477,28 +505,92 @@ export class GroupsViewComponent {
this.localGroupSearchTerm.set(value);
}
openGroupManagement(): void {
const groups = this.allGroups().map<GroupManagementDialogGroup>(
({ key, count }) => ({
key,
count,
})
);
/**
* Right-click accelerator of the parental lock for one group; the host
* persists the emitted list exactly as it does for the dialog. No menu
* while the feature is off (`lockedGroupTitles` is null then).
*/
onGroupContextMenu(groupKey: string, event: MouseEvent): void {
const locked = this.lockedGroupTitles();
if (locked === null) {
return;
}
this.groupLockKey = groupKey;
this.groupLockMenu()?.open(event, locked.includes(groupKey));
}
/**
* Emitted at once: the host asks for the PIN itself, after capturing the
* playlist the toggle belongs to — this view does not know it, and the
* user may navigate while the (lazily loaded) prompt is pending.
*/
onGroupLockToggle(lock: boolean): void {
const groupKey = this.groupLockKey;
this.groupLockKey = null;
if (groupKey === null || this.lockedGroupTitles() === null) {
return;
}
this.groupLockToggled.emit({ groupKey, locked: lock });
}
async requestParentalUnlock(): Promise<void> {
await this.parentalLock.requestUnlock();
}
async openGroupManagement(): Promise<void> {
// The dialog lists every group by name, locked ones included, and can
// rewrite the locks — so it sits behind the PIN like the portal
// dialogs do.
const playlistId = this.playlistId();
if (
!(await this.parentalLock.requestUnlock()) ||
this.playlistId() !== playlistId
) {
return;
}
const groups =
this.managementGroups() ??
this.allGroups().map<GroupManagementDialogGroup>(
({ key, count }) => ({
key,
count,
})
);
// Lock toggles only from a lock store that was read: a draft built
// from the empty fail-closed snapshot would, if storage recovered by
// Save, replace the real locks with nothing. Hidden groups stay
// editable either way.
const lockedGroupTitles =
this.lockedGroupTitles() !== null &&
playlistId &&
(await this.parentalLock.ensureLocksReadable()) &&
this.playlistId() === playlistId
? this.parentalLock.lockedGroupTitles(playlistId)
: null;
// Relocked while the store was being read: the dialog lists every
// group, locked ones included.
if (this.parentalLock.active() || this.playlistId() !== playlistId) {
return;
}
const dialogRef = this.dialog.open(GroupManagementDialogComponent, {
data: {
groups,
hiddenGroupTitles: this.hiddenGroupTitles(),
...(lockedGroupTitles ? { lockedGroupTitles } : {}),
},
width: '500px',
maxHeight: '90vh',
});
dialogRef.afterClosed().subscribe((hiddenGroupTitles) => {
if (hiddenGroupTitles === undefined) {
dialogRef.afterClosed().subscribe((result) => {
if (result === undefined || this.playlistId() !== playlistId) {
return;
}
this.hiddenGroupTitlesChanged.emit(hiddenGroupTitles);
this.hiddenGroupTitlesChanged.emit(result.hiddenGroupTitles);
if (result.lockedGroupTitles) {
this.lockedGroupTitlesChanged.emit(result.lockedGroupTitles);
}
});
}
@@ -0,0 +1,92 @@
<h2 mat-dialog-title class="pin-dialog__title">
<mat-icon aria-hidden="true">{{ isSetMode() ? 'lock_reset' : 'lock' }}</mat-icon>
<span>{{ titleKey() | translate }}</span>
</h2>
<form (ngSubmit)="submit()" novalidate>
<mat-dialog-content class="pin-dialog__content">
<p class="pin-dialog__description">{{ descriptionKey() | translate }}</p>
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
class="pin-dialog__field"
[class.pin-dialog__field--shake]="shake()"
>
<mat-label>{{ 'PARENTAL_LOCK.PIN_DIALOG.PIN_LABEL' | translate }}</mat-label>
<input
#pinInput
matInput
type="password"
inputmode="numeric"
autocomplete="off"
data-test-id="parental-lock-pin"
[attr.maxlength]="maxLength"
[disabled]="busy() || inCooldown()"
[ngModel]="pin()"
(ngModelChange)="onPinInput($event)"
name="pin"
/>
<mat-hint>
{{
'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT'
| translate: { min: minLength, max: maxLength }
}}
</mat-hint>
</mat-form-field>
@if (isSetMode()) {
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
class="pin-dialog__field"
>
<mat-label>{{ 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_LABEL' | translate }}</mat-label>
<input
matInput
type="password"
inputmode="numeric"
autocomplete="off"
data-test-id="parental-lock-pin-confirm"
[attr.maxlength]="maxLength"
[disabled]="busy()"
[ngModel]="confirmation()"
(ngModelChange)="onConfirmationInput($event)"
name="confirmation"
/>
</mat-form-field>
}
@if (error(); as errorKey) {
<p class="pin-dialog__error" role="alert" data-test-id="parental-lock-pin-error">
{{ errorKey | translate }}
</p>
}
@if (isSetMode()) {
<p class="pin-dialog__note">
{{ 'PARENTAL_LOCK.PIN_DIALOG.NO_RECOVERY' | translate }}
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="busy()">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="submit"
data-test-id="parental-lock-pin-submit"
[disabled]="!canSubmit()"
>
{{
(isSetMode()
? 'PARENTAL_LOCK.PIN_DIALOG.SAVE'
: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK'
) | translate
}}
</button>
</mat-dialog-actions>
</form>
@@ -0,0 +1,57 @@
.pin-dialog__title {
display: flex;
align-items: center;
gap: 8px;
}
.pin-dialog__content {
display: flex;
flex-direction: column;
gap: 12px;
}
.pin-dialog__description {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
}
.pin-dialog__field {
width: 100%;
input {
letter-spacing: 0.3em;
font-size: 1.1rem;
}
}
.pin-dialog__field--shake {
animation: pin-dialog-shake 0.4s ease;
}
.pin-dialog__error {
margin: 0;
color: var(--mat-sys-error);
font-size: 0.8125rem;
}
.pin-dialog__note {
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
}
@keyframes pin-dialog-shake {
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-6px);
}
40%,
80% {
transform: translateX(6px);
}
}
@@ -0,0 +1,53 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import {
createParentalLockPinThrottle,
PARENTAL_LOCK_PIN_MAX_ATTEMPTS,
} from '@iptvnator/shared/interfaces';
import { ParentalLockPinDialogComponent } from './parental-lock-pin-dialog.component';
describe('ParentalLockPinDialogComponent cooldown', () => {
const throttle = createParentalLockPinThrottle();
const verify = jest.fn(async () => false);
function openDialog(): ParentalLockPinDialogComponent {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [
ParentalLockPinDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: { mode: 'unlock', verify, throttle },
},
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
],
});
const fixture = TestBed.createComponent(ParentalLockPinDialogComponent);
fixture.detectChanges();
return fixture.componentInstance;
}
it('keeps the cooldown when the prompt is dismissed and opened again', async () => {
const first = openDialog();
for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) {
first.onPinInput('0000');
await first.submit();
}
expect(first.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS);
const reopened = openDialog();
expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
reopened.onPinInput('0000');
await reopened.submit();
expect(reopened.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS);
});
});
@@ -0,0 +1,204 @@
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
signal,
viewChild,
} from '@angular/core';
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 { TranslatePipe } from '@ngx-translate/core';
import {
createParentalLockPinThrottle,
isValidParentalLockPin,
ParentalLockPinThrottle,
PARENTAL_LOCK_PIN_MAX_LENGTH,
PARENTAL_LOCK_PIN_MIN_LENGTH,
} from '@iptvnator/shared/interfaces';
export type ParentalLockPinDialogMode = 'unlock' | 'set';
export interface ParentalLockPinDialogData {
mode: ParentalLockPinDialogMode;
/** Unlock only: whether the typed PIN is the right one. */
verify?: (pin: string) => Promise<boolean>;
/**
* Unlock only: wrong-PIN count and cooldown owned by the caller, so a
* dismissed and reopened prompt keeps them. A local one is used when
* absent.
*/
throttle?: ParentalLockPinThrottle;
titleKey?: string;
descriptionKey?: string;
}
/**
* PIN prompt for the parental lock. Pure UI: the caller supplies `verify`
* for the unlock mode and receives the accepted PIN (or `undefined` when
* dismissed) through the dialog result. `set` mode asks for the PIN twice.
*/
@Component({
selector: 'app-parental-lock-pin-dialog',
templateUrl: './parental-lock-pin-dialog.component.html',
styleUrl: './parental-lock-pin-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
TranslatePipe,
],
})
export class ParentalLockPinDialogComponent {
readonly data = inject<ParentalLockPinDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
MatDialogRef<ParentalLockPinDialogComponent, string | undefined>
);
private readonly pinInput =
viewChild<ElementRef<HTMLInputElement>>('pinInput');
readonly minLength = PARENTAL_LOCK_PIN_MIN_LENGTH;
readonly maxLength = PARENTAL_LOCK_PIN_MAX_LENGTH;
readonly pin = signal('');
readonly confirmation = signal('');
readonly busy = signal(false);
readonly error = signal<string | null>(null);
readonly shake = signal(false);
readonly cooldownUntil = signal(0);
private readonly throttle =
this.data.throttle ?? createParentalLockPinThrottle();
private cooldownTimer: number | null = null;
readonly isSetMode = computed(() => this.data.mode === 'set');
readonly titleKey = computed(
() =>
this.data.titleKey ??
(this.isSetMode()
? 'PARENTAL_LOCK.PIN_DIALOG.SET_TITLE'
: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK_TITLE')
);
readonly descriptionKey = computed(
() =>
this.data.descriptionKey ??
(this.isSetMode()
? 'PARENTAL_LOCK.PIN_DIALOG.SET_DESCRIPTION'
: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK_DESCRIPTION')
);
readonly inCooldown = computed(() => this.cooldownUntil() > Date.now());
readonly canSubmit = computed(
() =>
!this.busy() &&
!this.inCooldown() &&
isValidParentalLockPin(this.pin()) &&
(!this.isSetMode() || this.confirmation() === this.pin())
);
constructor() {
// Reopened during a cooldown: the pause carries on where it was.
const until = this.throttle.cooldownUntil();
if (until > 0) {
this.startCooldown(until);
this.error.set('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
}
}
static open(
dialog: MatDialog,
data: ParentalLockPinDialogData
): MatDialogRef<ParentalLockPinDialogComponent, string | undefined> {
return dialog.open(ParentalLockPinDialogComponent, {
data,
width: '400px',
maxWidth: 'calc(100vw - 32px)',
autoFocus: 'input',
restoreFocus: true,
});
}
onPinInput(value: string): void {
this.pin.set(value.replace(/\D/g, '').slice(0, this.maxLength));
this.error.set(null);
}
onConfirmationInput(value: string): void {
this.confirmation.set(
value.replace(/\D/g, '').slice(0, this.maxLength)
);
this.error.set(null);
}
async submit(): Promise<void> {
if (!this.canSubmit()) {
if (this.isSetMode() && this.confirmation() !== this.pin()) {
this.fail('PARENTAL_LOCK.PIN_DIALOG.MISMATCH');
}
return;
}
const pin = this.pin();
if (this.isSetMode()) {
this.dialogRef.close(pin);
return;
}
this.busy.set(true);
try {
const accepted = (await this.data.verify?.(pin)) === true;
if (accepted) {
this.throttle.recordSuccess();
this.dialogRef.close(pin);
return;
}
this.pin.set('');
if (this.throttle.recordFailure()) {
this.startCooldown(this.throttle.cooldownUntil());
this.fail('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
} else {
this.fail('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN');
}
} finally {
this.busy.set(false);
queueMicrotask(() => this.pinInput()?.nativeElement.focus());
}
}
cancel(): void {
this.dialogRef.close(undefined);
}
private fail(messageKey: string): void {
this.error.set(messageKey);
this.shake.set(true);
window.setTimeout(() => this.shake.set(false), 400);
}
private startCooldown(until: number): void {
this.cooldownUntil.set(until);
if (this.cooldownTimer !== null) {
window.clearTimeout(this.cooldownTimer);
}
this.cooldownTimer = window.setTimeout(
() => {
this.cooldownTimer = null;
// `inCooldown` compares against the clock only when a signal it
// reads changes; resetting the deadline is that change.
this.cooldownUntil.set(0);
this.error.set(null);
},
Math.max(0, until - Date.now())
);
}
}
+3 -3
View File
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -21,10 +21,10 @@ describe('EpgGuideKeyboardController', () => {
beforeEach(() => {
host = {
rowCount: jest.fn(() => 5),
blockCount: jest.fn(() => 3),
blockCount: jest.fn((_row: number) => 3),
activeRow: jest.fn(() => 2),
isBlocked: jest.fn(() => false),
isOwnedTarget: jest.fn(() => true),
isOwnedTarget: jest.fn((_target: EventTarget | null) => true),
play: jest.fn(),
details: jest.fn(),
jumpNow: jest.fn(),
@@ -5,7 +5,11 @@ import { By } from '@angular/platform-browser';
import { SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import { Observable, of, Subject } from 'rxjs';
import type {
EpgItemDialogAction,
EpgItemDialogData,
} from '../epg-item-description/epg-item-description.component';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import { EpgGuideComponent } from './epg-guide.component';
import {
@@ -65,7 +69,11 @@ describe('EpgGuideComponent', () => {
const searchHits = signal<EpgGuideSearchHit[]>([]);
const activate = jest.fn();
const setScope = jest.fn();
const dialogOpen = jest.fn(() => of(undefined));
const dialogOpen = jest.fn(
(
_data: EpgItemDialogData
): Observable<EpgItemDialogAction | undefined> => of(undefined)
);
beforeEach(() => {
localStorage.clear();
@@ -20,7 +20,7 @@ import {
EpgProgressService,
} from '@iptvnator/epg/data-access';
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { normalizeDateLocale } from '@iptvnator/pipes/date-format';
import { formatEpgImportDisplayUrl } from './epg-import-display-url';
interface EpgTrustConfirmDialogData {
@@ -7,10 +7,15 @@ import {
} from '@iptvnator/epg/data-access';
import { EpgSourceStatusComponent } from './epg-source-status.component';
/** Writable double for the bridge's read-only capability getters. */
type EpgBridgeStub = {
-readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K];
};
describe('EpgSourceStatusComponent', () => {
let fixture: ComponentFixture<EpgSourceStatusComponent>;
let component: EpgSourceStatusComponent;
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: EpgBridgeStub;
const imports = signal([]);
beforeEach(async () => {
+3 -3
View File
@@ -3,12 +3,12 @@
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -63,7 +63,8 @@ describe('ArtPlayerSourceSession caption preference without shared controls', ()
const { session, player, video } = startHls(() => false);
video.dispatchEvent(new Event('playing'));
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/second.m3u8',
player
@@ -90,7 +91,8 @@ describe('ArtPlayerSourceSession caption preference without shared controls', ()
showCaptions,
});
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -40,7 +40,8 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
});
session.attach(player);
session.customType['mpd']?.(
session.customType['mpd']?.call(
player,
video,
'https://example.test/live.mpd',
player
@@ -68,13 +69,15 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
});
session.attach(player);
session.customType['mpd']?.(
session.customType['mpd']?.call(
player,
video,
'https://example.test/live.mpd',
player
);
await flushShakaMicrotasks();
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -115,7 +118,8 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
});
session.attach(player);
session.customType['mpd']?.(
session.customType['mpd']?.call(
player,
video,
'https://example.test/failing.mpd',
player
@@ -29,7 +29,8 @@ describe('ArtPlayerSourceSession', () => {
session.attach(player);
detach.mockClear();
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -77,7 +78,8 @@ describe('ArtPlayerSourceSession', () => {
const attach = jest.spyOn(adapter, 'attach');
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -105,7 +107,8 @@ describe('ArtPlayerSourceSession', () => {
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -145,7 +148,8 @@ describe('ArtPlayerSourceSession', () => {
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -187,7 +191,8 @@ describe('ArtPlayerSourceSession', () => {
});
session.attach(player);
session.customType['ts']?.(
session.customType['ts']?.call(
player,
video,
'https://example.test/movie.ts',
player
@@ -218,7 +223,8 @@ describe('ArtPlayerSourceSession', () => {
});
session.attach(player);
session.customType['ts']?.(
session.customType['ts']?.call(
player,
video,
'https://example.test/movie.ts',
player
@@ -239,7 +245,8 @@ describe('ArtPlayerSourceSession', () => {
});
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -254,7 +261,8 @@ describe('ArtPlayerSourceSession', () => {
error: new Error('unsupported codec'),
});
session.customType['ts']?.(
session.customType['ts']?.call(
player,
video,
'https://example.test/live.ts',
player
@@ -300,7 +308,8 @@ describe('ArtPlayerSourceSession', () => {
});
const secret = 'art-hls-secret-sentinel';
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -354,12 +363,14 @@ describe('ArtPlayerSourceSession', () => {
session.attach(player);
session.destroy();
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/stale.m3u8',
player
);
session.customType['ts']?.(
session.customType['ts']?.call(
player,
video,
'https://example.test/stale.ts',
player
@@ -132,7 +132,7 @@ describe('ArtPlayerVideoSession', () => {
});
function createSession(
player: MockArtplayer,
player: MockArtplayer & Artplayer,
persistSharedVolume: boolean
): ArtPlayerVideoSession {
return new ArtPlayerVideoSession({
@@ -210,8 +210,7 @@ describe('EmbeddedMpvCommandRunner', () => {
it('ignores a command reply after the active session is replaced', async () => {
let resolveCommand:
| ((value: EmbeddedMpvSession | null) => void)
| null = null;
((value: EmbeddedMpvSession | null) => void) | undefined;
electron.seekEmbeddedMpv.mockImplementationOnce(
() =>
new Promise<EmbeddedMpvSession | null>((resolve) => {
@@ -239,8 +238,7 @@ describe('EmbeddedMpvCommandRunner', () => {
'keeps a newer same-session snapshot when a %s command reply settles',
async (_, replyUpdatedAt) => {
let resolveCommand:
| ((value: EmbeddedMpvSession | null) => void)
| null = null;
((value: EmbeddedMpvSession | null) => void) | undefined;
electron.startEmbeddedMpvRecording.mockImplementationOnce(
() =>
new Promise<EmbeddedMpvSession | null>((resolve) => {
@@ -302,7 +300,7 @@ describe('EmbeddedMpvCommandRunner', () => {
});
it('does not start recording after the session changes during folder lookup', async () => {
let resolveFolder: ((folder: string) => void) | null = null;
let resolveFolder: ((folder: string) => void) | undefined;
electron.getEmbeddedMpvDefaultRecordingFolder.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
@@ -239,7 +239,7 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
const successMessage =
operation === 'stop' ? `Saved to ${targetPath}` : null;
const failureMessage = `Failed to ${operation} recording`;
const setRecording = (recording: RecordingState) =>
const setRecording = (recording: NonNullable<RecordingState>) =>
controller.session.set(session({ recording }));
const originalCommand =
operation === 'start'
@@ -25,7 +25,7 @@ class DockPanelsHostComponent {
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'movie',
contentType: 'vod',
},
};
}
@@ -284,20 +284,13 @@ describe('EmbeddedMpvPlayerComponent dock panels', () => {
});
it('blocks seek and volume arrow shortcuts while a chip panel is open', () => {
const seekBy = jest
.spyOn(controller, 'seekBy')
.mockResolvedValue(true);
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',
]) {
for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) {
document.dispatchEvent(
new KeyboardEvent('keydown', {
key,
@@ -18,7 +18,7 @@ const PLAYBACK: ResolvedPortalPlayback = {
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'movie',
contentType: 'vod',
},
};
@@ -251,9 +251,7 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => {
fullscreenElement = target;
document.dispatchEvent(new Event('fullscreenchange'));
});
(target as HTMLElement & { requestFullscreen: jest.Mock })[
'requestFullscreen'
] = requestFullscreen;
target.requestFullscreen = requestFullscreen;
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
@@ -16,7 +16,7 @@ const PLAYBACK: ResolvedPortalPlayback = {
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'movie',
contentType: 'vod',
},
};
@@ -182,8 +182,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
});
it('disposes a session whose creation resolves only after teardown', async () => {
let resolveCreate: ((session: EmbeddedMpvSession) => void) | null =
null;
let resolveCreate: ((session: EmbeddedMpvSession) => void) | undefined;
electron.createEmbeddedMpvSession.mockImplementationOnce(
() =>
new Promise((resolve) => {
@@ -198,7 +197,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
0.5
);
await waitFor(
() => resolveCreate !== null,
() => resolveCreate !== undefined,
'startup to reach createEmbeddedMpvSession'
);
teardown();
@@ -218,7 +217,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
it('does not continue frame setup when teardown happens during playback load', async () => {
const frameCopySupport = createSupport('frame-copy');
let resolveLoad: (() => void) | null = null;
let resolveLoad: (() => void) | undefined;
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport);
electron.loadEmbeddedMpvPlayback.mockImplementationOnce(
@@ -258,7 +257,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
it('does not schedule bounds after teardown during frame view attachment', async () => {
const frameCopySupport = createSupport('frame-copy');
let resolveAttach: ((attached: boolean) => void) | null = null;
let resolveAttach: ((attached: boolean) => void) | undefined;
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport);
electron.attachEmbeddedMpvFrameView.mockImplementationOnce(
@@ -206,7 +206,7 @@ describe('EmbeddedMpvSessionController', () => {
});
it('ignores a late startup rejection after teardown so it cannot clobber a newer session', async () => {
let rejectPrepare: ((error: Error) => void) | null = null;
let rejectPrepare: ((error: Error) => void) | undefined;
electron.prepareEmbeddedMpv.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
@@ -221,7 +221,7 @@ describe('EmbeddedMpvSessionController', () => {
0.5
);
await waitFor(
() => rejectPrepare !== null,
() => rejectPrepare !== undefined,
'startup to reach prepareEmbeddedMpv'
);
teardown();
@@ -1,4 +1,3 @@
import { jest } from '@jest/globals';
import Hls from 'hls.js';
import { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
import { HtmlVideoPlayerControlsBridge } from './html-video-player-controls.bridge';
@@ -368,6 +368,10 @@ describe('HtmlVideoPlayerComponent', () => {
if (issue) issues.push(issue);
});
const secret = 'html-hls-secret-sentinel';
const networkDetails = new XMLHttpRequest();
Object.defineProperty(networkDetails, 'responseText', {
value: secret,
});
(
component as unknown as {
@@ -385,7 +389,7 @@ describe('HtmlVideoPlayerComponent', () => {
text: secret,
data: { body: secret },
},
networkDetails: { responseText: secret },
networkDetails,
});
expect(issues[0]).toEqual(
@@ -2,7 +2,11 @@ import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsMenu, ControlsMenuState } from './controls-menu-state';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
} from './player-controls.model';
describe('ControlsMenuState', () => {
it('keeps only one menu open at a time', () => {
@@ -94,7 +98,13 @@ describe('ControlsMenuState', () => {
expect(menus.anyOpen()).toBe(false);
});
it.each([
it.each<
[
ControlsMenu,
Partial<PlayerControlsCapabilities>,
Partial<PlayerControlsState>,
]
>([
['volume', { volume: false }, {}],
['audio', {}, { audioTracks: [] }],
['subtitle', {}, { subtitleTracks: [] }],
@@ -102,7 +112,7 @@ describe('ControlsMenuState', () => {
['speed', { playbackSpeed: false }, {}],
['aspect', { aspectRatio: false }, {}],
['stats', { streamStats: false }, {}],
] as const)(
])(
'maps runtime controller state to %s menu availability',
(menu, capabilityOverrides, stateOverrides) => {
const menus = new ControlsMenuState();
@@ -30,6 +30,7 @@ function createFakeController() {
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
@@ -211,9 +212,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
});
(
style.querySelector(
'[aria-label="Yellow"]'
) as HTMLButtonElement
style.querySelector('[aria-label="Yellow"]') as HTMLButtonElement
).click();
expect(fake.commands.setSubtitleStyle).toHaveBeenLastCalledWith({
sizePercent: 100,
@@ -278,9 +278,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
fullscreenElement = target;
document.dispatchEvent(new Event('fullscreenchange'));
});
(target as HTMLElement & { requestFullscreen: jest.Mock })[
'requestFullscreen'
] = requestTargetFullscreen;
target.requestFullscreen = requestTargetFullscreen;
fixture.componentRef.setInput('fullscreenTarget', target);
fixture.detectChanges();
@@ -145,11 +145,6 @@ describe('PortalInlinePlayerComponent', () => {
title: 'Header-bearing live stream',
isLive: true,
headers: { Authorization: 'Bearer token' },
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'live',
},
};
const request: PlaybackFallbackRequest = {
player: 'mpv',
@@ -158,11 +153,13 @@ describe('PortalInlinePlayerComponent', () => {
code: 'network-error',
player: 'videojs',
source: 'hls',
sourceUrl: playback.streamUrl,
container: '',
mimeType: '',
videoCodecs: [],
audioCodecs: [],
},
trackLaunch: jest.fn(),
};
let forwarded: PlaybackFallbackRequest | undefined;
component.externalFallbackRequested.subscribe(
@@ -233,7 +233,7 @@ function createHarness() {
const harness = {
tracks,
currentVideo: document.createElement('video'),
ready: () => undefined,
ready: (): void => undefined,
emit(event: string) {
for (const listener of listeners.get(event) ?? []) {
listener();
@@ -378,7 +378,7 @@ function createVideoJsPlayerHarness() {
currentVideo: document.createElement('video'),
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
ready: (): void => undefined,
mpegTsPlayers: [] as ReturnType<typeof createMpegTsPlayer>[],
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
@@ -255,7 +255,7 @@ function createVideoJsPlayerHarness() {
currentVideo: document.createElement('video'),
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
ready: (): void => undefined,
mpegTsPlayers: [] as ReturnType<typeof createMpegTsPlayer>[],
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
@@ -445,7 +445,7 @@ function createPlayerHarness() {
vhsActive: false,
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
ready: (): void => undefined,
play: jest.fn(() => Promise.resolve()),
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
+3 -3
View File
@@ -2,12 +2,12 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",