Merge origin/master into the programme guide branch

Adopts the compact icon-only timeline toolbar (#1557): the Guide button is
now an icon-only `epg-timeline__iconbtn` with its label in the tooltip and
aria-label, so the unused EPG.TIMELINE.GUIDE key is dropped from all locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:59:04 +02:00
commit 49fdd40c9d
123 files changed
+5976 -474

No files matched your search

@@ -0,0 +1,352 @@
import { AsyncPipe } from '@angular/common';
import { Component, NO_ERRORS_SCHEMA, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Overlay } from '@angular/cdk/overlay';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { BehaviorSubject, of } from 'rxjs';
import {
selectActive,
selectActiveEpgProgram,
selectActivePlaybackUrl,
selectChannels,
selectChannelsLoading,
selectCurrentEpgProgram,
} from '@iptvnator/m3u-state';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
LiveLayoutSidebarStateService,
liveSidebarStateStorageKey,
PORTAL_EXTERNAL_PLAYBACK,
} from '@iptvnator/portal/shared/util';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
Channel,
EpgProgram,
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
// The component's chain reaches video.js (ui/playback → VjsPlayer); the CJS
// bundle breaks under the ESM jest environment, so it is mocked before the
// dynamic import evaluates the chain.
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({
selector: 'app-channel-list-hidden-state',
standalone: true,
template: '',
})
class StubChannelListHiddenStateComponent {
readonly restore = output<void>();
}
/**
* The collapsible channels rail of the M3U player. Separate from
* `video-player.component.spec.ts` only because that file sits at the
* max-lines test budget; the harness here is the minimal subset needed to
* render the rail and the content-area empty states.
*/
describe('VideoPlayerComponent — collapsible channels rail', () => {
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
let component: VideoPlayerComponentInstance;
let sidebarState: LiveLayoutSidebarStateService;
const playlistId = signal('playlist-1');
const activeChannel = signal<Channel | null>(null);
const activePlaybackUrl = signal<string | null>(null);
const channels = signal<Channel[]>([]);
const channelsLoading = signal(false);
const currentEpgProgram = signal<EpgProgram | null>(null);
const activeEpgProgram = signal<EpgProgram | null>(null);
const channels$ = new BehaviorSubject<Channel[]>([]);
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
const showCaptions = signal(false);
const stripCountryPrefix = signal(false);
const storeMock = {
dispatch: jest.fn(),
selectSignal: jest.fn((selector: unknown) => {
switch (selector) {
case selectActive:
return activeChannel;
case selectActivePlaybackUrl:
return activePlaybackUrl;
case selectChannels:
return channels;
case selectChannelsLoading:
return channelsLoading;
case selectCurrentEpgProgram:
return currentEpgProgram;
case selectActiveEpgProgram:
return activeEpgProgram;
default:
return signal(null);
}
}),
select: jest.fn((selector: unknown) => {
switch (selector) {
case selectChannels:
return channels$.asObservable();
case selectActive:
return activeChannel$.asObservable();
case selectCurrentEpgProgram:
return currentEpgProgram$.asObservable();
default:
return of(null);
}
}),
};
function clearSidebarStorage(): void {
localStorage.removeItem(liveSidebarStateStorageKey('m3u'));
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
localStorage.removeItem(liveSidebarStateStorageKey('collection'));
}
function query(selector: string): Element | null {
return fixture.nativeElement.querySelector(selector);
}
function hiddenState() {
return fixture.debugElement.query(
By.directive(StubChannelListHiddenStateComponent)
);
}
beforeAll(async () => {
({ VideoPlayerComponent } = await import('./video-player.component'));
});
beforeEach(async () => {
clearSidebarStorage();
activeChannel.set(null);
activeChannel$.next(null);
channels.set([]);
channels$.next([]);
await TestBed.configureTestingModule({
imports: [VideoPlayerComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
},
{
provide: Router,
useValue: {
url: '/workspace/playlists/playlist-1/all',
navigate: jest.fn(),
currentNavigation: jest.fn().mockReturnValue(null),
},
},
{ provide: Store, useValue: storeMock },
{
provide: Overlay,
useValue: { position: jest.fn(), create: jest.fn() },
},
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: true,
isElectron: false,
supportsRemoteControl: false,
},
},
{
provide: PlaylistsService,
useValue: {
getPlaylist: jest.fn(() =>
of({ playlist: { items: [] }, favorites: [] })
),
getPlaylistWithGlobalFavorites: jest.fn(() =>
of({ playlist: { items: [] }, favorites: [] })
),
addM3uRecentlyViewed: jest.fn(() =>
of({ recentlyViewed: [] })
),
},
},
{
provide: EpgService,
useValue: {
currentEpgPrograms$: epgPrograms$.asObservable(),
getChannelMetadataForChannels: () => of(new Map()),
},
},
{
provide: PlaylistContextFacade,
useValue: { resolvedPlaylistId: playlistId },
},
{
provide: SettingsStore,
useValue: {
player,
showCaptions,
stripCountryPrefix,
m3uVodDetails: signal(true),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
epgUrl: signal<string[]>([]),
},
},
{
provide: StorageMap,
useValue: {
get: jest.fn(() =>
of({
player: player(),
showCaptions: showCaptions(),
} as Partial<Settings>)
),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: TmdbEnrichmentService,
useValue: { isEnabled: () => true },
},
],
})
// The rail and the content-area empty states are the subject;
// everything else renders as inert unknown elements.
.overrideComponent(VideoPlayerComponent, {
set: {
imports: [
AsyncPipe,
StubChannelListHiddenStateComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
schemas: [NO_ERRORS_SCHEMA],
},
})
.compileComponents();
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
sidebarState = TestBed.inject(LiveLayoutSidebarStateService);
});
afterEach(() => {
fixture?.destroy();
clearSidebarStorage();
});
it('starts with the rail visible and the "select a channel" empty state', () => {
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(false);
expect(query('.sidebar')?.classList.contains('sidebar-collapsed')).toBe(
false
);
expect(query('.sidebar-restore')).toBeNull();
expect(query('app-portal-empty-state')).not.toBeNull();
expect(hiddenState()).toBeNull();
});
it('hides the rail through the shared per-surface state and swaps the empty state for a full-size way back', () => {
fixture.detectChanges();
component.toggleSidebar();
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(true);
expect(query('.sidebar')?.classList.contains('sidebar-collapsed')).toBe(
true
);
expect(query('.sidebar-restore')).not.toBeNull();
expect(query('app-portal-empty-state')).toBeNull();
expect(hiddenState()).not.toBeNull();
expect(localStorage.getItem(liveSidebarStateStorageKey('m3u'))).toBe(
'collapsed'
);
hiddenState().componentInstance.restore.emit();
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(false);
expect(query('.sidebar-restore')).toBeNull();
expect(hiddenState()).toBeNull();
expect(query('app-portal-empty-state')).not.toBeNull();
expect(localStorage.getItem(liveSidebarStateStorageKey('m3u'))).toBe(
'expanded'
);
});
it('reflects a toggle made elsewhere for the M3U surface (the header) and ignores other surfaces', () => {
fixture.detectChanges();
sidebarState.setState('portal', 'collapsed');
sidebarState.setState('collection', 'collapsed');
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(false);
expect(query('.sidebar-restore')).toBeNull();
sidebarState.toggle('m3u');
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(true);
expect(query('.sidebar-restore')).not.toBeNull();
});
it('does not show the hidden-list state while a channel is playing', () => {
const liveChannel = {
id: 'channel-1',
url: 'http://localhost/live.m3u8',
name: 'Sample TV',
epgParams: '',
radio: 'false',
tvg: { id: '', logo: '', name: 'Sample TV' },
} as Channel;
activeChannel.set(liveChannel);
activeChannel$.next(liveChannel);
channels.set([liveChannel]);
channels$.next([liveChannel]);
fixture.detectChanges();
component.toggleSidebar();
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(true);
expect(query('.sidebar-restore')).not.toBeNull();
expect(hiddenState()).toBeNull();
expect(query('.video-player')).not.toBeNull();
});
});
@@ -251,6 +251,8 @@
</div>
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -84,17 +84,18 @@ import {
isTypingInInput,
isWorkspaceLayoutRoute,
LiveEpgPanelState,
LiveSidebarState,
LiveLayoutSidebarStateService,
persistLiveEpgPanelState,
persistLiveSidebarState,
PORTAL_EXTERNAL_PLAYBACK,
isLiveExternalPlayerSession,
REMOTE_CONTROL_RESET_STATUS,
restoreLiveEpgPanelState,
restoreLiveSidebarState,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
AudioPlayerComponent,
FULLSCREEN_CHANNEL_PANEL,
@@ -204,6 +205,7 @@ function isInsideScrollableRegion(
MatButtonModule,
MatIconModule,
MatTooltipModule,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
ResizableDirective,
SidebarComponent,
@@ -237,6 +239,9 @@ export class VideoPlayerComponent
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly workspaceHeaderContext = inject(
@@ -523,12 +528,9 @@ export class VideoPlayerComponent
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
readonly liveSidebarState = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly isSidebarCollapsed = computed(
() => this.liveSidebarState() === 'collapsed'
);
/** Shared with the workspace header toggle; persisted per surface. */
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('m3u');
/** Channels list */
readonly channels$: Observable<Channel[]> = this.store.select(
@@ -1075,11 +1077,7 @@ export class VideoPlayerComponent
}
toggleSidebar(): void {
const next: LiveSidebarState = this.isSidebarCollapsed()
? 'expanded'
: 'collapsed';
this.liveSidebarState.set(next);
persistLiveSidebarState(next);
this.liveSidebarStateService.toggle('m3u');
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
@@ -1,3 +1,4 @@
export * from './lib/collection';
export * from './lib/downloads';
export * from './lib/multi-source';
export * from './lib/live-channel-playback-queue';
@@ -0,0 +1,90 @@
import { LiveChannelPlaybackQueue } from './live-channel-playback-queue';
interface Channel {
id: string | number | null;
name: string;
}
const alpha: Channel = { id: 1, name: 'Alpha' };
const beta: Channel = { id: 2, name: 'Beta' };
const gamma: Channel = { id: 3, name: 'Gamma' };
describe('LiveChannelPlaybackQueue', () => {
let queue: LiveChannelPlaybackQueue<Channel>;
beforeEach(() => {
queue = new LiveChannelPlaybackQueue((item) => item.id);
});
it('captures the displayed order independently of subsequent browsing', () => {
const displayed = [beta, alpha];
queue.capture('source:itv', 'news:q:sort', displayed, alpha);
displayed.reverse();
queue.extend('source:itv', 'sports:q:sort', [gamma]);
expect(queue.items('source:itv')).toEqual([beta, alpha]);
});
it('does not expose a different source or content type queue', () => {
queue.capture('source:itv', 'news', [alpha, beta], alpha);
expect(queue.items('source:radio')).toEqual([]);
expect(queue.items('other:itv')).toEqual([]);
});
it('extends only the original loaded scope and retains channel numbers', () => {
queue.capture('source:itv', 'news', [alpha, beta], alpha);
queue.extend('source:itv', 'news', [gamma, beta, alpha]);
expect(queue.items('source:itv')).toEqual([alpha, beta, gamma]);
});
it('ignores a late page from a previous owner', () => {
queue.capture('source:radio', 'news', [alpha], alpha);
queue.extend('source:itv', 'news', [alpha, beta]);
expect(queue.items('source:radio')).toEqual([alpha]);
});
it('refreshes metadata without changing existing order', () => {
queue.capture('source:itv', 'news', [alpha, beta], alpha);
const updatedBeta = { ...beta, name: 'Beta HD' };
queue.extend('source:itv', 'news', [alpha, updatedBeta]);
expect(queue.items('source:itv')).toEqual([alpha, updatedBeta]);
});
it('does not lose a captured page when the browse resource temporarily clears', () => {
queue.capture('source:itv', 'news', [alpha, beta], alpha);
queue.extend('source:itv', 'news', []);
expect(queue.items('source:itv')).toEqual([alpha, beta]);
});
it('deduplicates normalized IDs and excludes invalid IDs', () => {
queue.capture(
'source',
'news',
[alpha, { ...alpha, id: '1' }, { id: null, name: '?' }, beta],
alpha
);
expect(queue.items('source')).toEqual([alpha, beta]);
});
it('falls back to the selected channel, never an unrelated list', () => {
queue.capture('source', 'news', [alpha, beta], gamma);
expect(queue.items('source')).toEqual([gamma]);
});
it('rejects invalid selected IDs', () => {
queue.capture('source', 'news', [alpha], { id: null, name: '?' });
expect(queue.items('source')).toEqual([]);
});
it('preserves snapshot identity when a page has not changed', () => {
queue.capture('source', 'news', [alpha, beta], alpha);
const previous = queue.items('source');
queue.extend('source', 'news', [alpha, beta]);
expect(queue.items('source')).toBe(previous);
});
it('replaces the queue on a new explicit channel selection and clears on teardown', () => {
queue.capture('source', 'news', [alpha, beta], alpha);
queue.capture('source', 'sports', [gamma], gamma);
expect(queue.items('source')).toEqual([gamma]);
queue.clear();
expect(queue.items('source')).toEqual([]);
});
});
@@ -0,0 +1,79 @@
import { signal } from '@angular/core';
interface PlaybackQueueSnapshot<T> {
readonly owner: string;
readonly scope: string;
readonly items: readonly T[];
}
/** Component-owned playback order, independent of the list being browsed. */
export class LiveChannelPlaybackQueue<T> {
private readonly snapshot = signal<PlaybackQueueSnapshot<T> | null>(null);
constructor(
private readonly getId: (item: T) => string | number | null | undefined
) {}
items(owner: string): readonly T[] {
const snapshot = this.snapshot();
return snapshot?.owner === owner ? snapshot.items : [];
}
capture(
owner: string,
scope: string,
items: readonly T[],
active: T
): void {
const activeId = this.key(active);
if (!activeId) {
this.clear();
return;
}
const unique = this.unique(items);
this.snapshot.set({
owner,
scope,
items: unique.has(activeId) ? [...unique.values()] : [active],
});
}
/** Append loaded pages only for the scope that supplied this queue. */
extend(owner: string, scope: string, items: readonly T[]): void {
const previous = this.snapshot();
if (!previous || previous.owner !== owner || previous.scope !== scope)
return;
const incoming = this.unique(items);
const next = previous.items.map((item) => {
const id = this.key(item);
const current = incoming.get(id) ?? item;
incoming.delete(id);
return current;
});
next.push(...incoming.values());
if (
next.length === previous.items.length &&
next.every((item, index) => item === previous.items[index])
)
return;
this.snapshot.set({ ...previous, items: next });
}
clear(): void {
this.snapshot.set(null);
}
private key(item: T): string {
const id = this.getId(item);
return id == null ? '' : String(id);
}
private unique(items: readonly T[]): Map<string, T> {
const result = new Map<string, T>();
for (const item of items) {
const id = this.key(item);
if (id && !result.has(id)) result.set(id, item);
}
return result;
}
}
+1
View File
@@ -8,6 +8,7 @@ export * from './lib/components/grid-list/grid-list.component';
export * from './lib/components/playlist-error-view/playlist-error-view.component';
export { ProgressCapsuleComponent } from '@iptvnator/ui/components';
export * from './lib/components/portal-empty-state/portal-empty-state.component';
export * from './lib/components/channel-list-hidden-state/channel-list-hidden-state.component';
export * from './lib/components/search-form/search-form.component';
export * from './lib/components/search-layout/search-layout.component';
export { WatchedBadgeComponent } from '@iptvnator/ui/components';
@@ -0,0 +1,49 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { ChannelListHiddenStateComponent } from './channel-list-hidden-state.component';
describe('ChannelListHiddenStateComponent', () => {
let fixture: ComponentFixture<ChannelListHiddenStateComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ChannelListHiddenStateComponent],
})
.overrideComponent(ChannelListHiddenStateComponent, {
remove: { imports: [TranslatePipe] },
add: {
imports: [MockPipe(TranslatePipe, (key: string) => key)],
},
})
.compileComponents();
fixture = TestBed.createComponent(ChannelListHiddenStateComponent);
fixture.detectChanges();
});
it('explains that the list is hidden instead of asking to pick a channel', () => {
const host: HTMLElement = fixture.nativeElement;
expect(host.querySelector('.empty-state-title')?.textContent).toContain(
'LAYOUT.CHANNELS_LIST_HIDDEN'
);
expect(host.querySelector('.empty-state-hint')?.textContent).toContain(
'LAYOUT.CHANNELS_LIST_HIDDEN_HINT'
);
expect(host.textContent).not.toContain('SELECT_CHANNEL_PLAYBACK');
});
it('offers a full-size restore action that emits once per click', () => {
const restore = jest.fn();
fixture.componentInstance.restore.subscribe(restore);
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector('button.empty-state-action');
expect(button?.textContent).toContain('LAYOUT.SHOW_CHANNELS_LIST');
button?.click();
expect(restore).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, output } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
/**
* Content-area placeholder for a live layout whose channel rail is collapsed
* and nothing is playing. It replaces the "select a channel" empty state,
* which would otherwise ask the user to pick from a list they cannot see,
* and gives the collapsed state a full-size way back next to the 32px
* restore chevron. Every live host (M3U player, Xtream/Stalker live layouts,
* unified live tab) renders this same component so the copy stays identical.
*/
@Component({
selector: 'app-channel-list-hidden-state',
imports: [PortalEmptyStateComponent, TranslatePipe],
template: `
<app-portal-empty-state
icon="view_sidebar"
[message]="'LAYOUT.CHANNELS_LIST_HIDDEN' | translate"
[hint]="'LAYOUT.CHANNELS_LIST_HIDDEN_HINT' | translate"
[actionLabel]="'LAYOUT.SHOW_CHANNELS_LIST' | translate"
actionIcon="chevron_right"
(action)="restore.emit()"
/>
`,
styles: `
:host {
display: flex;
flex: 1;
min-height: 0;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelListHiddenStateComponent {
readonly restore = output<void>();
}
@@ -1,4 +1,20 @@
<div class="empty-state-container">
<mat-icon class="empty-state-icon">{{ icon() }}</mat-icon>
<h2 class="empty-state-title">{{ message() }}</h2>
@if (hint(); as hint) {
<p class="empty-state-hint">{{ hint }}</p>
}
@if (actionLabel(); as label) {
<button
type="button"
mat-stroked-button
class="empty-state-action"
(click)="action.emit()"
>
@if (actionIcon(); as actionIcon) {
<mat-icon>{{ actionIcon }}</mat-icon>
}
{{ label }}
</button>
}
</div>
@@ -33,6 +33,27 @@
opacity: 0.6;
}
.empty-state-hint {
margin: 8px 0 0;
max-width: 36ch;
font-size: 0.88rem;
line-height: 1.4;
text-wrap: balance;
opacity: 0.55;
}
// The action is the one element here that must not fade into the backdrop:
// it is the way out of the state, so it keeps full opacity and a real
// hit target while the icon and copy stay muted.
.empty-state-action {
margin-top: 20px;
min-height: 40px;
mat-icon {
margin-right: 6px;
}
}
@keyframes portal-empty-state-fade-in {
from {
opacity: 0;
@@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PortalEmptyStateComponent } from './portal-empty-state.component';
describe('PortalEmptyStateComponent', () => {
let fixture: ComponentFixture<PortalEmptyStateComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PortalEmptyStateComponent],
}).compileComponents();
fixture = TestBed.createComponent(PortalEmptyStateComponent);
fixture.componentRef.setInput('message', 'Nothing here');
fixture.detectChanges();
});
it('renders only the icon and message by default', () => {
const host: HTMLElement = fixture.nativeElement;
expect(host.querySelector('.empty-state-icon')?.textContent).toBe(
'live_tv'
);
expect(host.querySelector('.empty-state-title')?.textContent).toBe(
'Nothing here'
);
expect(host.querySelector('.empty-state-hint')).toBeNull();
expect(host.querySelector('.empty-state-action')).toBeNull();
});
it('renders the hint and a labelled action that emits on click', () => {
const action = jest.fn();
fixture.componentInstance.action.subscribe(action);
fixture.componentRef.setInput('hint', 'Try the button');
fixture.componentRef.setInput('actionLabel', 'Do it');
fixture.componentRef.setInput('actionIcon', 'chevron_right');
fixture.detectChanges();
const host: HTMLElement = fixture.nativeElement;
const button = host.querySelector<HTMLButtonElement>(
'button.empty-state-action'
);
expect(host.querySelector('.empty-state-hint')?.textContent).toBe(
'Try the button'
);
expect(button?.textContent).toContain('Do it');
expect(button?.querySelector('mat-icon')?.textContent).toBe(
'chevron_right'
);
button?.click();
expect(action).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,10 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
@Component({
@@ -6,9 +12,15 @@ import { MatIcon } from '@angular/material/icon';
templateUrl: './portal-empty-state.component.html',
styleUrl: './portal-empty-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon],
imports: [MatButton, MatIcon],
})
export class PortalEmptyStateComponent {
readonly icon = input<string>('live_tv');
readonly message = input.required<string>();
/** One short secondary sentence under the title. */
readonly hint = input<string | null>(null);
/** Renders the primary action button; the host reacts to `action`. */
readonly actionLabel = input<string | null>(null);
readonly actionIcon = input<string | null>(null);
readonly action = output<void>();
}
@@ -191,6 +191,7 @@
(reorderItems)="onReorder($event)"
(itemPlayed)="onItemPlayed($event)"
(autoOpenHandled)="onLiveAutoOpenHandled()"
(restoreSidebarRequested)="toggleSidebar()"
/>
}
}
@@ -20,12 +20,17 @@ import {
ScopeToggleService,
UnifiedCollectionItem,
WorkspaceViewCommandService,
LiveLayoutSidebarStateService,
liveSidebarStateStorageKey,
} from '@iptvnator/portal/shared/util';
import {
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { BehaviorSubject } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -53,6 +58,7 @@ class StubUnifiedLiveTabComponent {
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
readonly restoreSidebarRequested = output<void>();
}
@Component({
@@ -507,19 +513,70 @@ describe('UnifiedCollectionPageComponent', () => {
await fixture.whenStable();
expect(favoritesData.removeFavorite).toHaveBeenCalledWith(recentItem);
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
.toBe(false);
expect(
fixture.componentInstance.favoriteUidSet().has(recentItem.uid)
).toBe(false);
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
liveTab.favoriteToggled.emit(recentItem);
await fixture.whenStable();
expect(favoritesData.addFavorite).toHaveBeenCalledWith(recentItem);
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
.toBe(true);
expect(
fixture.componentInstance.favoriteUidSet().has(recentItem.uid)
).toBe(true);
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
});
it('toggles the collection live rail with Cmd/Ctrl+B only while the live tab is on screen', async () => {
const recentItem = {
uid: 'm3u::playlist-1::https://example.com/one.m3u8',
name: 'Recent One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'playlist-1',
playlistName: 'Playlist One',
streamUrl: 'https://example.com/one.m3u8',
} satisfies UnifiedCollectionItem;
recentData.getRecentItems.mockResolvedValueOnce([recentItem]);
const sidebarState = TestBed.inject(LiveLayoutSidebarStateService);
sidebarState.setState('collection', 'expanded');
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.componentInstance.isLoading.set(false);
fixture.componentInstance.selectedContentType.set('live');
fixture.componentInstance.allItems.set([recentItem]);
fixture.detectChanges();
const press = () => {
const event = new KeyboardEvent('keydown', {
key: 'b',
ctrlKey: true,
cancelable: true,
bubbles: true,
});
document.dispatchEvent(event);
return event;
};
expect(press().defaultPrevented).toBe(true);
expect(sidebarState.isCollapsedFor('collection')()).toBe(true);
expect(sidebarState.isCollapsedFor('m3u')()).toBe(false);
// Not on the movies grid: there is no rail to hide there.
fixture.componentInstance.selectedContentType.set('movie');
fixture.detectChanges();
expect(press().defaultPrevented).toBe(false);
expect(sidebarState.isCollapsedFor('collection')()).toBe(true);
sidebarState.setState('collection', 'expanded');
localStorage.removeItem(liveSidebarStateStorageKey('collection'));
});
it('removes one recent live row through the live tab remove event', async () => {
const recentItems = [
{
@@ -826,9 +883,7 @@ describe('UnifiedCollectionPageComponent', () => {
},
}
);
expect(
fixture.componentInstance.selectedDetailItem()
).toBeNull();
expect(fixture.componentInstance.selectedDetailItem()).toBeNull();
} finally {
router.url = originalUrl;
}
@@ -7,6 +7,8 @@ import {
contentChild,
DestroyRef,
effect,
ElementRef,
HostListener,
inject,
linkedSignal,
input,
@@ -38,6 +40,7 @@ import {
getOpenLiveCollectionItemState,
getUnifiedCollectionNavigation,
isWorkspaceLayoutRoute,
isTypingInInput,
LiveLayoutSidebarStateService,
OPEN_COLLECTION_DETAIL_STATE_KEY,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
@@ -96,6 +99,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly destroyRef = inject(DestroyRef);
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly scopeService = inject(ScopeToggleService);
private readonly favoritesData = inject(UnifiedFavoritesDataService);
private readonly recentData = inject(UnifiedRecentDataService);
@@ -276,7 +280,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
this.selectedContentType() === 'live' &&
this.hasLive()
);
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('collection');
readonly showSidebarToggle = computed(
() => this.selectedContentType() === 'live' && this.hasLive()
);
@@ -469,7 +474,30 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle();
this.liveSidebarStateService.toggle('collection');
}
/**
* Cmd/Ctrl+B mirrors the routed live layouts (M3U, Xtream, Stalker): the
* hidden-list state advertises the shortcut, so the collection live tab
* must honour it too. Only while that tab, and therefore the rail, is on
* screen; the movies/series grids have nothing to hide.
*/
@HostListener('document:keydown', ['$event'])
handleSidebarShortcut(event: KeyboardEvent): void {
if (
this.showSidebarToggle() &&
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === 'b' &&
!isTypingInInput(event) &&
// Behind the workspace's phone context drawer the route content
// is inert; this document-level listener still fires, so it
// opts out itself instead of toggling an obscured rail.
!this.hostElement.nativeElement.closest('[inert]')
) {
event.preventDefault();
this.toggleSidebar();
}
}
setFavSortMode(mode: FavoritesChannelSortMode): void {
@@ -148,6 +148,10 @@
</div>
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state
(restore)="restoreSidebarRequested.emit()"
/>
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -58,6 +58,7 @@ import {
toLiveEpgPanelSummary,
} from './unified-live-epg-summary.util';
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
import { ChannelListHiddenStateComponent } from '../channel-list-hidden-state/channel-list-hidden-state.component';
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
import {
AudioPlayerComponent,
@@ -100,6 +101,7 @@ import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-ses
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
@@ -130,6 +132,8 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
readonly isSidebarCollapsed = input(false);
/** The rail is owned by the page header toggle; ask it to expand. */
readonly restoreSidebarRequested = output<void>();
private readonly streamResolver = inject(StreamResolverService);
private readonly recentData = inject(UnifiedRecentDataService);
@@ -0,0 +1,91 @@
import { TestBed } from '@angular/core/testing';
import { LiveLayoutSidebarStateService } from './live-layout-sidebar-state.service';
import {
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
LIVE_SIDEBAR_SURFACES,
liveSidebarStateStorageKey,
} from './live-sidebar-state';
describe('LiveLayoutSidebarStateService', () => {
function clearStorage(): void {
localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
for (const surface of LIVE_SIDEBAR_SURFACES) {
localStorage.removeItem(liveSidebarStateStorageKey(surface));
}
}
function createService(): LiveLayoutSidebarStateService {
TestBed.resetTestingModule();
return TestBed.inject(LiveLayoutSidebarStateService);
}
beforeEach(clearStorage);
afterEach(clearStorage);
it('starts every surface expanded', () => {
const service = createService();
for (const surface of LIVE_SIDEBAR_SURFACES) {
expect(service.stateOf(surface)()).toBe('expanded');
expect(service.isCollapsedFor(surface)()).toBe(false);
}
});
it('keeps surfaces independent: collapsing the M3U rail leaves portals and collections alone', () => {
const service = createService();
service.toggle('m3u');
expect(service.isCollapsedFor('m3u')()).toBe(true);
expect(service.isCollapsedFor('portal')()).toBe(false);
expect(service.isCollapsedFor('collection')()).toBe(false);
expect(localStorage.getItem(liveSidebarStateStorageKey('m3u'))).toBe(
'collapsed'
);
expect(
localStorage.getItem(liveSidebarStateStorageKey('portal'))
).toBeNull();
});
it('returns the same signal instance per surface so components can hold it', () => {
const service = createService();
expect(service.isCollapsedFor('portal')).toBe(
service.isCollapsedFor('portal')
);
expect(service.isCollapsedFor('portal')).not.toBe(
service.isCollapsedFor('collection')
);
});
it('restores each surface from its own storage key', () => {
localStorage.setItem(liveSidebarStateStorageKey('portal'), 'collapsed');
const service = createService();
expect(service.isCollapsedFor('portal')()).toBe(true);
expect(service.isCollapsedFor('m3u')()).toBe(false);
service.setState('portal', 'expanded');
expect(localStorage.getItem(liveSidebarStateStorageKey('portal'))).toBe(
'expanded'
);
});
it('ignores and removes the legacy shared key instead of inheriting it', () => {
localStorage.setItem(
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
'collapsed'
);
const service = createService();
for (const surface of LIVE_SIDEBAR_SURFACES) {
expect(service.isCollapsedFor(surface)()).toBe(false);
}
expect(
localStorage.getItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY)
).toBeNull();
});
});
@@ -1,31 +1,76 @@
import { computed, Injectable, signal } from '@angular/core';
import {
computed,
Injectable,
Signal,
signal,
WritableSignal,
} from '@angular/core';
import {
forgetLegacyLiveSidebarState,
LiveSidebarState,
LiveSidebarSurface,
liveSidebarStateStorageKey,
persistLiveSidebarState,
restoreLiveSidebarState,
} from './live-sidebar-state';
function bySurface<T>(
create: (surface: LiveSidebarSurface) => T
): Record<LiveSidebarSurface, T> {
return {
m3u: create('m3u'),
portal: create('portal'),
collection: create('collection'),
};
}
/**
* Shared collapse state for the live-TV sidebar across the workspace shell
* categories rail (Xtream/Stalker), the inline channels rail, and the
* unified-collection live tab. A single signal keeps all surfaces in sync
* within a session; localStorage persistence is delegated to the existing
* `live-sidebar-state` helpers so the storage key stays unchanged.
* Single owner of the live-TV sidebar collapse state. Every surface that
* renders a collapsible channel rail — the M3U player, the Xtream/Stalker
* live layouts together with the workspace shell categories rail, the
* unified favorites/recent live tab, and the workspace header toggle — reads
* and writes through this service, so a toggle in one place is reflected
* everywhere the same surface is rendered within the session.
*
* State is kept per surface (`live-sidebar-state:<surface>` in
* localStorage). The pre-split shared key is forgotten on construction.
*/
@Injectable({ providedIn: 'root' })
export class LiveLayoutSidebarStateService {
private readonly _state = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly state = this._state.asReadonly();
readonly isCollapsed = computed(() => this._state() === 'collapsed');
private readonly states: Record<
LiveSidebarSurface,
WritableSignal<LiveSidebarState>
>;
private readonly collapsed: Record<LiveSidebarSurface, Signal<boolean>>;
toggle(): void {
this.setState(this.isCollapsed() ? 'expanded' : 'collapsed');
constructor() {
forgetLegacyLiveSidebarState();
this.states = bySurface((surface) =>
signal(restoreLiveSidebarState(liveSidebarStateStorageKey(surface)))
);
this.collapsed = bySurface((surface) =>
computed(() => this.states[surface]() === 'collapsed')
);
}
setState(state: LiveSidebarState): void {
this._state.set(state);
persistLiveSidebarState(state);
stateOf(surface: LiveSidebarSurface): Signal<LiveSidebarState> {
return this.states[surface].asReadonly();
}
/** Stable per-surface signal; safe to assign to a component field. */
isCollapsedFor(surface: LiveSidebarSurface): Signal<boolean> {
return this.collapsed[surface];
}
toggle(surface: LiveSidebarSurface): void {
this.setState(
surface,
this.collapsed[surface]() ? 'expanded' : 'collapsed'
);
}
setState(surface: LiveSidebarSurface, state: LiveSidebarState): void {
this.states[surface].set(state);
persistLiveSidebarState(state, liveSidebarStateStorageKey(surface));
}
}
@@ -1,15 +1,21 @@
import {
DEFAULT_LIVE_SIDEBAR_STATE,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
forgetLegacyLiveSidebarState,
isLiveSidebarState,
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
LIVE_SIDEBAR_SURFACES,
liveSidebarStateStorageKey,
persistLiveSidebarState,
resolveRouteLiveSidebarSurface,
restoreLiveSidebarState,
} from './live-sidebar-state';
describe('live sidebar state', () => {
const key = liveSidebarStateStorageKey('m3u');
afterEach(() => {
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
localStorage.removeItem('custom-live-sidebar-state');
localStorage.removeItem(key);
localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
});
it('accepts only known sidebar states', () => {
@@ -19,32 +25,73 @@ describe('live sidebar state', () => {
expect(isLiveSidebarState(null)).toBe(false);
});
it('restores expanded as the default for missing or invalid storage', () => {
expect(restoreLiveSidebarState()).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
it('derives one storage key per surface under the legacy prefix', () => {
const keys = LIVE_SIDEBAR_SURFACES.map(liveSidebarStateStorageKey);
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'hidden');
expect(restoreLiveSidebarState()).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
expect(keys).toEqual([
'live-sidebar-state:m3u',
'live-sidebar-state:portal',
'live-sidebar-state:collection',
]);
expect(new Set(keys).size).toBe(keys.length);
expect(keys).not.toContain(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
});
it('restores and persists collapsed state', () => {
persistLiveSidebarState('collapsed');
it('restores expanded as the default for missing or invalid storage', () => {
expect(restoreLiveSidebarState(key)).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
expect(localStorage.getItem(LIVE_SIDEBAR_STATE_STORAGE_KEY)).toBe(
localStorage.setItem(key, 'hidden');
expect(restoreLiveSidebarState(key)).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
});
it('restores and persists collapsed state under the given key', () => {
persistLiveSidebarState('collapsed', key);
expect(localStorage.getItem(key)).toBe('collapsed');
expect(restoreLiveSidebarState(key)).toBe('collapsed');
expect(restoreLiveSidebarState(key, 'expanded')).toBe('collapsed');
});
it('never reads the legacy shared key and can forget it', () => {
localStorage.setItem(
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
'collapsed'
);
expect(restoreLiveSidebarState()).toBe('collapsed');
expect(restoreLiveSidebarState(key)).toBe('expanded');
forgetLegacyLiveSidebarState();
expect(
localStorage.getItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY)
).toBeNull();
});
it('supports custom storage keys and fallback values', () => {
expect(
restoreLiveSidebarState('custom-live-sidebar-state', 'collapsed')
).toBe('collapsed');
persistLiveSidebarState('expanded', 'custom-live-sidebar-state');
expect(restoreLiveSidebarState('custom-live-sidebar-state')).toBe(
'expanded'
it('maps only routes that render their own live rail to a surface', () => {
expect(resolveRouteLiveSidebarSurface('playlists', 'all')).toBe('m3u');
expect(resolveRouteLiveSidebarSurface('playlists', 'groups')).toBe(
'm3u'
);
expect(resolveRouteLiveSidebarSurface('xtreams', 'live')).toBe(
'portal'
);
expect(resolveRouteLiveSidebarSurface('stalker', 'itv')).toBe('portal');
expect(resolveRouteLiveSidebarSurface('stalker', 'radio')).toBe(
'portal'
);
// Collection pages own their toggle: only they know whether the live
// tab is on screen.
expect(
resolveRouteLiveSidebarSurface('playlists', 'recent')
).toBeNull();
expect(
resolveRouteLiveSidebarSurface('xtreams', 'favorites')
).toBeNull();
expect(resolveRouteLiveSidebarSurface('xtreams', 'vod')).toBeNull();
expect(resolveRouteLiveSidebarSurface('stalker', 'series')).toBeNull();
expect(resolveRouteLiveSidebarSurface(null, 'live')).toBeNull();
expect(resolveRouteLiveSidebarSurface('xtreams', null)).toBeNull();
});
});
@@ -1,14 +1,48 @@
import type {
PortalProvider,
PortalRailSection,
} from './navigation/portal-rail-links';
export type LiveSidebarState = 'expanded' | 'collapsed';
export const LIVE_SIDEBAR_STATE_STORAGE_KEY = 'live-sidebar-state';
/**
* Collapsible live-channel rails. Each surface remembers its own state:
* hiding the list in the M3U player must not hide the channel rail of an
* Xtream/Stalker portal or of the favorites/recent collection pages — the
* user made that choice in one context, not in all of them.
*/
export type LiveSidebarSurface = 'm3u' | 'portal' | 'collection';
export const LIVE_SIDEBAR_SURFACES: readonly LiveSidebarSurface[] = [
'm3u',
'portal',
'collection',
];
/**
* Key every live surface shared before the per-surface split. It is no longer
* read: a stored `collapsed` left every playlist and portal without a channel
* list and only a 32px chevron to recover it (issue #1458). The state service
* removes it once, so the update itself restores the list for everyone.
*/
export const LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY = 'live-sidebar-state';
/** @deprecated The legacy shared key; use `liveSidebarStateStorageKey()`. */
export const LIVE_SIDEBAR_STATE_STORAGE_KEY =
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY;
export const DEFAULT_LIVE_SIDEBAR_STATE: LiveSidebarState = 'expanded';
export function liveSidebarStateStorageKey(
surface: LiveSidebarSurface
): string {
return `${LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY}:${surface}`;
}
export function isLiveSidebarState(value: unknown): value is LiveSidebarState {
return value === 'expanded' || value === 'collapsed';
}
export function restoreLiveSidebarState(
storageKey: string = LIVE_SIDEBAR_STATE_STORAGE_KEY,
storageKey: string,
fallback: LiveSidebarState = DEFAULT_LIVE_SIDEBAR_STATE
): LiveSidebarState {
const storedValue = localStorage.getItem(storageKey);
@@ -17,7 +51,41 @@ export function restoreLiveSidebarState(
export function persistLiveSidebarState(
state: LiveSidebarState,
storageKey: string = LIVE_SIDEBAR_STATE_STORAGE_KEY
storageKey: string
): void {
localStorage.setItem(storageKey, state);
}
export function forgetLegacyLiveSidebarState(): void {
try {
localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
} catch {
// Storage unavailable (private mode, blocked site data): nothing to forget.
}
}
/**
* The rail a portal route renders itself, for a toggle that lives outside the
* rail (the workspace header). Favorites/recent routes return `null` on
* purpose: the collection page owns that toggle, because only it knows
* whether the live tab, and therefore the rail, is on screen.
*/
export function resolveRouteLiveSidebarSurface(
provider: PortalProvider | null | undefined,
section: PortalRailSection | null | undefined
): LiveSidebarSurface | null {
if (!provider || !section) {
return null;
}
switch (provider) {
case 'playlists':
return section === 'all' || section === 'groups' ? 'm3u' : null;
case 'xtreams':
return section === 'live' ? 'portal' : null;
case 'stalker':
return section === 'itv' || section === 'radio' ? 'portal' : null;
default:
return null;
}
}
@@ -0,0 +1,273 @@
import { signal } from '@angular/core';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { Router } from '@angular/router';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import {
StalkerItvChannel,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerLiveNavigation } from './stalker-live-navigation';
const channel = (id: string, category = 'news'): StalkerItvChannel => ({
id,
name: id,
cmd: `stream-${id}`,
tv_genre_id: category,
});
describe('StalkerLiveNavigation', () => {
let navigation: StalkerLiveNavigation;
const first = channel('1');
const second = channel('2');
const third = channel('3', 'sports');
const rows = signal([first, second]);
const loading = signal(false);
const store = {
currentPlaylist: signal({ _id: 'source-a' }),
selectedContentType: signal('itv'),
selectedCategoryId: signal<string | null>('news'),
searchPhrase: signal(''),
selectedItem: signal<StalkerItvChannel | null>(null),
page: signal(0),
hasMoreChannels: signal(false),
getCategoryResource: signal([
{ category_id: 'news' },
{ category_id: 'sports' },
]),
setSearchPhrase: jest.fn((query: string) =>
store.searchPhrase.set(query)
),
setSelectedCategory: jest.fn((category: string) =>
store.selectedCategoryId.set(category)
),
setPage: jest.fn((page: number) => store.page.set(page)),
};
const router = {
url: '/workspace/stalker/source-a/itv?q=sport&keep=1',
parseUrl: jest.fn(() => ({ queryParams: { q: 'sport', keep: '1' } })),
navigateByUrl: jest.fn().mockResolvedValue(true),
};
const sidebar = { setState: jest.fn() };
const play = jest.fn();
const revealRow = jest.fn(() => true);
beforeEach(() => {
TestBed.configureTestingModule({});
jest.clearAllMocks();
rows.set([first, second]);
loading.set(false);
store.currentPlaylist.set({ _id: 'source-a' });
store.selectedContentType.set('itv');
store.selectedCategoryId.set('news');
store.searchPhrase.set('');
store.selectedItem.set(null);
store.page.set(0);
store.hasMoreChannels.set(false);
store.getCategoryResource.set([
{ category_id: 'news' },
{ category_id: 'sports' },
]);
router.navigateByUrl.mockResolvedValue(true);
navigation = TestBed.runInInjectionContext(
() =>
new StalkerLiveNavigation({
store: store as unknown as InstanceType<
typeof StalkerStore
>,
router: router as unknown as Router,
sidebar:
sidebar as unknown as LiveLayoutSidebarStateService,
rows: (term) => {
const query = term?.() ?? store.searchPhrase();
return rows().filter(
(row) => !query || row.name?.includes(query)
);
},
loading,
play,
revealRow,
})
);
TestBed.tick();
});
afterEach(() => navigation.reset());
it('captures resolved selection order and keeps it during category/search browsing', () => {
const commit = navigation.prepare(first);
store.selectedCategoryId.set('sports');
rows.set([third]);
commit();
store.searchPhrase.set('3');
TestBed.tick();
expect(navigation.channels()).toEqual([first, second]);
navigation.adjacent('down');
expect(play).toHaveBeenLastCalledWith(second);
navigation.selectNumber(1);
expect(play).toHaveBeenLastCalledWith(first);
expect(store.selectedCategoryId()).toBe('sports');
});
it('does not replace playback navigation for a failed resolution', () => {
navigation.prepare(first)();
rows.set([third]);
navigation.prepare(third); // Rejected resolver never calls the commit.
navigation.adjacent('down');
expect(play).toHaveBeenCalledWith(second);
});
it('preserves the queue on remote selection and same-channel replay', () => {
navigation.prepare(first)();
rows.set([third]);
navigation.prepare(second, 'preserve')();
navigation.prepare(second)();
navigation.selectNumber(1);
expect(play).toHaveBeenCalledWith(first);
});
it('captures the fullscreen filtered list independently of sidebar search', () => {
store.searchPhrase.set('1');
rows.set([second, first]);
navigation.prepare(second, signal(''))();
expect(navigation.channels()).toEqual([second, first]);
});
it('extends only loaded pages of the original category and query', () => {
navigation.prepare(first)();
rows.set([first, second, channel('4')]);
store.page.set(1);
TestBed.tick();
expect(navigation.channels().map((item) => item.id)).toEqual([
'1',
'2',
'4',
]);
store.searchPhrase.set('5');
rows.set([channel('5')]);
TestBed.tick();
store.searchPhrase.set('');
store.selectedCategoryId.set('sports');
rows.set([third]);
TestBed.tick();
expect(navigation.channels().map((item) => item.id)).toEqual([
'1',
'2',
'4',
]);
});
it('does not append another provider search after a fullscreen capture', () => {
store.selectedContentType.set('radio');
TestBed.tick();
store.searchPhrase.set('1');
navigation.prepare(first, signal(''))();
store.searchPhrase.set('3');
rows.set([third]);
TestBed.tick();
expect(navigation.channels()).toEqual([first, second]);
});
it('includes same-scope pages that finish while playback resolution is pending', () => {
const commit = navigation.prepare(first);
const nextPage = channel('4');
rows.set([first, second, nextPage]);
TestBed.tick();
commit();
TestBed.tick();
expect(navigation.channels()).toEqual([first, second, nextPage]);
});
it('rejects late commits from a different source or content type and after reset', () => {
const commit = navigation.prepare(first);
store.selectedContentType.set('radio');
commit();
rows.set([third]);
TestBed.tick();
expect(navigation.channels()).toEqual([third]);
const radioCommit = navigation.prepare(third);
navigation.reset();
rows.set([]);
radioCommit();
expect(navigation.channels()).toEqual([]);
});
it('reveals in the original category, clears only q and never plays', fakeAsync(() => {
navigation.prepare(first)();
store.selectedCategoryId.set('sports');
store.searchPhrase.set('3');
rows.set([third]);
expect(navigation.canReveal()).toBe(true);
loading.set(true);
void navigation.reveal();
tick();
rows.set([first, second]);
loading.set(false);
TestBed.tick();
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith(
{ queryParams: { keep: '1' } },
{ replaceUrl: true }
);
expect(store.selectedCategoryId()).toBe('news');
expect(store.searchPhrase()).toBe('');
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
expect(revealRow).toHaveBeenCalledWith('1');
expect(play).not.toHaveBeenCalled();
expect(navigation.canReveal()).toBe(false);
}));
it('withholds reveal for removed categories', () => {
navigation.prepare(first)();
rows.set([third]);
store.getCategoryResource.set([{ category_id: 'sports' }]);
expect(navigation.canReveal()).toBe(false);
});
it('cancels reveal if browsing changes while routing is pending', async () => {
navigation.prepare(first)();
store.selectedCategoryId.set('sports');
let resolve!: (value: boolean) => void;
router.navigateByUrl.mockReturnValue(
new Promise<boolean>((done) => (resolve = done))
);
const pending = navigation.reveal();
store.selectedCategoryId.set('another');
resolve(true);
await pending;
expect(store.setSelectedCategory).not.toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
});
it('reveals the known row beyond loaded pages and deduplicates when it arrives', fakeAsync(() => {
store.searchPhrase.set('2');
rows.set([second]);
navigation.prepare(second)();
store.selectedCategoryId.set('sports');
rows.set([third]);
void navigation.reveal();
tick();
rows.set([first]);
TestBed.tick();
tick();
expect(navigation.withRevealedItem(rows())).toEqual([second, first]);
expect(revealRow).toHaveBeenCalledWith('2');
rows.set([first, second]);
expect(navigation.withRevealedItem(rows())).toEqual([first, second]);
expect(navigation.channels()).toEqual([second]);
store.selectedCategoryId.set('sports');
TestBed.tick();
store.selectedCategoryId.set('news');
rows.set([first]);
expect(navigation.withRevealedItem(rows())).toEqual([first]);
}));
it('returns within the same route without navigating when q is absent', async () => {
navigation.prepare(first)();
store.selectedCategoryId.set('sports');
router.parseUrl.mockReturnValueOnce({ queryParams: {} } as ReturnType<
typeof router.parseUrl
>);
await navigation.reveal();
expect(router.navigateByUrl).not.toHaveBeenCalled();
expect(store.selectedCategoryId()).toBe('news');
});
});
@@ -0,0 +1,344 @@
import {
computed,
effect,
signal,
untracked,
type Signal,
} from '@angular/core';
import { Router } from '@angular/router';
import { LiveChannelPlaybackQueue } from '@iptvnator/portal/shared/data-access';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
LiveLayoutSidebarStateService,
} from '@iptvnator/portal/shared/util';
import {
StalkerItvChannel,
StalkerStore,
normalizeStalkerEntityId,
} from '@iptvnator/portal/stalker/data-access';
type SelectionContext = Signal<string> | 'preserve' | undefined;
interface LiveNavigationOptions {
store: InstanceType<typeof StalkerStore>;
router: Router | null;
sidebar: LiveLayoutSidebarStateService;
rows: (panelTerm?: Signal<string>) => StalkerItvChannel[];
play: (item: StalkerItvChannel) => void;
revealRow: (id: string) => boolean;
loading: () => boolean;
}
interface PlayingOrigin {
owner: string;
item: StalkerItvChannel;
categories: ReadonlyMap<string, string>;
scope: string;
}
interface RevealRequest {
owner: string;
id: string;
category: string;
}
/** Owns live navigation without letting category browsing replace playback order. */
export class StalkerLiveNavigation {
private readonly queue = new LiveChannelPlaybackQueue<StalkerItvChannel>(
(item) => item.id
);
private readonly origin = signal<PlayingOrigin | null>(null);
private readonly pendingReveal = signal<RevealRequest | null>(null);
private readonly revealed = signal<{
owner: string;
category: string;
item: StalkerItvChannel;
} | null>(null);
private readonly panelTerm = signal<Signal<string> | undefined>(undefined);
private ownerSeen = '';
private generation = 0;
private revealGeneration = 0;
private revealTimer: ReturnType<typeof setTimeout> | undefined;
readonly owner = computed(() =>
JSON.stringify([
this.options.store.currentPlaylist()?._id,
this.options.store.selectedContentType(),
])
);
readonly channels = computed(() => {
const captured = this.queue.items(this.owner());
return captured.length ? [...captured] : this.options.rows();
});
readonly canReveal = computed(() => {
const active = this.activeItem();
return (
!!active &&
!!this.categoryFor(active) &&
!this.withRevealedItem(this.options.rows()).some(
(row) => this.id(row) === this.id(active)
)
);
});
constructor(private readonly options: LiveNavigationOptions) {
effect(() => {
const owner = this.owner();
if (owner !== this.ownerSeen) {
this.ownerSeen = owner;
untracked(() => this.reset());
}
});
effect(() => {
const owner = this.owner();
const scope = this.scope(this.panelTerm());
const rows = this.options.rows(this.panelTerm());
const origin = this.origin();
if (this.options.loading()) return;
untracked(() => {
this.queue.extend(owner, scope, rows);
if (origin?.owner === owner && origin.scope === scope) {
const categories = new Map(origin.categories);
for (const row of rows)
categories.set(
this.id(row),
String(
row.tv_genre_id ??
row.category_id ??
this.options.store.selectedCategoryId() ??
'*'
)
);
if (categories.size > origin.categories.size)
this.origin.set({
...origin,
categories,
});
}
});
});
effect(() => {
const revealed = this.revealed();
if (
revealed &&
(revealed.owner !== this.owner() ||
revealed.category !==
this.options.store.selectedCategoryId() ||
this.options.store.searchPhrase() ||
this.id(revealed.item) !== this.id(this.activeItem()))
)
this.revealed.set(null);
});
effect(() => {
const request = this.pendingReveal();
if (!request) return;
const owner = this.owner();
const category = this.options.store.selectedCategoryId();
const query = this.options.store.searchPhrase();
this.options.rows();
const loading = this.options.loading();
untracked(() => {
if (
owner !== request.owner ||
category !== request.category ||
query ||
this.id(this.activeItem()) !== request.id
) {
this.pendingReveal.set(null);
return;
}
if (loading) return;
if (this.revealTimer) clearTimeout(this.revealTimer);
// Render after the category resource and its reset effect settle.
this.revealTimer = setTimeout(() => {
if (
this.pendingReveal() !== request ||
this.options.loading()
)
return;
if (
this.id(this.activeItem()) !== request.id ||
this.owner() !== request.owner ||
this.options.store.selectedCategoryId() !==
request.category ||
this.options.store.searchPhrase()
) {
this.pendingReveal.set(null);
return;
}
if (this.options.revealRow(request.id))
this.pendingReveal.set(null);
}, 0);
});
});
}
/** Capture before resolution; only the winning playback request commits it. */
prepare(item: StalkerItvChannel, context?: SelectionContext): () => void {
const owner = this.owner();
const generation = this.generation;
const previous = this.origin();
const keep =
context === 'preserve' ||
(previous?.owner === owner &&
this.id(previous.item) === this.id(item));
const term = typeof context === 'function' ? context : undefined;
const rows = term
? [...this.options.rows(term)]
: this.withRevealedItem(this.options.rows());
const scope = this.scope(term);
const category = this.options.store.selectedCategoryId() ?? '*';
const categories =
keep && previous?.owner === owner
? previous.categories
: new Map(
rows
.concat(item)
.map((row) => [
this.id(row),
String(
row.tv_genre_id ?? row.category_id ?? category
),
])
);
return () => {
if (owner !== this.owner() || generation !== this.generation)
return;
if (!keep || !this.queue.items(owner).length) {
this.queue.capture(owner, scope, rows, item);
this.panelTerm.set(term);
}
this.revealed.set(null);
this.pendingReveal.set(null);
this.origin.set({
owner,
item,
categories,
scope: keep ? (previous?.scope ?? scope) : scope,
});
};
}
adjacent(direction: 'up' | 'down'): void {
const next = getAdjacentChannelItem(
this.channels(),
this.activeItem()?.id,
direction,
(item) => item.id
);
if (next) this.options.play(next);
}
selectNumber(number?: number): void {
if (!number) return;
const channel = getChannelItemByNumber(this.channels(), number);
if (channel) this.options.play(channel);
}
async reveal(): Promise<void> {
const active = this.activeItem();
const category = active && this.categoryFor(active);
if (!active || !category) return;
const owner = this.owner();
const generation = ++this.revealGeneration;
const browsedCategory = this.options.store.selectedCategoryId();
this.pendingReveal.set(null);
if (
this.options.router &&
this.options.router.parseUrl(this.options.router.url).queryParams[
'q'
] != null
) {
const tree = this.options.router.parseUrl(this.options.router.url);
delete tree.queryParams['q'];
const navigated = await this.options.router
.navigateByUrl(tree, {
replaceUrl: true,
})
.catch(() => false);
if (
!navigated ||
generation !== this.revealGeneration ||
owner !== this.owner() ||
this.id(active) !== this.id(this.activeItem()) ||
browsedCategory !== this.options.store.selectedCategoryId() ||
category !== this.categoryFor(active)
)
return;
}
this.options.store.setSearchPhrase('');
this.options.store.setSelectedCategory(category);
this.options.store.setPage(0);
this.options.sidebar.setState('portal', 'expanded');
this.revealed.set({ owner, category, item: active });
this.pendingReveal.set({
owner,
category,
id: this.id(active),
});
}
reset(): void {
this.generation++;
this.revealGeneration++;
this.revealed.set(null);
this.queue.clear();
this.origin.set(null);
this.pendingReveal.set(null);
this.panelTerm.set(undefined);
if (this.revealTimer) clearTimeout(this.revealTimer);
}
/** A known playing row keeps return bounded on provider-paginated search. */
withRevealedItem(rows: StalkerItvChannel[]): StalkerItvChannel[] {
const revealed = this.revealed();
if (
!revealed ||
revealed.owner !== this.owner() ||
revealed.category !== this.options.store.selectedCategoryId() ||
this.options.store.searchPhrase() ||
this.id(revealed.item) !== this.id(this.activeItem()) ||
!this.categoryFor(revealed.item) ||
rows.some((row) => this.id(row) === this.id(revealed.item))
)
return rows;
return [revealed.item, ...rows];
}
private scope(panelTerm?: Signal<string>): string {
return JSON.stringify([
this.options.store.selectedCategoryId(),
panelTerm ? 'panel' : 'sidebar',
this.options.store.selectedContentType() === 'radio'
? this.options.store.searchPhrase()
: '',
panelTerm?.() ?? this.options.store.searchPhrase(),
]);
}
activeItem(): StalkerItvChannel | undefined {
const origin = this.origin();
return origin?.owner === this.owner()
? origin.item
: (this.options.store.selectedItem() as
StalkerItvChannel | undefined);
}
private categoryFor(item: StalkerItvChannel): string | undefined {
const origin = this.origin();
const category = String(
item.tv_genre_id ??
item.category_id ??
(origin?.owner === this.owner()
? origin.categories.get(this.id(item))
: '') ??
''
);
return this.options.store
.getCategoryResource?.()
.some((entry) => String(entry.category_id) === category)
? category
: undefined;
}
private id(item: StalkerItvChannel | null | undefined): string {
return normalizeStalkerEntityId(item?.id);
}
}
@@ -27,6 +27,19 @@
>
}
</div>
@if (navigation.canReveal()) {
<button
mat-icon-button
type="button"
[attr.aria-label]="
'CHANNELS.SHOW_PLAYING_CHANNEL' | translate
"
[matTooltip]="'CHANNELS.SHOW_PLAYING_CHANNEL' | translate"
(click)="navigation.reveal()"
>
<mat-icon>playlist_play</mat-icon>
</button>
}
@if (isFullListMode()) {
<button
mat-icon-button
@@ -129,6 +142,8 @@
<ng-container *ngTemplateOutlet="epgPanelContent" />
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -262,6 +277,7 @@
track item.id ?? $index
) {
<app-channel-list-item
[attr.data-channel-id]="normalizeStalkerEntityId(item.id)"
[name]="item.o_name || item.name || ''"
[logo]="item.logo"
[selected]="isSelectedChannel(item)"
@@ -290,9 +306,11 @@
favorites.get(normalizeStalkerEntityId(item.id)) ??
false
"
(clicked)="playChannel(item)"
(activated)="playChannel(item, true)"
(keyboardActivated)="playChannel(item, true)"
(clicked)="playChannel(item, undefined, panelSearchTerm)"
(activated)="playChannel(item, true, panelSearchTerm)"
(keyboardActivated)="
playChannel(item, true, panelSearchTerm)
"
(favoriteToggled)="toggleFavorite(item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)"
/>
@@ -19,6 +19,7 @@ import {
viewChild,
viewChildren,
} from '@angular/core';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -77,15 +78,16 @@ import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
createLogger,
getAdjacentChannelItem,
getChannelItemByNumber,
isTypingInInput,
LiveEpgPanelState,
persistLiveEpgPanelState,
REMOTE_CONTROL_RESET_STATUS,
restoreLiveEpgPanelState,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
ACTIVE_EPG_FALLBACK_SIZE,
StalkerFavoriteItem,
@@ -134,6 +136,8 @@ function matchesStalkerChannelTerm(
.includes(term);
}
import { StalkerLiveNavigation } from './stalker-live-navigation';
@Component({
selector: 'app-stalker-live-stream-layout',
templateUrl: './stalker-live-stream-layout.component.html',
@@ -151,6 +155,7 @@ function matchesStalkerChannelTerm(
MatProgressSpinnerModule,
MatTooltipModule,
NgTemplateOutlet,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
ResizableDirective,
StalkerItvAllItemsComponent,
@@ -267,9 +272,11 @@ export class StalkerLiveStreamLayoutComponent
computation: () => FULL_LIST_RENDER_CHUNK,
});
readonly visibleChannels = computed(() =>
this.isCategoryFromCache() || Boolean(this.searchTerm())
? this.filteredChannels().slice(0, this.renderLimit())
: this.filteredChannels()
this.navigation.withRevealedItem(
this.isCategoryFromCache() || Boolean(this.searchTerm())
? this.filteredChannels().slice(0, this.renderLimit())
: this.filteredChannels()
)
);
/**
* The fullscreen panel's rows while its own search field is blank: the
@@ -313,7 +320,7 @@ export class StalkerLiveStreamLayoutComponent
readonly isInitialChannelsLoading = computed(
() =>
!!this.stalkerStore.selectedCategoryId() &&
this.channels().length === 0 &&
this.visibleChannels().length === 0 &&
!this.searchTerm() &&
(this.isFullListLoading() ||
this.stalkerStore.isPaginatedContentLoading())
@@ -323,7 +330,7 @@ export class StalkerLiveStreamLayoutComponent
() =>
!!this.stalkerStore.selectedCategoryId() &&
!this.searchTerm() &&
this.channels().length === 0 &&
this.visibleChannels().length === 0 &&
!this.isInitialChannelsLoading()
);
@@ -481,7 +488,8 @@ export class StalkerLiveStreamLayoutComponent
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('portal');
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
);
@@ -566,6 +574,19 @@ export class StalkerLiveStreamLayoutComponent
: (this.fullscreenChannelPanelTemplate() ?? null)
);
readonly panelTitle = computed(() => this.selectedCategoryTitle() ?? '');
readonly navigation = new StalkerLiveNavigation({
store: this.stalkerStore,
router: inject(Router, { optional: true }),
sidebar: this.liveSidebarStateService,
rows: (term) => this.navigationRows(term),
play: (item) => {
void this.playChannel(item, true, 'preserve');
},
revealRow: (id) => this.revealPlayingRow(id),
loading: () =>
this.isLoadingMore() ||
this.stalkerStore.isPaginatedContentLoading(),
});
private epgPreviewRefreshTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
@@ -739,9 +760,9 @@ export class StalkerLiveStreamLayoutComponent
return;
}
const selectedItem = this.stalkerStore.selectedItem();
const selectedItem = this.navigation.activeItem();
const selectedType = this.stalkerStore.selectedContentType();
const channels = this.filteredChannels();
const channels = this.navigation.channels();
// Radio shares this layout and its remote channel handlers, so
// it must publish live status too — otherwise the remote shows
@@ -808,6 +829,7 @@ export class StalkerLiveStreamLayoutComponent
}
ngOnDestroy() {
this.navigation.reset();
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
// Leaving the live view would otherwise keep the last channel
@@ -842,8 +864,13 @@ export class StalkerLiveStreamLayoutComponent
async playChannel(
item: StalkerItvChannel,
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
startPlayback = !this.settingsStore.openStreamOnDoubleClick(),
navigationContext?: Signal<string> | 'preserve'
) {
const commitNavigation = this.navigation.prepare(
item,
navigationContext
);
const requestId = ++this.playbackRequestId;
const channelId = normalizeStalkerEntityId(item.id);
const sourceId = normalizeStalkerEntityId(
@@ -904,12 +931,14 @@ export class StalkerLiveStreamLayoutComponent
) {
return;
}
commitNavigation();
this.setActivePlayback(playback, null);
return;
}
if (deferSelection && (!sourceId || !channelId)) return;
commitNavigation();
if (deferSelection) {
if (!sourceId || !channelId) return;
this.stalkerStore.setSelectedItem(item);
}
@@ -1131,7 +1160,7 @@ export class StalkerLiveStreamLayoutComponent
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle();
this.liveSidebarStateService.toggle('portal');
}
handleRadioChannelSwitch(direction: 'next' | 'previous'): void {
@@ -1710,24 +1739,7 @@ export class StalkerLiveStreamLayoutComponent
}
private handleAdjacentChannelChange(direction: 'up' | 'down'): void {
const activeItem = this.stalkerStore.selectedItem();
if (!activeItem?.id) {
return;
}
const channels = this.filteredChannels();
const nextItem = getAdjacentChannelItem(
channels,
activeItem.id,
direction,
(item) => item.id
);
if (!nextItem) {
return;
}
void this.playChannel(nextItem, true);
this.navigation.adjacent(direction);
}
private handleRemoteControlCommand(command: {
@@ -1738,19 +1750,40 @@ export class StalkerLiveStreamLayoutComponent
| 'volume-toggle-mute';
number?: number;
}): void {
if (command.type !== 'channel-select-number' || !command.number) {
return;
}
if (command.type === 'channel-select-number')
this.navigation.selectNumber(command.number);
}
const channel = getChannelItemByNumber(
this.filteredChannels(),
command.number
);
if (!channel) {
return;
}
private navigationRows(term?: Signal<string>): StalkerItvChannel[] {
if (!term)
return this.showItvAllItems()
? this.itvFullChannelList().filter((item) =>
matchesStalkerChannelTerm(item, this.searchTerm())
)
: this.filteredChannels();
const query = term().trim().toLowerCase();
return query
? this.searchableChannels().filter((item) =>
matchesStalkerChannelTerm(item, query)
)
: this.panelIdleSource();
}
void this.playChannel(channel, true);
private revealPlayingRow(id: string): boolean {
this.ensureChannelWithinRenderWindow(id);
this.cdr.detectChanges();
const pane = this.scrollContainers().find(
(container) => container.nativeElement.id === 'live-channels'
)?.nativeElement as HTMLElement | undefined;
const row =
pane &&
Array.from(
pane.querySelectorAll<HTMLElement>('[data-channel-id]')
).find((element) => element.dataset['channelId'] === id);
if (!row || !pane) return false;
row.scrollIntoView({ block: 'nearest' });
pane.focus({ preventScroll: true });
return true;
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
@@ -165,7 +165,10 @@ describe('StalkerLiveStreamLayoutComponent EPG fallback races', () => {
},
{
provide: LiveLayoutSidebarStateService,
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
useValue: {
isCollapsedFor: () => signal(false),
toggle: jest.fn(),
},
},
{ provide: EpgRuntimeBridgeService, useValue: {} },
{ provide: MatDialog, useValue: { open: jest.fn() } },
@@ -141,7 +141,10 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
},
{
provide: LiveLayoutSidebarStateService,
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
useValue: {
isCollapsedFor: () => signal(false),
toggle: jest.fn(),
},
},
{ provide: EpgRuntimeBridgeService, useValue: {} },
{ provide: MatDialog, useValue: { open: jest.fn() } },
@@ -138,7 +138,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
},
{
provide: LiveLayoutSidebarStateService,
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
useValue: {
isCollapsedFor: () => signal(false),
toggle: jest.fn(),
},
},
{ provide: EpgRuntimeBridgeService, useValue: {} },
{ provide: MatDialog, useValue: { open: jest.fn() } },
@@ -27,12 +27,37 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
const originalElectron = window.electron;
const itvChannels = signal([
{ id: '10001', cmd: 'ffrt4://itv/1', name: 'Alpha TV', o_name: 'Alpha TV', logo: 'a.png' },
{ id: '10002', cmd: 'ffrt4://itv/2', name: 'Beta TV', o_name: 'Beta TV', logo: 'b.png' },
{
id: '10001',
cmd: 'ffrt4://itv/1',
name: 'Alpha TV',
o_name: 'Alpha TV',
logo: 'a.png',
},
{
id: '10002',
cmd: 'ffrt4://itv/2',
name: 'Beta TV',
o_name: 'Beta TV',
logo: 'b.png',
},
]);
const originalItvChannels = itvChannels();
const radioChannels = signal([
{ id: 'radio-1', cmd: 'ifm https://s/jazz.mp3', name: 'Jazz FM', o_name: 'Jazz FM', logo: 'j.png' },
{ id: 'radio-2', cmd: 'ifm https://s/news.mp3', name: 'News Radio', o_name: 'News Radio', logo: 'n.png' },
{
id: 'radio-1',
cmd: 'ifm https://s/jazz.mp3',
name: 'Jazz FM',
o_name: 'Jazz FM',
logo: 'j.png',
},
{
id: 'radio-2',
cmd: 'ifm https://s/news.mp3',
name: 'News Radio',
o_name: 'News Radio',
logo: 'n.png',
},
]);
const selectedItem = signal<{
id: string;
@@ -93,8 +118,35 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
const updateRemoteControlStatus = jest.fn();
it('keeps remote order and number when browsing excludes the playing channel', async () => {
const original = itvChannels();
stalkerStore.setSelectedItem.mockImplementation((item) =>
selectedItem.set(item)
);
fixture.detectChanges();
await fixture.componentInstance.playChannel(original[0]);
fixture.detectChanges();
stalkerStore.selectedCategoryId.set('sports');
itvChannels.set([{ ...original[0], id: '90001', name: 'Sports TV' }]);
fixture.detectChanges();
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
expect.objectContaining({ channelNumber: 1 })
);
const host = fixture.componentInstance as unknown as {
handleRemoteChannelChange(direction: 'down'): void;
};
host.handleRemoteChannelChange('down');
await fixture.whenStable();
expect(selectedItem()?.id).toBe(original[1].id);
itvChannels.set(original);
stalkerStore.selectedCategoryId.set('1001');
});
beforeEach(async () => {
updateRemoteControlStatus.mockClear();
itvChannels.set(originalItvChannels);
stalkerStore.selectedCategoryId.set('1001');
stalkerStore.setSelectedItem.mockReset();
window.electron = {
platform: 'darwin',
setUserAgent: jest.fn().mockResolvedValue(true),
@@ -128,8 +180,7 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
// remote-control bridge method must be present.
get supportsRemoteControl() {
const bridge = window.electron as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
return [
'updateRemoteControlStatus',
'onChannelChange',
@@ -120,6 +120,48 @@ describe('XtreamUrlService', () => {
expect(url).toBe('http://demo.example/live/demo/secret/101.ts');
});
it('builds an Auto TS alternative with encoded credentials and the provider subpath', () => {
streamFormat.set(StreamFormat.AutoStreamFormat);
expect(
service.constructAutoLiveTsUrl(
{
serverUrl:
'https://demo.example/panel/player_api.php?token=unused',
username: 'a/b',
password: 'x?y#z',
allowedOutputFormats: [' M3U8 ', ' TS '],
},
101
)
).toBe('https://demo.example/panel/live/a%2Fb/x%3Fy%23z/101.ts');
});
it.each([undefined, [], ['m3u8'], ['rtmp'], ['ts']])(
'does not guess an HLS-to-TS alternative for %j',
(allowedOutputFormats) => {
streamFormat.set(StreamFormat.AutoStreamFormat);
expect(
service.constructAutoLiveTsUrl(
{ ...credentials, allowedOutputFormats },
101
)
).toBeUndefined();
}
);
it.each([StreamFormat.TsStreamFormat, StreamFormat.M3u8StreamFormat])(
'respects manual %s even with both formats advertised',
(format) => {
streamFormat.set(format);
expect(
service.constructAutoLiveTsUrl(
{ ...credentials, allowedOutputFormats: ['m3u8', 'ts'] },
101
)
).toBeUndefined();
}
);
it('constructs a VOD URL from catalog fields when metadata is empty', () => {
const vodItem = {
info: [],
@@ -113,6 +113,29 @@ export class XtreamUrlService {
return `${normalizedCredentials.serverUrl}/live/${normalizedCredentials.username}/${normalizedCredentials.password}/${xtreamId}.${streamFormat}`;
}
/** No probe: eligibility is explicit account evidence plus the user's Auto intent. */
constructAutoLiveTsUrl(
credentials: XtreamCredentials,
xtreamId: number
): string | undefined {
const requested =
this.settingsStore.streamFormat() ?? StreamFormat.AutoStreamFormat;
const formats = this.getNormalizedAllowedOutputFormats(credentials);
if (
requested !== StreamFormat.AutoStreamFormat ||
!formats?.includes('m3u8') ||
!formats.includes('ts')
)
return undefined;
return (
this.constructLiveUrl(
credentials,
xtreamId,
StreamFormat.TsStreamFormat
) || undefined
);
}
/**
* Construct VOD stream URL
* Format: {serverUrl}/movie/{username}/{password}/{streamId}.{extension}
@@ -0,0 +1,184 @@
import { computed, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ActivatedRoute,
convertToParamMap,
NavigationEnd,
Router,
} from '@angular/router';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import {
FavoritesService,
XtreamStore,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { BehaviorSubject, of, Subject } from 'rxjs';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import { XtreamLiveChannelItem } from './xtream-live-channel-navigation.service';
const target = { xtream_id: 202, category_id: '7', name: 'Search Channel' };
const alpha = { xtream_id: 201, category_id: '7', name: 'Alpha' };
const zulu = { xtream_id: 203, category_id: '7', name: 'Zulu' };
const other = { xtream_id: 101, category_id: '1', name: 'Other' };
const catalog = [other, target, zulu, alpha];
describe('Xtream live auto-open playback queue', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
let component: LiveStreamLayoutComponent;
const selectedCategoryId = signal<number | null>(1);
const selectedItem = signal<XtreamLiveChannelItem | null>(null);
const categorySearchTerm = signal('');
const visibleItems = computed(() =>
catalog.filter(
(item) =>
(!selectedCategoryId() ||
Number(item.category_id) === selectedCategoryId()) &&
item.name
.toLowerCase()
.includes(categorySearchTerm().toLowerCase())
)
);
let query: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let events: Subject<NavigationEnd>;
const store = {
getCategoriesBySelectedType: signal([
{ category_id: 1 },
{ category_id: 7 },
]),
getCategoryItemCounts: signal(new Map()),
epgItems: signal([]),
currentEpgItem: signal(null),
selectedTypeContentLoading: signal(false),
isLoadingEpg: signal(false),
selectedCategoryId,
selectedItem,
categorySearchTerm,
selectedContentType: signal('live'),
currentPlaylist: signal({ id: 'playlist-1' }),
liveStreams: signal(catalog),
getPaginatedContent: signal(catalog),
hasMoreContent: signal(false),
selectItemsFromSelectedCategory: visibleItems,
setSelectedCategory: jest.fn((id: number) =>
selectedCategoryId.set(id)
),
setSelectedItem: jest.fn((item: XtreamLiveChannelItem) =>
selectedItem.set(item)
),
constructStreamUrl: jest.fn((item: XtreamLiveChannelItem) => {
selectedItem.set(item);
return `https://example.test/live/${item.xtream_id}.ts`;
}),
};
beforeEach(async () => {
selectedCategoryId.set(1);
selectedItem.set(null);
categorySearchTerm.set('');
store.constructStreamUrl.mockClear();
store.setSelectedCategory.mockClear();
window.history.replaceState({}, '');
localStorage.removeItem('xtream-live-channel-sort-mode');
query = new BehaviorSubject(convertToParamMap({}));
events = new Subject();
await TestBed.configureTestingModule({
imports: [LiveStreamLayoutComponent],
providers: [
{ provide: XtreamStore, useValue: store },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
params: {},
queryParamMap: convertToParamMap({}),
},
queryParamMap: query,
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{ provide: Router, useValue: { events, navigate: jest.fn() } },
{
provide: FavoritesService,
useValue: { getFavorites: () => of([]) },
},
{ provide: XtreamUrlService, useValue: {} },
{
provide: PORTAL_PLAYER,
useValue: { isEmbeddedPlayer: () => true },
},
{ provide: RecordingsService, useValue: {} },
{
provide: RuntimeCapabilitiesService,
useValue: {
isElectron: false,
supportsEpg: false,
supportsRemoteControl: false,
},
},
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
},
},
],
})
.overrideComponent(LiveStreamLayoutComponent, {
set: { template: '', imports: [] },
})
.compileComponents();
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.setLiveChannelSortMode('name-asc');
});
afterEach(() => {
fixture.destroy();
window.history.replaceState({}, '');
localStorage.removeItem('xtream-live-channel-sort-mode');
});
it.each([
{ category: 1, query: 'Other' },
{ category: null, query: '' },
])(
'auto-opens with the destination category queue from $category',
({ category, query: previousQuery }) => {
selectedCategoryId.set(category);
categorySearchTerm.set(previousQuery);
query.next(convertToParamMap({ q: previousQuery }));
window.history.replaceState(
{ openXtreamLiveItemId: target.xtream_id },
''
);
events.next(new NavigationEnd(1, '/live', '/live'));
fixture.detectChanges();
expect(store.setSelectedCategory).toHaveBeenCalledWith(7);
expect(component.channelNavigation.remoteChannels()).toEqual([
alpha,
target,
zulu,
]);
component['handleRemoteChannelChange']('down');
expect(store.constructStreamUrl).toHaveBeenLastCalledWith(zulu);
}
);
it('keeps the actual displayed cross-category list for an explicit All Items click', () => {
selectedCategoryId.set(null);
component.onLiveRootItemClick(target);
expect(component.channelNavigation.remoteChannels()).toEqual(catalog);
});
});
@@ -22,6 +22,19 @@
}}</span>
}
</div>
@if (channelNavigation.canReveal()) {
<button
mat-icon-button
type="button"
[attr.aria-label]="
'CHANNELS.SHOW_PLAYING_CHANNEL' | translate
"
[matTooltip]="'CHANNELS.SHOW_PLAYING_CHANNEL' | translate"
(click)="channelNavigation.reveal()"
>
<mat-icon>list_alt</mat-icon>
</button>
}
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
@@ -71,6 +84,7 @@
</mat-menu>
</div>
<app-portal-channels-list
[revealRequest]="channelNavigation.revealRequest()"
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
@@ -156,6 +170,8 @@
/>
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -215,11 +231,16 @@
<!-- Stamped into the player's fullscreen channel panel (FULLSCREEN_CHANNEL_PANEL). -->
<ng-template #fullscreenChannelPanel let-searchTerm="searchTerm">
<app-portal-channels-list
#fullscreenList
[sortMode]="liveChannelSortMode()"
[channelsOverride]="fullscreenPanelChannels()"
[searchTermInput]="searchTerm()"
[fullscreenPanelCopy]="true"
(playClicked)="playLive($event)"
(playbackRequested)="playLive($event, true)"
(playClicked)="
playLive($event, undefined, fullscreenList.filteredChannels())
"
(playbackRequested)="
playLive($event, true, fullscreenList.filteredChannels())
"
/>
</ng-template>
@@ -13,7 +13,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { BehaviorSubject, Subject, of } from 'rxjs';
import {
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
liveSidebarStateStorageKey,
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
ResizableDirective,
@@ -57,6 +57,8 @@ class StubPortalChannelsListComponent {
readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server');
readonly channelsOverride = input<unknown[] | null>(null);
readonly searchTermInput = input('');
readonly revealRequest = input<unknown>(null);
readonly filteredChannels = signal<unknown[]>([]);
readonly playClicked = output<unknown>();
readonly playbackRequested = output<unknown>();
}
@@ -194,6 +196,7 @@ describe('LiveStreamLayoutComponent', () => {
getFavorites: jest.fn().mockReturnValue(of([])),
};
const xtreamUrlService = {
constructAutoLiveTsUrl: jest.fn(() => undefined),
resolveCatchupUrl: jest
.fn()
.mockResolvedValue('https://example.com/timeshift.ts'),
@@ -219,7 +222,7 @@ describe('LiveStreamLayoutComponent', () => {
settingsStore.resolvedEpgViewMode.set('timeline');
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
settingsStore.openStreamOnDoubleClick.set(false);
window.electron = {
@@ -342,19 +345,29 @@ describe('LiveStreamLayoutComponent', () => {
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
});
afterEach(() => {
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
fixture.destroy();
jest.useRealTimers();
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
window.electron = originalElectron;
});
it('retires committed live playback when the playlist owner changes', () => {
fixture.detectChanges();
component.playLive({ xtream_id: 10000, title: 'Synthetic' });
expect(component.activePlayback()).not.toBeNull();
currentPlaylist.set({ ...playlist, id: 'other-playlist' });
fixture.detectChanges();
expect(component.activePlayback()).toBeNull();
expect(component.playbackSessionKey()).toBe('');
});
it('renders the controlled epg list for electron playback', () => {
epgItems.set([
buildEpgItem(
@@ -545,9 +558,7 @@ describe('LiveStreamLayoutComponent', () => {
fixture.nativeElement.querySelector('.category-subtitle')
.textContent
).toContain('3 channels');
expect(
fixture.nativeElement.querySelector('mat-paginator')
).toBeNull();
expect(fixture.nativeElement.querySelector('mat-paginator')).toBeNull();
expect(
fixture.debugElement.query(
By.directive(StubPortalChannelsListComponent)
@@ -1027,9 +1038,29 @@ describe('LiveStreamLayoutComponent', () => {
expect(epgTimeline.componentInstance.activeProgram()).toBeNull();
});
it('keeps remote order after browsing another category and changing sort', () => {
const first = { ...sampleChannel, category_id: '1', name: 'Zulu' };
const next = { ...first, xtream_id: 102, name: 'Alpha' };
liveStreams.set([first, next]);
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([
first,
next,
]);
selectedItem.set(first);
component.playLive(first);
xtreamStore.setSelectedCategory.mockClear();
selectedCategoryId.set(2);
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([]);
component.setLiveChannelSortMode('name-desc');
component['handleRemoteChannelChange']('down');
expect(xtreamStore.constructStreamUrl).toHaveBeenLastCalledWith(next);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
});
it('starts external playback from remote channel navigation when double-click opening is enabled', () => {
const nextChannel = {
...sampleChannel,
category_id: '1',
xtream_id: 102,
name: 'Channel 102',
};
@@ -1041,6 +1072,9 @@ describe('LiveStreamLayoutComponent', () => {
nextChannel,
]);
liveStreams.set([{ ...sampleChannel, category_id: '1' }, nextChannel]);
component.playLive(sampleChannel);
xtreamStore.openPlayer.mockClear();
(
component as unknown as {
handleRemoteChannelChange(direction: 'up' | 'down'): void;
@@ -1115,9 +1149,13 @@ describe('LiveStreamLayoutComponent', () => {
);
});
// Collapsing with a selected category and no stream renders the real
// `app-channel-list-hidden-state` (its TranslatePipe needs a
// TranslateService); this spec is at the max-lines budget, so that branch
// is covered by video-player-sidebar.spec.ts and the Electron E2E instead.
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
selectedCategoryId.set(null);
TestBed.inject(LiveLayoutSidebarStateService).setState('collapsed');
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'collapsed');
fixture.detectChanges();
expect(
@@ -1127,7 +1165,7 @@ describe('LiveStreamLayoutComponent', () => {
it('hides the floating restore button when the sidebar is expanded', () => {
selectedCategoryId.set(1);
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
fixture.detectChanges();
expect(
@@ -26,6 +26,7 @@ import { ResizableDirective } from '@iptvnator/ui/components';
import {
GridListComponent,
InfiniteScrollDirective,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
@@ -94,20 +95,12 @@ import {
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
import {
XtreamLiveChannelNavigationService,
type XtreamLiveChannelItem,
} from './xtream-live-channel-navigation.service';
interface XtreamLiveChannelItem {
readonly added?: string;
readonly category_id?: string | number;
readonly last_modified?: string;
readonly name?: string;
readonly poster_url?: string;
readonly stream_icon?: string;
readonly title?: string;
readonly tv_archive?: number | null;
readonly tv_archive_duration?: number | string | null;
readonly xtream_id: number;
}
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@Component({
selector: 'app-live-stream-layout',
@@ -115,6 +108,7 @@ interface XtreamLiveChannelItem {
styleUrls: ['./live-stream-layout.component.scss'],
providers: [
LiveStreamAutoOpenStateService,
XtreamLiveChannelNavigationService,
// The fullscreen channel panel inside the player renders this
// category's channel list (see the `fullscreenChannelPanel` template).
{
@@ -134,6 +128,7 @@ interface XtreamLiveChannelItem {
GridListComponent,
InfiniteScrollDirective,
PortalChannelsListComponent,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
@@ -167,12 +162,12 @@ export class LiveStreamLayoutComponent
this.xtreamStore.selectedTypeContentLoading;
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly liveChannelSortMode = signal<PortalChannelSortMode>('server');
readonly channelNavigation = inject(XtreamLiveChannelNavigationService);
readonly liveChannelSortMode = this.channelNavigation.sortMode;
private readonly fullscreenChannelPanelTemplate =
viewChild<TemplateRef<FullscreenChannelPanelContext>>(
'fullscreenChannelPanel'
);
private readonly fullscreenChannelPanelTemplate = viewChild<
TemplateRef<FullscreenChannelPanelContext>
>('fullscreenChannelPanel');
/** FULLSCREEN_CHANNEL_PANEL: the current category's list, unless opted out. */
readonly panelTemplate = computed(() =>
this.settingsStore.fullscreenChannelPanel?.() === false
@@ -327,7 +322,8 @@ export class LiveStreamLayoutComponent
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('portal');
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(
this.activeCatchupProgram() ?? this.currentEpgItem()
@@ -379,6 +375,7 @@ export class LiveStreamLayoutComponent
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
private playbackRequestId = 0;
private activePlaylistId: string | null = null;
readonly usesEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
@@ -398,6 +395,16 @@ export class LiveStreamLayoutComponent
favorites = new Map<number, boolean>();
constructor() {
effect(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id ?? null;
if (this.activePlaylistId && this.activePlaylistId !== playlistId) {
this.playbackRequestId += 1;
this.activePlaylistId = null;
this.activePlayback.set(null);
this.activeLiveItemId.set(null);
this.activeCatchupProgram.set(null);
}
});
effect((onCleanup) => {
const intervalId = window.setInterval(() => {
this.currentTimeMs.set(Date.now());
@@ -444,7 +451,11 @@ export class LiveStreamLayoutComponent
return;
}
this.playLive(item);
this.playLive(
item,
undefined,
this.channelNavigation.channelsForAutoOpen(item)
);
// Ensure selectedItem is set so EPG loading and remote-control
// status reflect the channel (constructStreamUrl also does this
// internally, but an explicit call makes the intent clear and
@@ -537,18 +548,32 @@ export class LiveStreamLayoutComponent
playLive(
item: XtreamLiveChannelItem,
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
startPlayback = !this.settingsStore.openStreamOnDoubleClick(),
displayedChannels?: readonly XtreamLiveChannelItem[],
remote = false
) {
this.playbackRequestId += 1;
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.channelNavigation.capture(item, displayedChannels, remote);
this.activeCatchupProgram.set(null);
// Keep both root/recently-added playback and same-category replays in
// sync with the category rail. For already-selected channels this is a
// store no-op.
this.selectLiveItemCategory(item);
if (!remote) this.selectLiveItemCategory(item);
this.activeLiveItemId.set(item.xtream_id);
const playlist = this.xtreamStore.currentPlaylist();
this.activePlaylistId = playlist?.id ?? null;
this.activePlayback.set({
streamUrl,
liveAutoTsUrl: playlist
? this.xtreamUrlService.constructAutoLiveTsUrl(
playlist,
item.xtream_id
)
: undefined,
userAgent: playlist?.userAgent,
referer: playlist?.referrer,
origin: playlist?.origin,
title: item.title ?? item.name ?? '',
thumbnail: item.poster_url ?? item.stream_icon ?? null,
isLive: true,
@@ -587,7 +612,11 @@ export class LiveStreamLayoutComponent
}
onLiveRootItemClick(item: unknown): void {
this.playLive(item as XtreamLiveChannelItem);
this.playLive(
item as XtreamLiveChannelItem,
undefined,
this.liveRootItems() as unknown as XtreamLiveChannelItem[]
);
}
onLiveEpgPanelCollapsedChange(collapsed: boolean): void {
@@ -597,7 +626,7 @@ export class LiveStreamLayoutComponent
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle();
this.liveSidebarStateService.toggle('portal');
}
@HostListener('document:keydown', ['$event'])
@@ -664,7 +693,7 @@ export class LiveStreamLayoutComponent
return;
}
this.playLive(nextItem, true);
this.playLive(nextItem, true, undefined, true);
}
private handleRemoteControlCommand(command: {
@@ -685,7 +714,7 @@ export class LiveStreamLayoutComponent
return;
}
this.playLive(channel, true);
this.playLive(channel, true, undefined, true);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
@@ -702,7 +731,7 @@ export class LiveStreamLayoutComponent
}
private getVisibleChannels(): XtreamLiveChannelItem[] {
return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[];
return [...this.channelNavigation.remoteChannels()];
}
private selectLiveItemCategory(item: XtreamLiveChannelItem): void {
@@ -757,6 +786,7 @@ export class LiveStreamLayoutComponent
return;
}
this.activePlaylistId = playlist.id;
this.activeCatchupProgram.set(program);
this.activePlayback.set({
streamUrl: catchupUrl,
@@ -0,0 +1,282 @@
import { Component, computed, inject, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
ActivatedRoute,
convertToParamMap,
NavigationStart,
Router,
provideRouter,
} from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { BehaviorSubject, Subject } from 'rxjs';
import { XtreamLiveChannelNavigationService } from './xtream-live-channel-navigation.service';
const first = { xtream_id: 1, category_id: '10', title: 'Zulu' };
const second = { xtream_id: 2, category_id: '10', title: 'Alpha' };
const other = { xtream_id: 3, category_id: '20', title: 'Other' };
@Component({
template: '',
providers: [XtreamLiveChannelNavigationService],
})
class NavigationHostComponent {
readonly navigation = inject(XtreamLiveChannelNavigationService);
}
describe('XtreamLiveChannelNavigationService', () => {
const currentPlaylist = signal({ id: 'source-1' });
const selectedContentType = signal('live');
const selectedCategoryId = signal<number | null>(10);
const categorySearchTerm = signal('');
const liveStreams = signal([first, second, other]);
const categories = signal([
{ category_id: 10, hidden: false },
{ category_id: 20, hidden: false },
]);
const selectedChannels = computed(() =>
liveStreams().filter(
(item) =>
(!selectedCategoryId() ||
Number(item.category_id) === selectedCategoryId()) &&
item.title
.toLowerCase()
.includes(categorySearchTerm().toLowerCase())
)
);
const store = {
currentPlaylist,
selectedContentType,
selectedCategoryId,
categorySearchTerm,
liveStreams,
getCategoriesBySelectedType: categories,
selectItemsFromSelectedCategory: selectedChannels,
setSelectedCategory: jest.fn((id: number) =>
selectedCategoryId.set(id)
),
setCategorySearchTerm: jest.fn((term: string) =>
categorySearchTerm.set(term)
),
constructStreamUrl: jest.fn(),
openPlayer: jest.fn(),
setSelectedItem: jest.fn(),
};
let service: XtreamLiveChannelNavigationService;
let query: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let events: Subject<NavigationStart>;
let route: {
snapshot: {
params: Record<string, string>;
queryParamMap: ReturnType<typeof convertToParamMap>;
};
queryParamMap: unknown;
};
const navigate = jest.fn();
const sidebar = { setState: jest.fn() };
beforeEach(() => {
currentPlaylist.set({ id: 'source-1' });
selectedContentType.set('live');
selectedCategoryId.set(10);
categorySearchTerm.set('');
liveStreams.set([first, second, other]);
categories.set([
{ category_id: 10, hidden: false },
{ category_id: 20, hidden: false },
]);
query = new BehaviorSubject(convertToParamMap({}));
events = new Subject();
route = {
snapshot: { params: {}, queryParamMap: convertToParamMap({}) },
queryParamMap: query.asObservable(),
};
jest.clearAllMocks();
navigate.mockImplementation(() => {
events.next(new NavigationStart(1, '/live'));
query.next(convertToParamMap({}));
return Promise.resolve(true);
});
TestBed.configureTestingModule({
providers: [
XtreamLiveChannelNavigationService,
{ provide: XtreamStore, useValue: store },
{ provide: ActivatedRoute, useValue: route },
{ provide: Router, useValue: { events, navigate } },
{ provide: LiveLayoutSidebarStateService, useValue: sidebar },
],
});
service = TestBed.inject(XtreamLiveChannelNavigationService);
TestBed.flushEffects();
});
it('captures the displayed sort and keeps it after category, search, and sort changes', () => {
service.sortMode.set('name-asc');
service.capture(first);
selectedCategoryId.set(20);
service.sortMode.set('name-desc');
query.next(convertToParamMap({ q: 'Other' }));
expect(service.remoteChannels()).toEqual([second, first]);
service.capture(second, service.displayedChannels(), true);
expect(service.remoteChannels()).toEqual([second, first]);
service.capture(second);
expect(service.remoteChannels()).toEqual([second, first]);
});
it('captures fullscreen filtered rows and replaces the queue on a new explicit selection', () => {
service.capture(first, [first]);
expect(service.remoteChannels()).toEqual([first]);
service.capture(other, [other, second]);
expect(service.remoteChannels()).toEqual([other, second]);
});
it('drops hidden and removed channels without adopting the browsed list', () => {
selectedCategoryId.set(null);
service.capture(first);
categories.set([
{ category_id: 10, hidden: false },
{ category_id: 20, hidden: true },
]);
expect(service.remoteChannels()).toEqual([first, second]);
liveStreams.set([second, other]);
expect(service.remoteChannels()).toEqual([second]);
expect(service.canReveal()).toBe(false);
});
it('invalidates a queue when its source or content type changes', () => {
service.capture(first);
currentPlaylist.set({ id: 'source-2' });
expect(service.remoteChannels()).toEqual([]);
TestBed.flushEffects();
currentPlaylist.set({ id: 'source-1' });
expect(service.remoteChannels()).toEqual([]);
service.capture(first);
selectedContentType.set('vod');
TestBed.flushEffects();
selectedContentType.set('live');
expect(service.remoteChannels()).toEqual([]);
expect(service.activeItem()).toBeNull();
});
it('shows reveal only when the playing channel is absent from the actual filtered list', () => {
service.capture(first);
expect(service.canReveal()).toBe(false);
query.next(convertToParamMap({ q: 'Alpha' }));
expect(service.canReveal()).toBe(true);
categories.set([{ category_id: 10, hidden: true }]);
expect(service.canReveal()).toBe(false);
});
it.each([false, true])(
'reveals from a category route=%s without touching playback or queue',
async (categoryRoute) => {
service.capture(first);
selectedCategoryId.set(20);
categorySearchTerm.set('Other');
query.next(convertToParamMap({ q: 'Other' }));
if (categoryRoute) route.snapshot.params = { categoryId: '20' };
await service.reveal();
expect(navigate).toHaveBeenCalledWith(
categoryRoute ? ['../', 10] : [],
{
relativeTo: route,
queryParams: { q: null },
queryParamsHandling: 'merge',
}
);
expect(selectedCategoryId()).toBe(10);
expect(categorySearchTerm()).toBe('');
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
expect(service.revealRequest()).toEqual(
expect.objectContaining({ channelId: 1 })
);
expect(service.remoteChannels()).toEqual([first, second]);
expect(store.constructStreamUrl).not.toHaveBeenCalled();
expect(store.openPlayer).not.toHaveBeenCalled();
expect(store.setSelectedItem).not.toHaveBeenCalled();
}
);
it.each(['live', 'live/20', 'live-without-query'])(
'keeps the live host mounted and synchronizes real %s navigation',
async (path) => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideRouter([
{
path: 'workspace/xtreams/:id',
children: [
{
path: 'live',
component: NavigationHostComponent,
},
{
path: 'live/:categoryId',
component: NavigationHostComponent,
},
],
},
]),
{ provide: XtreamStore, useValue: store },
{
provide: LiveLayoutSidebarStateService,
useValue: sidebar,
},
],
});
selectedCategoryId.set(20);
const harness = await RouterTestingHarness.create();
const host = await harness.navigateByUrl(
`/workspace/xtreams/source-1/${path === 'live-without-query' ? 'live' : `${path}?q=Other`}`,
NavigationHostComponent
);
host.navigation.capture(first, [first, second]);
await host.navigation.reveal();
harness.detectChanges();
expect(TestBed.inject(Router).url).toBe(
`/workspace/xtreams/source-1/${path !== 'live/20' ? 'live' : 'live/10'}`
);
expect(harness.routeDebugElement?.componentInstance).toBe(host);
expect(selectedCategoryId()).toBe(10);
expect(host.navigation.revealRequest()?.channelId).toBe(
first.xtream_id
);
expect(host.navigation.remoteChannels()).toEqual([first, second]);
}
);
it.each([
'playback',
'source',
'category',
'navigation',
'hidden',
'cancelled',
])('ignores a stale reveal after %s changes', async (change) => {
route.snapshot.params = { categoryId: '20' };
service.capture(first);
selectedCategoryId.set(20);
let finish!: (value: boolean) => void;
navigate.mockImplementation(() => {
events.next(new NavigationStart(1, '/live'));
return new Promise<boolean>((resolve) => {
finish = resolve;
});
});
const reveal = service.reveal();
if (change === 'playback') service.capture(other);
if (change === 'source') currentPlaylist.set({ id: 'source-2' });
if (change === 'category') selectedCategoryId.set(30);
if (change === 'navigation')
events.next(new NavigationStart(2, '/elsewhere'));
if (change === 'hidden')
categories.set([{ category_id: 10, hidden: true }]);
finish(change !== 'cancelled');
await reveal;
expect(store.setSelectedCategory).not.toHaveBeenCalled();
expect(sidebar.setState).not.toHaveBeenCalled();
expect(service.revealRequest()).toBeNull();
});
});
@@ -0,0 +1,245 @@
import {
computed,
DestroyRef,
effect,
inject,
Injectable,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
import { LiveChannelPlaybackQueue } from '@iptvnator/portal/shared/data-access';
import {
LiveLayoutSidebarStateService,
PortalChannelSortMode,
queryParamSignal,
sortPortalChannelItems,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
export interface XtreamLiveChannelItem {
readonly added?: string;
readonly category_id?: string | number;
readonly last_modified?: string;
readonly name?: string;
readonly poster_url?: string;
readonly stream_icon?: string;
readonly title?: string;
readonly tv_archive?: number | null;
readonly tv_archive_duration?: number | string | null;
readonly xtream_id: number;
}
/** Browse state is independent from the ordered list that started playback. */
@Injectable()
export class XtreamLiveChannelNavigationService {
private readonly store = inject(XtreamStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly sidebar = inject(LiveLayoutSidebarStateService);
private readonly destroyRef = inject(DestroyRef);
private readonly queue =
new LiveChannelPlaybackQueue<XtreamLiveChannelItem>(
(item) => item.xtream_id
);
private readonly playing = signal<{
owner: string;
item: XtreamLiveChannelItem;
} | null>(null);
readonly sortMode = signal<PortalChannelSortMode>('server');
private readonly query = queryParamSignal(this.route, 'q', (q) => q ?? '');
private readonly owner = computed(() =>
this.store.selectedContentType() === 'live'
? `${this.store.currentPlaylist()?.id ?? ''}:live`
: ''
);
readonly activeItem = computed(() => {
const playing = this.playing();
return playing?.owner === this.owner() ? playing.item : null;
});
readonly displayedChannels = computed(() => {
const term = this.query().trim().toLowerCase();
const channels = sortPortalChannelItems(
this.store.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[],
this.sortMode(),
(item) => item.title ?? item.name
);
return term
? channels.filter((item) =>
`${item.title ?? ''} ${item.name ?? ''}`
.toLowerCase()
.includes(term)
)
: channels;
});
private readonly accessibleChannels = computed(() => {
const categories = new Set(
this.store
.getCategoriesBySelectedType()
.filter(
(category) => !('hidden' in category && category.hidden)
)
.map((category) => Number(category.category_id ?? category.id))
);
return (this.store.liveStreams() as XtreamLiveChannelItem[]).filter(
(item) => categories.has(Number(item.category_id))
);
});
readonly remoteChannels = computed(() => {
const available = new Map(
this.accessibleChannels().map((item) => [
Number(item.xtream_id),
item,
])
);
return this.queue
.items(this.owner())
.map((item) => available.get(Number(item.xtream_id)))
.filter(
(item): item is XtreamLiveChannelItem => item !== undefined
);
});
readonly canReveal = computed(() => {
const active = this.activeItem();
return (
!!active &&
this.accessibleChannels().some(
(item) => Number(item.xtream_id) === Number(active.xtream_id)
) &&
!this.displayedChannels().some(
(item) => Number(item.xtream_id) === Number(active.xtream_id)
)
);
});
private readonly pendingReveal = signal<{
channelId: number;
categoryId: number;
sequence: number;
} | null>(null);
readonly revealRequest = computed(() => {
const request = this.pendingReveal();
return request &&
request.channelId === this.activeItem()?.xtream_id &&
request.categoryId === this.store.selectedCategoryId() &&
!this.query().trim()
? request
: null;
});
private requestSequence = 0;
private navigationGeneration = 0;
constructor() {
let previousOwner = this.owner();
effect(() => {
const owner = this.owner();
if (owner !== previousOwner) {
previousOwner = owner;
untracked(() => {
this.queue.clear();
this.playing.set(null);
this.pendingReveal.set(null);
this.requestSequence++;
});
}
});
this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
if (event instanceof NavigationStart) {
this.navigationGeneration++;
this.pendingReveal.set(null);
}
});
}
/** History handoffs carry an item, not a browsed list or its old filter. */
channelsForAutoOpen(
item: XtreamLiveChannelItem
): readonly XtreamLiveChannelItem[] {
return sortPortalChannelItems(
this.accessibleChannels().filter(
(channel) =>
Number(channel.category_id) === Number(item.category_id)
),
this.sortMode(),
(channel) => channel.title ?? channel.name
);
}
capture(
item: XtreamLiveChannelItem,
items: readonly XtreamLiveChannelItem[] = this.displayedChannels(),
preserveQueue = false
): void {
this.requestSequence++;
this.pendingReveal.set(null);
const owner = this.owner();
if (!preserveQueue && this.activeItem()?.xtream_id !== item.xtream_id) {
this.queue.capture(
owner,
JSON.stringify([
this.store.selectedCategoryId(),
this.query(),
this.sortMode(),
]),
items,
item
);
}
this.playing.set({ owner, item });
}
async reveal(): Promise<void> {
if (!this.canReveal()) return;
const active = this.activeItem();
const target = this.accessibleChannels().find(
(item) => item.xtream_id === active?.xtream_id
);
if (!target) return;
const categoryId = Number(target.category_id);
const owner = this.owner();
const sequence = ++this.requestSequence;
const previousCategory = this.store.selectedCategoryId();
const generation = this.navigationGeneration;
const routeCategoryId = this.route.snapshot.params?.['categoryId'];
const needsNavigation =
this.query() ||
this.route.snapshot.queryParamMap.has('q') ||
(routeCategoryId && Number(routeCategoryId) !== categoryId);
// Root category browsing is store-only: Angular reports a same-URL
// navigation as false, even though there is nothing to cancel.
const navigation = needsNavigation
? this.router.navigate(routeCategoryId ? ['../', categoryId] : [], {
relativeTo: this.route,
queryParams: { q: null },
queryParamsHandling: 'merge',
})
: Promise.resolve(true);
try {
const navigated = await navigation;
if (
navigated === false ||
this.destroyRef.destroyed ||
sequence !== this.requestSequence ||
owner !== this.owner() ||
this.navigationGeneration > generation + 1 ||
this.activeItem()?.xtream_id !== target.xtream_id ||
!this.accessibleChannels().some(
(item) => item.xtream_id === target.xtream_id
) ||
(this.store.selectedCategoryId() !== previousCategory &&
this.store.selectedCategoryId() !== categoryId)
)
return;
this.store.setCategorySearchTerm('');
this.store.setSelectedCategory(categoryId);
this.sidebar.setState('portal', 'expanded');
this.pendingReveal.set({
channelId: target.xtream_id,
categoryId,
sequence,
});
} catch {
// Cancelled/failed navigation leaves playback and browse state intact.
}
}
}
@@ -421,6 +421,41 @@ describe('PortalChannelsListComponent', () => {
expect(scrollToIndex).toHaveBeenCalledWith(15, 'smooth');
});
it('explicitly reveals and focuses an unchanged selected channel after browsing away', () => {
const requestFrame = jest
.spyOn(window, 'requestAnimationFrame')
.mockReturnValue(100000);
selectedTypeContentLoading.set(false);
selectedChannels.set(
Array.from({ length: 20 }, (_, index) => ({
xtream_id: index + 1,
title: `Channel ${index + 1}`,
}))
);
selectedItem.set({ xtream_id: 16 });
fixture.detectChanges();
const viewport = fixture.componentInstance.viewport();
if (!viewport) throw new Error('Expected channel viewport');
const scroll = jest.spyOn(viewport, 'scrollToIndex');
const focus = jest.spyOn(viewport.elementRef.nativeElement, 'focus');
fixture.componentRef.setInput('revealRequest', {
channelId: 16,
sequence: 1,
});
fixture.detectChanges();
expect(scroll).not.toHaveBeenCalled();
const frame =
requestFrame.mock.calls[requestFrame.mock.calls.length - 1]?.[0];
frame?.(0);
requestFrame.mockRestore();
expect(scroll).toHaveBeenCalledWith(15, 'auto');
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
scroll.mockClear();
selectedChannels.set([...selectedChannels()]);
fixture.detectChanges();
expect(scroll).not.toHaveBeenCalled();
});
it('does not start smooth alignment when a selected row is already visible', () => {
selectedTypeContentLoading.set(false);
selectedChannels.set(
@@ -4,6 +4,7 @@ import {
ScrollingModule,
} from '@angular/cdk/scrolling';
import {
afterRenderEffect,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -105,6 +106,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
* invalid and could point that hand-off at the hidden copy.
*/
readonly fullscreenPanelCopy = input(false);
readonly revealRequest = input<{
channelId: number;
sequence: number;
} | null>(null);
readonly xtreamStore = inject(XtreamStore);
private readonly favoritesService = inject(FavoritesService);
@@ -190,6 +195,36 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
untracked(() => this.repickPreviewsForOffsetChange());
});
let appliedReveal = -1;
// A filtered-out viewport is recreated on reveal. CDK attaches its
// scroll strategy in a microtask after rendering; align on the next
// frame so scrollToIndex cannot silently run before that attachment.
afterRenderEffect((onCleanup) => {
const request = this.revealRequest();
const viewport = this.viewport();
const channels = this.filteredChannels();
if (!request || !viewport || appliedReveal === request.sequence)
return;
const index = channels.findIndex(
(item) => item.xtream_id === request.channelId
);
if (index < 0) return;
const frame = requestAnimationFrame(() => {
if (
this.revealRequest()?.sequence !== request.sequence ||
this.viewport() !== viewport
)
return;
appliedReveal = request.sequence;
viewport.checkViewportSize();
viewport.scrollToIndex(index, 'auto');
viewport.elementRef.nativeElement.focus({
preventScroll: true,
});
});
onCleanup(() => cancelAnimationFrame(frame));
});
const selectedChannelId = computed(() =>
Number(
(
@@ -10,6 +10,8 @@ export interface PlayerContentInfo extends Omit<
export interface ResolvedPortalPlayback {
streamUrl: string;
/** Source-owner supplied, advertised TS alternative for Xtream live Auto only. */
liveAutoTsUrl?: string;
title: string;
thumbnail?: string | null;
isLive?: boolean;
@@ -11,10 +11,30 @@ import { TIMELINE_MINUTE_MS, TimelineBlock } from './epg-timeline.utils';
export const TIMELINE_MIN_BLOCK_WIDTH_PX = 40;
/** Gap subtracted from each block so neighbours don't touch. */
export const TIMELINE_BLOCK_GAP_PX = 4;
/** Zoom (px/min) slider bounds. */
/** Zoom (px/min) bounds for the Ctrl/⌘ + wheel fine-tune gesture. */
export const TIMELINE_ZOOM_MIN = 0.8;
export const TIMELINE_ZOOM_MAX = 6;
export const TIMELINE_ZOOM_STEP = 0.1;
/**
* Multiplicative zoom per wheel pixel: one mouse notch (~100px) is about
* ×1.22, and a trackpad pinch (a few px per event) stays smooth.
*/
export const TIMELINE_WHEEL_ZOOM_RATE = 0.002;
export type TimelineZoomLevel = 'day' | 'hours' | 'detail';
/**
* The three zoom presets the toolbar button cycles through. `hours` is the
* default scale; `detail` matches the zoom a group chip expands to, so the
* cycle keeps reading consistently after an expand.
*/
export const TIMELINE_ZOOM_LEVELS: readonly {
readonly level: TimelineZoomLevel;
readonly scale: number;
}[] = [
{ level: 'day', scale: 1 },
{ level: 'hours', scale: 1.75 },
{ level: 'detail', scale: 3.4 },
];
/** Below this zoom, dense runs of short programmes collapse into a group chip. */
export const TIMELINE_GROUP_ZOOM_MAX = 1.3;
/** Zoom applied when a group chip is expanded. */
@@ -69,6 +89,24 @@ export function tierFor(widthPx: number): TimelineTier {
return 'micro';
}
/** Which preset band a (possibly wheel-tuned) scale falls into. */
export function timelineZoomLevelForScale(scale: number): TimelineZoomLevel {
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'day';
if (scale < 3) return 'hours';
return 'detail';
}
/** The preset after the current band, wrapping detail → day. */
export function nextTimelineZoomScale(scale: number): number {
const current = timelineZoomLevelForScale(scale);
const index = TIMELINE_ZOOM_LEVELS.findIndex(
(entry) => entry.level === current
);
const next =
TIMELINE_ZOOM_LEVELS[(index + 1) % TIMELINE_ZOOM_LEVELS.length];
return next.scale;
}
/** Adaptive tick spacing (minutes) — denser as the user zooms in. */
export function timelineTickStepForScale(scale: number): number {
if (scale < 2.2) return 120;
@@ -151,7 +189,8 @@ export function buildTimelineRenderItems(
if (run.length >= TIMELINE_GROUP_MIN_RUN) {
const first = run[0];
const last = run[run.length - 1];
const spanPx = ((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
const spanPx =
((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
items.push({
kind: 'group',
key: `group-${first.key}-${last.key}`,
@@ -0,0 +1,125 @@
import { TimelineZoomController } from './epg-timeline-zoom.controller';
import {
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
} from './epg-timeline-render.util';
function fakeScroller(clientWidth: number, scrollLeft: number): HTMLElement {
return {
clientWidth,
scrollLeft,
getBoundingClientRect: () => ({ left: 100 }),
} as unknown as HTMLElement;
}
describe('TimelineZoomController', () => {
let scale: number;
let scroller: HTMLElement | undefined;
let controller: TimelineZoomController;
let rafCallbacks: FrameRequestCallback[];
beforeEach(() => {
scale = 2;
scroller = fakeScroller(600, 1200);
rafCallbacks = [];
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
controller = new TimelineZoomController({
ribbon: () => scroller,
scale: () => scale,
setScale: (next) => (scale = next),
});
});
afterEach(() => jest.restoreAllMocks());
function flushFrames(): void {
for (const cb of rafCallbacks.splice(0)) cb(0);
}
it('keeps the viewport centre minute fixed on a programmatic zoom', () => {
// centre = (1200 + 300) / 2 px/min = 750 min
controller.zoomTo(4);
flushFrames();
expect(scale).toBe(4);
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
});
it('clamps to the zoom bounds and ignores non-finite input', () => {
controller.zoomTo(99);
expect(scale).toBe(TIMELINE_ZOOM_MAX);
controller.zoomTo(-1);
expect(scale).toBe(TIMELINE_ZOOM_MIN);
controller.zoomTo(Number.NaN);
expect(scale).toBe(TIMELINE_ZOOM_MIN);
});
it('does nothing when the clamped scale is unchanged', () => {
controller.zoomTo(2);
expect(rafCallbacks).toHaveLength(0);
});
it('zooms around the cursor on Ctrl + wheel', () => {
// cursor 150px into the 600px viewport → anchor 0.25
const event = new WheelEvent('wheel', {
deltaY: -100,
ctrlKey: true,
clientX: 250,
cancelable: true,
});
controller.onWheel(event);
flushFrames();
expect(event.defaultPrevented).toBe(true);
expect(scale).toBeCloseTo(2 * Math.exp(0.2), 6);
// minute under the cursor before: (1200 + 150) / 2 = 675
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
});
it('keeps the cursor minute anchored across a burst of wheel events', () => {
const burst = () =>
new WheelEvent('wheel', {
deltaY: -50,
ctrlKey: true,
clientX: 250,
cancelable: true,
});
// Three events before any frame runs: the DOM scrollLeft is stale for
// the 2nd and 3rd, so the anchor must come from the logical position.
controller.onWheel(burst());
controller.onWheel(burst());
controller.onWheel(burst());
expect(rafCallbacks).toHaveLength(1); // coalesced into one frame
flushFrames();
// minute under the cursor before the burst: (1200 + 150) / 2 = 675
expect(scale).toBeCloseTo(2 * Math.exp(0.3), 6);
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
});
it('re-anchors on the DOM position once a frame has flushed', () => {
controller.zoomTo(4);
flushFrames();
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
controller.zoomTo(2);
flushFrames();
expect(scroller?.scrollLeft).toBe(750 * 2 - 300);
});
it('leaves plain wheel events alone', () => {
const event = new WheelEvent('wheel', {
deltaY: -100,
cancelable: true,
});
controller.onWheel(event);
expect(event.defaultPrevented).toBe(false);
expect(scale).toBe(2);
});
it('zooms without a rendered ribbon', () => {
scroller = undefined;
controller.cycle();
expect(scale).toBe(3.4);
expect(rafCallbacks).toHaveLength(0);
});
});
@@ -0,0 +1,102 @@
import {
nextTimelineZoomScale,
TIMELINE_WHEEL_ZOOM_RATE,
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
} from './epg-timeline-render.util';
export interface TimelineZoomContext {
/** The horizontal scroller hosting the track (undefined until rendered). */
readonly ribbon: () => HTMLElement | undefined;
readonly scale: () => number;
readonly setScale: (scale: number) => void;
}
/**
* Ribbon zoom (px per minute) owned by the toolbar button and the
* Ctrl/⌘ + wheel gesture. Every change keeps one ribbon minute anchored on
* screen: the viewport centre for button/programmatic zooms, the cursor for
* wheel zooms.
*/
export class TimelineZoomController {
/**
* Scroll position the next animation frame will apply. Rapid wheel/pinch
* events arrive faster than frames, so the DOM `scrollLeft` still reflects
* the previous scale by the time the next event computes its anchor; the
* logical position keeps every event anchored on the same minute.
*/
private pendingScrollLeft: number | null = null;
private frame = 0;
constructor(private readonly ctx: TimelineZoomContext) {}
/** Clamp + apply a scale, keeping the viewport centre stable. */
zoomTo(value: number): void {
this.applyScale(value, 0.5);
}
/** Toolbar zoom button: step to the next preset (day → hours → detail). */
cycle(): void {
this.applyScale(nextTimelineZoomScale(this.ctx.scale()), 0.5);
}
/**
* Ctrl/⌘ + wheel (and trackpad pinch, which Chromium reports the same
* way) zooms around the cursor. Plain wheel keeps scrolling.
* `preventDefault` also stops Chromium's page zoom for the same gesture.
*/
onWheel(event: WheelEvent): void {
if (!event.ctrlKey && !event.metaKey) {
return;
}
event.preventDefault();
const scroller = this.ctx.ribbon();
const anchor = scroller
? (event.clientX - scroller.getBoundingClientRect().left) /
scroller.clientWidth
: 0.5;
const factor = Math.exp(-event.deltaY * TIMELINE_WHEEL_ZOOM_RATE);
this.applyScale(this.ctx.scale() * factor, anchor);
}
/**
* Set the scale so the ribbon minute under `anchorFrac` (0 = left edge,
* 1 = right edge of the viewport) stays put.
*/
private applyScale(value: number, anchorFrac: number): void {
const requested = Number(value);
const prev = this.ctx.scale();
const next = Number.isFinite(requested)
? Math.min(
TIMELINE_ZOOM_MAX,
Math.max(TIMELINE_ZOOM_MIN, requested)
)
: prev;
if (next === prev) {
return;
}
const scroller = this.ctx.ribbon();
this.ctx.setScale(next);
if (!scroller) {
return;
}
const anchorPx = scroller.clientWidth * anchorFrac;
const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft;
const anchorMin = (currentLeft + anchorPx) / prev;
this.pendingScrollLeft = anchorMin * next - anchorPx;
if (this.frame === 0) {
this.frame = requestAnimationFrame(() => this.flushScroll());
}
}
/** One frame applies the last coalesced position for a burst of events. */
private flushScroll(): void {
this.frame = 0;
const left = this.pendingScrollLeft;
this.pendingScrollLeft = null;
const scroller = this.ctx.ribbon();
if (scroller && left !== null) {
scroller.scrollLeft = left;
}
}
}
@@ -103,39 +103,36 @@
@if (guideAvailable()) {
<button
type="button"
class="epg-timeline__guide"
class="epg-timeline__iconbtn epg-timeline__guide"
(click)="openGuide.emit()"
[matTooltip]="'EPG.TIMELINE.OPEN_GUIDE' | translate"
[attr.aria-label]="'EPG.TIMELINE.OPEN_GUIDE' | translate"
>
<mat-icon>grid_view</mat-icon>
{{ 'EPG.TIMELINE.GUIDE' | translate }}
</button>
}
<button
type="button"
class="epg-timeline__jump"
class="epg-timeline__iconbtn epg-timeline__jump"
(click)="jumpToNow()"
[matTooltip]="'EPG.TIMELINE.JUMP_NOW' | translate"
[attr.aria-label]="'EPG.TIMELINE.JUMP_NOW' | translate"
>
<mat-icon>my_location</mat-icon>
{{ 'EPG.TIMELINE.NOW' | translate }}
</button>
<label
class="epg-timeline__zoom"
[matTooltip]="zoomLabelKey() | translate"
@let zoomTooltip =
'EPG.TIMELINE.ZOOM_TOOLTIP'
| translate: { level: (zoomLabelKey() | translate) };
<button
type="button"
class="epg-timeline__iconbtn epg-timeline__zoom"
[attr.data-zoom-level]="zoomLevel()"
(click)="cycleZoom()"
[matTooltip]="zoomTooltip"
[attr.aria-label]="zoomTooltip"
>
<mat-icon>zoom_in</mat-icon>
<input
#zoomInput
type="range"
[min]="zoomMin"
[max]="zoomMax"
[step]="zoomStep"
[value]="scale()"
(input)="onZoom(zoomInput.valueAsNumber)"
[attr.aria-label]="'EPG.TIMELINE.ZOOM' | translate"
/>
</label>
<mat-icon>{{ zoomIcon() }}</mat-icon>
</button>
}
@if (showDateStepper()) {
<div class="epg-timeline__stepper">
@@ -205,6 +202,7 @@
#ribbon
class="epg-timeline__ribbon app-scrollbar"
(scroll)="onRibbonScroll()"
(wheel)="onRibbonWheel($event)"
>
<app-epg-timeline-track
[items]="renderItems()"
@@ -53,8 +53,10 @@
padding: 4px 6px;
border-radius: 7px;
font: inherit;
/* Shrinks only when the toolbar overflows: the channel + programme title
take every pixel the fixed-width controls leave (no hard width cap). */
flex: 0 1 auto;
min-width: 0;
max-width: 60%;
&:hover {
background: $surface-2;
@@ -161,35 +163,44 @@
}
}
.epg-timeline__jump,
.epg-timeline__guide {
display: inline-flex;
align-items: center;
gap: 7px;
/* icon-only toolbar buttons: "Now" + zoom level (label lives in the tooltip
and aria-label, so the channel/programme title keeps the toolbar width) */
.epg-timeline__iconbtn {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0 13px;
padding: 0;
border-radius: 999px;
background: $surface-2;
border: 1px solid $line-strong;
color: $text-secondary;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
flex: 0 0 auto;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
color: $text-primary;
border-color: $accent-blue;
}
&:focus-visible {
outline: 2px solid $accent-blue;
outline-offset: 1px;
}
}
.epg-timeline__zoom[data-zoom-level='detail'] {
color: $accent-text;
}
/* the Guide button opens the multi-channel grid; accent so it reads as the
way "out" of the single-channel ribbon */
.epg-timeline__guide {
color: $accent-text;
border-color: $accent-blue;
@@ -345,32 +356,6 @@
}
}
/* ── zoom slider ── */
.epg-timeline__zoom {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 12px;
border-radius: 999px;
background: $surface-2;
border: 1px solid $line;
color: $text-tertiary;
flex: 0 0 auto;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
input[type='range'] {
width: 96px;
accent-color: $accent-text;
cursor: pointer;
}
}
/* ── ribbon ── */
.epg-timeline__ribbon {
flex: 1 1 auto;
@@ -230,6 +230,60 @@ describe('EpgTimelineComponent', () => {
expect(component.renderItems().length).toBe(before);
});
it('cycles the zoom button through day → hours → detail → day', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
expect(component.zoomLevel()).toBe('hours'); // default scale
component.cycleZoom();
expect(component.zoomLevel()).toBe('detail');
expect(component.zoomIcon()).toBe('zoom_in');
component.cycleZoom();
expect(component.zoomLevel()).toBe('day');
expect(component.zoomIcon()).toBe('zoom_out');
component.cycleZoom();
expect(component.zoomLevel()).toBe('hours');
expect(component.zoomLabelKey()).toBe('EPG.TIMELINE.ZOOM_HOURS');
});
it('snaps a wheel-tuned scale to the next preset band when cycling', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
component.onZoom(2.6); // still "hours" band, but off-preset
expect(component.zoomLevel()).toBe('hours');
component.cycleZoom();
expect(component.zoomLevel()).toBe('detail');
expect(component.scale()).toBe(3.4);
});
function wheel(init: WheelEventInit): WheelEvent {
return new WheelEvent('wheel', { cancelable: true, ...init });
}
it('zooms the ribbon with Ctrl/⌘ + wheel and blocks page zoom', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
const before = component.scale();
const zoomIn = wheel({ deltaY: -100, ctrlKey: true });
component.onRibbonWheel(zoomIn);
expect(zoomIn.defaultPrevented).toBe(true);
expect(component.scale()).toBeGreaterThan(before);
const zoomOut = wheel({ deltaY: 300, metaKey: true });
component.onRibbonWheel(zoomOut);
expect(component.scale()).toBeLessThan(before);
expect(component.scale()).toBeGreaterThanOrEqual(component.zoomMin);
});
it('leaves a plain wheel to native scrolling', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
const before = component.scale();
const plain = wheel({ deltaY: -100 });
component.onRibbonWheel(plain);
expect(plain.defaultPrevented).toBe(false);
expect(component.scale()).toBe(before);
});
it('expands a group chip by zooming in', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
component.onGroupExpand({
@@ -44,6 +44,7 @@ import {
EpgTimelineEmptyStateComponent,
} from './epg-timeline-empty-state.component';
import { TimelineScrollController } from './epg-timeline-scroll.controller';
import { TimelineZoomController } from './epg-timeline-zoom.controller';
import { EpgTimelineTrackComponent } from './epg-timeline-track.component';
import {
buildTimelineAxis,
@@ -59,10 +60,10 @@ import {
TIMELINE_MINUTE_MS,
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
TIMELINE_ZOOM_STEP,
TimelineBlock,
TimelineRenderGroup,
timelineTickStepForScale,
timelineZoomLevelForScale,
} from './epg-timeline.utils';
type RenderState = 'loading' | 'ribbon' | EpgTimelineEmptyReason;
@@ -118,7 +119,6 @@ export class EpgTimelineComponent {
readonly scale = signal(TIMELINE_DEFAULT_SCALE);
readonly zoomMin = TIMELINE_ZOOM_MIN;
readonly zoomMax = TIMELINE_ZOOM_MAX;
readonly zoomStep = TIMELINE_ZOOM_STEP;
readonly skeletonWidths = [120, 170, 150, 200, 140, 180];
private readonly nowMs = signal(Date.now());
@@ -130,6 +130,13 @@ export class EpgTimelineComponent {
return key ? key : getTodayEpgDateKey();
});
/** Zoom button + Ctrl/⌘ wheel, anchored so a ribbon minute stays put. */
private readonly zoom = new TimelineZoomController({
ribbon: () => this.ribbon()?.nativeElement,
scale: () => this.scale(),
setScale: (scale) => this.scale.set(scale),
});
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
private readonly scroll = new TimelineScrollController({
ribbon: () => this.ribbon()?.nativeElement,
@@ -199,12 +206,23 @@ export class EpgTimelineComponent {
((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale()
);
});
readonly zoomLevel = computed(() =>
timelineZoomLevelForScale(this.scale())
);
readonly zoomLabelKey = computed(() => {
const scale = this.scale();
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'EPG.TIMELINE.ZOOM_DAY';
if (scale < 3) return 'EPG.TIMELINE.ZOOM_HOURS';
return 'EPG.TIMELINE.ZOOM_DETAIL';
switch (this.zoomLevel()) {
case 'day':
return 'EPG.TIMELINE.ZOOM_DAY';
case 'hours':
return 'EPG.TIMELINE.ZOOM_HOURS';
default:
return 'EPG.TIMELINE.ZOOM_DETAIL';
}
});
/** `zoom_out` reads as "overview"; both finer bands share `zoom_in`. */
readonly zoomIcon = computed(() =>
this.zoomLevel() === 'day' ? 'zoom_out' : 'zoom_in'
);
readonly nowLabel = computed(() => formatClockTime(this.nowMs()));
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
@@ -304,24 +322,19 @@ export class EpgTimelineComponent {
this.collapsedChange.emit(!this.collapsed());
}
/** Clamp + apply a scale, keeping the viewport centre stable. */
onZoom(value: number): void {
const requested = Number(value);
const next = Number.isFinite(requested)
? Math.min(this.zoomMax, Math.max(this.zoomMin, requested))
: this.scale();
// Keep the viewport centre stable across a zoom change.
const scroller = this.ribbon()?.nativeElement;
const prev = this.scale();
const centreMin = scroller
? (scroller.scrollLeft + scroller.clientWidth / 2) / prev
: null;
this.scale.set(next);
if (scroller && centreMin !== null) {
requestAnimationFrame(() => {
scroller.scrollLeft =
centreMin * next - scroller.clientWidth / 2;
});
}
this.zoom.zoomTo(value);
}
/** Toolbar zoom button: next preset (day → hours → detail → day). */
cycleZoom(): void {
this.zoom.cycle();
}
/** Ctrl/⌘ + wheel over the ribbon zooms around the cursor. */
onRibbonWheel(event: WheelEvent): void {
this.zoom.onWheel(event);
}
onGroupExpand(group: TimelineRenderGroup): void {
@@ -9,12 +9,16 @@ import {
dayKeyAtOffset,
hasProgramsForDateKey,
nearestDateKeyWithPrograms,
nextTimelineZoomScale,
TIMELINE_GROUP_EXPAND_ZOOM,
TIMELINE_MINUTE_MS,
TIMELINE_MIN_BLOCK_WIDTH_PX,
TIMELINE_ZOOM_LEVELS,
TimelineRenderBlock,
TimelineRenderGroup,
tierFor,
timelineTickStepForScale,
timelineZoomLevelForScale,
} from './epg-timeline.utils';
function localIso(
@@ -66,15 +70,15 @@ describe('epg-timeline.utils', () => {
describe('classifyTimelineWhen', () => {
it('classifies past, now and future relative to now', () => {
expect(classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)).toBe(
'past'
);
expect(
classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)
).toBe('past');
expect(classifyTimelineWhen(NOW - 60000, NOW + 60000, NOW)).toBe(
'now'
);
expect(classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)).toBe(
'future'
);
expect(
classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)
).toBe('future');
});
});
@@ -91,18 +95,30 @@ describe('epg-timeline.utils', () => {
const block = blocks[0];
expect(block.when).toBe('now');
expect(block.durationMin).toBe(120);
const expectedOffset = (block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
const expectedOffset =
(block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
expect(block.offsetMin).toBeCloseTo(expectedOffset, 5);
});
it('sorts blocks chronologically', () => {
const programs = [
program(localIso(2026, 6, 28, 18), localIso(2026, 6, 28, 19), 'late'),
program(localIso(2026, 6, 28, 8), localIso(2026, 6, 28, 9), 'early'),
program(
localIso(2026, 6, 28, 18),
localIso(2026, 6, 28, 19),
'late'
),
program(
localIso(2026, 6, 28, 8),
localIso(2026, 6, 28, 9),
'early'
),
];
const axis = buildTimelineAxis(programs, NOW);
const blocks = buildTimelineBlocks(programs, axis, NOW);
expect(blocks.map((b) => b.program.title)).toEqual(['early', 'late']);
expect(blocks.map((b) => b.program.title)).toEqual([
'early',
'late',
]);
});
});
@@ -167,7 +183,8 @@ describe('epg-timeline.utils', () => {
[program(localIso(2026, 6, 28, 10), localIso(2026, 6, 28, 11))],
NOW
);
const offsetForNoon = (12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
const offsetForNoon =
(12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
expect(dayKeyAtOffset(axis, offsetForNoon)).toBe('2026-06-28');
});
@@ -177,7 +194,9 @@ describe('epg-timeline.utils', () => {
program(localIso(2026, 6, 30, 10), localIso(2026, 6, 30, 11)),
];
// Jun 30 10:00 (~1d22h away) is closer to Jun 28 12:00 than Jun 25.
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe('2026-06-30');
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe(
'2026-06-30'
);
});
it('keys, positions and picks days in display time when an offset is set', () => {
@@ -189,8 +208,12 @@ describe('epg-timeline.utils', () => {
'Late'
),
];
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(false);
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(true);
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(
false
);
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(
true
);
expect(nearestDateKeyWithPrograms(programs, NOW, 60)).toBe(
'2026-06-30'
);
@@ -205,6 +228,35 @@ describe('epg-timeline.utils', () => {
});
});
describe('zoom presets', () => {
it('maps every preset scale back to its own level', () => {
for (const { level, scale } of TIMELINE_ZOOM_LEVELS) {
expect(timelineZoomLevelForScale(scale)).toBe(level);
}
});
it('classifies off-preset scales by band', () => {
expect(timelineZoomLevelForScale(0.8)).toBe('day');
expect(timelineZoomLevelForScale(1.29)).toBe('day');
expect(timelineZoomLevelForScale(1.3)).toBe('hours');
expect(timelineZoomLevelForScale(2.99)).toBe('hours');
expect(timelineZoomLevelForScale(3)).toBe('detail');
expect(timelineZoomLevelForScale(6)).toBe('detail');
});
it('cycles to the next preset and wraps from detail to day', () => {
expect(nextTimelineZoomScale(1)).toBe(1.75);
expect(nextTimelineZoomScale(1.75)).toBe(3.4);
expect(nextTimelineZoomScale(3.4)).toBe(1);
// an expanded group chip sits on the detail preset, so the cycle
// continues from there instead of re-entering "detail"
expect(nextTimelineZoomScale(TIMELINE_GROUP_EXPAND_ZOOM)).toBe(1);
// wheel-tuned values snap to the band's successor
expect(nextTimelineZoomScale(2.6)).toBe(3.4);
expect(nextTimelineZoomScale(5)).toBe(1);
});
});
describe('short-programme strategy', () => {
it('classifies content tiers by rendered width', () => {
expect(tierFor(200)).toBe('wide');
@@ -220,11 +272,19 @@ describe('epg-timeline.utils', () => {
expect(timelineTickStepForScale(6)).toBe(15);
});
function blocksFor(durations: number[]): ReturnType<typeof buildTimelineBlocks> {
function blocksFor(
durations: number[]
): ReturnType<typeof buildTimelineBlocks> {
let startMin = 6 * 60; // 06:00
const programs = durations.map((dur) => {
const p = program(
localIso(2026, 6, 28, Math.floor(startMin / 60), startMin % 60),
localIso(
2026,
6,
28,
Math.floor(startMin / 60),
startMin % 60
),
localIso(
2026,
6,
@@ -278,7 +338,10 @@ describe('epg-timeline.utils', () => {
eh: number,
em: number
): ReturnType<typeof program> =>
program(localIso(2026, 6, 28, sh, sm), localIso(2026, 6, 28, eh, em));
program(
localIso(2026, 6, 28, sh, sm),
localIso(2026, 6, 28, eh, em)
);
const programs = [
mk(11, 38, 11, 43),
mk(11, 43, 11, 48),
@@ -15,6 +15,7 @@ export interface ArtPlayerVideoSessionConfig {
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
/** Owns native video and ArtPlayer event listeners for one player instance. */
@@ -35,6 +36,11 @@ export class ArtPlayerVideoSession {
);
};
private readonly handlePlaying = (): void => {
this.clearPlaybackIssue();
this.config.emitPlaybackStarted?.();
};
private readonly clearPlaybackIssue = (): void => {
this.config.emitPlaybackIssue(null);
};
@@ -73,7 +79,7 @@ export class ArtPlayerVideoSession {
> = [
['error', this.handleNativePlaybackError],
['loadeddata', this.clearPlaybackIssue],
['playing', this.clearPlaybackIssue],
['playing', this.handlePlaying],
['volumechange', this.handleVolumeChange],
['ended', this.handlePlaybackEnded],
];
@@ -66,6 +66,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
}>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly playbackStarted = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -187,6 +188,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
this.videoSession = videoSession;
videoSession.attach();
@@ -15,6 +15,7 @@ export interface HtmlVideoElementSessionConfig {
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
export class HtmlVideoElementSession {
@@ -34,6 +35,11 @@ export class HtmlVideoElementSession {
);
};
private readonly handlePlaying = (): void => {
this.clearPlaybackIssue();
this.config.emitPlaybackStarted?.();
};
private readonly clearPlaybackIssue = (): void => {
this.config.emitPlaybackIssue(null);
};
@@ -71,7 +77,7 @@ export class HtmlVideoElementSession {
['timeupdate', this.handleTimeUpdate],
['error', this.handleNativePlaybackError],
['loadeddata', this.clearPlaybackIssue],
['playing', this.clearPlaybackIssue],
['playing', this.handlePlaying],
['ended', this.handlePlaybackEnded],
];
@@ -87,6 +87,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
}>();
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
@Output() playbackEnded = new EventEmitter<void>();
@Output() playbackStarted = new EventEmitter<void>();
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
@@ -382,6 +383,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
return this.videoSession;
}
@@ -90,6 +90,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly playbackStarted = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -103,6 +104,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
releasePictureInPicture: !this.sharedControls,
clearPlaybackIssue: () => this.playbackIssue.emit(null),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
private readonly mpegTsSession = new VjsMpegTsSession({
player: () => this.player,
@@ -146,7 +148,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
vjsOptions,
this.handlePlayerReady
) as unknown as VideoJsPlayer;
this.bindPlayerEvents();
this.bindPlayerEvents(true);
initializeVjsPlugins(this.player);
if (!this.sharedControls) {
this.legacyShortcuts = attachVjsLegacyShortcuts({
@@ -220,7 +222,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.mpegTsSession.destroy();
this.videoSession.destroy();
if (this.player) {
this.unbindPlayerEvents();
this.bindPlayerEvents(false);
this.player.dispose();
}
}
@@ -410,21 +412,18 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.legacyTracks.bind();
}
private bindPlayerEvents(): void {
this.player.on('loadedmetadata', this.handleLoadedMetadata);
this.player.on('error', this.handleVideoJsError);
this.player.on('volumechange', this.handleVolumeChange);
this.player.on('timeupdate', this.handleTimeUpdate);
this.player.on('pause', this.handlePauseForReset);
this.player.on('playerreset', this.handlePlayerReset);
}
private unbindPlayerEvents(): void {
this.player.off('loadedmetadata', this.handleLoadedMetadata);
this.player.off('error', this.handleVideoJsError);
this.player.off('volumechange', this.handleVolumeChange);
this.player.off('timeupdate', this.handleTimeUpdate);
this.player.off('pause', this.handlePauseForReset);
this.player.off('playerreset', this.handlePlayerReset);
private bindPlayerEvents(bind: boolean): void {
const events = {
loadedmetadata: this.handleLoadedMetadata,
error: this.handleVideoJsError,
volumechange: this.handleVolumeChange,
timeupdate: this.handleTimeUpdate,
pause: this.handlePauseForReset,
playerreset: this.handlePlayerReset,
};
for (const [event, listener] of Object.entries(events)) {
if (bind) this.player.on(event, listener);
else this.player.off(event, listener);
}
}
}
@@ -4,12 +4,18 @@ export interface VjsVideoElementSessionConfig {
releasePictureInPicture?: boolean;
clearPlaybackIssue: () => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
export class VjsVideoElementSession {
private currentVideo: HTMLVideoElement | null = null;
private destroyed = false;
private readonly handlePlaying = (): void => {
this.clearPlaybackIssue();
this.config.emitPlaybackStarted?.();
};
private readonly clearPlaybackIssue = () => {
this.config.clearPlaybackIssue();
};
@@ -28,7 +34,7 @@ export class VjsVideoElementSession {
this.detach();
this.currentVideo = video;
video.addEventListener('loadeddata', this.clearPlaybackIssue);
video.addEventListener('playing', this.clearPlaybackIssue);
video.addEventListener('playing', this.handlePlaying);
video.addEventListener('ended', this.emitPlaybackEnded);
}
@@ -57,10 +63,7 @@ export class VjsVideoElementSession {
'loadeddata',
this.clearPlaybackIssue
);
this.currentVideo.removeEventListener(
'playing',
this.clearPlaybackIssue
);
this.currentVideo.removeEventListener('playing', this.handlePlaying);
this.currentVideo.removeEventListener('ended', this.emitPlaybackEnded);
this.currentVideo = null;
}
@@ -0,0 +1,30 @@
import { linkedSignal } from '@angular/core';
import type { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
/**
* The fullscreen channel panel is DOM content over the video. Embedded
* MPV's native-view engine paints a platform view above the page, where
* no DOM layer can show, so the panel exists only while the rendered
* engine is a web player or Embedded MPV's frame-copy canvas. Fails
* closed until frame-copy has been confirmed. Retain that confirmation
* while a replacement component probes support, so channel changes do
* not reset the panel. A confirmed native/unsupported result revokes it.
*/
export function createChannelPanelAvailability(
embedded: () => boolean,
support: () => EmbeddedMpvSupport | null
) {
return linkedSignal<
{ embedded: boolean; support: EmbeddedMpvSupport | null },
boolean
>({
source: () => ({ embedded: embedded(), support: support() }),
computation: ({ embedded, support }, previous) => {
if (!embedded) return true;
if (support === null) {
return previous?.source.embedded ? previous.value : false;
}
return support.supported && support.engine === 'frame-copy';
},
});
}
@@ -0,0 +1,184 @@
import { Component, Injector, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import {
VideoPlayer,
type ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { ErrorDetails } from 'hls.js';
import { WebPlayerLiveAutoFormat } from './web-player-live-auto-format';
@Component({ template: '' })
class Host {}
const failure: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: '',
container: 'm3u8',
player: 'videojs',
httpStatus: 403,
audioCodecs: [],
videoCodecs: [],
};
const source: ResolvedPortalPlayback = {
streamUrl: 'https://fixture.test/stream.php?token=synthetic&extension=m3u8',
liveAutoTsUrl:
'https://fixture.test/other.php?token=synthetic&extension=ts',
title: 'Fixture',
isLive: true,
userAgent: 'fixture',
headers: { Authorization: 'Bearer synthetic' },
};
describe('Auto live source-format attempt lifecycle', () => {
const playback = signal<ResolvedPortalPlayback | null>(source);
const sessionKey = signal('playlist-a/channel-1');
const player = signal(VideoPlayer.VideoJs);
const intent = signal(0);
const autoEnabled = signal(true);
let controller: WebPlayerLiveAutoFormat;
let fixture: ReturnType<typeof TestBed.createComponent<Host>>;
beforeEach(() => {
playback.set({ ...source });
sessionKey.set('playlist-a/channel-1');
player.set(VideoPlayer.VideoJs);
intent.set(0);
autoEnabled.set(true);
fixture = TestBed.createComponent(Host);
controller = new WebPlayerLiveAutoFormat({
playback,
sessionKey,
player,
intent,
autoEnabled,
injector: fixture.debugElement.injector.get(Injector),
});
fixture.detectChanges();
});
afterEach(() => {
controller.destroy();
fixture.destroy();
});
async function render() {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('waits for the old transport render, preserves the whole payload and consumes only one attempt', async () => {
expect(controller.tryFallback(failure)).toBe(true);
expect(controller.pending()).toBe(true);
expect(controller.playback()).toBe(playback());
expect(controller.tryFallback(failure)).toBe(false);
await render();
expect(controller.playback()).toEqual({
...source,
streamUrl: source.liveAutoTsUrl,
liveAutoTsUrl: undefined,
});
const mounted = controller.playback();
controller.started();
expect(controller.playback()).toBe(mounted);
expect(controller.tryFallback(failure)).toBe(false);
intent.update((n) => n + 1);
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
player.set(VideoPlayer.Html5Player);
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
});
it('does not downgrade after actual playing', () => {
controller.started();
expect(controller.tryFallback(failure)).toBe(false);
});
it.each([0, 200, undefined])(
'does not guess from HTTP status %s',
(httpStatus) => {
expect(controller.tryFallback({ ...failure, httpStatus })).toBe(
false
);
}
);
it.each([
PlaybackDiagnosticCode.UnknownPlaybackError,
PlaybackDiagnosticCode.DrmOrEncryption,
PlaybackDiagnosticCode.MediaDecodeError,
])('does not downgrade %s', (code) => {
expect(controller.tryFallback({ ...failure, code })).toBe(false);
});
it.each(['key', 'media', 'unknown'] as const)(
'does not downgrade HLS %s failures even with an HTTP status',
(stage) => {
expect(
controller.tryFallback({
...failure,
hls: {
engineType: 'unknown',
engineDetails: ErrorDetails.KEY_LOAD_ERROR,
disposition: 'fatal',
stage,
failure: 'http',
httpStatus: 403,
},
})
).toBe(false);
}
);
it('respects manual preference changes before the failure and before pending completion', async () => {
autoEnabled.set(false);
expect(controller.tryFallback(failure)).toBe(false);
autoEnabled.set(true);
expect(controller.tryFallback(failure)).toBe(true);
autoEnabled.set(false);
await render();
expect(controller.playback()).toBe(playback());
});
it('ignores a delayed completion after another playlist or channel takes ownership', async () => {
controller.tryFallback(failure);
sessionKey.set('playlist-b/channel-1');
playback.set({ ...source, title: 'Other playlist' });
await render();
expect(controller.playback()).toBe(playback());
expect(controller.tryFallback(failure)).toBe(true);
await render();
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
});
it('cancels on disposal and does not transfer attempts to another mounted session', async () => {
controller.tryFallback(failure);
controller.destroy();
await render();
expect(controller.playback()).toBe(playback());
expect(controller.tryFallback(failure)).toBe(false);
});
it('does not rearm when same-session source metadata refreshes', async () => {
controller.tryFallback(failure);
await render();
playback.set({ ...source, title: 'Refreshed' });
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
expect(controller.playback()?.title).toBe('Refreshed');
playback.set({ ...source, streamUrl: 'https://other.test/live.m3u8' });
expect(controller.playback()?.streamUrl).toBe(
'https://other.test/live.m3u8'
);
expect(controller.tryFallback(failure)).toBe(false);
});
it.each([
{ isLive: false },
{ liveAutoTsUrl: undefined },
{ drm: {} },
{ contentInfo: {} },
])(
'excludes VOD, catchup, missing advertisement and DRM: %j',
(overrides) => {
playback.set({ ...source, ...overrides } as ResolvedPortalPlayback);
expect(controller.tryFallback(failure)).toBe(false);
}
);
it('does not start an Embedded MPV attempt', () => {
player.set(VideoPlayer.EmbeddedMpv);
expect(controller.tryFallback(failure)).toBe(false);
});
});
@@ -0,0 +1,171 @@
import {
afterNextRender,
computed,
linkedSignal,
type AfterRenderRef,
type Injector,
type Signal,
} from '@angular/core';
import {
type PlaybackDiagnostic,
resolvePlaybackUrlSourceKind,
} from '@iptvnator/playback/util';
import {
type ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
/** One source-format attempt per mounted logical live session, never an engine switch. */
export class WebPlayerLiveAutoFormat {
private callback?: AfterRenderRef;
private destroyed = false;
// Metadata refreshes reuse the transport revision. The returned ownership
// value is opaque; URLs/headers remain solely in the canonical source input.
private readonly origin = linkedSignal({
source: () => this.deps.playback(),
computation: (
source,
previous?: { source: ResolvedPortalPlayback | null; value: symbol }
) =>
previous && sameLiveTransport(source, previous.source)
? previous.value
: Symbol(),
});
private readonly intent = computed(() => {
void this.deps.intent();
void this.deps.player();
void this.deps.autoEnabled();
return Symbol();
});
private readonly state = linkedSignal({
source: () => ({ key: this.deps.sessionKey(), origin: this.origin() }),
computation: (
source,
previous?: {
source: { key: string; origin: symbol };
value: {
attempted: boolean;
started: boolean;
pending: boolean;
useTs: boolean;
};
}
) => ({
attempted:
previous?.source.key === source.key &&
!!previous.value.attempted,
started:
previous?.source.key === source.key && !!previous.value.started,
pending: false,
useTs: false,
}),
});
readonly pending = computed(() => this.state().pending);
private readonly useTs = computed(() => this.state().useTs);
readonly playback = computed(() => {
const playback = this.deps.playback();
return this.useTs() && playback?.liveAutoTsUrl
? {
...playback,
streamUrl: playback.liveAutoTsUrl,
liveAutoTsUrl: undefined,
}
: playback;
});
constructor(
private readonly deps: {
playback: Signal<ResolvedPortalPlayback | null>;
sessionKey: Signal<string>;
player: Signal<VideoPlayer>;
intent: () => unknown;
autoEnabled: () => boolean;
injector: Injector;
}
) {}
started(): void {
this.state.update((state) => ({ ...state, started: true }));
}
tryFallback(issue: PlaybackDiagnostic): boolean {
const source = this.deps.playback();
const state = this.state();
const status = issue.httpStatus;
if (
this.destroyed ||
!this.deps.autoEnabled() ||
state.attempted ||
state.started ||
!this.deps.sessionKey() ||
!source?.liveAutoTsUrl ||
source.isLive !== true ||
source.contentInfo ||
source.drm ||
this.deps.player() === VideoPlayer.EmbeddedMpv ||
resolvePlaybackUrlSourceKind(source.streamUrl) !== 'hls' ||
resolvePlaybackUrlSourceKind(source.liveAutoTsUrl) !== 'mpegts' ||
issue.code !== 'network-error' ||
status === undefined ||
status < 400 ||
status > 599 ||
// Key/license requests must never cause a format downgrade.
(issue.hls &&
!['manifest', 'level', 'segment'].includes(issue.hls.stage))
)
return false;
this.state.set({ ...state, attempted: true, pending: true });
const origin = this.origin();
const key = this.deps.sessionKey();
const intent = this.intent();
this.callback?.destroy();
// Render an empty application first. All web transports synchronously destroy
// their loaders in ngOnDestroy before the TS header handoff may start.
this.callback = afterNextRender(
() => {
this.callback = undefined;
if (
this.destroyed ||
origin !== this.origin() ||
key !== this.deps.sessionKey()
)
return;
this.state.update((current) => ({
...current,
pending: false,
useTs: intent === this.intent(),
}));
},
{ injector: this.deps.injector }
);
return true;
}
destroy(): void {
this.destroyed = true;
this.callback?.destroy();
this.callback = undefined;
}
}
function sameLiveTransport(
a: ResolvedPortalPlayback | null,
b: ResolvedPortalPlayback | null
): boolean {
if (!a || !b) return a === b;
const headerKeys = new Set([
...Object.keys(a.headers ?? {}),
...Object.keys(b.headers ?? {}),
]);
return (
a.streamUrl === b.streamUrl &&
a.liveAutoTsUrl === b.liveAutoTsUrl &&
a.isLive === b.isLive &&
a.drm === b.drm &&
a.contentInfo === b.contentInfo &&
a.userAgent === b.userAgent &&
a.referer === b.referer &&
a.origin === b.origin &&
[...headerKeys].every((key) => a.headers?.[key] === b.headers?.[key])
);
}
@@ -40,6 +40,7 @@ export interface WebPlayerRecoveryControllerDeps {
readonly playbackExternallyTransferable: Signal<boolean>;
readonly alternativeSourceCount: () => number;
readonly managedExternalPlayersAvailable: () => boolean;
readonly tryAutoLiveFormat?: (issue: PlaybackDiagnostic) => boolean;
readonly emitPlaybackFailed: (code: PlaybackDiagnosticCode) => void;
readonly emitExternalFallbackRequested: (
request: PlaybackFallbackRequest
@@ -120,6 +121,12 @@ export class WebPlayerRecoveryController {
this.clearDiagnostic();
return;
}
if (this.deps.tryAutoLiveFormat?.(issue)) {
applicationHandoff.invalidate();
recoverySession.clearPlaybackBinding();
this.clearDiagnostic();
return;
}
if (!recoverySession.recordFailure(binding)) {
return;
}
@@ -14,6 +14,7 @@
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackStarted)="handlePlaybackStarted(binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
@@ -42,6 +43,7 @@
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackStarted)="handlePlaybackStarted(binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
@@ -70,6 +72,7 @@
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackStarted)="handlePlaybackStarted(binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
@@ -0,0 +1,288 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ComponentFixture,
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { DestroyRef, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
type ExternalPlayerSession,
STORE_KEY,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { of } from 'rxjs';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
} from './web-player-view.spec-stubs';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
jest.unstable_mockModule('video.js', () => ({ default: jest.fn() }));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
describe('WebPlayerViewComponent live format integration', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
let component: WebPlayerViewComponentInstance;
const storedSettings = { player: VideoPlayer.VideoJs };
const storage = {
get: jest.fn((key: string) =>
of(key === STORE_KEY.Settings ? storedSettings : undefined)
),
};
let runtime: { supportsManagedExternalPlayers: boolean };
let activeExternalSession: ReturnType<
typeof signal<ExternalPlayerSession | null>
>;
let closeExternalSession: jest.Mock<Promise<void>, [ExternalPlayerSession]>;
let holdHeaderHandoff: boolean;
let headerResolvers: Array<(stillCurrent: boolean) => void>;
let headerRejectors: Array<(reason?: unknown) => void>;
const streamHeaders = {
apply: jest.fn(
() =>
(holdHeaderHandoff
? new Promise<boolean>((resolve, reject) => {
headerResolvers.push(resolve);
headerRejectors.push(reject);
})
: null) as Promise<boolean> | null
),
clear: jest.fn(),
};
beforeAll(async () => {
({ WebPlayerViewComponent } =
await import('./web-player-view.component'));
});
beforeEach(async () => {
runtime = { supportsManagedExternalPlayers: true };
activeExternalSession = signal<ExternalPlayerSession | null>(null);
closeExternalSession = jest.fn(async (session) => {
activeExternalSession.set({
...session,
status: 'closed',
canClose: false,
updatedAt: '2026-08-08T10:00:02.000Z',
});
});
holdHeaderHandoff = false;
headerResolvers = [];
headerRejectors = [];
streamHeaders.apply.mockClear();
streamHeaders.clear.mockClear();
await TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Playthrough,
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
providers: [
{ provide: StorageMap, useValue: storage },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{
provide: ElectronStreamHeadersService,
useValue: streamHeaders,
},
{
provide: SettingsStore,
useValue: {
showCaptions: () => false,
webPlayerSharedControls: () => false,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: activeExternalSession,
visibleSession: activeExternalSession,
dismissActiveSession: jest.fn(),
closeSession: closeExternalSession,
},
},
],
})
.overrideComponent(WebPlayerViewComponent, {
set: {
imports: [
ClipboardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
PlaybackDiagnosticPanelComponent,
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
TranslateModule,
VodSourceRowComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WebPlayerViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
'https://example.com/live.m3u8'
);
fixture.componentRef.setInput('title', 'Recovery stream');
fixture.componentRef.setInput('playbackSessionKey', 'content-a');
});
afterEach(() => fixture.destroy());
it('tries advertised TS once after initial terminal HLS HTTP failure', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/live.ts',
headers: { 'User-Agent': 'fixture-agent' },
});
await render();
const oldBinding = currentBinding();
component.handlePlaybackIssue(
{ ...networkIssue('videojs'), httpStatus: 403 },
oldBinding
);
await render();
expect(component.resolvedPlayback().streamUrl).toBe(
'https://example.com/live.ts'
);
expect(component.resolvedPlayback().headers).toEqual({
'User-Agent': 'fixture-agent',
});
expect(component.selectedPlayer()).toBe(VideoPlayer.VideoJs);
const tsBinding = currentBinding();
component.handlePlaybackIssue(networkIssue('videojs'), tsBinding);
await render();
expect(component.activeBinding()).toBe(tsBinding);
expect(component.visiblePlaybackDiagnostic()).not.toBeNull();
component.handlePlaybackIssue(networkIssue('videojs'), oldBinding);
expect(component.activeBinding()).toBe(tsBinding);
});
it('uses actual playing output to suppress fallback after playback starts', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/live.ts',
});
await render();
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
player.playbackStarted.emit();
player.playbackIssue.emit({
...networkIssue('videojs'),
httpStatus: 403,
});
await render();
expect(component.resolvedPlayback().streamUrl).toBe(
'https://example.com/live.m3u8'
);
expect(component.visiblePlaybackDiagnostic()).not.toBeNull();
});
it('destroys the previous transport before the TS header handoff', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/live.ts',
});
await render();
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
);
const events: string[] = [];
player.injector
.get(DestroyRef)
.onDestroy(() => events.push('destroy-hls'));
streamHeaders.apply.mockImplementationOnce(() => {
events.push('ts-headers');
return null;
});
(player.componentInstance as StubVjsPlayerComponent).playbackIssue.emit(
{ ...networkIssue('videojs'), httpStatus: 403 }
);
await render();
expect(events).toEqual(['destroy-hls', 'ts-headers']);
});
it('ignores playing and failure from the old channel before the new render', async () => {
await render();
const oldBinding = currentBinding();
fixture.componentRef.setInput(
'playbackSessionKey',
'another-playlist/channel'
);
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/new.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/new.ts',
});
component.handlePlaybackStarted(oldBinding);
component.handlePlaybackIssue(
{ ...networkIssue('videojs'), httpStatus: 403 },
oldBinding
);
await render();
const binding = currentBinding();
component.handlePlaybackIssue(
{ ...networkIssue('videojs'), httpStatus: 403 },
binding
);
await render();
expect(component.resolvedPlayback().streamUrl).toBe(
'https://example.com/new.ts'
);
});
function currentBinding() {
const current = component.activeBinding();
if (!current) throw new Error('Expected a mounted playback binding');
return current;
}
async function render(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
});
function networkIssue(player: 'videojs' | 'html5'): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: 'https://example.com/live.m3u8',
container: 'm3u8',
player,
audioCodecs: [],
videoCodecs: [],
};
}
@@ -1,8 +1,8 @@
import {
Component,
ElementRef,
Injector,
OnDestroy,
type Signal,
ViewEncapsulation,
computed,
effect,
@@ -25,7 +25,6 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
type Channel,
type EmbeddedMpvSupport,
type RecordingStartMetadata,
type RecordingStoppedEvent,
type ResolvedPortalPlayback,
@@ -57,6 +56,8 @@ import {
} from './web-player-application-ownership';
import { createWebPlayerApplicationState } from './web-player-application-state';
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
import { createChannelPanelAvailability } from './web-player-channel-panel-state';
import { WebPlayerLiveAutoFormat } from './web-player-live-auto-format';
import { WebPlayerRecoveryController } from './web-player-recovery-controller';
import {
isPlaybackExternallyTransferable,
@@ -65,11 +66,6 @@ import {
toVideoPlayer,
} from './web-player-recovery-policy';
/** What the view needs from a rendered Embedded MPV player: its engine. */
interface EmbeddedMpvEngineReporter {
readonly support: Signal<EmbeddedMpvSupport | null>;
}
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
const fallback = WEB_PLAYER_SHARED_CONTROLS_ENABLED;
@@ -113,34 +109,11 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly fullscreenSurface: HTMLElement = inject(ElementRef<HTMLElement>)
.nativeElement;
private readonly embeddedMpvPlayer =
viewChild<EmbeddedMpvEngineReporter>('embeddedMpvPlayer');
/**
* The fullscreen channel panel is DOM content over the video. Embedded
* MPV's native-view engine paints a platform view above the page, where
* no DOM layer can show, so the panel exists only while the rendered
* engine is a web player or Embedded MPV's frame-copy canvas. Fails
* closed until frame-copy has been confirmed. Retain that confirmation
* while a replacement component probes support, so channel changes do
* not reset the panel. A confirmed native/unsupported result revokes it.
*/
readonly channelPanelAvailable = linkedSignal<
{ embedded: boolean; support: EmbeddedMpvSupport | null },
boolean
>({
source: () => ({
embedded: this.renderedApplications().some(
(application) => application.embeddedMpv
),
support: this.embeddedMpvPlayer()?.support() ?? null,
}),
computation: ({ embedded, support }, previous) => {
if (!embedded) return true;
if (support === null) {
return previous?.source.embedded ? previous.value : false;
}
return support.supported && support.engine === 'frame-copy';
},
});
viewChild<EmbeddedMpvPlayerComponent>('embeddedMpvPlayer');
readonly channelPanelAvailable = createChannelPanelAvailability(
() => this.renderedApplications().some((app) => app.embeddedMpv),
() => this.embeddedMpvPlayer()?.support() ?? null
);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, {
@@ -223,8 +196,17 @@ export class WebPlayerViewComponent implements OnDestroy {
const temporary = this.recoverySession.temporaryPlayerOverride();
return temporary ? toVideoPlayer(temporary) : this.renderablePlayer();
});
private readonly applicationState = createWebPlayerApplicationState({
private readonly liveAutoFormat = new WebPlayerLiveAutoFormat({
playback: this.playback,
sessionKey: this.playbackSessionKey,
player: this.selectedPlayer,
intent: this.reloadToken,
autoEnabled: () =>
(this.settingsStore.streamFormat?.() ?? 'auto') === 'auto',
injector: inject(Injector),
});
private readonly applicationState = createWebPlayerApplicationState({
playback: this.liveAutoFormat.playback,
streamUrl: this.streamUrl,
title: this.title,
startTime: this.startTime,
@@ -243,7 +225,7 @@ export class WebPlayerViewComponent implements OnDestroy {
externalRecovery: this.externalRecovery,
applicationHandoff: this.applicationHandoff,
playbackSessionKey: this.playbackSessionKey,
playback: this.playback,
playback: this.liveAutoFormat.playback,
streamUrl: this.streamUrl,
startTime: this.startTime,
selectedPlayer: this.selectedPlayer,
@@ -255,6 +237,7 @@ export class WebPlayerViewComponent implements OnDestroy {
alternativeSourceCount: () => this.alternativeSources().length,
managedExternalPlayersAvailable: () =>
this.runtime.supportsManagedExternalPlayers,
tryAutoLiveFormat: (issue) => this.liveAutoFormat.tryFallback(issue),
emitPlaybackFailed: (code) => this.playbackFailed.emit(code),
emitExternalFallbackRequested: (request) =>
this.externalFallbackRequested.emit(request),
@@ -284,6 +267,7 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly renderedApplications = computed<
readonly PlaybackApplicationOwnership[]
>(() => {
if (this.liveAutoFormat.pending()) return [];
const binding = this.activeBinding();
const embeddedMpv =
this.selectedPlayer() === VideoPlayer.EmbeddedMpv && !binding;
@@ -311,6 +295,7 @@ export class WebPlayerViewComponent implements OnDestroy {
// Session sync may clear a temporary player override. Run it before
// tracking intent so that reset is folded into this application.
this.recovery.syncSession();
if (this.liveAutoFormat.pending()) return;
void this.recovery.diagnosticIntentToken();
const sourceRevision = this.playbackSourceRevisionToken();
untracked(() =>
@@ -349,6 +334,7 @@ export class WebPlayerViewComponent implements OnDestroy {
}
ngOnDestroy(): void {
this.liveAutoFormat.destroy();
this.externalRecovery.destroy();
this.applicationHandoff.destroy();
}
@@ -360,6 +346,18 @@ export class WebPlayerViewComponent implements OnDestroy {
this.recovery.handlePlaybackIssue(issue, binding);
}
handlePlaybackStarted(binding: PlaybackBinding): void {
this.recovery.syncSession();
if (
this.applicationHandoff.owns(
binding,
this.playbackApplicationToken()
) &&
!this.liveAutoFormat.pending()
)
this.liveAutoFormat.started();
}
handleTimeUpdate(
event: { currentTime: number; duration: number },
ownership: PlaybackApplicationOwnership
@@ -27,6 +27,7 @@ export class StubVjsPlayerComponent {
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -48,6 +49,7 @@ export class StubHtmlVideoPlayerComponent {
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -69,6 +71,7 @@ export class StubArtPlayerComponent {
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -58,7 +58,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
let liveSidebarService: LiveLayoutSidebarStateService;
beforeEach(async () => {
localStorage.removeItem('live-sidebar-state');
localStorage.removeItem('live-sidebar-state:portal');
await TestBed.configureTestingModule({
imports: [WorkspaceShellContextSidebarComponent],
@@ -99,7 +99,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
fixture = TestBed.createComponent(WorkspaceShellContextSidebarComponent);
liveSidebarService = TestBed.inject(LiveLayoutSidebarStateService);
liveSidebarService.setState('expanded');
liveSidebarService.setState('portal', 'expanded');
});
it('closes the phone drawer from its always-available close button', () => {
@@ -170,7 +170,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('collapses the categories rail when the shared service is collapsed on a live route', () => {
setupLiveCategory('live');
liveSidebarService.setState('collapsed');
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -184,7 +184,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('also collapses on the Stalker itv section', () => {
setupLiveCategory('itv');
liveSidebarService.setState('collapsed');
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -198,7 +198,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('does not collapse the categories rail on non-live sections', () => {
setupLiveCategory('vod');
liveSidebarService.setState('collapsed');
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -70,7 +70,7 @@ export class WorkspaceShellContextSidebarComponent {
readonly isContextPanelCollapsed = computed(
() =>
this.isLiveCategoryRoute() &&
this.liveSidebarStateService.isCollapsed()
this.liveSidebarStateService.isCollapsedFor('portal')()
);
closeDrawer(): void {
@@ -115,6 +115,22 @@
<mat-icon>add</mat-icon>
</button>
@if (!isSettingsRoute()) {
@if (headerSidebarToggle(); as sidebarToggle) {
<button
type="button"
class="header-shortcut header-sidebar-toggle"
[class.header-sidebar-toggle--collapsed]="
!sidebarToggle.expanded
"
mat-icon-button
(click)="onHeaderSidebarToggleRequested()"
[matTooltip]="sidebarToggle.tooltip"
[attr.aria-label]="sidebarToggle.ariaLabel"
[attr.aria-pressed]="sidebarToggle.expanded"
>
<mat-icon>view_sidebar</mat-icon>
</button>
}
@if (headerShortcut(); as action) {
<button
type="button"
@@ -209,6 +209,12 @@
}
}
// The rail is visible most of the time, so the toggle stays neutral then and
// only tints once the rail is hidden: the exception is what deserves the cue.
.header-sidebar-toggle--collapsed {
color: var(--mat-sys-primary);
}
.header-actions--leading {
margin-left: -4px;
}
@@ -392,6 +398,13 @@
.shortcuts-trigger {
display: none;
}
// At the phone breakpoint the live rail is a bottom drawer with its
// own toggle, and the header has no room left for another permanent
// icon: it already holds the drawer toggle, switcher, search and Add.
.header-sidebar-toggle {
display: none;
}
}
}
@@ -158,6 +158,71 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(requested).toHaveBeenCalledTimes(1);
});
it('does not render the live rail toggle when the route has no rail', () => {
expect(
fixture.nativeElement.querySelector('.header-sidebar-toggle')
).toBeNull();
});
it('renders the live rail toggle in both rail states and emits toggle requests', () => {
const requested = jest.fn();
component.headerSidebarToggleRequested.subscribe(requested);
fixture.componentRef.setInput('headerSidebarToggle', {
expanded: true,
tooltip: 'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP',
ariaLabel: 'LAYOUT.HIDE_CHANNELS_LIST',
});
fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'.header-sidebar-toggle'
);
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.getAttribute('aria-label')).toBe(
'LAYOUT.HIDE_CHANNELS_LIST'
);
expect(button.classList).not.toContain(
'header-sidebar-toggle--collapsed'
);
button.click();
expect(requested).toHaveBeenCalledTimes(1);
// The control stays in place once the rail is hidden; only its
// pressed state, label and tint change.
fixture.componentRef.setInput('headerSidebarToggle', {
expanded: false,
tooltip: 'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP',
ariaLabel: 'LAYOUT.SHOW_CHANNELS_LIST',
});
fixture.detectChanges();
const collapsedButton: HTMLButtonElement =
fixture.nativeElement.querySelector('.header-sidebar-toggle');
expect(collapsedButton).toBe(button);
expect(collapsedButton.getAttribute('aria-pressed')).toBe('false');
expect(collapsedButton.getAttribute('aria-label')).toBe(
'LAYOUT.SHOW_CHANNELS_LIST'
);
expect(collapsedButton.classList).toContain(
'header-sidebar-toggle--collapsed'
);
});
it('hides the live rail toggle on the settings route', () => {
fixture.componentRef.setInput('headerSidebarToggle', {
expanded: true,
tooltip: 'tooltip',
ariaLabel: 'aria',
});
fixture.componentRef.setInput('isSettingsRoute', true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.header-sidebar-toggle')
).toBeNull();
});
it('renders scope and status chips when search metadata is provided', () => {
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
fixture.componentRef.setInput(
@@ -14,7 +14,10 @@ import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shell-constants';
import {
WorkspaceHeaderBulkAction,
WorkspaceHeaderSidebarToggle,
} from '../../services/helpers/workspace-shell-constants';
@Component({
selector: 'app-workspace-shell-header',
@@ -54,6 +57,9 @@ export class WorkspaceShellHeaderComponent {
readonly searchStatusLabel = input('');
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = input<WorkspaceHeaderSidebarToggle | null>(
null
);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
@@ -101,6 +107,7 @@ export class WorkspaceShellHeaderComponent {
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
@@ -167,6 +174,10 @@ export class WorkspaceShellHeaderComponent {
this.headerBulkActionRequested.emit();
}
onHeaderSidebarToggleRequested(): void {
this.headerSidebarToggleRequested.emit();
}
onRefreshPlaylistRequested(): void {
this.refreshPlaylistRequested.emit();
}
@@ -43,3 +43,15 @@ export interface WorkspaceHeaderBulkAction {
ariaLabel: string;
disabled: boolean;
}
/**
* Header toggle for the live-channel rail of the current route. It exists in
* both rail states so the control never moves: inside the rail the chevron
* disappears together with the rail it hides.
*/
export interface WorkspaceHeaderSidebarToggle {
/** True while the rail is visible; drives `aria-pressed`. */
expanded: boolean;
tooltip: string;
ariaLabel: string;
}
@@ -10,6 +10,8 @@ import {
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/ui';
import {
LiveLayoutSidebarStateService,
resolveRouteLiveSidebarSurface,
WorkspaceHeaderAction,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
@@ -30,6 +32,7 @@ import {
CLEAR_RECENTLY_VIEWED_ARIA,
CLEAR_RECENTLY_VIEWED_TOOLTIP,
WorkspaceHeaderBulkAction,
WorkspaceHeaderSidebarToggle,
} from './helpers/workspace-shell-constants';
import { bumpRefreshQueryParam } from './helpers/workspace-shell-route-utils';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
@@ -48,6 +51,7 @@ export class WorkspaceShellHeaderService {
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly liveSidebar = inject(LiveLayoutSidebarStateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -75,6 +79,38 @@ export class WorkspaceShellHeaderService {
return action;
});
/**
* The live rail of the current route, when the route renders one itself.
* The header toggle needs a rail that exists in both states; collection
* pages keep their own toggle beside the live/movies/series switch.
*/
private readonly liveSidebarSurface = computed(() =>
resolveRouteLiveSidebarSurface(
this.routeState.currentContext()?.provider,
this.routeState.currentSection()
)
);
readonly headerSidebarToggle = computed<WorkspaceHeaderSidebarToggle | null>(
() => {
this.languageTick();
const surface = this.liveSidebarSurface();
if (!surface) {
return null;
}
const collapsed = this.liveSidebar.isCollapsedFor(surface)();
return {
expanded: !collapsed,
tooltip: this.translateText('LAYOUT.TOGGLE_SIDEBAR_TOOLTIP'),
ariaLabel: this.translateText(
collapsed
? 'LAYOUT.SHOW_CHANNELS_LIST'
: 'LAYOUT.HIDE_CHANNELS_LIST'
),
};
}
);
readonly canOpenPlaylistInfo = computed(() =>
Boolean(this.routeState.activePlaylist())
);
@@ -219,6 +255,13 @@ export class WorkspaceShellHeaderService {
}
}
toggleLiveSidebar(): void {
const surface = this.liveSidebarSurface();
if (surface) {
this.liveSidebar.toggle(surface);
}
}
navigateToGlobalFavorites(): void {
void this.router.navigate(['/workspace/global-favorites']);
}
@@ -127,6 +127,7 @@ export class WorkspaceShellFacade {
readonly canRefreshPlaylist = this.header.canRefreshPlaylist;
readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist;
readonly headerBulkAction = this.header.headerBulkAction;
readonly headerSidebarToggle = this.header.headerSidebarToggle;
readonly playlistSubtitle = this.header.playlistSubtitle;
readonly activeDownloadsCount = computed(() =>
this.supportsDownloads ? this.downloadsService.activeCount() : 0
@@ -205,6 +206,10 @@ export class WorkspaceShellFacade {
return this.header.runHeaderBulkAction();
}
toggleLiveSidebar(): void {
this.header.toggleLiveSidebar();
}
navigateToGlobalFavorites(): void {
this.header.navigateToGlobalFavorites();
}
@@ -39,6 +39,7 @@
[searchStatusLabel]="facade.searchStatusLabel()"
[headerShortcut]="facade.headerShortcut()"
[headerBulkAction]="facade.headerBulkAction()"
[headerSidebarToggle]="facade.headerSidebarToggle()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
@@ -59,6 +60,7 @@
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
(headerShortcutRequested)="facade.runHeaderShortcut()"
(headerBulkActionRequested)="facade.runHeaderBulkAction()"
(headerSidebarToggleRequested)="facade.toggleLiveSidebar()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
(accountInfoRequested)="facade.openAccountInfo()"
@@ -64,6 +64,7 @@ class MockWorkspaceShellHeaderComponent {
readonly contextDrawerToggleAriaKey = input('');
readonly contextDrawerTooltipKey = input('');
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = input<unknown>(null);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
@@ -73,6 +74,7 @@ class MockWorkspaceShellHeaderComponent {
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly contextDrawerToggleRequested = output<void>();
@@ -162,6 +164,8 @@ class MockWorkspaceShellFacade {
readonly isDownloadsView = signal(false);
readonly activeDownloadsCount = signal(3);
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = signal(null);
toggleLiveSidebar = jest.fn();
readonly showContextPanel = signal(true);
readonly hasContextPanelContent = signal(true);
readonly contextDrawerLabelKeys = signal({