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:
4grayandClaude Fable 5.1 committed 2026-09-06 14:31:04 +02:00
1 parent 465c5dc5d1
commit cb599c7bb4
17 files changed
+295 -68

No files matched your search

+15
View File
@@ -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
@@ -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');
@@ -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,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,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
@@ -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({
@@ -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,
@@ -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();
@@ -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);