mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
465c5dc5d1
commit
cb599c7bb4
17 files changed
+295
-68
No files matched your search
@@ -1138,6 +1138,21 @@ in the main process (manual mappings first, then the metadata lookup shared
|
||||
with the sidebar) and return programmes keyed by the requested key. Queries
|
||||
are unscoped, like the timeline.
|
||||
|
||||
Row ids are not channel ids: `createChannel` falls back to the stream URL for
|
||||
an entry without an explicit id, so one stream listed in two groups yields two
|
||||
channels sharing an id. The adapter numbers repeats within the scope (`<id>`,
|
||||
`<id>#1`, …) — the guide keys its programme, coverage and selection maps by
|
||||
row id, so without that both copies lit up as playing and activating either
|
||||
one played the first. The active channel marks the FIRST row carrying its id,
|
||||
and `activate(rowId)` resolves the row back to its own channel. Opening the
|
||||
guide mirrors the sidebar view (`applyInitialScope`): favorites stays
|
||||
favorites, and the groups view opens on the group the sidebar's rail is
|
||||
SHOWING — forwarded from `GroupsViewComponent.selectedGroupChange` through the
|
||||
channel list container and `app-sidebar` into
|
||||
`VideoPlayerComponent.selectedSidebarGroup` — because the user may have
|
||||
browsed away from the playing channel's group before opening the guide. The
|
||||
playing channel's group is the fallback, then `all`.
|
||||
|
||||
Guide mode is host layout, not an overlay: `VideoPlayerComponent.guideOpen`
|
||||
hides the sidebar and the timeline, renders the guide, and CSS reflows the
|
||||
untouched `app-web-player-view` into a 128 px docked strip
|
||||
|
||||
+49
-1
@@ -45,6 +45,7 @@ describe('M3uEpgGuideSourceService', () => {
|
||||
const channels = signal<Channel[]>([]);
|
||||
const favoriteKeys = signal<string[]>([]);
|
||||
const activeChannel = signal<Channel | null>(null);
|
||||
const selectedGroup = signal<string | null>(null);
|
||||
const dispatch = jest.fn();
|
||||
const getProgramsForChannels = jest.fn();
|
||||
const getProgramCoverage = jest.fn();
|
||||
@@ -67,6 +68,7 @@ describe('M3uEpgGuideSourceService', () => {
|
||||
]);
|
||||
favoriteKeys.set(['https://example.com/b.m3u8']);
|
||||
activeChannel.set(channels()[0]);
|
||||
selectedGroup.set(null);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
M3uEpgGuideSourceService,
|
||||
@@ -95,7 +97,7 @@ describe('M3uEpgGuideSourceService', () => {
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(M3uEpgGuideSourceService);
|
||||
service.bind({ channels, favoriteKeys, activeChannel });
|
||||
service.bind({ channels, favoriteKeys, activeChannel, selectedGroup });
|
||||
});
|
||||
|
||||
it('offers all / groups / favorites scopes and lists channels in playlist order', () => {
|
||||
@@ -169,6 +171,35 @@ describe('M3uEpgGuideSourceService', () => {
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps duplicate channel ids apart and activates the row that was clicked', () => {
|
||||
// `createChannel` falls back to the URL as the id, so one stream
|
||||
// listed twice yields two channels sharing an id.
|
||||
const duplicated = signal<Channel[]>([
|
||||
makeChannel('dup', { name: 'First copy', group: 'News' }),
|
||||
makeChannel('dup', { name: 'Second copy', group: 'News' }),
|
||||
]);
|
||||
service.bind({
|
||||
channels: duplicated,
|
||||
favoriteKeys,
|
||||
activeChannel: signal<Channel | null>(duplicated()[0]),
|
||||
selectedGroup,
|
||||
});
|
||||
|
||||
const rowIds = service.channels().map((channel) => channel.id);
|
||||
expect(rowIds).toEqual(['dup', 'dup#1']);
|
||||
// The playing channel marks the first of the two rows.
|
||||
expect(service.activeChannelId()).toBe('dup');
|
||||
|
||||
service.activate('dup#1');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
ChannelActions.setActiveChannel({
|
||||
channel: duplicated()[1],
|
||||
startPlayback: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds the initial scope from the sidebar view', () => {
|
||||
service.applyInitialScope('favorites');
|
||||
expect(service.scopeId()).toBe('favorites');
|
||||
@@ -178,6 +209,22 @@ describe('M3uEpgGuideSourceService', () => {
|
||||
expect(service.scopeId()).toBe('all');
|
||||
});
|
||||
|
||||
it("prefers the sidebar's selected group over the playing channel's group", () => {
|
||||
selectedGroup.set('Sports');
|
||||
|
||||
service.applyInitialScope('groups');
|
||||
|
||||
expect(service.scopeId()).toBe('group:Sports');
|
||||
});
|
||||
|
||||
it('falls back to the playing group when the selected one offers no rows', () => {
|
||||
selectedGroup.set('Nothing Here');
|
||||
|
||||
service.applyInitialScope('groups');
|
||||
|
||||
expect(service.scopeId()).toBe('group:News');
|
||||
});
|
||||
|
||||
it("falls back to the all scope when the active channel's group has no eligible channels", () => {
|
||||
const sportsOnlyChannels = signal<Channel[]>([
|
||||
makeChannel('b', { name: 'Beta', group: 'Sports' }),
|
||||
@@ -189,6 +236,7 @@ describe('M3uEpgGuideSourceService', () => {
|
||||
channels: sportsOnlyChannels,
|
||||
favoriteKeys,
|
||||
activeChannel: newsActiveChannel,
|
||||
selectedGroup,
|
||||
});
|
||||
|
||||
service.applyInitialScope('groups');
|
||||
|
||||
+93
-30
@@ -28,6 +28,12 @@ export interface M3uEpgGuideInputs {
|
||||
*/
|
||||
favoriteKeys: Signal<string[]>;
|
||||
activeChannel: Signal<Channel | null>;
|
||||
/**
|
||||
* Group the sidebar's groups view currently shows, by title. The guide
|
||||
* opens on what the user was looking at, which is not necessarily the
|
||||
* playing channel's group — they may have browsed away from it.
|
||||
*/
|
||||
selectedGroup: Signal<string | null>;
|
||||
}
|
||||
|
||||
const SCOPE_ALL = 'all';
|
||||
@@ -110,10 +116,38 @@ export class M3uEpgGuideSourceService implements EpgGuideSource {
|
||||
return channels;
|
||||
});
|
||||
|
||||
/**
|
||||
* Rows the guide renders, each with an id that is unique within the scope.
|
||||
* `createChannel` falls back to the stream URL for an M3U entry without an
|
||||
* explicit id, so one stream listed in two groups — or simply repeated —
|
||||
* yields two channels sharing an id. The guide keys its programme, status
|
||||
* and selection maps by row id, so duplicates used to collide: both rows
|
||||
* lit up as playing and activating either one played the first. The first
|
||||
* occurrence keeps the channel id (nothing changes for a playlist without
|
||||
* duplicates) and every later one is suffixed.
|
||||
*/
|
||||
private readonly scopedRows = computed<
|
||||
Array<{ rowId: string; channel: Channel }>
|
||||
>(() => {
|
||||
const occurrences = new Map<string, number>();
|
||||
return this.scopedChannels().map((channel) => {
|
||||
const seen = occurrences.get(channel.id) ?? 0;
|
||||
occurrences.set(channel.id, seen + 1);
|
||||
return {
|
||||
rowId: seen === 0 ? channel.id : `${channel.id}#${seen}`,
|
||||
channel,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
private readonly channelsByRowId = computed(
|
||||
() => new Map(this.scopedRows().map((row) => [row.rowId, row.channel]))
|
||||
);
|
||||
|
||||
readonly channels = computed<EpgGuideChannel[]>(() => {
|
||||
const strip = this.settingsStore.stripCountryPrefix?.();
|
||||
return this.scopedChannels().map((channel, index) => ({
|
||||
id: channel.id,
|
||||
return this.scopedRows().map(({ rowId, channel }, index) => ({
|
||||
id: rowId,
|
||||
number: index + 1,
|
||||
name: applyChannelNameStrip(channel.name, strip) || channel.name,
|
||||
logoUrl: channel.tvg?.logo?.trim() || null,
|
||||
@@ -121,37 +155,64 @@ export class M3uEpgGuideSourceService implements EpgGuideSource {
|
||||
}));
|
||||
});
|
||||
|
||||
readonly activeChannelId = computed(
|
||||
() => this.inputs()?.activeChannel()?.id ?? null
|
||||
);
|
||||
/**
|
||||
* The first row carrying the active channel's id — duplicates are
|
||||
* indistinguishable from here, so the guide marks the first of them.
|
||||
*/
|
||||
readonly activeChannelId = computed(() => {
|
||||
const active = this.inputs()?.activeChannel();
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
this.scopedRows().find((row) => row.channel.id === active.id)
|
||||
?.rowId ?? active.id
|
||||
);
|
||||
});
|
||||
|
||||
bind(inputs: M3uEpgGuideInputs): void {
|
||||
this.inputs.set(inputs);
|
||||
}
|
||||
|
||||
/** Called by the host when the guide opens: mirror the sidebar view. */
|
||||
/**
|
||||
* Called by the host when the guide opens: mirror the sidebar view. In the
|
||||
* groups view the sidebar's SELECTED group wins over the playing channel's
|
||||
* group — the user may have browsed to another group before opening the
|
||||
* guide, and that is what they expect to see. The playing channel's group
|
||||
* remains the fallback (nothing selected yet, or a group with no
|
||||
* guide-eligible channels left).
|
||||
*/
|
||||
applyInitialScope(view: string): void {
|
||||
if (view === SCOPE_FAVORITES) {
|
||||
this.scope.set(SCOPE_FAVORITES);
|
||||
return;
|
||||
}
|
||||
const activeGroup = this.inputs()
|
||||
?.activeChannel()
|
||||
?.group?.title?.trim();
|
||||
const groupScopeId = activeGroup
|
||||
? `${GROUP_PREFIX}${activeGroup}`
|
||||
: null;
|
||||
if (
|
||||
view === 'groups' &&
|
||||
groupScopeId &&
|
||||
this.scopes().some((scope) => scope.id === groupScopeId)
|
||||
) {
|
||||
this.scope.set(groupScopeId);
|
||||
return;
|
||||
if (view === 'groups') {
|
||||
const groupScopeId =
|
||||
this.groupScopeIdFor(this.inputs()?.selectedGroup()) ??
|
||||
this.groupScopeIdFor(
|
||||
this.inputs()?.activeChannel()?.group?.title
|
||||
);
|
||||
if (groupScopeId) {
|
||||
this.scope.set(groupScopeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.scope.set(SCOPE_ALL);
|
||||
}
|
||||
|
||||
/** The scope id for a group title, or null when it offers no rows. */
|
||||
private groupScopeIdFor(title: string | null | undefined): string | null {
|
||||
const trimmed = title?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const scopeId = `${GROUP_PREFIX}${trimmed}`;
|
||||
return this.scopes().some((scope) => scope.id === scopeId)
|
||||
? scopeId
|
||||
: null;
|
||||
}
|
||||
|
||||
setScope(id: string): void {
|
||||
if (this.scopes().some((scope) => scope.id === id)) {
|
||||
this.scope.set(id);
|
||||
@@ -201,13 +262,12 @@ export class M3uEpgGuideSourceService implements EpgGuideSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the channel in the current scope because the guide only ever
|
||||
* passes back ids it rendered from `channels()`.
|
||||
* Resolves the row id back to its own channel — the guide only ever passes
|
||||
* back ids it rendered from `channels()`, and a duplicated channel id can
|
||||
* only be told apart by that row id.
|
||||
*/
|
||||
activate(channelId: string): void {
|
||||
const channel = this.scopedChannels().find(
|
||||
(candidate) => candidate.id === channelId
|
||||
);
|
||||
activate(rowId: string): void {
|
||||
const channel = this.channelsByRowId().get(rowId);
|
||||
if (channel) {
|
||||
this.store.dispatch(createM3uChannelPlaybackRequest(channel));
|
||||
}
|
||||
@@ -217,11 +277,14 @@ export class M3uEpgGuideSourceService implements EpgGuideSource {
|
||||
async searchPrograms(query: string): Promise<EpgGuideSearchHit[]> {
|
||||
const programs =
|
||||
(await this.epgBridge.searchPrograms(query, SEARCH_LIMIT)) ?? [];
|
||||
const byKey = new Map(
|
||||
this.channels()
|
||||
.filter((channel) => channel.epgKey !== null)
|
||||
.map((channel) => [channel.epgKey as string, channel.id])
|
||||
);
|
||||
const byKey = new Map<string, string>();
|
||||
for (const channel of this.channels()) {
|
||||
// Duplicate rows share an EPG key; jump to the first, like
|
||||
// `activeChannelId`.
|
||||
if (channel.epgKey !== null && !byKey.has(channel.epgKey)) {
|
||||
byKey.set(channel.epgKey, channel.id);
|
||||
}
|
||||
}
|
||||
return programs.map((program) => ({
|
||||
channelId: byKey.get(program.channel) ?? null,
|
||||
program,
|
||||
|
||||
+4
-11
@@ -4,9 +4,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { BehaviorSubject, EMPTY, of } from 'rxjs';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import {
|
||||
selectActive,
|
||||
selectActiveEpgProgram,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import { translateServiceProvider } from './video-player.spec-harness';
|
||||
|
||||
// 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
|
||||
@@ -166,15 +167,7 @@ describe('VideoPlayerComponent — M3U movie recognition gate', () => {
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{
|
||||
// The component provides `M3uEpgGuideSourceService`,
|
||||
// which resolves its scope labels through this service.
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: EMPTY,
|
||||
},
|
||||
},
|
||||
translateServiceProvider,
|
||||
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, NO_ERRORS_SCHEMA, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ComponentFixture,
|
||||
DeferBlockState,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
@@ -38,6 +42,8 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import { translateServiceProvider } from './video-player.spec-harness';
|
||||
import { StubSidebarComponent } from './video-player.spec-stubs';
|
||||
|
||||
// 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
|
||||
@@ -62,10 +68,12 @@ class StubChannelListHiddenStateComponent {
|
||||
* 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.
|
||||
* render the rail and the content-area empty states, plus the outputs the
|
||||
* rail forwards to the host.
|
||||
*/
|
||||
describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
|
||||
let M3uEpgGuideSourceService: typeof import('../epg-guide/m3u-epg-guide-source.service').M3uEpgGuideSourceService;
|
||||
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
|
||||
let component: VideoPlayerComponentInstance;
|
||||
let sidebarState: LiveLayoutSidebarStateService;
|
||||
@@ -77,6 +85,10 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
const channelsLoading = signal(false);
|
||||
const currentEpgProgram = signal<EpgProgram | null>(null);
|
||||
const activeEpgProgram = signal<EpgProgram | null>(null);
|
||||
const routeParams$ = new BehaviorSubject<Record<string, string>>({
|
||||
id: 'playlist-1',
|
||||
view: 'all',
|
||||
});
|
||||
const channels$ = new BehaviorSubject<Channel[]>([]);
|
||||
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
|
||||
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
|
||||
@@ -138,6 +150,8 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VideoPlayerComponent } = await import('./video-player.component'));
|
||||
({ M3uEpgGuideSourceService } =
|
||||
await import('../epg-guide/m3u-epg-guide-source.service'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -146,6 +160,7 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
activeChannel$.next(null);
|
||||
channels.set([]);
|
||||
channels$.next([]);
|
||||
routeParams$.next({ id: playlistId(), view: 'all' });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VideoPlayerComponent],
|
||||
@@ -153,7 +168,7 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ id: playlistId(), view: 'all' }),
|
||||
params: routeParams$.asObservable(),
|
||||
queryParams: of({}),
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
@@ -170,6 +185,7 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: storeMock },
|
||||
translateServiceProvider,
|
||||
{
|
||||
provide: Overlay,
|
||||
useValue: { position: jest.fn(), create: jest.fn() },
|
||||
@@ -248,6 +264,7 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
StubChannelListHiddenStateComponent,
|
||||
StubSidebarComponent,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
@@ -349,4 +366,43 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
expect(hiddenState()).toBeNull();
|
||||
expect(query('.video-player')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens the guide on the group the sidebar rail is showing', async () => {
|
||||
// The user can browse to another group before pressing the guide
|
||||
// shortcut; the playing channel's group is only the fallback.
|
||||
const makeChannel = (id: string, group: string) =>
|
||||
({
|
||||
id,
|
||||
url: `http://localhost/${id}.m3u8`,
|
||||
name: id,
|
||||
epgParams: '',
|
||||
radio: 'false',
|
||||
group: { title: group },
|
||||
tvg: { id: '', logo: '', name: id },
|
||||
}) as Channel;
|
||||
const newsChannel = makeChannel('news-1', 'News');
|
||||
const sportsChannel = makeChannel('sports-1', 'Sports');
|
||||
activeChannel.set(newsChannel);
|
||||
activeChannel$.next(newsChannel);
|
||||
channels.set([newsChannel, sportsChannel]);
|
||||
channels$.next([newsChannel, sportsChannel]);
|
||||
routeParams$.next({ id: playlistId(), view: 'groups' });
|
||||
fixture.detectChanges();
|
||||
// The rail sits behind an `@defer`; TestBed leaves such a block on its
|
||||
// placeholder until the test renders it.
|
||||
const [railBlock] = await fixture.getDeferBlocks();
|
||||
await railBlock.render(DeferBlockState.Complete);
|
||||
|
||||
fixture.debugElement
|
||||
.query(By.directive(StubSidebarComponent))
|
||||
.componentInstance.selectedGroupChange.emit('Sports');
|
||||
fixture.detectChanges();
|
||||
component.openGuide();
|
||||
|
||||
const guideSource = fixture.debugElement.injector.get(
|
||||
M3uEpgGuideSourceService
|
||||
);
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
expect(guideSource.scopeId()).toBe('group:Sports');
|
||||
});
|
||||
});
|
||||
+3
-11
@@ -3,8 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, EMPTY, of } from 'rxjs';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import {
|
||||
ChannelActions,
|
||||
selectActive,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import { translateServiceProvider } from './video-player.spec-harness';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
@@ -158,15 +158,7 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => {
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{
|
||||
// The component provides `M3uEpgGuideSourceService`,
|
||||
// which resolves its scope labels through this service.
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: EMPTY,
|
||||
},
|
||||
},
|
||||
translateServiceProvider,
|
||||
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
onGroupedSidebarWidthRequestEnded($event)
|
||||
"
|
||||
(sidebarToggleRequested)="toggleSidebar()"
|
||||
(selectedGroupChange)="onSidebarGroupSelected($event)"
|
||||
/>
|
||||
} @loading {
|
||||
<app-channel-list-loading-state
|
||||
|
||||
+4
-11
@@ -5,9 +5,9 @@ import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { of } from 'rxjs';
|
||||
import { ChannelActions, EpgActions } from '@iptvnator/m3u-state';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
storeMock,
|
||||
stripCountryPrefix,
|
||||
syncStoreState,
|
||||
translateServiceProvider,
|
||||
} from './video-player.spec-harness';
|
||||
import {
|
||||
StubAudioPlayerComponent,
|
||||
@@ -144,15 +145,7 @@ describe('VideoPlayerComponent', () => {
|
||||
provide: Store,
|
||||
useValue: storeMock,
|
||||
},
|
||||
{
|
||||
// The guide source service reads labels through
|
||||
// `instant` and re-computes them on `onLangChange`.
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: EMPTY,
|
||||
},
|
||||
},
|
||||
translateServiceProvider,
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: dataServiceMock,
|
||||
|
||||
@@ -318,6 +318,12 @@ export class VideoPlayerComponent
|
||||
channel.radio !== 'true' && !this.opensMovieDetail(channel)
|
||||
)
|
||||
);
|
||||
/**
|
||||
* Group the sidebar's groups view is showing, mirrored from its
|
||||
* `selectedGroupChange` output. The guide opens on the group the user was
|
||||
* browsing, which is not necessarily the playing channel's.
|
||||
*/
|
||||
readonly selectedSidebarGroup = signal<string | null>(null);
|
||||
readonly canOpenGuide = computed(() => {
|
||||
const channel = this.activeChannel();
|
||||
return (
|
||||
@@ -723,6 +729,7 @@ export class VideoPlayerComponent
|
||||
// `selectActive` is `Channel | undefined`; the contract is
|
||||
// nullable, so normalize rather than widen the shared type.
|
||||
activeChannel: computed(() => this.activeChannel() ?? null),
|
||||
selectedGroup: this.selectedSidebarGroup.asReadonly(),
|
||||
});
|
||||
// Radio, a recognised movie, or a lost channel takes the guide's host
|
||||
// (and its player) away — close instead of leaving it stranded.
|
||||
@@ -1227,6 +1234,10 @@ export class VideoPlayerComponent
|
||||
}
|
||||
}
|
||||
|
||||
onSidebarGroupSelected(group: string | null): void {
|
||||
this.selectedSidebarGroup.set(group);
|
||||
}
|
||||
|
||||
openGuide(): void {
|
||||
if (!this.canOpenGuide() || this.guideOpen()) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
selectActive,
|
||||
selectActiveEpgProgram,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
ExternalPlayerSession,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { BehaviorSubject, EMPTY, of } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Shared state and collaborator mocks for the `VideoPlayerComponent` spec.
|
||||
@@ -115,6 +116,21 @@ export const dataServiceMock = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
|
||||
/**
|
||||
* `VideoPlayerComponent` provides `M3uEpgGuideSourceService`, which reads its
|
||||
* scope labels through `TranslateService.instant` and re-computes them on
|
||||
* `onLangChange`. Every fixture of the host therefore needs the real service
|
||||
* token — a mocked `TranslatePipe` alone leaves it unprovided (NG0201) — so
|
||||
* the provider lives here and is shared by all four host specs.
|
||||
*/
|
||||
export const translateServiceProvider = {
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: EMPTY,
|
||||
},
|
||||
};
|
||||
|
||||
export const sampleChannel: Channel = {
|
||||
id: 'channel-1',
|
||||
url: 'http://localhost/live.m3u8',
|
||||
|
||||
@@ -72,6 +72,7 @@ export class StubSidebarComponent {
|
||||
readonly sidebarWidthRequested = output<number>();
|
||||
readonly sidebarWidthRequestEnded = output<number>();
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
readonly selectedGroupChange = output<string | null>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
+1
@@ -70,6 +70,7 @@
|
||||
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
|
||||
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
|
||||
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
|
||||
(selectedGroupChange)="selectedGroupChange.emit($event)"
|
||||
/>
|
||||
}
|
||||
@case ('favorites') {
|
||||
|
||||
@@ -211,6 +211,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
readonly sidebarWidthRequested = output<number>();
|
||||
readonly sidebarWidthRequestEnded = output<number>();
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
/** Groups view only: the group whose channels are currently listed. */
|
||||
readonly selectedGroupChange = output<string | null>();
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
private readonly routeSearchTerm = queryParamSignal(
|
||||
this.route,
|
||||
|
||||
+17
@@ -362,6 +362,23 @@ describe('GroupsViewComponent', () => {
|
||||
expect(component.selectedGroupKey()).toBe('Movies');
|
||||
});
|
||||
|
||||
it('reports the shown group for auto-selection and manual clicks', () => {
|
||||
const selected: Array<string | null> = [];
|
||||
component.selectedGroupChange.subscribe((key) => selected.push(key));
|
||||
|
||||
setInputs({ activeChannelUrl: worldUpdate.url });
|
||||
expect(selected).toEqual(['News']);
|
||||
|
||||
component.selectGroup('Movies');
|
||||
fixture.detectChanges();
|
||||
expect(selected).toEqual(['News', 'Movies']);
|
||||
|
||||
// Re-selecting the shown group is not a change.
|
||||
component.selectGroup('Movies');
|
||||
fixture.detectChanges();
|
||||
expect(selected).toEqual(['News', 'Movies']);
|
||||
});
|
||||
|
||||
it('retains a visible manual selection and falls back to the first visible group', () => {
|
||||
component.selectGroup('Movies');
|
||||
fixture.detectChanges();
|
||||
|
||||
+15
@@ -167,6 +167,14 @@ export class GroupsViewComponent {
|
||||
/** Emits when the user clicks the inline collapse toggle in the groups header */
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
|
||||
/**
|
||||
* The group currently shown in the channel pane, by title. Emitted for
|
||||
* both manual clicks and the auto-selection below, so a host can follow
|
||||
* what the user is looking at — the M3U player seeds the programme
|
||||
* guide's initial scope from it.
|
||||
*/
|
||||
readonly selectedGroupChange = output<string | null>();
|
||||
|
||||
readonly isGroupSearchOpen = signal(false);
|
||||
readonly localGroupSearchTerm = signal('');
|
||||
readonly selectedGroupKey = signal<string | null>(null);
|
||||
@@ -229,6 +237,13 @@ export class GroupsViewComponent {
|
||||
}
|
||||
});
|
||||
|
||||
// A signal effect only re-runs when the value actually changes, so
|
||||
// this emits once per selection — `selectGroup()` included, since it
|
||||
// writes the same signal.
|
||||
effect(() => {
|
||||
this.selectedGroupChange.emit(this.selectedGroupKey());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const selectedGroupKey = this.selectedGroupKey();
|
||||
if (selectedGroupKey == null) {
|
||||
|
||||
@@ -26,4 +26,5 @@
|
||||
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
|
||||
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
|
||||
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
|
||||
(selectedGroupChange)="selectedGroupChange.emit($event)"
|
||||
/>
|
||||
@@ -32,6 +32,8 @@ export class SidebarComponent {
|
||||
readonly sidebarWidthRequested = output<number>();
|
||||
readonly sidebarWidthRequestEnded = output<number>();
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
/** Groups view only: the group whose channels are currently listed. */
|
||||
readonly selectedGroupChange = output<string | null>();
|
||||
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
Reference in new issue
Block a user