From 033a08cad9f23c09d153ac5a1802cab7538a3a36 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:01:17 +0200 Subject: [PATCH] feat(collections): open a favorite or recent live channel inside its playlist (#1634) * fix(xtream): keep a live-channel handoff alive until its playlist catalog is loaded Arriving at /workspace/xtreams/:id/live from another route with openXtreamLiveItemId in history state silently did nothing: the Xtream shell mounts the live layout after its session bootstrap, i.e. after the arrival's NavigationEnd, so the layout's NavigationEnd subscription never saw it. When the layout was reused instead (playlist switch), the shared store still held the previous playlist's catalog at NavigationEnd and the "not in liveStreams" verdict dropped the pending id. Read the state once at mount as well, carry openXtreamLivePlaylistId in the navigation state, and treat a miss as final only once currentPlaylist is the requested playlist and isContentInitialized is true. Co-Authored-By: Claude Fable 5.1 * feat(collections): open a favorite or recent live channel inside its playlist Live channels watched from Favorites / Recently viewed had no visible playlist and no way to jump there, unlike movies and series with "View in portal". Add the live counterpart: - getLiveCollectionPlaylistNavigation() resolves the channel inside its playlist (Xtream via the live layout's auto-open state, M3U via openM3uChannelUrl on the player's all view); Stalker resolves to null until its ITV layout gets an open-on-arrival contract, so nothing is shown there instead of landing on the section root. - app-open-in-playlist-chip, projected into the EPG timeline / list-view toolbar through a new [epgToolbarAction] slot beside the channel name, visible in the collapsed state too. - "Open in " entry in the channel row context menu, which also covers radio rows and rows that are not playing. Both label with playlistDisplayLabel and reuse PORTALS.VIEW_IN_PORTAL_TOOLTIP. Co-Authored-By: Claude Fable 5.1 * fix(xtream): only match an auto-open channel against the requested playlist's catalog Review finding (Greptile/Codex P1): the playlist check ran only on a miss, so a colliding provider-local xtream_id in the previous playlist's catalog was accepted, played the wrong channel and consumed the handoff. Check the playlist before consulting the catalog at all. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- ...llections-open-live-channel-in-playlist.md | 6 + .../xtream-live-auto-open-other-portal.md | 6 + CLAUDE.md | 17 ++ docs/architecture/portal-detail-navigation.md | 41 ++++ .../global-favorites-list.component.html | 13 + .../global-favorites-list.component.spec.ts | 72 +++++- .../global-favorites-list.component.ts | 26 ++ .../open-in-playlist-chip.component.spec.ts | 50 ++++ .../open-in-playlist-chip.component.ts | 101 ++++++++ .../unified-live-tab.component.html | 28 ++- .../unified-live-tab.component.ts | 40 +++ .../unified-live-tab.open-in-playlist.spec.ts | 231 ++++++++++++++++++ .../unified-live-tab.spec-stubs.ts | 5 +- libs/portal/shared/util/src/index.ts | 1 + ...ive-collection-playlist-navigation.spec.ts | 95 +++++++ .../live-collection-playlist-navigation.ts | 86 +++++++ .../navigation/workspace-portal-navigation.ts | 10 +- ...e-stream-auto-open-queue.component.spec.ts | 113 +++++++++ ...ive-stream-auto-open-state.service.spec.ts | 33 +++ .../live-stream-auto-open-state.service.ts | 24 +- .../live-stream-layout.component.spec.ts | 2 + .../live-stream-layout.component.ts | 34 ++- .../live-stream-layout.sidebar-levels.spec.ts | 1 + .../epg-list-view.component.html | 4 + .../epg-list-view.component.spec.ts | 61 +++++ .../epg-timeline/epg-timeline.component.html | 4 + .../epg-timeline.component.spec.ts | 61 +++++ 27 files changed, 1145 insertions(+), 20 deletions(-) create mode 100644 .changes/collections-open-live-channel-in-playlist.md create mode 100644 .changes/xtream-live-auto-open-other-portal.md create mode 100644 libs/portal/shared/ui/src/lib/components/open-in-playlist-chip/open-in-playlist-chip.component.spec.ts create mode 100644 libs/portal/shared/ui/src/lib/components/open-in-playlist-chip/open-in-playlist-chip.component.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts create mode 100644 libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts create mode 100644 libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts diff --git a/.changes/collections-open-live-channel-in-playlist.md b/.changes/collections-open-live-channel-in-playlist.md new file mode 100644 index 000000000..76862304f --- /dev/null +++ b/.changes/collections-open-live-channel-in-playlist.md @@ -0,0 +1,6 @@ +--- +type: feature +area: collections +--- + +A live channel you watch from Favorites or Recently viewed now says which playlist it belongs to: a chip with the playlist name sits in the programme panel next to the channel, and clicking it (or "Open in " in the channel's right-click menu) jumps to that channel inside its own playlist. Works for Xtream and M3U sources; Stalker follows in a later release. diff --git a/.changes/xtream-live-auto-open-other-portal.md b/.changes/xtream-live-auto-open-other-portal.md new file mode 100644 index 000000000..2a9ae524b --- /dev/null +++ b/.changes/xtream-live-auto-open-other-portal.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +Opening a live channel in another Xtream playlist (from global search, the dashboard or the new "open in playlist" chip) no longer lands on an empty Live TV page: the jump used to get lost while the app was still opening that playlist, and now waits for its catalog before selecting and playing the channel. diff --git a/CLAUDE.md b/CLAUDE.md index 2650dfbb6..44b439f06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1671,6 +1671,23 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - Per-playlist favorites and global favorites - Recently viewed tracks watch history +- Live channels in the unified favorites/recent live tab (global collections + and a portal's own tabs) carry the live counterpart of the VOD "View in + portal" handoff: `getLiveCollectionPlaylistNavigation()` + (`libs/portal/shared/util`) resolves the channel INSIDE its playlist — + Xtream via `buildXtreamNavigationTarget` + `openXtreamLiveItemId` (the live + layout's auto-open service plays it), M3U via `/workspace/playlists/:id/all` + + `openM3uChannelUrl` (the player selects it by URL); Stalker resolves to + `null` until its ITV layout gets an open-on-arrival contract, so the + affordance is hidden there rather than landing on the section root. Two + surfaces share that verdict: `app-open-in-playlist-chip` + (`libs/portal/shared/ui`), projected into the EPG timeline/list-view + toolbars through their `[epgToolbarAction]` slot beside the channel name + (visible collapsed too; absent for radio and without EPG support), and an + "Open in " row in `app-global-favorites-list`'s context menu + (`openInPlaylistRequested`), which also covers radio rows. Both label with + `playlistDisplayLabel` and reuse `PORTALS.VIEW_IN_PORTAL_TOOLTIP`; the tab + navigates. Contract: `docs/architecture/portal-detail-navigation.md`. **Internationalization**: diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 04fe06cd9..ab49ff539 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -136,6 +136,47 @@ with a return handler keep Back available. and those rows keep the history return as well. The marker is set only by this builder and only alongside `returnTo`, so the dashboard handoff and every other `stalkerReturnTo` caller keeps its re-navigating behaviour. +- Live channels in the same collections (the unified live tab of global + favorites/recent and a portal's own favorites/recent tabs) get the live + counterpart of that bridge, "open in playlist". Targets come from + `getLiveCollectionPlaylistNavigation()` (`libs/portal/shared/util`), which + lands on the channel, never on the playlist root: Xtream reuses + `buildXtreamNavigationTarget` and its `openXtreamLiveItemId` state, which + `LiveStreamAutoOpenStateService` turns into a selected, playing channel. + The layout reads that state once at mount and again on every + `NavigationEnd`: arriving from another route, the Xtream shell mounts the + layout only after its session bootstrap, i.e. after the arrival's + `NavigationEnd`, which a subscription made in the layout can never observe + (the handoff used to be silently lost for every cross-route arrival), while + the event read still covers re-navigation to the same `/live` route with the + component reused. The state also carries `openXtreamLivePlaylistId`: `NavigationEnd` fires + before the route session resets the shared `XtreamStore`, so at capture + time `liveStreams()` can still be the PREVIOUS playlist's catalog, and the + live layout used to read "not in this list" as "channel gone" and drop the + handoff (a jump from a collection or global search into another portal then + landed on the live root with nothing selected). The layout now consults the + catalog at all only once `currentPlaylist()` is the requested playlist — + stream ids are provider-local, so a colliding id in the previous catalog + would otherwise play the wrong channel — and treats a miss as final only + once `isContentInitialized()` is true; until then it keeps waiting and the + effect re-runs as the store switches and loads; + M3U navigates to `/workspace/playlists/:id/all` with `openM3uChannelUrl` + (`OPEN_M3U_CHANNEL_URL_STATE_KEY`), the same key global search writes and + the M3U player selects by URL. Stalker resolves to `null` — its ITV layout + has no open-on-arrival contract yet, and a jump that only reached `/itv` + would not be the affordance promised — so the action is hidden there. + Two surfaces render the one verdict: `app-open-in-playlist-chip` + (`libs/portal/shared/ui`), projected into the EPG timeline / list-view + toolbar through the panels' `[epgToolbarAction]` content slot beside the + channel name (the "where is this from" answer sits on the now-playing + surface and survives the collapsed state; it is absent for radio, which + has no EPG panel, and without EPG support), and an "Open in " + entry in `app-global-favorites-list`'s row context menu + (`openInPlaylistRequested`), which also covers radio rows and rows that + are not playing. Both label with `playlistDisplayLabel` (a stored playlist + name can be a URL carrying credentials) and reuse + `PORTALS.VIEW_IN_PORTAL_TOOLTIP`; `UnifiedLiveTabComponent` performs the + navigation. A row whose target does not resolve shows neither. - Do not force both portals into the same browse/detail behavior unless the full portal detail architecture is being changed. diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index 896e4654d..a96126687 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -76,6 +76,19 @@ {{ 'CHANNELS.SHOW_DETAILS' | translate }} } + @if (contextMenuCanOpenInPlaylist()) { + + } @if (mode() === 'recent') { + `, + styles: ` + :host { + display: inline-flex; + flex: 0 1 auto; + min-width: 0; + max-width: 220px; + } + + /* Same pill geometry as the EPG toolbar's own icon buttons. */ + .open-in-playlist-chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 34px; + min-width: 0; + max-width: 100%; + padding: 0 12px 0 10px; + border-radius: 999px; + background: var(--app-widget-header-bg); + border: 1px solid var(--app-separator); + color: color-mix(in srgb, var(--app-on-surface) 82%, transparent); + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + + mat-icon { + flex: 0 0 auto; + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover { + background: var(--app-card-hover-bg); + color: var(--app-on-surface); + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: 2px; + } + } + + .open-in-playlist-chip__name { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpenInPlaylistChipComponent { + /** Display label of the owning playlist (already credential-safe). */ + readonly playlistName = input.required(); + readonly activated = output(); + + protected readonly tooltipParams = computed(() => ({ + name: this.playlistName(), + })); +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index ec31978f2..b584f2e26 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -24,6 +24,7 @@ (epgMappingChanged)="onEpgMappingChanged()" (favoriteToggled)="onFavoriteToggled($event)" (removeRequested)="onRemoveRequested($event)" + (openInPlaylistRequested)="onOpenInPlaylistRequested($event)" /> @@ -114,7 +115,18 @@ (collapsedChange)=" onLiveEpgPanelCollapsedChange($event) " - /> + > + + + @if (openInPlaylistTarget()) { + + } + + } @else { + > + + + @if (openInPlaylistTarget()) { + + } + + } @@ -180,5 +203,6 @@ (epgMappingChanged)="onEpgMappingChanged()" (favoriteToggled)="onFavoriteToggled($event)" (removeRequested)="onRemoveRequested($event)" + (openInPlaylistRequested)="onOpenInPlaylistRequested($event)" /> diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 4b52efbf0..364c2c9c1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -17,6 +17,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { applyChannelNameStrip, @@ -30,6 +31,7 @@ import { DEFAULT_FAVORITES_CHANNEL_SORT_MODE, deriveVisibleFavoriteChannels, FavoritesChannelSortMode, + getLiveCollectionPlaylistNavigation, LiveEpgPanelState, matchesOpenLiveCollectionItem, OpenLiveCollectionItemState, @@ -61,6 +63,7 @@ import { toLiveEpgPanelSummary, } from './unified-live-epg-summary.util'; import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; +import { OpenInPlaylistChipComponent } from '../open-in-playlist-chip/open-in-playlist-chip.component'; import { ChannelListHiddenStateComponent } from '../channel-list-hidden-state/channel-list-hidden-state.component'; import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component'; import { @@ -106,6 +109,7 @@ import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-ses MatIconModule, MatProgressSpinnerModule, ChannelListHiddenStateComponent, + OpenInPlaylistChipComponent, PortalEmptyStateComponent, ResizableDirective, TranslatePipe, @@ -156,6 +160,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { private readonly destroyRef = inject(DestroyRef); private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); + private readonly router = inject(Router); /** Stream URL of the radio playback whose header override this tab configured. */ private radioHeaderScopeUrl: string | null = null; @@ -188,6 +193,18 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { * portal archive fields (tvArchive/tvArchiveDuration) and to * supply credentials for Xtream catch-up URL resolution. */ readonly activeItem = signal(null); + /** + * "Open in playlist" for the channel on screen: the EPG panel chip + * renders only while this resolves (Xtream/M3U; Stalker has no + * open-on-arrival contract yet). Same verdict the row menu uses. + */ + readonly openInPlaylistTarget = computed(() => { + const item = this.activeItem(); + return item ? getLiveCollectionPlaylistNavigation(item) : null; + }); + readonly openInPlaylistName = computed(() => + playlistDisplayLabel(this.activeItem()?.playlistName) + ); readonly playbackSessionKey = computed(() => createUnifiedLivePlaybackSessionKey(this.activeItem()) ); @@ -636,6 +653,29 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } } + openActiveItemInPlaylist(): void { + const item = this.activeItem(); + if (item) { + this.openInPlaylist(item); + } + } + + onOpenInPlaylistRequested(channel: UnifiedFavoriteChannel): void { + const item = this.items().find( + (candidate) => candidate.uid === channel.uid + ); + if (item) { + this.openInPlaylist(item); + } + } + + private openInPlaylist(item: UnifiedCollectionItem): void { + const target = getLiveCollectionPlaylistNavigation(item); + if (target) { + void this.router.navigate(target.link, { state: target.state }); + } + } + onRemoveRequested(channel: UnifiedFavoriteChannel): void { const item = this.items().find( (candidate) => candidate.uid === channel.uid diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts new file mode 100644 index 000000000..10d8bbf9c --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts @@ -0,0 +1,231 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { By } from '@angular/platform-browser'; +import { provideRouter, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; +import { + StreamResolverService, + UnifiedRecentDataService, +} from '@iptvnator/portal/shared/data-access'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { ResizableDirective } from '@iptvnator/ui/components'; +import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg'; +import { + AudioPlayerComponent, + WebPlayerViewComponent, +} from '@iptvnator/ui/playback'; +import { VideoPlayer } from '@iptvnator/shared/interfaces'; +import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; +import { OpenInPlaylistChipComponent } from '../open-in-playlist-chip/open-in-playlist-chip.component'; +import { + buildCurrentEpgItem, + buildLiveItem, +} from './unified-live-tab.spec-data'; +import { + StubAudioPlayerComponent, + StubEpgTimelineComponent, + StubGlobalFavoritesListComponent, + StubResizableDirective, + StubWebPlayerViewComponent, +} from './unified-live-tab.spec-stubs'; +import { UnifiedLiveTabComponent } from './unified-live-tab.component'; + +/** + * "Open in playlist" for the live tab — kept separate from the main layout + * spec, which sits at the max-lines test budget. + */ +describe('UnifiedLiveTabComponent open in playlist', () => { + let fixture: ComponentFixture; + let component: UnifiedLiveTabComponent; + let navigate: jest.SpyInstance; + let streamResolver: { + resolveLiveDetail: jest.Mock; + resolveM3uPlaybackDetail: jest.Mock; + resolveXtreamCatchupUrl: jest.Mock; + loadM3uProgramsForItem: jest.Mock; + loadEpgForItems: jest.Mock; + }; + let recentData: { recordLivePlayback: jest.Mock }; + const originalElectron = window.electron; + + beforeEach(async () => { + window.electron = { platform: 'darwin' } as typeof window.electron; + localStorage.removeItem('live-epg-panel-state'); + + streamResolver = { + resolveLiveDetail: jest.fn(), + resolveM3uPlaybackDetail: jest.fn(), + resolveXtreamCatchupUrl: jest.fn().mockResolvedValue(null), + loadM3uProgramsForItem: jest.fn().mockResolvedValue([]), + loadEpgForItems: jest.fn().mockResolvedValue(new Map()), + }; + recentData = { recordLivePlayback: jest.fn() }; + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent], + providers: [ + provideRouter([]), + { provide: StreamResolverService, useValue: streamResolver }, + { provide: UnifiedRecentDataService, useValue: recentData }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsEpg: true }, + }, + { + provide: SettingsStore, + useValue: { + openStreamOnDoubleClick: signal(false), + player: signal(VideoPlayer.VideoJs), + stripCountryPrefix: signal(false), + resolvedEpgViewMode: signal<'timeline' | 'list'>( + 'timeline' + ), + resolvedEpgOffsetMinutes: signal(0), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + ], + }) + .overrideComponent(UnifiedLiveTabComponent, { + remove: { + imports: [ + AudioPlayerComponent, + EpgListViewComponent, + EpgTimelineComponent, + GlobalFavoritesListComponent, + ResizableDirective, + WebPlayerViewComponent, + ], + }, + add: { + imports: [ + StubAudioPlayerComponent, + StubEpgTimelineComponent, + StubGlobalFavoritesListComponent, + StubResizableDirective, + StubWebPlayerViewComponent, + ], + }, + }) + .compileComponents(); + + navigate = jest + .spyOn(TestBed.inject(Router), 'navigate') + .mockResolvedValue(true); + fixture = TestBed.createComponent(UnifiedLiveTabComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + fixture.destroy(); + window.electron = originalElectron; + }); + + async function activate(sourceType: 'xtream' | 'stalker') { + const item = buildLiveItem(sourceType); + streamResolver.resolveLiveDetail.mockResolvedValue({ + epgMode: 'portal', + playback: { + streamUrl: `https://example.com/${sourceType}.m3u8`, + title: item.name, + }, + epgItems: [buildCurrentEpgItem('Now')], + }); + recentData.recordLivePlayback.mockResolvedValue({ + ...item, + viewedAt: '2026-03-26T12:00:00.000Z', + }); + + fixture.componentRef.setInput('items', [item]); + fixture.componentRef.setInput('mode', 'recent'); + fixture.detectChanges(); + await fixture.whenStable(); + + await component.onChannelSelected(component.channelsForList()[0]); + fixture.detectChanges(); + await fixture.whenStable(); + return item; + } + + function chip() { + return fixture.debugElement.query( + By.css('.stub-epg-timeline app-open-in-playlist-chip') + ); + } + + it('projects the playlist chip into the EPG panel for an Xtream channel and navigates on click', async () => { + await activate('xtream'); + + const projected = chip(); + expect(projected).not.toBeNull(); + expect( + ( + projected.componentInstance as OpenInPlaylistChipComponent + ).playlistName() + ).toBe('Playlist Two'); + + ( + projected.componentInstance as OpenInPlaylistChipComponent + ).activated.emit(); + + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'xtreams', 'pl-2', 'live'], + { + state: { + openXtreamLiveItemId: 20, + openXtreamLivePlaylistId: 'pl-2', + openXtreamLiveTitle: 'Xtream Live', + openXtreamLivePoster: 'xtream.png', + }, + } + ); + }); + + it('keeps the chip out of the panel for a Stalker channel', async () => { + await activate('stalker'); + + expect(component.openInPlaylistTarget()).toBeNull(); + expect(chip()).toBeNull(); + }); + + it('navigates when a sidebar row asks to open in its playlist', async () => { + const item = buildLiveItem('m3u'); + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + + const list = fixture.debugElement.query( + By.directive(StubGlobalFavoritesListComponent) + ).componentInstance as StubGlobalFavoritesListComponent; + list.openInPlaylistRequested.emit(list.channels()[0]); + + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'playlists', 'pl-1', 'all'], + { state: { openM3uChannelUrl: 'https://example.com/m3u.m3u8' } } + ); + }); + + it('ignores row requests whose target does not resolve', async () => { + const item = buildLiveItem('stalker'); + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + + const list = fixture.debugElement.query( + By.directive(StubGlobalFavoritesListComponent) + ).componentInstance as StubGlobalFavoritesListComponent; + list.openInPlaylistRequested.emit(list.channels()[0]); + + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts index f6ce4fdbc..b383ad633 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts @@ -59,13 +59,16 @@ export class StubGlobalFavoritesListComponent { readonly channelsReordered = output(); readonly favoriteToggled = output(); readonly removeRequested = output(); + readonly openInPlaylistRequested = output(); } // Matches both live-panel selectors so the host's timeline ↔ list swap can be // asserted by tag name; both branches share the identical contract. @Component({ selector: 'app-epg-timeline, app-epg-list-view', - template: '
', + // The real panels project `[epgToolbarAction]` into their toolbar. + template: + '
', changeDetection: ChangeDetectionStrategy.OnPush, }) export class StubEpgTimelineComponent { diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 5a53bbb1a..f139e536b 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -28,6 +28,7 @@ export * from './lib/workspace-view-command.types'; export * from './lib/workspace-view-command.service'; export * from './lib/navigation/collection-detail-portal-navigation'; export * from './lib/navigation/discover-facet-navigation'; +export * from './lib/navigation/live-collection-playlist-navigation'; export * from './lib/navigation/discover-link.util'; export * from './lib/navigation/portal-rail-links'; export * from './lib/navigation/provider-detail-mode'; diff --git a/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts new file mode 100644 index 000000000..888f2a198 --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.spec.ts @@ -0,0 +1,95 @@ +import { + getLiveCollectionPlaylistNavigation, + OPEN_M3U_CHANNEL_URL_STATE_KEY, +} from './live-collection-playlist-navigation'; + +describe('getLiveCollectionPlaylistNavigation', () => { + it('opens an Xtream channel inside its playlist live layout', () => { + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'xtream', + playlistId: 'pl-2', + contentType: 'live', + name: 'Xtream Live', + logo: 'xtream.png', + xtreamId: 20, + }) + ).toEqual({ + link: ['/workspace', 'xtreams', 'pl-2', 'live'], + state: { + openXtreamLiveItemId: 20, + openXtreamLivePlaylistId: 'pl-2', + openXtreamLiveTitle: 'Xtream Live', + openXtreamLivePoster: 'xtream.png', + }, + }); + }); + + it('opens an M3U channel inside its playlist by stream URL', () => { + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'm3u', + playlistId: 'pl-1', + streamUrl: ' https://example.com/m3u.m3u8 ', + }) + ).toEqual({ + link: ['/workspace', 'playlists', 'pl-1', 'all'], + state: { + [OPEN_M3U_CHANNEL_URL_STATE_KEY]: + 'https://example.com/m3u.m3u8', + }, + }); + }); + + it('hides the action for Stalker rows until their ITV layout can open a channel', () => { + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'stalker', + playlistId: 'pl-3', + contentType: 'live', + }) + ).toBeNull(); + }); + + it('never degrades to a playlist-only route', () => { + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'xtream', + playlistId: 'pl-2', + xtreamId: 0, + }) + ).toBeNull(); + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'xtream', + playlistId: 'pl-2', + xtreamId: undefined, + }) + ).toBeNull(); + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'm3u', + playlistId: 'pl-1', + streamUrl: ' ', + }) + ).toBeNull(); + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'm3u', + playlistId: '', + streamUrl: 'https://example.com/m3u.m3u8', + }) + ).toBeNull(); + }); + + it('applies only to live rows when the content type is known', () => { + expect( + getLiveCollectionPlaylistNavigation({ + sourceType: 'xtream', + playlistId: 'pl-2', + contentType: 'movie', + xtreamId: 20, + }) + ).toBeNull(); + }); +}); diff --git a/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts new file mode 100644 index 000000000..6c04ac3de --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/live-collection-playlist-navigation.ts @@ -0,0 +1,86 @@ +import { + buildXtreamNavigationTarget, + WorkspaceNavigationTarget, +} from './workspace-portal-navigation'; + +/** + * History-state key the M3U player consumes on arrival to select the channel + * with this stream URL (`VideoPlayerComponent`, `all` view). Global search + * writes the same key. + */ +export const OPEN_M3U_CHANNEL_URL_STATE_KEY = 'openM3uChannelUrl'; + +/** + * The fields a live collection row needs to be located inside its owning + * playlist. Both `UnifiedCollectionItem` and `UnifiedFavoriteChannel` satisfy + * it, so the EPG-panel chip (resolved from the active item) and the row + * context menu (resolved from the list row) share one verdict. + */ +export interface LiveCollectionPlaylistNavigationSource { + sourceType: string; + playlistId: string; + /** Absent on `UnifiedFavoriteChannel`, whose rows are always live. */ + contentType?: string; + name?: string; + logo?: string | null; + xtreamId?: number | null; + streamUrl?: string; +} + +/** + * "Open in playlist" for a live channel shown outside its playlist (global + * favorites/recent, a portal's own favorites/recent tabs). + * + * The target is the channel itself, not the playlist root: Xtream lands on + * the live layout with `openXtreamLiveItemId`, which its auto-open state + * service turns into a playing, selected channel; M3U lands on the player's + * `all` view with `openM3uChannelUrl`, which selects the channel by URL. + * Stalker returns `null` for now — its ITV layout has no open-on-arrival + * contract yet, and a jump that only reached the section root would not be + * the affordance this promises. + * + * Like `getUnifiedCollectionDetailNavigation`, this never degrades to a + * playlist-only route: without a positive stream id (Xtream) or a stream URL + * (M3U) the caller is expected to hide the action. + */ +export function getLiveCollectionPlaylistNavigation( + source: LiveCollectionPlaylistNavigationSource +): WorkspaceNavigationTarget | null { + if (source.contentType !== undefined && source.contentType !== 'live') { + return null; + } + + const playlistId = (source.playlistId ?? '').trim(); + if (!playlistId) { + return null; + } + + if (source.sourceType === 'xtream') { + const streamId = Number(source.xtreamId); + if (!Number.isFinite(streamId) || streamId <= 0) { + return null; + } + + return buildXtreamNavigationTarget({ + playlistId, + type: 'live', + itemId: streamId, + title: source.name, + imageUrl: source.logo ?? null, + }); + } + + if (source.sourceType === 'm3u') { + const streamUrl = (source.streamUrl ?? '').trim(); + if (!streamUrl) { + return null; + } + + return { + link: ['/workspace', 'playlists', playlistId, 'all'], + state: { [OPEN_M3U_CHANNEL_URL_STATE_KEY]: streamUrl }, + }; + } + + return null; +} diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 0d6c42964..4e7970c0c 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -77,9 +77,7 @@ export function getRecentItemNavigation( const collectionItem = buildDashboardCollectionDetailItem(item); if (collectionItem) { const detailItem = - item.source === 'xtream' && - item.type === 'series' && - seriesResume + item.source === 'xtream' && item.type === 'series' && seriesResume ? { ...collectionItem, uid: buildXtreamCollectionUid( @@ -185,6 +183,12 @@ export function buildXtreamNavigationTarget(params: { link, state: { openXtreamLiveItemId: streamId, + // Lets the live layout tell "this channel is not in the catalog" + // apart from "the store still holds the previous playlist's + // catalog" — the XtreamStore is shared and resets asynchronously + // after NavigationEnd, so a verdict against a foreign catalog + // would silently drop the handoff. + openXtreamLivePlaylistId: params.playlistId, openXtreamLiveTitle: params.title || '', openXtreamLivePoster: params.imageUrl || '', }, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts index e0fe93d15..de016edda 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts @@ -20,6 +20,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { BehaviorSubject, of, Subject } from 'rxjs'; +import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service'; import { LiveStreamLayoutComponent } from './live-stream-layout.component'; import { XtreamLiveChannelItem } from './xtream-live-channel-navigation.service'; @@ -63,6 +64,7 @@ describe('Xtream live auto-open playback queue', () => { selectedContentType: signal('live'), currentPlaylist: signal({ id: 'playlist-1' }), liveStreams: signal(catalog), + isContentInitialized: signal(true), getPaginatedContent: signal(catalog), hasMoreContent: signal(false), selectItemsFromSelectedCategory: visibleItems, @@ -84,6 +86,9 @@ describe('Xtream live auto-open playback queue', () => { categorySearchTerm.set(''); store.constructStreamUrl.mockClear(); store.setSelectedCategory.mockClear(); + store.currentPlaylist.set({ id: 'playlist-1' }); + store.liveStreams.set(catalog); + store.isContentInitialized.set(true); window.history.replaceState({}, ''); localStorage.removeItem('xtream-live-channel-sort-mode'); query = new BehaviorSubject(convertToParamMap({})); @@ -189,6 +194,114 @@ describe('Xtream live auto-open playback queue', () => { } ); + function autoOpenState(): LiveStreamAutoOpenStateService { + return fixture.debugElement.injector.get( + LiveStreamAutoOpenStateService + ); + } + + it('serves a handoff that landed before the layout was mounted', () => { + // The Xtream shell mounts the live layout after its session bootstrap, + // so the arrival's NavigationEnd has already fired; the history entry + // still carries the keys and the constructor read consumes them. + fixture.destroy(); + window.history.replaceState( + { + openXtreamLiveItemId: target.xtream_id, + openXtreamLivePlaylistId: 'playlist-1', + }, + '' + ); + + fixture = TestBed.createComponent(LiveStreamLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(store.constructStreamUrl).toHaveBeenCalledWith(target); + expect(window.history.state).toEqual({}); + }); + + it('keeps waiting while the shared store still serves another playlist', () => { + // NavigationEnd fires before the route session resets the store, so + // liveStreams is still the previous playlist's catalog here. + window.history.replaceState( + { + openXtreamLiveItemId: 555, + openXtreamLivePlaylistId: 'playlist-2', + }, + '' + ); + events.next(new NavigationEnd(1, '/live', '/live')); + fixture.detectChanges(); + + expect(store.constructStreamUrl).not.toHaveBeenCalled(); + expect(autoOpenState().pendingItemId()).toBe(555); + + // The session switches to the requested playlist and its catalog + // lands: the handoff completes and the state keys are consumed. + const arrived = { xtream_id: 555, category_id: '7', name: 'Arrived' }; + store.currentPlaylist.set({ id: 'playlist-2' }); + store.liveStreams.set([arrived]); + fixture.detectChanges(); + + expect(store.constructStreamUrl).toHaveBeenCalledWith(arrived); + expect(autoOpenState().pendingItemId()).toBeNull(); + expect(window.history.state).toEqual({}); + }); + + it('never plays a colliding id from the previous playlist catalog', () => { + // Stream ids are provider-local: playlist-1's catalog holds the same + // numeric id as the channel requested in playlist-2. + window.history.replaceState( + { + openXtreamLiveItemId: target.xtream_id, + openXtreamLivePlaylistId: 'playlist-2', + }, + '' + ); + events.next(new NavigationEnd(1, '/live', '/live')); + fixture.detectChanges(); + + expect(store.constructStreamUrl).not.toHaveBeenCalled(); + expect(autoOpenState().pendingItemId()).toBe(target.xtream_id); + expect(window.history.state.openXtreamLiveItemId).toBe( + target.xtream_id + ); + + const theirs = { + xtream_id: target.xtream_id, + category_id: '9', + name: 'Theirs', + }; + store.currentPlaylist.set({ id: 'playlist-2' }); + store.liveStreams.set([theirs]); + fixture.detectChanges(); + + expect(store.constructStreamUrl).toHaveBeenCalledTimes(1); + expect(store.constructStreamUrl).toHaveBeenCalledWith(theirs); + }); + + it('gives up only once the requested playlist has loaded its catalog', () => { + window.history.replaceState( + { + openXtreamLiveItemId: 555, + openXtreamLivePlaylistId: 'playlist-1', + }, + '' + ); + store.isContentInitialized.set(false); + events.next(new NavigationEnd(1, '/live', '/live')); + fixture.detectChanges(); + + expect(autoOpenState().pendingItemId()).toBe(555); + + store.isContentInitialized.set(true); + fixture.detectChanges(); + + expect(autoOpenState().pendingItemId()).toBeNull(); + expect(store.constructStreamUrl).not.toHaveBeenCalled(); + }); + it('keeps the actual displayed cross-category list for an explicit All Items click', () => { selectedCategoryId.set(null); component.onLiveRootItemClick(target); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.spec.ts index 7f02f3b67..10d934767 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.spec.ts @@ -21,6 +21,39 @@ describe('LiveStreamAutoOpenStateService', () => { service.captureFromHistoryState(); expect(service.pendingItemId()).toBe(101); + expect(service.pendingPlaylistId()).toBeNull(); + }); + + it('captures the owning playlist id beside the item id', () => { + window.history.replaceState( + { openXtreamLiveItemId: 101, openXtreamLivePlaylistId: ' pl-2 ' }, + document.title + ); + + service.captureFromHistoryState(); + + expect(service.pendingItemId()).toBe(101); + expect(service.pendingPlaylistId()).toBe('pl-2'); + }); + + it('drops both keys and the playlist id when the item is consumed', () => { + window.history.replaceState( + { + openXtreamLiveItemId: 101, + openXtreamLivePlaylistId: 'pl-2', + openXtreamLiveTitle: 'T', + openXtreamLivePoster: 'p.png', + keep: true, + }, + document.title + ); + service.captureFromHistoryState(); + + service.clearPendingItem(); + service.clearHistoryState(); + + expect(service.pendingPlaylistId()).toBeNull(); + expect(window.history.state).toEqual({ keep: true }); }); it('clears the pending item id when history state has no live item id', () => { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.ts index a7450527b..5f9870d0d 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.ts @@ -3,23 +3,34 @@ import { Injectable, signal } from '@angular/core'; @Injectable() export class LiveStreamAutoOpenStateService { readonly pendingItemId = signal(null); + /** + * Playlist the pending item belongs to (`openXtreamLivePlaylistId`). + * The shared XtreamStore still holds the previous playlist's catalog when + * NavigationEnd fires, so a "not found" verdict counts only once the store + * serves this playlist; `null` for legacy state without the key. + */ + readonly pendingPlaylistId = signal(null); captureFromHistoryState(): void { - const requestedItemId = Number( - (window.history.state as Record | null)?.[ - 'openXtreamLiveItemId' - ] - ); + const state = window.history.state as Record | null; + const requestedItemId = Number(state?.['openXtreamLiveItemId']); if (Number.isFinite(requestedItemId) && requestedItemId > 0) { + const playlistId = state?.['openXtreamLivePlaylistId']; + this.pendingPlaylistId.set( + typeof playlistId === 'string' && playlistId.trim() + ? playlistId.trim() + : null + ); this.pendingItemId.set(requestedItemId); return; } - this.pendingItemId.set(null); + this.clearPendingItem(); } clearPendingItem(): void { this.pendingItemId.set(null); + this.pendingPlaylistId.set(null); } clearHistoryState(): void { @@ -34,6 +45,7 @@ export class LiveStreamAutoOpenStateService { const nextState = { ...state }; delete nextState['openXtreamLiveItemId']; + delete nextState['openXtreamLivePlaylistId']; delete nextState['openXtreamLiveTitle']; delete nextState['openXtreamLivePoster']; window.history.replaceState(nextState, document.title); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 83bbb4982..c20032af8 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -75,6 +75,7 @@ describe('LiveStreamLayoutComponent', () => { const selectedItem = signal(sampleChannel); const currentPlaylist = signal(playlist); const liveStreams = signal([]); + const isContentInitialized = signal(true); const paginatedContent = signal([]); const hasMoreContent = signal(false); @@ -92,6 +93,7 @@ describe('LiveStreamLayoutComponent', () => { selectedItem, currentPlaylist, liveStreams, + isContentInitialized, selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]), constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'), openPlayer: jest.fn(), diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index ab032c4c6..8d74334a4 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -447,9 +447,16 @@ export class LiveStreamLayoutComponent onCleanup(() => clearInterval(intervalId)); }); - // Read pending auto-open state on every NavigationEnd — covers both the - // initial navigation (Angular fires NavigationEnd after component creation) - // and re-navigation to the same /live route when the component is reused. + // Read pending auto-open state once at mount and again on every + // NavigationEnd. Arriving from another route (a collection's "open in + // playlist", the dashboard, global search into another portal), the + // Xtream shell mounts this layout only after its session bootstrap — + // i.e. AFTER that navigation's NavigationEnd, which a subscription + // made here can never observe; the history entry still carries the + // keys, so the mount-time read is what serves those arrivals. The + // NavigationEnd read covers re-navigation to the same /live route + // while the component is reused (in-portal search, playlist switch). + this.liveAutoOpenState.captureFromHistoryState(); this.router.events .pipe( filter((e) => e instanceof NavigationEnd), @@ -470,6 +477,21 @@ export class LiveStreamLayoutComponent return; } + // The shared store may still serve the PREVIOUS playlist here: + // NavigationEnd fires before the route session resets it. Stream + // ids are provider-local, so a colliding id in that catalog would + // otherwise play the wrong channel and consume the handoff. Wait + // until the store is the requested playlist's — the effect + // re-runs when the playlist, catalog or init flag changes. + const pendingPlaylistId = + this.liveAutoOpenState.pendingPlaylistId(); + if ( + pendingPlaylistId && + this.xtreamStore.currentPlaylist()?.id !== pendingPlaylistId + ) { + return; + } + // Search across all live streams, not just the category-filtered view, // so a channel from a different category can still be auto-opened. const allChannels = this.getAllLiveStreams(); @@ -481,7 +503,11 @@ export class LiveStreamLayoutComponent (channel) => Number(channel?.xtream_id) === pendingId ); if (!item) { - this.pendingAutoOpenLiveItemId.set(null); + // "Not in the catalog" is a verdict only once this + // playlist's catalog has finished loading. + if (this.xtreamStore.isContentInitialized()) { + this.liveAutoOpenState.clearPendingItem(); + } return; } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts index 523dfbc9e..cfcc8307f 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts @@ -125,6 +125,7 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { selectedItem: signal(null), currentPlaylist: signal({ _id: 'pl-1', title: 'Playlist' }), liveStreams: emptyList, + isContentInitialized: signal(true), selectItemsFromSelectedCategory: jest.fn(() => []), constructStreamUrl: jest.fn(() => ''), openPlayer: jest.fn(), diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html index 4f14462fd..6b13737dc 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html @@ -26,6 +26,10 @@ + + + @if (collapsed()) { + + @if (collapsed()) { @if (loading()) { {{ diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts index 9a7db3dfc..d38af4366 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts @@ -1,3 +1,4 @@ +import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { EpgProgram } from '@iptvnator/shared/interfaces'; @@ -399,3 +400,63 @@ describe('EpgTimelineComponent', () => { expect(openGuide).toHaveBeenCalledTimes(1); }); }); + +describe('EpgTimelineComponent toolbar action slot', () => { + @Component({ + imports: [EpgTimelineComponent], + template: ` + + + + `, + }) + class HostComponent { + readonly collapsed = signal(false); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [ + { + provide: MatDialog, + useValue: { + open: () => ({ afterClosed: () => of(undefined) }), + }, + }, + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + onLangChange: new BehaviorSubject(null), + onTranslationChange: new BehaviorSubject(null), + onDefaultLangChange: new BehaviorSubject(null), + get: (key: string) => of(key), + }, + }, + ], + }); + }); + + it('projects [epgToolbarAction] into the toolbar in both panel states', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + const inToolbar = () => + fixture.nativeElement.querySelector( + '.epg-timeline__toolbar [data-testid="host-action"]' + ); + expect(inToolbar()).not.toBeNull(); + + fixture.componentInstance.collapsed.set(true); + fixture.detectChanges(); + expect(inToolbar()).not.toBeNull(); + }); +});