mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
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 <noreply@anthropic.com> * 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 <playlist>" 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4cce4acaad
commit
033a08cad9
27 files changed
+1145
-20
No files matched your search
@@ -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 <playlist>" 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.
|
||||
@@ -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.
|
||||
@@ -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 <playlist>" 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**:
|
||||
|
||||
|
||||
@@ -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 <playlist>"
|
||||
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.
|
||||
|
||||
|
||||
+13
@@ -76,6 +76,19 @@
|
||||
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuCanOpenInPlaylist()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
data-testid="channel-open-in-playlist"
|
||||
(click)="openContextMenuChannelInPlaylist()"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
<span>{{
|
||||
'PORTALS.VIEW_IN_PORTAL_TOOLTIP'
|
||||
| translate: { name: contextMenuPlaylistName() }
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@if (mode() === 'recent') {
|
||||
<button mat-menu-item (click)="removeContextMenuChannel()">
|
||||
<mat-icon>delete</mat-icon>
|
||||
|
||||
+68
-4
@@ -58,10 +58,7 @@ describe('GlobalFavoritesListComponent', () => {
|
||||
|
||||
it('renders favorite state from the supplied favorite ids in recent mode', () => {
|
||||
fixture.componentRef.setInput('mode', 'recent');
|
||||
fixture.componentRef.setInput(
|
||||
'favoriteUids',
|
||||
new Set<string>(['b'])
|
||||
);
|
||||
fixture.componentRef.setInput('favoriteUids', new Set<string>(['b']));
|
||||
fixture.componentRef.setInput('channels', [
|
||||
buildChannel('a', 'Alpha'),
|
||||
buildChannel('b', 'Beta'),
|
||||
@@ -247,6 +244,73 @@ describe('GlobalFavoritesListComponent', () => {
|
||||
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
});
|
||||
it('offers "open in playlist" for rows whose playlist target resolves', async () => {
|
||||
const row = buildChannel('a', 'Alpha', {
|
||||
playlistName: 'http://user:secret@panel.example.com/get.php',
|
||||
});
|
||||
const requested = jest.fn();
|
||||
fixture.componentInstance.openInPlaylistRequested.subscribe(requested);
|
||||
fixture.componentRef.setInput('channels', [row]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.hasChannelContextMenu(row)).toBe(true);
|
||||
const closeMenu = jest
|
||||
.spyOn(fixture.componentInstance.contextMenuTrigger(), 'closeMenu')
|
||||
.mockImplementation();
|
||||
jest.spyOn(
|
||||
fixture.componentInstance.contextMenuTrigger(),
|
||||
'openMenu'
|
||||
).mockImplementation();
|
||||
|
||||
fixture.componentInstance.onChannelContextMenu(
|
||||
fixture.componentInstance.enrichedChannels()[0],
|
||||
{ clientX: 24, clientY: 32 } as MouseEvent
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.componentInstance.contextMenuCanOpenInPlaylist()).toBe(
|
||||
true
|
||||
);
|
||||
// Credential-bearing stored names collapse to the host.
|
||||
expect(fixture.componentInstance.contextMenuPlaylistName()).toBe(
|
||||
'panel.example.com'
|
||||
);
|
||||
|
||||
fixture.componentInstance.openContextMenuChannelInPlaylist();
|
||||
|
||||
expect(closeMenu).toHaveBeenCalled();
|
||||
expect(requested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ uid: row.uid })
|
||||
);
|
||||
});
|
||||
|
||||
it('hides "open in playlist" for Stalker rows and never emits for them', async () => {
|
||||
const row = buildChannel('s', 'Stalker', {
|
||||
sourceType: 'stalker',
|
||||
streamUrl: undefined,
|
||||
stalkerCmd: 'ffmpeg http://stalker/30',
|
||||
});
|
||||
const requested = jest.fn();
|
||||
fixture.componentInstance.openInPlaylistRequested.subscribe(requested);
|
||||
fixture.componentRef.setInput('channels', [row]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.canOpenInPlaylist(row)).toBe(false);
|
||||
// Favorites mode, no EPG mapping support, no M3U metadata: nothing
|
||||
// else earns the row a context menu either.
|
||||
expect(fixture.componentInstance.hasChannelContextMenu(row)).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
fixture.componentInstance.contextMenuChannel.set(
|
||||
fixture.componentInstance.enrichedChannels()[0]
|
||||
);
|
||||
expect(fixture.componentInstance.contextMenuCanOpenInPlaylist()).toBe(
|
||||
false
|
||||
);
|
||||
fixture.componentInstance.openContextMenuChannelInPlaylist();
|
||||
expect(requested).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function buildChannel(
|
||||
|
||||
+26
@@ -27,6 +27,7 @@ import {
|
||||
buildXtreamEpgMappingKey,
|
||||
EpgProgram,
|
||||
epgProviderClockMs,
|
||||
playlistDisplayLabel,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
|
||||
import { EpgMappingDialogComponent } from '@iptvnator/ui/components';
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
|
||||
deriveVisibleFavoriteChannels,
|
||||
FavoritesChannelSortMode,
|
||||
getLiveCollectionPlaylistNavigation,
|
||||
getXtreamCatchupDays,
|
||||
isXtreamCatchupAvailable,
|
||||
UnifiedFavoriteChannel,
|
||||
@@ -93,10 +95,19 @@ export class GlobalFavoritesListComponent {
|
||||
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
|
||||
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
|
||||
readonly removeRequested = output<UnifiedFavoriteChannel>();
|
||||
/** Row asks to be opened inside its owning playlist (host navigates). */
|
||||
readonly openInPlaylistRequested = output<UnifiedFavoriteChannel>();
|
||||
/** Emitted after the mapping dialog closes having changed a mapping. */
|
||||
readonly epgMappingChanged = output<void>();
|
||||
|
||||
readonly contextMenuChannel = signal<EnrichedUnifiedFavorite | null>(null);
|
||||
readonly contextMenuCanOpenInPlaylist = computed(() => {
|
||||
const channel = this.contextMenuChannel();
|
||||
return !!channel && this.canOpenInPlaylist(channel);
|
||||
});
|
||||
readonly contextMenuPlaylistName = computed(() =>
|
||||
playlistDisplayLabel(this.contextMenuChannel()?.playlistName)
|
||||
);
|
||||
readonly contextMenuPosition = signal({
|
||||
x: '0px',
|
||||
y: '0px',
|
||||
@@ -192,12 +203,27 @@ export class GlobalFavoritesListComponent {
|
||||
return (
|
||||
Boolean(channel.m3uChannel) ||
|
||||
this.mode() === 'recent' ||
|
||||
this.canOpenInPlaylist(channel) ||
|
||||
(this.supportsEpgMapping &&
|
||||
(channel.xtreamId != null ||
|
||||
Boolean(this.stalkerItemId(channel))))
|
||||
);
|
||||
}
|
||||
|
||||
canOpenInPlaylist(channel: UnifiedFavoriteChannel): boolean {
|
||||
return getLiveCollectionPlaylistNavigation(channel) !== null;
|
||||
}
|
||||
|
||||
openContextMenuChannelInPlaylist(): void {
|
||||
const channel = this.contextMenuChannel();
|
||||
if (!channel || !this.canOpenInPlaylist(channel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.contextMenuTrigger().closeMenu();
|
||||
this.openInPlaylistRequested.emit(channel);
|
||||
}
|
||||
|
||||
openEpgMapping(): void {
|
||||
const item = this.contextMenuChannel();
|
||||
if (!item) {
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { OpenInPlaylistChipComponent } from './open-in-playlist-chip.component';
|
||||
|
||||
const BUTTON_SELECTOR = '[data-testid="live-open-in-playlist"]';
|
||||
|
||||
describe('OpenInPlaylistChipComponent', () => {
|
||||
let fixture: ComponentFixture<OpenInPlaylistChipComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [OpenInPlaylistChipComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
PORTALS: { VIEW_IN_PORTAL_TOOLTIP: 'Open in {{name}}' },
|
||||
});
|
||||
translate.use('en');
|
||||
|
||||
fixture = TestBed.createComponent(OpenInPlaylistChipComponent);
|
||||
fixture.componentRef.setInput('playlistName', 'Provider A');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('names the playlist and describes the jump in the tooltip and label', () => {
|
||||
const button = fixture.debugElement.query(By.css(BUTTON_SELECTOR));
|
||||
|
||||
expect(button.nativeElement.textContent).toContain('Provider A');
|
||||
expect(button.nativeElement.getAttribute('aria-label')).toBe(
|
||||
'Open in Provider A'
|
||||
);
|
||||
expect(button.injector.get(MatTooltip).message).toBe(
|
||||
'Open in Provider A'
|
||||
);
|
||||
});
|
||||
|
||||
it('emits when clicked', () => {
|
||||
const activated = jest.fn();
|
||||
fixture.componentInstance.activated.subscribe(activated);
|
||||
|
||||
fixture.debugElement
|
||||
.query(By.css(BUTTON_SELECTOR))
|
||||
.nativeElement.click();
|
||||
|
||||
expect(activated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Pill naming the playlist a collection's live channel belongs to; clicking
|
||||
* it hands the channel off to that playlist. Projected into the EPG panel
|
||||
* toolbar (`[epgToolbarAction]`) beside the channel name so the "where is
|
||||
* this from" answer sits on the now-playing surface, in the collapsed state
|
||||
* too. The host decides whether the target resolves and renders the chip
|
||||
* only then — an unresolvable jump is hidden, never disabled.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-open-in-playlist-chip',
|
||||
imports: [MatIconModule, MatTooltipModule, TranslateModule],
|
||||
template: `
|
||||
@let tooltip =
|
||||
'PORTALS.VIEW_IN_PORTAL_TOOLTIP' | translate: tooltipParams();
|
||||
<button
|
||||
type="button"
|
||||
class="open-in-playlist-chip"
|
||||
data-testid="live-open-in-playlist"
|
||||
[matTooltip]="tooltip"
|
||||
[attr.aria-label]="tooltip"
|
||||
(click)="activated.emit()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||
<span class="open-in-playlist-chip__name">{{
|
||||
playlistName()
|
||||
}}</span>
|
||||
</button>
|
||||
`,
|
||||
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<string>();
|
||||
readonly activated = output<void>();
|
||||
|
||||
protected readonly tooltipParams = computed(() => ({
|
||||
name: this.playlistName(),
|
||||
}));
|
||||
}
|
||||
+26
-2
@@ -24,6 +24,7 @@
|
||||
(epgMappingChanged)="onEpgMappingChanged()"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(removeRequested)="onRemoveRequested($event)"
|
||||
(openInPlaylistRequested)="onOpenInPlaylistRequested($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +115,18 @@
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
>
|
||||
<!-- ngProjectAs: a control-flow block only reaches
|
||||
a selected slot when the wrapper names it -->
|
||||
<ng-container ngProjectAs="[epgToolbarAction]">
|
||||
@if (openInPlaylistTarget()) {
|
||||
<app-open-in-playlist-chip
|
||||
[playlistName]="openInPlaylistName()"
|
||||
(activated)="openActiveItemInPlaylist()"
|
||||
/>
|
||||
}
|
||||
</ng-container>
|
||||
</app-epg-list-view>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="timelinePrograms()"
|
||||
@@ -145,7 +157,18 @@
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
>
|
||||
<!-- ngProjectAs: a control-flow block only reaches
|
||||
a selected slot when the wrapper names it -->
|
||||
<ng-container ngProjectAs="[epgToolbarAction]">
|
||||
@if (openInPlaylistTarget()) {
|
||||
<app-open-in-playlist-chip
|
||||
[playlistName]="openInPlaylistName()"
|
||||
(activated)="openActiveItemInPlaylist()"
|
||||
/>
|
||||
}
|
||||
</ng-container>
|
||||
</app-epg-timeline>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,5 +203,6 @@
|
||||
(epgMappingChanged)="onEpgMappingChanged()"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(removeRequested)="onRemoveRequested($event)"
|
||||
(openInPlaylistRequested)="onOpenInPlaylistRequested($event)"
|
||||
/>
|
||||
</ng-template>
|
||||
+40
@@ -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<UnifiedCollectionItem | null>(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
|
||||
|
||||
+231
@@ -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<UnifiedLiveTabComponent>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -59,13 +59,16 @@ export class StubGlobalFavoritesListComponent {
|
||||
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
|
||||
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
|
||||
readonly removeRequested = output<UnifiedFavoriteChannel>();
|
||||
readonly openInPlaylistRequested = output<UnifiedFavoriteChannel>();
|
||||
}
|
||||
|
||||
// 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: '<div class="stub-epg-timeline"></div>',
|
||||
// The real panels project `[epgToolbarAction]` into their toolbar.
|
||||
template:
|
||||
'<div class="stub-epg-timeline"><ng-content select="[epgToolbarAction]" /></div>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StubEpgTimelineComponent {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+95
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 || '',
|
||||
},
|
||||
|
||||
+113
@@ -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);
|
||||
|
||||
+33
@@ -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', () => {
|
||||
|
||||
+18
-6
@@ -3,23 +3,34 @@ import { Injectable, signal } from '@angular/core';
|
||||
@Injectable()
|
||||
export class LiveStreamAutoOpenStateService {
|
||||
readonly pendingItemId = signal<number | null>(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<string | null>(null);
|
||||
|
||||
captureFromHistoryState(): void {
|
||||
const requestedItemId = Number(
|
||||
(window.history.state as Record<string, unknown> | null)?.[
|
||||
'openXtreamLiveItemId'
|
||||
]
|
||||
);
|
||||
const state = window.history.state as Record<string, unknown> | 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);
|
||||
|
||||
+2
@@ -75,6 +75,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
const selectedItem = signal<unknown>(sampleChannel);
|
||||
const currentPlaylist = signal(playlist);
|
||||
const liveStreams = signal<unknown[]>([]);
|
||||
const isContentInitialized = signal(true);
|
||||
const paginatedContent = signal<unknown[]>([]);
|
||||
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(),
|
||||
|
||||
+30
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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(),
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
|
||||
<span class="g-spacer"></span>
|
||||
|
||||
<!-- host-owned action that stays visible in both states (e.g. the
|
||||
collection tab's "open in playlist" chip) -->
|
||||
<ng-content select="[epgToolbarAction]" />
|
||||
|
||||
@if (collapsed()) {
|
||||
<!-- collapsed inline summary lives IN the toolbar row: hosts clamp
|
||||
the collapsed panel to 56px, so a block below it would be
|
||||
|
||||
@@ -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';
|
||||
@@ -313,3 +314,63 @@ describe('EpgListViewComponent', () => {
|
||||
expect(fixture.nativeElement.querySelector('.g-guide')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpgListViewComponent toolbar action slot', () => {
|
||||
@Component({
|
||||
imports: [EpgListViewComponent],
|
||||
template: `
|
||||
<app-epg-list-view [programs]="[]" [collapsed]="collapsed()">
|
||||
<button
|
||||
epgToolbarAction
|
||||
type="button"
|
||||
data-testid="host-action"
|
||||
>
|
||||
Host action
|
||||
</button>
|
||||
</app-epg-list-view>
|
||||
`,
|
||||
})
|
||||
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(
|
||||
'.g-head [data-testid="host-action"]'
|
||||
);
|
||||
expect(inToolbar()).not.toBeNull();
|
||||
|
||||
fixture.componentInstance.collapsed.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(inToolbar()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,10 @@
|
||||
|
||||
<span class="epg-timeline__spacer"></span>
|
||||
|
||||
<!-- host-owned action that stays visible in both states (e.g. the
|
||||
collection tab's "open in playlist" chip) -->
|
||||
<ng-content select="[epgToolbarAction]" />
|
||||
|
||||
@if (collapsed()) {
|
||||
@if (loading()) {
|
||||
<span class="epg-timeline__summary-title">{{
|
||||
|
||||
@@ -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: `
|
||||
<app-epg-timeline [programs]="[]" [collapsed]="collapsed()">
|
||||
<button
|
||||
epgToolbarAction
|
||||
type="button"
|
||||
data-testid="host-action"
|
||||
>
|
||||
Host action
|
||||
</button>
|
||||
</app-epg-timeline>
|
||||
`,
|
||||
})
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user