diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 396f35604..f885ca4a1 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 (``, +`#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 diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts index bbcd58aff..0cc598ec1 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts @@ -45,6 +45,7 @@ describe('M3uEpgGuideSourceService', () => { const channels = signal([]); const favoriteKeys = signal([]); const activeChannel = signal(null); + const selectedGroup = signal(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([ + makeChannel('dup', { name: 'First copy', group: 'News' }), + makeChannel('dup', { name: 'Second copy', group: 'News' }), + ]); + service.bind({ + channels: duplicated, + favoriteKeys, + activeChannel: signal(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([ makeChannel('b', { name: 'Beta', group: 'Sports' }), @@ -189,6 +236,7 @@ describe('M3uEpgGuideSourceService', () => { channels: sportsOnlyChannels, favoriteKeys, activeChannel: newsActiveChannel, + selectedGroup, }); service.applyInitialScope('groups'); diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts index 154180dc1..a10c3a8e6 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts @@ -28,6 +28,12 @@ export interface M3uEpgGuideInputs { */ favoriteKeys: Signal; activeChannel: Signal; + /** + * 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; } 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(); + 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(() => { 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 { 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(); + 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, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts index 00c1af864..a0d9aeece 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts @@ -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, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-sidebar.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-sidebar.spec.ts index d79fe3c72..8743ea02a 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-sidebar.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-sidebar.spec.ts @@ -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; let component: VideoPlayerComponentInstance; let sidebarState: LiveLayoutSidebarStateService; @@ -77,6 +85,10 @@ describe('VideoPlayerComponent — collapsible channels rail', () => { const channelsLoading = signal(false); const currentEpgProgram = signal(null); const activeEpgProgram = signal(null); + const routeParams$ = new BehaviorSubject>({ + id: 'playlist-1', + view: 'all', + }); const channels$ = new BehaviorSubject([]); const activeChannel$ = new BehaviorSubject(null); const currentEpgProgram$ = new BehaviorSubject(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'); + }); }); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts index e4b959c4f..d8ebfef71 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts @@ -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, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index db1932265..b56859c7f 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -44,6 +44,7 @@ onGroupedSidebarWidthRequestEnded($event) " (sidebarToggleRequested)="toggleSidebar()" + (selectedGroupChange)="onSidebarGroupSelected($event)" /> } @loading { { 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, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 9b18d2ca5..10541045b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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(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; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts index 535ce6471..6e0bc3069 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts @@ -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', diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts index 9a155edab..8140190ac 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts @@ -72,6 +72,7 @@ export class StubSidebarComponent { readonly sidebarWidthRequested = output(); readonly sidebarWidthRequestEnded = output(); readonly sidebarToggleRequested = output(); + readonly selectedGroupChange = output(); } @Component({ diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index f5439d36a..d26d74dda 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -70,6 +70,7 @@ (sidebarWidthRequested)="onSidebarWidthRequested($event)" (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" (sidebarToggleRequested)="sidebarToggleRequested.emit()" + (selectedGroupChange)="selectedGroupChange.emit($event)" /> } @case ('favorites') { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 19ca0ac22..57cf66041 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -211,6 +211,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { readonly sidebarWidthRequested = output(); readonly sidebarWidthRequestEnded = output(); readonly sidebarToggleRequested = output(); + /** Groups view only: the group whose channels are currently listed. */ + readonly selectedGroupChange = output(); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); private readonly routeSearchTerm = queryParamSignal( this.route, diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index c388a7f23..ff4e35945 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -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 = []; + 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(); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 7f8316e11..b2d1f0302 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -167,6 +167,14 @@ export class GroupsViewComponent { /** Emits when the user clicks the inline collapse toggle in the groups header */ readonly sidebarToggleRequested = output(); + /** + * 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(); + readonly isGroupSearchOpen = signal(false); readonly localGroupSearchTerm = signal(''); readonly selectedGroupKey = signal(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) { diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html index d32411fd9..2484b133d 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html @@ -26,4 +26,5 @@ (sidebarWidthRequested)="onSidebarWidthRequested($event)" (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" (sidebarToggleRequested)="sidebarToggleRequested.emit()" + (selectedGroupChange)="selectedGroupChange.emit($event)" /> diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index 24c79202c..b7821f4e5 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -32,6 +32,8 @@ export class SidebarComponent { readonly sidebarWidthRequested = output(); readonly sidebarWidthRequestEnded = output(); readonly sidebarToggleRequested = output(); + /** Groups view only: the group whose channels are currently listed. */ + readonly selectedGroupChange = output(); private readonly playlistContext = inject(PlaylistContextFacade); private readonly translate = inject(TranslateService);