mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
+49
@@ -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);
|
||||
});
|
||||
});
|
||||
+37
@@ -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>();
|
||||
}
|
||||
+16
@@ -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>
|
||||
+21
@@ -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;
|
||||
|
||||
+55
@@ -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);
|
||||
});
|
||||
});
|
||||
+14
-2
@@ -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>();
|
||||
}
|
||||
+1
@@ -191,6 +191,7 @@
|
||||
(reorderItems)="onReorder($event)"
|
||||
(itemPlayed)="onItemPlayed($event)"
|
||||
(autoOpenHandled)="onLiveAutoOpenHandled()"
|
||||
(restoreSidebarRequested)="toggleSidebar()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
+63
-8
@@ -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;
|
||||
}
|
||||
|
||||
+30
-2
@@ -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 {
|
||||
|
||||
+4
@@ -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"
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
}
|
||||
+273
@@ -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');
|
||||
});
|
||||
});
|
||||
+344
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -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)"
|
||||
/>
|
||||
|
||||
+76
-43
@@ -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 {
|
||||
|
||||
+4
-1
@@ -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() } },
|
||||
|
||||
+4
-1
@@ -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() } },
|
||||
|
||||
+4
-1
@@ -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() } },
|
||||
|
||||
+57
-6
@@ -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}
|
||||
|
||||
+184
@@ -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);
|
||||
});
|
||||
});
|
||||
+23
-2
@@ -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>
|
||||
+48
-10
@@ -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(
|
||||
|
||||
+57
-27
@@ -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,
|
||||
|
||||
+282
@@ -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();
|
||||
});
|
||||
});
|
||||
+245
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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(
|
||||
|
||||
+35
@@ -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()
|
||||
|
||||
+288
@@ -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>();
|
||||
|
||||
+5
-5
@@ -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(
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export class WorkspaceShellContextSidebarComponent {
|
||||
readonly isContextPanelCollapsed = computed(
|
||||
() =>
|
||||
this.isLiveCategoryRoute() &&
|
||||
this.liveSidebarStateService.isCollapsed()
|
||||
this.liveSidebarStateService.isCollapsedFor('portal')()
|
||||
);
|
||||
|
||||
closeDrawer(): void {
|
||||
|
||||
+16
@@ -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"
|
||||
|
||||
+13
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -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(
|
||||
|
||||
+12
-1
@@ -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();
|
||||
}
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
+43
@@ -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']);
|
||||
}
|
||||
|
||||
+5
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user