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(); + }); +});