mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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:
commit
02ed4e589c
378 files changed
+16812
-1098
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -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()"
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
+74
-1
@@ -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 = [
|
||||
|
||||
+149
-7
@@ -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();
|
||||
|
||||
|
||||
+33
@@ -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">
|
||||
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
});
|
||||
});
|
||||
+55
-2
@@ -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 {
|
||||
|
||||
+34
-1
@@ -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)"
|
||||
/>
|
||||
+47
@@ -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;
|
||||
}
|
||||
}
|
||||
+138
-7
@@ -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();
|
||||
|
||||
+103
-11
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+92
@@ -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>
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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);
|
||||
});
|
||||
});
|
||||
+204
@@ -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,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,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) => {
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+3
-10
@@ -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,
|
||||
|
||||
+2
-4
@@ -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'
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const PLAYBACK: ResolvedPortalPlayback = {
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'movie',
|
||||
contentType: 'vod',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+4
-5
@@ -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(
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
+2
-3
@@ -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();
|
||||
|
||||
|
||||
+2
-5
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user