mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(playback): identify content recovery sessions
This commit is contained in:
1 parent
4c16f9b7a0
commit
5cc10ad7e2
58 files changed
+2895
-460
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
issues: [1159]
|
||||
---
|
||||
|
||||
Playback recovery no longer crosses into a different selected channel, movie,
|
||||
or exact Stalker episode. Switching content while a stream resolves no longer
|
||||
restores stale playback or shows an error from the previous selection.
|
||||
@@ -38,6 +38,37 @@ Collection/search VOD surfaces that expose embedded playback must host
|
||||
`PlayerService.openPlayer(...)` or `PlayerService.openResolvedPlayback(...)` to
|
||||
create embedded UI.
|
||||
|
||||
## Logical Playback Identity
|
||||
|
||||
Every inline playback host owns a required, URL-independent
|
||||
`playbackSessionKey`. Live hosts derive it from the playlist/source and the
|
||||
current channel identity; an M3U session uses `Channel.id` rather than a mutable
|
||||
stream or catch-up URL. VOD and series hosts use the route or catalog
|
||||
content identity, with series episode coordinates. Stalker episode identity
|
||||
also includes the series mode, normalized parent, exact season key, and the
|
||||
original provider command or episode ID; synthesized episode hashes are not
|
||||
identity. Selection and pending-resolution guards use that exact provider
|
||||
episode identity, so colliding tracking hashes cannot select a sibling episode.
|
||||
Shared wrappers (`VodDetailsComponent` and `PortalInlinePlayerComponent`) pass
|
||||
the key unchanged to `WebPlayerViewComponent`.
|
||||
|
||||
Hosts invalidate both committed playback and pending resolution when the
|
||||
canonical owner changes (playlist/source, content, and mode where applicable).
|
||||
Refreshing data for the same canonical owner preserves the mounted player.
|
||||
Collection UIDs remain a separate persistence concern; legacy M3U collection
|
||||
UIDs continue to use stream URLs so saved favorites ordering remains compatible.
|
||||
|
||||
Temporary portal URLs, catch-up URLs, headers, DRM data, and alternative source
|
||||
payloads are transport details and must not change this logical identity. A
|
||||
content or episode change must produce a different key. The serialized key is
|
||||
created with `createPlaybackSessionKey()` from `@iptvnator/playback/util` so
|
||||
delimiter-bearing provider IDs remain unambiguous.
|
||||
|
||||
Inline hosts capture this identity before asynchronous playback resolution. A
|
||||
completion may mount only while the same owner is current; stale completions
|
||||
and embedded starts without a complete canonical identity are ignored without
|
||||
replacing an already committed session.
|
||||
|
||||
## Embedded MPV Harness
|
||||
|
||||
The repository now contains a first-pass native embedded MPV harness for Electron:
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
/>
|
||||
} @else {
|
||||
<app-web-player-view
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
|
||||
[title]="inlinePlayerTitle()"
|
||||
[playback]="embeddedPlayback()"
|
||||
@@ -112,7 +113,9 @@
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archivePlaybackAvailable]="
|
||||
archivePlaybackAvailable()
|
||||
"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
@@ -138,7 +141,9 @@
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archivePlaybackAvailable]="
|
||||
archivePlaybackAvailable()
|
||||
"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
|
||||
+48
-14
@@ -41,12 +41,14 @@ import {
|
||||
import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
ResolvedPortalPlayback,
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
@@ -113,6 +115,7 @@ class StubAudioPlayerComponent {
|
||||
template: '',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
@@ -311,6 +314,7 @@ describe('VideoPlayerComponent', () => {
|
||||
} as typeof window.electron;
|
||||
|
||||
syncStoreState(null);
|
||||
playlistId.set('playlist-1');
|
||||
localStorage.removeItem('m3u-sidebar-width');
|
||||
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
@@ -535,7 +539,9 @@ describe('VideoPlayerComponent', () => {
|
||||
fixture.nativeElement.querySelector('app-web-player-view')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
expect(headerContext.action()).toBeNull();
|
||||
});
|
||||
|
||||
@@ -564,12 +570,19 @@ describe('VideoPlayerComponent', () => {
|
||||
},
|
||||
} as Channel);
|
||||
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
|
||||
title: 'Sample TV',
|
||||
isLive: true,
|
||||
headers: {
|
||||
'user-agent': 'IPTVnator Test',
|
||||
Referer: 'https://referrer.example.com',
|
||||
Origin: 'https://origin.example.com',
|
||||
},
|
||||
};
|
||||
component.handleExternalFallbackRequest({
|
||||
player: 'mpv',
|
||||
playback: {
|
||||
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
|
||||
title: 'Archive Sample',
|
||||
},
|
||||
playback,
|
||||
diagnostic: {
|
||||
code: 'unsupported-codec',
|
||||
source: 'hls',
|
||||
@@ -583,13 +596,13 @@ describe('VideoPlayerComponent', () => {
|
||||
|
||||
expect(dataServiceMock.sendIpcEvent).toHaveBeenCalledWith(
|
||||
'OPEN_MPV_PLAYER',
|
||||
expect.objectContaining({
|
||||
{
|
||||
url: 'https://archive.example.com/live.m3u8?utc=1',
|
||||
title: 'Sample TV',
|
||||
'user-agent': 'IPTVnator Test',
|
||||
referer: 'https://referrer.example.com',
|
||||
origin: 'https://origin.example.com',
|
||||
})
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -640,12 +653,36 @@ describe('VideoPlayerComponent', () => {
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
);
|
||||
expect(playerView).not.toBeNull();
|
||||
const stub =
|
||||
playerView.componentInstance as StubWebPlayerViewComponent;
|
||||
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
|
||||
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
|
||||
expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('owns a collision-safe live session key that ignores resolved URL changes', () => {
|
||||
playlistId.set('playlist|one');
|
||||
syncStoreState({ ...sampleChannel, id: 'channel|one' } as Channel);
|
||||
activePlaybackUrl.set('https://archive.example/first.m3u8');
|
||||
fixture.detectChanges();
|
||||
|
||||
const playerView = fixture.debugElement.query(
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
).componentInstance as StubWebPlayerViewComponent;
|
||||
const expected = createPlaybackSessionKey({
|
||||
kind: 'live',
|
||||
sourceId: 'playlist|one',
|
||||
contentId: 'channel|one',
|
||||
});
|
||||
expect(playerView.playbackSessionKey()).toBe(expected);
|
||||
|
||||
activePlaybackUrl.set('https://archive.example/second.m3u8');
|
||||
fixture.detectChanges();
|
||||
expect(playerView.playbackSessionKey()).toBe(expected);
|
||||
|
||||
syncStoreState({ ...sampleChannel, id: 'channel|two' } as Channel);
|
||||
fixture.detectChanges();
|
||||
expect(playerView.playbackSessionKey()).not.toBe(expected);
|
||||
});
|
||||
|
||||
it('routes catch-up playback that resolves to a DASH URL inline as well', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
activePlaybackUrl.set('http://localhost/archive/replay.mpd');
|
||||
@@ -657,8 +694,7 @@ describe('VideoPlayerComponent', () => {
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
);
|
||||
expect(playerView).not.toBeNull();
|
||||
const stub =
|
||||
playerView.componentInstance as StubWebPlayerViewComponent;
|
||||
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
|
||||
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
|
||||
});
|
||||
|
||||
@@ -675,9 +711,7 @@ describe('VideoPlayerComponent', () => {
|
||||
// The external-player guard declines DASH channels, so the inline
|
||||
// player must stay — otherwise the session has no player at all.
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
)
|
||||
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
DataService,
|
||||
@@ -166,13 +167,18 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
readonly activePlaybackUrl = this.store.selectSignal(
|
||||
selectActivePlaybackUrl
|
||||
);
|
||||
readonly activeEpgProgram = this.store.selectSignal(
|
||||
selectActiveEpgProgram
|
||||
);
|
||||
readonly activeEpgProgram = this.store.selectSignal(selectActiveEpgProgram);
|
||||
readonly activeEpgProgramOrNull = computed(
|
||||
() => this.activeEpgProgram() ?? null
|
||||
);
|
||||
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const sourceId = this.activePlaylistId();
|
||||
const contentId = this.activeChannel()?.id;
|
||||
return sourceId && contentId !== undefined
|
||||
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
|
||||
: '';
|
||||
});
|
||||
readonly channels = this.store.selectSignal(selectChannels);
|
||||
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
|
||||
readonly archivePlaybackAvailable = computed(() =>
|
||||
@@ -235,9 +241,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
private readonly epgChannelLogo = toSignal(
|
||||
toObservable(this.activeChannel).pipe(
|
||||
switchMap((channel) => {
|
||||
const key = channel
|
||||
? resolveChannelEpgLookupKey(channel)
|
||||
: '';
|
||||
const key = channel ? resolveChannelEpgLookupKey(channel) : '';
|
||||
if (!key) {
|
||||
return of('');
|
||||
}
|
||||
@@ -587,9 +591,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const currentEpgProgram = epgProgram as
|
||||
| EpgProgram
|
||||
| null
|
||||
| undefined;
|
||||
EpgProgram | null | undefined;
|
||||
const currentIndex = channels.findIndex(
|
||||
(channel) => channel.url === activeChannel.url
|
||||
);
|
||||
|
||||
+4
@@ -383,6 +383,10 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
'https://example.com/2.m3u8',
|
||||
'https://example.com/1.m3u8',
|
||||
]);
|
||||
expect(items.map((item) => item.uid)).toEqual([
|
||||
'm3u::m3u-1::https://example.com/2.m3u8',
|
||||
'm3u::m3u-1::https://example.com/1.m3u8',
|
||||
]);
|
||||
expect(items[1].channelId).toBe('channel-1');
|
||||
expect(items[0].radio).toBe('true');
|
||||
expect(items[0].m3uChannel).toBe(m3uChannels[1]);
|
||||
|
||||
@@ -195,6 +195,7 @@ describe('UnifiedRecentDataService', () => {
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
uid: 'm3u::m3u-1::https://example.com/2.m3u8',
|
||||
sourceType: 'm3u',
|
||||
streamUrl: 'https://example.com/2.m3u8',
|
||||
channelId: 'channel-2',
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
|
||||
|
||||
describe('createUnifiedLivePlaybackSessionKey', () => {
|
||||
it('keeps an M3U key across transport changes and separates channels', () => {
|
||||
const first = m3uItem({
|
||||
uid: 'm3u::pl-1::https://old.example/live.m3u8',
|
||||
streamUrl: 'https://old.example/live.m3u8',
|
||||
channelId: 'provider-channel-7',
|
||||
});
|
||||
const expected = createPlaybackSessionKey({
|
||||
kind: 'live',
|
||||
sourceId: 'pl-1',
|
||||
contentId: 'provider-channel-7',
|
||||
});
|
||||
|
||||
expect(createUnifiedLivePlaybackSessionKey(first)).toBe(expected);
|
||||
expect(
|
||||
createUnifiedLivePlaybackSessionKey({
|
||||
...first,
|
||||
uid: 'm3u::pl-1::https://new.example/timeshift.m3u8',
|
||||
streamUrl: 'https://new.example/timeshift.m3u8',
|
||||
})
|
||||
).toBe(expected);
|
||||
expect(
|
||||
createUnifiedLivePlaybackSessionKey({
|
||||
...first,
|
||||
channelId: 'provider-channel-8',
|
||||
})
|
||||
).not.toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
function m3uItem(
|
||||
overrides: Partial<UnifiedCollectionItem>
|
||||
): UnifiedCollectionItem {
|
||||
return {
|
||||
uid: 'm3u::pl-1::channel',
|
||||
name: 'Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'pl-1',
|
||||
playlistName: 'Playlist',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
|
||||
export function createUnifiedLivePlaybackSessionKey(
|
||||
item: UnifiedCollectionItem | null
|
||||
): string {
|
||||
const sourceId = item?.playlistId.trim() ?? '';
|
||||
const contentId = item ? getUnifiedLiveContentId(item) : '';
|
||||
return sourceId && contentId
|
||||
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
|
||||
: '';
|
||||
}
|
||||
|
||||
function getUnifiedLiveContentId(item: UnifiedCollectionItem): string {
|
||||
switch (item.sourceType) {
|
||||
case 'm3u':
|
||||
return item.channelId?.trim() || item.m3uChannel?.id?.trim() || '';
|
||||
case 'xtream':
|
||||
return item.xtreamId == null ? '' : String(item.xtreamId);
|
||||
case 'stalker':
|
||||
return item.stalkerId == null ? '' : String(item.stalkerId).trim();
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -59,6 +59,7 @@
|
||||
/>
|
||||
} @else {
|
||||
<app-web-player-view
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[streamUrl]="currentStreamUrl()"
|
||||
[title]="activeDetail()?.playback?.title ?? ''"
|
||||
[playback]="inlinePlayback()"
|
||||
@@ -86,7 +87,9 @@
|
||||
[programs]="timelinePrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
||||
[archivePlaybackAvailable]="
|
||||
timelineArchiveAvailable()
|
||||
"
|
||||
[archiveDays]="timelineArchiveDays()"
|
||||
[activeProgram]="activeTimeshiftProgram()"
|
||||
[isLivePlayback]="!activeTimeshift()"
|
||||
@@ -113,7 +116,9 @@
|
||||
[programs]="timelinePrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
||||
[archivePlaybackAvailable]="
|
||||
timelineArchiveAvailable()
|
||||
"
|
||||
[archiveDays]="timelineArchiveDays()"
|
||||
[activeProgram]="activeTimeshiftProgram()"
|
||||
[isLivePlayback]="!activeTimeshift()"
|
||||
|
||||
+23
-22
@@ -25,10 +25,7 @@ import {
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import {
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
@@ -46,6 +43,7 @@ import {
|
||||
StreamResolverService,
|
||||
UnifiedRecentDataService,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import { createPlaybackSessionKey as sessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
@@ -125,6 +123,7 @@ class StubAudioPlayerComponent {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
||||
@@ -586,6 +585,13 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
|
||||
expect(webPlayer.streamUrl()).toBe('https://example.com/xtream.m3u8');
|
||||
expect(webPlayer.title()).toBe('Xtream Live');
|
||||
const sourceId = item.playlistId;
|
||||
const contentId = String(item.xtreamId);
|
||||
const key = sessionKey({ kind: 'live', sourceId, contentId });
|
||||
expect(webPlayer.playbackSessionKey()).toBe(key);
|
||||
component.activeItem.set({ ...item, playlistId: 'p2', uid: 'c2' });
|
||||
expect(component.playbackSessionKey()).not.toBe(key);
|
||||
component.activeItem.set(item);
|
||||
const playback = webPlayer.playback();
|
||||
expect(playback).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -597,15 +603,10 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
throw new Error('Expected wrapper playback to be set');
|
||||
}
|
||||
expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-vjs-player')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-html-video-player')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-art-player')
|
||||
).toBeNull();
|
||||
const host = fixture.nativeElement;
|
||||
expect(host.querySelector('app-vjs-player')).toBeNull();
|
||||
expect(host.querySelector('app-html-video-player')).toBeNull();
|
||||
expect(host.querySelector('app-art-player')).toBeNull();
|
||||
|
||||
webPlayer.externalFallbackRequested.emit({
|
||||
player: 'mpv',
|
||||
@@ -622,12 +623,10 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
}),
|
||||
'mpv'
|
||||
);
|
||||
const [forwardedPlayback, forwardedPlayer] =
|
||||
portalPlayer.openExternalPlayback.mock.calls[0];
|
||||
expect(forwardedPlayback).toBe(playback);
|
||||
expect(forwardedPlayer).toBe('mpv');
|
||||
});
|
||||
|
||||
it('does not wait for M3U program lookup before opening playback', async () => {
|
||||
@@ -749,9 +748,9 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
// auth-gated streams) before the audio element gets the URL, and
|
||||
// clears it again on close.
|
||||
const setUserAgent = jest.fn().mockResolvedValue(true);
|
||||
(
|
||||
window.electron as unknown as Record<string, unknown>
|
||||
)['setUserAgent'] = setUserAgent;
|
||||
(window.electron as unknown as Record<string, unknown>)[
|
||||
'setUserAgent'
|
||||
] = setUserAgent;
|
||||
const item = {
|
||||
...buildLiveItem('stalker'),
|
||||
name: 'Jazz Radio',
|
||||
@@ -1177,6 +1176,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
'https://example.com/timeshift.m3u8'
|
||||
);
|
||||
const item = await selectXtreamArchiveChannel();
|
||||
const key = component.playbackSessionKey();
|
||||
|
||||
component.onTimelineProgramActivated(timeshiftEvent());
|
||||
await fixture.whenStable();
|
||||
@@ -1192,6 +1192,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
'https://example.com/timeshift.m3u8'
|
||||
);
|
||||
expect(component.inlinePlayback()?.isLive).toBe(false);
|
||||
expect(component.playbackSessionKey()).toBe(key);
|
||||
expect(component.activeTimeshiftProgram()?.title).toBe(
|
||||
'Xtream Show'
|
||||
);
|
||||
|
||||
+6
-8
@@ -62,6 +62,7 @@ import {
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unified-live-tab',
|
||||
@@ -123,6 +124,9 @@ export class UnifiedLiveTabComponent {
|
||||
* portal archive fields (tvArchive/tvArchiveDuration) and to
|
||||
* supply credentials for Xtream catch-up URL resolution. */
|
||||
readonly activeItem = signal<UnifiedCollectionItem | null>(null);
|
||||
readonly playbackSessionKey = computed(() =>
|
||||
createUnifiedLivePlaybackSessionKey(this.activeItem())
|
||||
);
|
||||
readonly isSelecting = signal(false);
|
||||
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
|
||||
readonly progressTick = signal(0);
|
||||
@@ -266,9 +270,7 @@ export class UnifiedLiveTabComponent {
|
||||
return getLiveEpgPanelSummary(this.activeDetail());
|
||||
});
|
||||
readonly liveEpgPanelSummaryLabelKey = computed(() =>
|
||||
this.activeTimeshift()
|
||||
? 'EPG.ARCHIVE_PLAYBACK'
|
||||
: 'EPG.CURRENT_PROGRAM'
|
||||
this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM'
|
||||
);
|
||||
|
||||
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
|
||||
@@ -480,10 +482,7 @@ export class UnifiedLiveTabComponent {
|
||||
program.startTimestamp,
|
||||
program.start
|
||||
);
|
||||
const stopEpoch = toEpochSeconds(
|
||||
program.stopTimestamp,
|
||||
program.stop
|
||||
);
|
||||
const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop);
|
||||
if (startEpoch == null || stopEpoch == null) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
|
||||
@@ -709,5 +708,4 @@ export class UnifiedLiveTabComponent {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
+1
@@ -8,6 +8,7 @@
|
||||
<app-stalker-series-view [providerOnly]="providerOnly()" />
|
||||
} @else if (vodDetailsItem()) {
|
||||
<app-vod-details
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[item]="vodDetailsItem()!"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
[playbackPosition]="selectedVodPlaybackPosition()"
|
||||
|
||||
+144
-3
@@ -19,9 +19,21 @@ import { EMPTY, of } from 'rxjs';
|
||||
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail.component';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-vod-details', template: '' })
|
||||
class StubVodDetailsComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly item = input.required<unknown>();
|
||||
readonly providerOnly = input(false);
|
||||
readonly isFavorite = input(false);
|
||||
@@ -47,7 +59,15 @@ class StubStalkerSeriesViewComponent {
|
||||
|
||||
describe('StalkerCatalogDetailComponent provider presentation', () => {
|
||||
let fixture: ComponentFixture<StalkerCatalogDetailComponent>;
|
||||
const resolveVodPlayback = jest.fn();
|
||||
const portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn(() => true),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
openExternalPlayback: jest.fn(),
|
||||
};
|
||||
const contentType = signal<'vod' | 'series'>('vod');
|
||||
const catalogPlaylist = signal({ id: 'stalker-1' });
|
||||
const snackBar = { open: jest.fn() };
|
||||
const selectedItem = signal<unknown>({
|
||||
id: '42',
|
||||
cmd: '/media/42',
|
||||
@@ -61,6 +81,10 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
||||
cmd: '/media/42',
|
||||
info: { name: 'Portal movie' },
|
||||
});
|
||||
resolveVodPlayback.mockReset();
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
catalogPlaylist.set({ id: 'stalker-1' });
|
||||
snackBar.open.mockReset();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerCatalogDetailComponent],
|
||||
@@ -70,8 +94,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
||||
useValue: {
|
||||
contentType,
|
||||
selectedItem,
|
||||
playlist: signal({ id: 'stalker-1' }),
|
||||
playlist: catalogPlaylist,
|
||||
clearSelectedItem: jest.fn(),
|
||||
resolveVodPlayback,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -83,7 +108,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: { isEmbeddedPlayer: jest.fn() },
|
||||
useValue: portalPlayer,
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
@@ -99,7 +124,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
||||
},
|
||||
{ provide: DownloadsService, useValue: {} },
|
||||
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{ provide: MatSnackBar, useValue: snackBar },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
@@ -139,6 +164,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
||||
).componentInstance as StubVodDetailsComponent;
|
||||
expect(fixture.componentInstance.providerOnly()).toBe(true);
|
||||
expect(child.providerOnly()).toBe(true);
|
||||
expect(child.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'stalker-1',
|
||||
contentId: '42',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps provider-only presentation disabled for a regular VOD open', async () => {
|
||||
@@ -154,6 +186,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
||||
).componentInstance as StubVodDetailsComponent;
|
||||
expect(fixture.componentInstance.providerOnly()).toBe(false);
|
||||
expect(child.providerOnly()).toBe(false);
|
||||
expect(child.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'stalker-1',
|
||||
contentId: '99',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -178,4 +217,106 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
|
||||
expect(child.providerOnly()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not mount a VOD resolution after the catalog owner changes', async () => {
|
||||
let resolve!: (value: { streamUrl: string }) => void;
|
||||
resolveVodPlayback.mockReturnValueOnce(
|
||||
new Promise((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.onVodPlay({
|
||||
type: 'stalker',
|
||||
cmd: '/media/42',
|
||||
data: selectedItem(),
|
||||
} as never);
|
||||
|
||||
selectedItem.set({
|
||||
id: '99',
|
||||
cmd: '/media/99',
|
||||
info: { name: 'Replacement movie' },
|
||||
});
|
||||
fixture.detectChanges();
|
||||
resolve({ streamUrl: 'https://stale.example/movie.mpg' });
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
});
|
||||
|
||||
it('clears committed VOD playback when the playlist changes with the same provider id', async () => {
|
||||
const playback = { streamUrl: 'https://a.example/movie.mpg' };
|
||||
resolveVodPlayback.mockResolvedValue(playback);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.onVodPlay(
|
||||
fixture.componentInstance.vodDetailsItem()!
|
||||
);
|
||||
await fixture.whenStable();
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
|
||||
|
||||
catalogPlaylist.set({ id: 'stalker-2' });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'stalker-2',
|
||||
contentId: '42',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mount a pending VOD resolution after only the playlist owner changes', async () => {
|
||||
const pending = deferred<{ streamUrl: string }>();
|
||||
resolveVodPlayback.mockReturnValue(pending.promise);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.onVodPlay(
|
||||
fixture.componentInstance.vodDetailsItem()!
|
||||
);
|
||||
|
||||
catalogPlaylist.set({ id: 'stalker-2' });
|
||||
fixture.detectChanges();
|
||||
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses a pending VOD error after the playlist owner changes', async () => {
|
||||
const pending = deferred<{ streamUrl: string }>();
|
||||
resolveVodPlayback.mockReturnValue(pending.promise);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.onVodPlay(
|
||||
fixture.componentInstance.vodDetailsItem()!
|
||||
);
|
||||
|
||||
catalogPlaylist.set({ id: 'stalker-2' });
|
||||
fixture.detectChanges();
|
||||
pending.reject(new Error('stale playlist failure'));
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves committed playback across a same-owner item refresh', async () => {
|
||||
const playback = { streamUrl: 'https://a.example/movie.mpg' };
|
||||
resolveVodPlayback.mockResolvedValue(playback);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.onVodPlay(
|
||||
fixture.componentInstance.vodDetailsItem()!
|
||||
);
|
||||
await fixture.whenStable();
|
||||
|
||||
selectedItem.set({
|
||||
...(selectedItem() as Record<string, unknown>),
|
||||
screenshot_uri: 'refreshed.jpg',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
|
||||
});
|
||||
});
|
||||
+37
-3
@@ -23,6 +23,7 @@ import {
|
||||
createRefreshTrigger,
|
||||
isStalkerSeriesFlag,
|
||||
isSelectedStalkerVodFavorite,
|
||||
normalizeStalkerEntityId,
|
||||
StalkerSelectedVodItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
@@ -46,6 +47,7 @@ import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
|
||||
import { startStalkerVodDownload } from './stalker-vod-download';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-catalog-detail',
|
||||
@@ -76,6 +78,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly logger = createLogger('StalkerCatalogDetail');
|
||||
private readonly favoritesRefresh = createRefreshTrigger();
|
||||
private playbackRequestId = 0;
|
||||
private currentPlaybackOwnerKey = '';
|
||||
|
||||
readonly contentType = this.catalog.contentType;
|
||||
readonly selectedItem = computed<StalkerSelectedVodItem | null>(
|
||||
@@ -85,6 +89,16 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
);
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
readonly providerOnly = input(false);
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const sourceId = this.catalog.playlist()?.id;
|
||||
const contentId = normalizeStalkerEntityId(this.selectedItem()?.id);
|
||||
return sourceId && contentId
|
||||
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
|
||||
: '';
|
||||
});
|
||||
private readonly playbackOwnerKey = computed(() =>
|
||||
JSON.stringify([this.playbackSessionKey(), this.contentType()])
|
||||
);
|
||||
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
|
||||
null
|
||||
);
|
||||
@@ -147,8 +161,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const selectedItemId = this.selectedItem()?.id;
|
||||
void selectedItemId;
|
||||
const ownerKey = this.playbackOwnerKey();
|
||||
if (ownerKey === this.currentPlaybackOwnerKey) return;
|
||||
this.currentPlaybackOwnerKey = ownerKey;
|
||||
this.closeInlinePlayer();
|
||||
});
|
||||
|
||||
@@ -236,6 +251,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.playbackRequestId += 1;
|
||||
this.inlinePlayback.set(null);
|
||||
this.positionWriter.reset();
|
||||
}
|
||||
@@ -305,6 +321,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
thumbnail?: string,
|
||||
startTime?: number
|
||||
): Promise<void> {
|
||||
const requestId = ++this.playbackRequestId;
|
||||
const sessionKey = this.playbackSessionKey();
|
||||
const ownerKey = this.playbackOwnerKey();
|
||||
const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer();
|
||||
if (usesEmbeddedPlayer && !sessionKey) return;
|
||||
|
||||
try {
|
||||
const playback = await this.catalog.resolveVodPlayback(
|
||||
cmd,
|
||||
@@ -312,9 +334,15 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
thumbnail,
|
||||
startTime
|
||||
);
|
||||
if (
|
||||
requestId !== this.playbackRequestId ||
|
||||
this.playbackOwnerKey() !== ownerKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.positionWriter.reset();
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
if (usesEmbeddedPlayer) {
|
||||
this.inlinePlayback.set(playback);
|
||||
return;
|
||||
}
|
||||
@@ -322,6 +350,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
this.closeInlinePlayer();
|
||||
void this.portalPlayer.openResolvedPlayback(playback, true);
|
||||
} catch (error) {
|
||||
if (
|
||||
requestId !== this.playbackRequestId ||
|
||||
this.playbackOwnerKey() !== ownerKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.logger.error('Failed to start inline VOD playback', error);
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message === 'nothing_to_play'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from '@iptvnator/ui/components';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { of } from 'rxjs';
|
||||
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
|
||||
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-hero',
|
||||
@@ -36,6 +38,7 @@ class StubContentHeroComponent {
|
||||
template: '',
|
||||
})
|
||||
class StubStalkerInlineDetailComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly categoryId = input<'vod' | 'series' | null>(null);
|
||||
readonly seriesItem = input<unknown>(null);
|
||||
readonly isSeries = input(false);
|
||||
@@ -89,6 +92,8 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
getAllPlaybackPositions: jest.Mock;
|
||||
clearPlaybackPosition: jest.Mock;
|
||||
};
|
||||
let snackBar: { open: jest.Mock };
|
||||
let playlistsService: { getPlaylistById: jest.Mock };
|
||||
|
||||
const playlist = {
|
||||
_id: 'stalker-1',
|
||||
@@ -140,6 +145,12 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
getAllPlaybackPositions: jest.fn(),
|
||||
clearPlaybackPosition: jest.fn(),
|
||||
};
|
||||
snackBar = { open: jest.fn() };
|
||||
playlistsService = {
|
||||
getPlaylistById: jest.fn((playlistId: string) =>
|
||||
of({ ...playlist, _id: playlistId })
|
||||
),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerCollectionDetailComponent],
|
||||
@@ -167,9 +178,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -185,7 +194,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getPlaylistById: jest.fn(() => of(playlist)),
|
||||
getPlaylistById: playlistsService.getPlaylistById,
|
||||
getPortalFavorites: jest.fn(() => of([])),
|
||||
},
|
||||
},
|
||||
@@ -315,9 +324,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
).toEqual([1, 2]);
|
||||
// The snapshot renders immediately, but a background portal
|
||||
// re-fetch must be triggered so new episodes can appear.
|
||||
expect(
|
||||
stalkerStore.refreshEmbeddedSeriesSelection
|
||||
).toHaveBeenCalled();
|
||||
expect(stalkerStore.refreshEmbeddedSeriesSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows newly released episodes when the background snapshot refresh patches the selection', async () => {
|
||||
@@ -393,6 +400,131 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled();
|
||||
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.inlinePlayback()).toEqual(playback);
|
||||
const detail = fixture.debugElement.query(
|
||||
By.directive(StubStalkerInlineDetailComponent)
|
||||
).componentInstance as StubStalkerInlineDetailComponent;
|
||||
expect(detail.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'stalker-1',
|
||||
contentId: '1701',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a pending payload after the collection detail owner changes and lets the new owner commit', async () => {
|
||||
const pendingA = deferred<ResolvedPortalPlayback>();
|
||||
const pendingB = deferred<ResolvedPortalPlayback>();
|
||||
stalkerStore.resolveVodPlayback.mockImplementation((cmd: string) =>
|
||||
cmd.includes('1701') ? pendingA.promise : pendingB.promise
|
||||
);
|
||||
const itemA = buildCollectionItem({
|
||||
stalkerId: '1701',
|
||||
stalkerItem: buildVodSource('1701', 'Movie A'),
|
||||
});
|
||||
const itemB = buildCollectionItem({
|
||||
stalkerId: '1702',
|
||||
stalkerItem: buildVodSource('1702', 'Movie B'),
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('item', itemA);
|
||||
await settleDetail(fixture);
|
||||
fixture.componentInstance.onVodPlay(
|
||||
createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId)
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
fixture.componentRef.setInput('item', itemB);
|
||||
await settleDetail(fixture);
|
||||
pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' });
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'stalker-1',
|
||||
contentId: '1702',
|
||||
})
|
||||
);
|
||||
|
||||
fixture.componentInstance.onVodPlay(
|
||||
createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId)
|
||||
);
|
||||
pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' });
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toEqual({
|
||||
streamUrl: 'https://streams.test/b.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('suppresses a pending error after the collection playlist owner changes', async () => {
|
||||
const pending = deferred<ResolvedPortalPlayback>();
|
||||
stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise);
|
||||
const source = buildVodSource('1701', 'Movie A');
|
||||
const itemA = buildCollectionItem({
|
||||
playlistId: 'stalker-1',
|
||||
stalkerId: '1701',
|
||||
stalkerItem: source,
|
||||
});
|
||||
const itemB = buildCollectionItem({
|
||||
playlistId: 'stalker-2',
|
||||
stalkerId: '1701',
|
||||
stalkerItem: { ...source },
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('item', itemA);
|
||||
await settleDetail(fixture);
|
||||
fixture.componentInstance.onVodPlay(
|
||||
createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId)
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
fixture.componentRef.setInput('item', itemB);
|
||||
await settleDetail(fixture);
|
||||
pending.reject(new Error('stale A failure'));
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'stalker-2',
|
||||
contentId: '1701',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a pending request when the same canonical owner is refreshed with a new object', async () => {
|
||||
const pending = deferred<ResolvedPortalPlayback>();
|
||||
stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise);
|
||||
const item = buildCollectionItem({
|
||||
stalkerId: '1701',
|
||||
stalkerItem: buildVodSource('1701', 'Movie A'),
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('item', item);
|
||||
await settleDetail(fixture);
|
||||
fixture.componentInstance.onVodPlay(
|
||||
createStalkerVodItem(item.stalkerItem as never, item.playlistId)
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
fixture.componentRef.setInput('item', {
|
||||
...item,
|
||||
stalkerItem: { ...(item.stalkerItem as object) },
|
||||
});
|
||||
await settleDetail(fixture);
|
||||
pending.resolve({ streamUrl: 'https://streams.test/a.mp4' });
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toEqual({
|
||||
streamUrl: 'https://streams.test/a.mp4',
|
||||
});
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not load VOD playback position when the playlist id is missing', async () => {
|
||||
@@ -444,6 +576,26 @@ function buildCollectionItem(
|
||||
};
|
||||
}
|
||||
|
||||
function buildVodSource(id: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
category_id: 'vod',
|
||||
cmd: `/media/file_${id}.mpg`,
|
||||
info: { name: title },
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function settleDetail(
|
||||
fixture: ComponentFixture<StalkerCollectionDetailComponent>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
|
||||
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
interface StalkerCollectionStateSnapshot {
|
||||
currentPlaylist: Playlist | undefined;
|
||||
@@ -65,12 +66,19 @@ interface StalkerCollectionDetailMode {
|
||||
needsSeriesFetch: boolean;
|
||||
}
|
||||
|
||||
interface StalkerCollectionPlaybackOwner {
|
||||
readonly sourceId: string;
|
||||
readonly contentId: string;
|
||||
readonly sessionKey: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-collection-detail',
|
||||
imports: [PortalDetailShellComponent, StalkerInlineDetailComponent],
|
||||
template: `
|
||||
@if (inlineDetail().categoryId) {
|
||||
<app-stalker-inline-detail
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
@@ -124,6 +132,12 @@ export class StalkerCollectionDetailComponent {
|
||||
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
|
||||
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
private readonly playbackOwner = computed(() =>
|
||||
captureStalkerCollectionPlaybackOwner(this.item())
|
||||
);
|
||||
readonly playbackSessionKey = computed(
|
||||
() => this.playbackOwner()?.sessionKey ?? ''
|
||||
);
|
||||
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
|
||||
null
|
||||
);
|
||||
@@ -149,6 +163,7 @@ export class StalkerCollectionDetailComponent {
|
||||
);
|
||||
|
||||
private initRequestId = 0;
|
||||
private currentPlaybackOwnerKey = '';
|
||||
private readonly vodPlayback = new StalkerVodPlaybackController({
|
||||
inlinePlayback: this.inlinePlayback,
|
||||
selectedVodPosition: this.selectedVodPosition,
|
||||
@@ -158,6 +173,7 @@ export class StalkerCollectionDetailComponent {
|
||||
translateService: this.translateService,
|
||||
logger: this.logger,
|
||||
playbackErrorLogMessage: 'Failed to start collection VOD playback',
|
||||
playbackOwnerKey: () => this.playbackSessionKey(),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
@@ -168,7 +184,12 @@ export class StalkerCollectionDetailComponent {
|
||||
|
||||
effect(() => {
|
||||
const item = this.item();
|
||||
const playbackOwnerKey = this.playbackSessionKey();
|
||||
untracked(() => {
|
||||
if (playbackOwnerKey !== this.currentPlaybackOwnerKey) {
|
||||
this.currentPlaybackOwnerKey = playbackOwnerKey;
|
||||
this.closeInlinePlayer();
|
||||
}
|
||||
void this.prepareDetail(item);
|
||||
});
|
||||
});
|
||||
@@ -490,3 +511,31 @@ export class StalkerCollectionDetailComponent {
|
||||
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
|
||||
}
|
||||
}
|
||||
|
||||
function captureStalkerCollectionPlaybackOwner(
|
||||
item: UnifiedCollectionItem | null
|
||||
): StalkerCollectionPlaybackOwner | null {
|
||||
if (!item) return null;
|
||||
|
||||
const sourceId = item.playlistId.trim();
|
||||
const providerItem = item.stalkerItem as
|
||||
{ id?: unknown; stream_id?: unknown } | undefined;
|
||||
const uidParts = item.uid.split('::');
|
||||
const contentId = normalizeStalkerEntityId(
|
||||
providerItem?.id ??
|
||||
providerItem?.stream_id ??
|
||||
item.stalkerId ??
|
||||
uidParts[uidParts.length - 1]
|
||||
);
|
||||
if (!sourceId || !contentId) return null;
|
||||
|
||||
return Object.freeze({
|
||||
sourceId,
|
||||
contentId,
|
||||
sessionKey: createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId,
|
||||
contentId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
+1
@@ -15,6 +15,7 @@
|
||||
} @else if (vodDetailsItem(); as item) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[item]="item"
|
||||
[isFavorite]="isFavorite()"
|
||||
[playbackPosition]="playbackPosition()"
|
||||
|
||||
+6
@@ -8,6 +8,7 @@ import { StalkerInlineDetailComponent } from './stalker-inline-detail.component'
|
||||
|
||||
@Component({ selector: 'app-vod-details', template: '' })
|
||||
class StubVodDetailsComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly item = input.required<unknown>();
|
||||
readonly providerOnly = input(false);
|
||||
readonly isFavorite = input(false);
|
||||
@@ -57,6 +58,10 @@ describe('StalkerInlineDetailComponent provider presentation', () => {
|
||||
.compileComponents();
|
||||
fixture = TestBed.createComponent(StalkerInlineDetailComponent);
|
||||
fixture.componentRef.setInput('providerOnly', true);
|
||||
fixture.componentRef.setInput(
|
||||
'playbackSessionKey',
|
||||
'collection-owned-vod-key'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
@@ -70,6 +75,7 @@ describe('StalkerInlineDetailComponent provider presentation', () => {
|
||||
By.directive(StubVodDetailsComponent)
|
||||
).componentInstance as StubVodDetailsComponent;
|
||||
expect(child.providerOnly()).toBe(true);
|
||||
expect(child.playbackSessionKey()).toBe('collection-owned-vod-key');
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie
|
||||
],
|
||||
})
|
||||
export class StalkerInlineDetailComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly categoryId = input<'vod' | 'series' | null>(null);
|
||||
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
|
||||
readonly isSeries = input<boolean>(false);
|
||||
|
||||
+10
-3
@@ -32,7 +32,9 @@
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isFullListLoading()"
|
||||
[attr.aria-label]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
|
||||
[attr.aria-label]="
|
||||
'PORTALS.REFRESH_CHANNEL_LIST' | translate
|
||||
"
|
||||
[matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
|
||||
(click)="refreshChannels()"
|
||||
>
|
||||
@@ -83,7 +85,9 @@
|
||||
<div class="empty-search-state">
|
||||
<app-portal-empty-state
|
||||
icon="tv_off"
|
||||
[message]="'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate"
|
||||
[message]="
|
||||
'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
@@ -120,7 +124,9 @@
|
||||
(clicked)="playChannel(item)"
|
||||
(activated)="playChannel(item, true)"
|
||||
(favoriteToggled)="toggleFavorite(item)"
|
||||
(contextMenuRequested)="onChannelContextMenu(item, $event)"
|
||||
(contextMenuRequested)="
|
||||
onChannelContextMenu(item, $event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -180,6 +186,7 @@
|
||||
/>
|
||||
} @else {
|
||||
<app-web-player-view
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[streamUrl]="streamUrl()"
|
||||
[title]="activePlayback()?.title ?? ''"
|
||||
[playback]="activePlayback()"
|
||||
|
||||
+69
-22
@@ -17,11 +17,11 @@ import {
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||
import {
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
|
||||
AudioPlayerComponent,
|
||||
type PlaybackFallbackRequest,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgProgram,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
|
||||
@@ -66,10 +69,11 @@ class StubChannelListItemComponent {
|
||||
template: '',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -202,9 +206,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
loaded: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const itvFullChannelList = signal<
|
||||
ReturnType<typeof defaultItvChannels>
|
||||
>([]);
|
||||
const itvFullChannelList = signal<ReturnType<typeof defaultItvChannels>>(
|
||||
[]
|
||||
);
|
||||
const itvSelectedCategoryFromCache = signal(false);
|
||||
const isPaginatedContentLoading = signal(false);
|
||||
|
||||
@@ -266,6 +270,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
const portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn(() => true),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
openExternalPlayback: jest.fn(),
|
||||
};
|
||||
const settingsStore = {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
@@ -277,6 +282,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
// The store mock is module-scoped: reset so a failed test can't leak
|
||||
// 'list' into siblings.
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' });
|
||||
window.electron = {
|
||||
platform: 'darwin',
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
@@ -327,6 +333,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReset();
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
portalPlayer.openResolvedPlayback.mockClear();
|
||||
portalPlayer.openExternalPlayback.mockClear();
|
||||
fetchChannelEpg.mockReset();
|
||||
fetchChannelEpg.mockResolvedValue([]);
|
||||
ensureBulkItvEpg.mockReset();
|
||||
@@ -407,9 +414,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
useValue: {
|
||||
supportsEpgMapping: false,
|
||||
getEpgMapping: jest.fn().mockResolvedValue(null),
|
||||
getEpgMappingsBatch: jest
|
||||
.fn()
|
||||
.mockResolvedValue(null),
|
||||
getEpgMappingsBatch: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -503,7 +508,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
fixture.nativeElement.querySelector('app-web-player-view')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
const channelRows = fixture.debugElement.queryAll(
|
||||
By.directive(StubChannelListItemComponent)
|
||||
);
|
||||
@@ -526,6 +533,44 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards the exact resolved live playback to external fallback', () => {
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://example.com/stalker-fallback.m3u8',
|
||||
title: 'Alpha TV',
|
||||
isLive: true,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 10001,
|
||||
contentType: 'live',
|
||||
},
|
||||
};
|
||||
const request: PlaybackFallbackRequest = {
|
||||
player: 'vlc',
|
||||
playback,
|
||||
diagnostic: {
|
||||
code: 'network-error',
|
||||
player: 'html5',
|
||||
source: 'hls',
|
||||
container: '',
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
audioCodecs: [],
|
||||
externalFallbackRecommended: true,
|
||||
},
|
||||
};
|
||||
|
||||
component.handleExternalFallbackRequest(request);
|
||||
|
||||
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
|
||||
playback,
|
||||
'vlc'
|
||||
);
|
||||
expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe(
|
||||
playback
|
||||
);
|
||||
});
|
||||
|
||||
it('renders radio channel rows with compact no-EPG density', () => {
|
||||
stalkerStore.selectedContentType.set('radio');
|
||||
|
||||
@@ -1115,7 +1160,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
|
||||
expect(fetchChannelEpg).not.toHaveBeenCalled();
|
||||
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-audio-player')
|
||||
).not.toBeNull();
|
||||
@@ -1185,14 +1232,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
);
|
||||
// Once: only the apply call gets the pending promise — the destroy
|
||||
// clear below also calls the bridge and must not steal the resolver.
|
||||
(
|
||||
window.electron?.setUserAgent as jest.Mock
|
||||
).mockImplementationOnce(() => {
|
||||
signalApplyIssued();
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveApply = () => resolve(true);
|
||||
});
|
||||
});
|
||||
(window.electron?.setUserAgent as jest.Mock).mockImplementationOnce(
|
||||
() => {
|
||||
signalApplyIssued();
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveApply = () => resolve(true);
|
||||
});
|
||||
}
|
||||
);
|
||||
resolveRadioPlayback.mockResolvedValue({
|
||||
streamUrl: 'http://portal.example/radio_2.mpg',
|
||||
title: 'Portal FM',
|
||||
|
||||
+81
-25
@@ -76,12 +76,24 @@ import {
|
||||
normalizeStalkerEntityId,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
type StalkerPlayableChannel = StalkerPortalItem & {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
};
|
||||
|
||||
interface StalkerActiveLivePlaybackIdentity {
|
||||
readonly sourceId: string;
|
||||
readonly contentId: string;
|
||||
}
|
||||
|
||||
interface StalkerPlaybackResolutionOwner {
|
||||
readonly sourceId: string;
|
||||
readonly contentType: string;
|
||||
readonly channelId: string;
|
||||
}
|
||||
|
||||
/** Channels rendered per "page" when the full list is served from the cache. */
|
||||
const FULL_LIST_RENDER_CHUNK = 100;
|
||||
|
||||
@@ -270,6 +282,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
this.portalPlayer.isEmbeddedPlayer()
|
||||
);
|
||||
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
private readonly activePlaybackIdentity =
|
||||
signal<StalkerActiveLivePlaybackIdentity | null>(null);
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const identity = this.activePlaybackIdentity();
|
||||
return identity
|
||||
? createPlaybackSessionKey({ kind: 'live', ...identity })
|
||||
: '';
|
||||
});
|
||||
readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? '');
|
||||
readonly activePlaybackTitle = computed(
|
||||
() =>
|
||||
@@ -366,7 +386,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
/** Stream URL of the radio playback whose header override this layout configured. */
|
||||
private radioHeaderScopeUrl: string | null = null;
|
||||
private playbackResolution: {
|
||||
channelId: string;
|
||||
ownerKey: string;
|
||||
promise: Promise<ResolvedPortalPlayback>;
|
||||
} | null = null;
|
||||
private lastPlaylistId: string | null | undefined = undefined;
|
||||
@@ -414,9 +434,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
untracked(() => {
|
||||
if (contentType === 'radio') {
|
||||
this.stalkerStore.setRadioChannels([]);
|
||||
} else if (
|
||||
!this.stalkerStore.itvSelectedCategoryFromCache()
|
||||
) {
|
||||
} else if (!this.stalkerStore.itvSelectedCategoryFromCache()) {
|
||||
this.stalkerStore.setItvChannels([]);
|
||||
}
|
||||
this.stalkerStore.setPage(0);
|
||||
@@ -449,8 +467,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
// store dedupes per channel id, so this is cheap on rerenders.
|
||||
if (this.supportsEpgMapping && channels.length > 0) {
|
||||
const channelIds = channels.map((channel) => channel.id);
|
||||
untracked(() =>
|
||||
void this.stalkerStore.applyMappedItvEpg(channelIds)
|
||||
untracked(
|
||||
() => void this.stalkerStore.applyMappedItvEpg(channelIds)
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -567,6 +585,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
// override slot.
|
||||
this.playbackRequestId += 1;
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
this.clearActivePlayback();
|
||||
}
|
||||
|
||||
isSelectedChannel(item: StalkerItvChannel): boolean {
|
||||
@@ -581,6 +600,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
) {
|
||||
const requestId = ++this.playbackRequestId;
|
||||
const channelId = normalizeStalkerEntityId(item.id);
|
||||
const sourceId = normalizeStalkerEntityId(
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
);
|
||||
const contentType = this.stalkerStore.selectedContentType();
|
||||
const isRadioMode = this.isRadioMode();
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
this.ensureChannelWithinRenderWindow(channelId);
|
||||
// A previously owned radio override must not survive into a
|
||||
@@ -591,15 +615,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
this.radioHeaderScopeUrl = null;
|
||||
|
||||
try {
|
||||
const isRadioMode = this.isRadioMode();
|
||||
const playback = await this.resolvePlaybackForChannel(
|
||||
item,
|
||||
channelId
|
||||
{ sourceId, contentType, channelId },
|
||||
isRadioMode
|
||||
);
|
||||
if (
|
||||
requestId !== this.playbackRequestId ||
|
||||
this.selectedChannelId() !== channelId
|
||||
) {
|
||||
const owner = { sourceId, contentType, channelId };
|
||||
if (!this.isPlaybackRequestCurrent(requestId, owner)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -618,12 +640,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
const stillCurrent = headerSync ? await headerSync : true;
|
||||
if (
|
||||
!stillCurrent ||
|
||||
requestId !== this.playbackRequestId ||
|
||||
this.selectedChannelId() !== channelId
|
||||
!this.isPlaybackRequestCurrent(requestId, owner)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.activePlayback.set(playback);
|
||||
this.setActivePlayback(playback, null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -632,12 +653,22 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
if (this.usesEmbeddedPlayer()) {
|
||||
this.activePlayback.set(playback);
|
||||
if (!sourceId || !channelId) return;
|
||||
this.setActivePlayback(playback, {
|
||||
sourceId,
|
||||
contentId: channelId,
|
||||
});
|
||||
} else if (startPlayback) {
|
||||
void this.portalPlayer.openResolvedPlayback(playback, true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== this.playbackRequestId) {
|
||||
if (
|
||||
!this.isPlaybackRequestCurrent(requestId, {
|
||||
sourceId,
|
||||
contentType,
|
||||
channelId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -652,21 +683,19 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
|
||||
private resolvePlaybackForChannel(
|
||||
item: StalkerItvChannel,
|
||||
channelId: string
|
||||
owner: StalkerPlaybackResolutionOwner,
|
||||
isRadioMode: boolean
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
const playbackChannelId = [
|
||||
this.stalkerStore.selectedContentType(),
|
||||
channelId,
|
||||
].join(':');
|
||||
if (this.playbackResolution?.channelId === playbackChannelId) {
|
||||
const ownerKey = JSON.stringify(owner);
|
||||
if (this.playbackResolution?.ownerKey === ownerKey) {
|
||||
return this.playbackResolution.promise;
|
||||
}
|
||||
|
||||
const playableItem = this.toPlayableChannel(item);
|
||||
const promise = this.isRadioMode()
|
||||
const promise = isRadioMode
|
||||
? this.stalkerStore.resolveRadioPlayback(playableItem)
|
||||
: this.stalkerStore.resolveItvPlayback(playableItem);
|
||||
this.playbackResolution = { channelId: playbackChannelId, promise };
|
||||
this.playbackResolution = { ownerKey, promise };
|
||||
|
||||
const cleanup = () => {
|
||||
if (this.playbackResolution?.promise === promise) {
|
||||
@@ -679,6 +708,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
return promise;
|
||||
}
|
||||
|
||||
private isPlaybackRequestCurrent(
|
||||
requestId: number,
|
||||
owner: StalkerPlaybackResolutionOwner
|
||||
): boolean {
|
||||
return (
|
||||
requestId === this.playbackRequestId &&
|
||||
this.selectedChannelId() === owner.channelId &&
|
||||
normalizeStalkerEntityId(
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
) === owner.sourceId &&
|
||||
this.stalkerStore.selectedContentType() === owner.contentType
|
||||
);
|
||||
}
|
||||
|
||||
private setActivePlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
identity: StalkerActiveLivePlaybackIdentity | null
|
||||
): void {
|
||||
this.activePlaybackIdentity.set(identity);
|
||||
this.activePlayback.set(playback);
|
||||
}
|
||||
|
||||
private clearActivePlayback(): void {
|
||||
this.activePlaybackIdentity.set(null);
|
||||
this.activePlayback.set(null);
|
||||
}
|
||||
|
||||
toggleFavorite(item: StalkerItvChannel) {
|
||||
const itemId = normalizeStalkerEntityId(item.id);
|
||||
if (this.favorites.has(itemId)) {
|
||||
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import {
|
||||
LiveLayoutSidebarStateService,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
|
||||
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
|
||||
let component: StalkerLiveStreamLayoutComponent;
|
||||
const playlist = signal({ _id: 'playlist-one', title: 'Portal One' });
|
||||
const channels = [
|
||||
{
|
||||
id: 'channel-one',
|
||||
cmd: 'ffrt4://itv/channel-one',
|
||||
name: 'One',
|
||||
o_name: 'One',
|
||||
logo: 'one.png',
|
||||
},
|
||||
{
|
||||
id: 'channel-two',
|
||||
cmd: 'ffrt4://itv/channel-two',
|
||||
name: 'Two',
|
||||
o_name: 'Two',
|
||||
logo: 'two.png',
|
||||
},
|
||||
];
|
||||
const itvChannels = signal(channels);
|
||||
const selectedItvId = signal<string | undefined>(channels[0].id);
|
||||
const selectedItem = signal<(typeof channels)[number] | null>(channels[0]);
|
||||
const selectedContentType = signal<'itv' | 'radio'>('itv');
|
||||
const resolveItvPlayback = jest.fn();
|
||||
const snackBar = { open: jest.fn() };
|
||||
const store = {
|
||||
getSelectedCategoryName: signal('All'),
|
||||
currentPlaylist: playlist,
|
||||
selectedContentType,
|
||||
selectedCategoryId: signal<string | null>('all'),
|
||||
selectedItvId,
|
||||
selectedItem,
|
||||
itvChannels,
|
||||
radioChannels: signal([]),
|
||||
searchPhrase: signal(''),
|
||||
hasMoreChannels: signal(false),
|
||||
itvFullListActive: signal(false),
|
||||
itvSelectedCategoryFromCache: signal(false),
|
||||
itvFullListLoading: signal(false),
|
||||
itvFullListProgress: signal(null),
|
||||
itvFullChannelList: signal([]),
|
||||
isPaginatedContentLoading: signal(false),
|
||||
selectedItvEpgPrograms: signal([]),
|
||||
bulkItvEpgByChannel: signal({}),
|
||||
isLoadingBulkItvEpg: signal(false),
|
||||
setItvChannels: jest.fn(),
|
||||
setRadioChannels: jest.fn(),
|
||||
setPage: jest.fn(),
|
||||
preloadItvChannels: jest.fn(),
|
||||
applyMappedItvEpg: jest.fn(),
|
||||
clearBulkItvEpgCache: jest.fn(),
|
||||
ensureBulkItvEpg: jest.fn(),
|
||||
fetchChannelEpg: jest.fn(),
|
||||
resolveItvPlayback,
|
||||
resolveRadioPlayback: jest.fn(),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
setSelectedItem: jest.fn((item: (typeof channels)[number]) => {
|
||||
selectedItem.set(item);
|
||||
selectedItvId.set(String(item.id));
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
playlist.set({ _id: 'playlist-one', title: 'Portal One' });
|
||||
selectedContentType.set('itv');
|
||||
selectedItvId.set(channels[0].id);
|
||||
selectedItem.set(channels[0]);
|
||||
resolveItvPlayback.mockReset();
|
||||
snackBar.open.mockReset();
|
||||
store.setSelectedItem.mockClear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerLiveStreamLayoutComponent],
|
||||
providers: [
|
||||
{ provide: StalkerStore, useValue: store },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
supportsEpg: false,
|
||||
isElectron: false,
|
||||
supportsEpgMapping: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: { getPortalFavorites: () => of([]) },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { openStreamOnDoubleClick: signal(false) },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: () => true,
|
||||
openResolvedPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ElectronStreamHeadersService,
|
||||
useValue: { apply: jest.fn(), clear: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: LiveLayoutSidebarStateService,
|
||||
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
|
||||
},
|
||||
{ provide: EpgRuntimeBridgeService, useValue: {} },
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
{ provide: MatSnackBar, useValue: snackBar },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(StalkerLiveStreamLayoutComponent, {
|
||||
set: { template: '' },
|
||||
})
|
||||
.compileComponents();
|
||||
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('changes identity only when the current embedded playback commits', async () => {
|
||||
const firstPlayback = { streamUrl: 'https://one.example/live.m3u8' };
|
||||
resolveItvPlayback.mockResolvedValueOnce(firstPlayback);
|
||||
await component.playChannel(channels[0]);
|
||||
const firstKey = component.playbackSessionKey();
|
||||
const session = () => [
|
||||
component.activePlayback(),
|
||||
component.playbackSessionKey(),
|
||||
];
|
||||
expect(session()).toEqual([firstPlayback, firstKey]);
|
||||
|
||||
const failed = deferred<ResolvedPortalPlayback>();
|
||||
resolveItvPlayback.mockReturnValueOnce(failed.promise);
|
||||
const failedSelection = component.playChannel(channels[1]);
|
||||
failed.reject(new Error('resolution failed'));
|
||||
await failedSelection;
|
||||
expect(session()).toEqual([firstPlayback, firstKey]);
|
||||
|
||||
const stale = deferred<ResolvedPortalPlayback>();
|
||||
const current = deferred<ResolvedPortalPlayback>();
|
||||
resolveItvPlayback
|
||||
.mockReturnValueOnce(stale.promise)
|
||||
.mockReturnValueOnce(current.promise);
|
||||
const staleSelection = component.playChannel(channels[0]);
|
||||
const currentSelection = component.playChannel(channels[1]);
|
||||
const currentPlayback = {
|
||||
streamUrl: 'https://two.example/live.m3u8',
|
||||
};
|
||||
current.resolve(currentPlayback);
|
||||
stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
|
||||
await Promise.all([staleSelection, currentSelection]);
|
||||
expect(session()).toEqual([
|
||||
currentPlayback,
|
||||
createPlaybackSessionKey({
|
||||
kind: 'live',
|
||||
sourceId: 'playlist-one',
|
||||
contentId: 'channel-two',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not dedupe the same channel id across playlist owners', async () => {
|
||||
const sourceA = deferred<ResolvedPortalPlayback>();
|
||||
const sourceB = deferred<ResolvedPortalPlayback>();
|
||||
resolveItvPlayback
|
||||
.mockReturnValueOnce(sourceA.promise)
|
||||
.mockReturnValueOnce(sourceB.promise);
|
||||
const shared = { ...channels[0], id: 'shared-channel' };
|
||||
playlist.set({ _id: 'playlist-a', title: 'Portal A' });
|
||||
const requestA = component.playChannel(shared);
|
||||
playlist.set({ _id: 'playlist-b', title: 'Portal B' });
|
||||
const requestB = component.playChannel(shared);
|
||||
const playbackB = { streamUrl: 'https://b.example/shared.m3u8' };
|
||||
|
||||
sourceB.resolve(playbackB);
|
||||
sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' });
|
||||
await Promise.all([requestA, requestB]);
|
||||
|
||||
expect(resolveItvPlayback).toHaveBeenCalledTimes(2);
|
||||
expect(component.activePlayback()).toBe(playbackB);
|
||||
expect(component.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'live',
|
||||
sourceId: 'playlist-b',
|
||||
contentId: 'shared-channel',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the committed pair when source or channel identity is absent', async () => {
|
||||
const valid = { streamUrl: 'https://valid.example/live.m3u8' };
|
||||
resolveItvPlayback.mockResolvedValueOnce(valid);
|
||||
await component.playChannel(channels[0]);
|
||||
const key = component.playbackSessionKey();
|
||||
|
||||
playlist.set({ _id: '', title: 'Missing source' });
|
||||
resolveItvPlayback.mockResolvedValueOnce({
|
||||
streamUrl: 'https://invalid.example/source.m3u8',
|
||||
});
|
||||
await component.playChannel(channels[1]);
|
||||
expect([
|
||||
component.activePlayback(),
|
||||
component.playbackSessionKey(),
|
||||
]).toEqual([valid, key]);
|
||||
|
||||
playlist.set({ _id: 'playlist-one', title: 'Portal One' });
|
||||
resolveItvPlayback.mockResolvedValueOnce({
|
||||
streamUrl: 'https://invalid.example/channel.m3u8',
|
||||
});
|
||||
await component.playChannel({ ...channels[1], id: '' });
|
||||
expect([
|
||||
component.activePlayback(),
|
||||
component.playbackSessionKey(),
|
||||
]).toEqual([valid, key]);
|
||||
});
|
||||
|
||||
it('does not commit a pending ITV request after the mode changes to radio with the same channel id', async () => {
|
||||
const pending = deferred<ResolvedPortalPlayback>();
|
||||
resolveItvPlayback.mockReturnValue(pending.promise);
|
||||
const request = component.playChannel(channels[0]);
|
||||
|
||||
selectedContentType.set('radio');
|
||||
pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' });
|
||||
await request;
|
||||
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
expect(component.playbackSessionKey()).toBe('');
|
||||
});
|
||||
|
||||
it('suppresses a stale rejection after playlist, mode, and channel ownership change', async () => {
|
||||
const pending = deferred<ResolvedPortalPlayback>();
|
||||
resolveItvPlayback.mockReturnValue(pending.promise);
|
||||
const request = component.playChannel(channels[0]);
|
||||
|
||||
playlist.set({ _id: 'playlist-two', title: 'Portal Two' });
|
||||
selectedContentType.set('radio');
|
||||
selectedItvId.set(channels[1].id);
|
||||
pending.reject(new Error('stale owner failure'));
|
||||
await request;
|
||||
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still reports an error for the current full owner', async () => {
|
||||
resolveItvPlayback.mockRejectedValueOnce(new Error('current failure'));
|
||||
|
||||
await component.playChannel(channels[0]);
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'PORTALS.PLAYBACK_ERROR',
|
||||
undefined,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
@if (showingDetails) {
|
||||
<app-stalker-inline-detail
|
||||
details
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { Location } from '@angular/common';
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerPortalRepairService,
|
||||
StalkerSessionService,
|
||||
StalkerStore,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerSearchComponent } from './stalker-search.component';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-inline-detail',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubStalkerInlineDetailComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly inlinePlayback = input<unknown>(null);
|
||||
readonly inlinePlaybackClosed = output<void>();
|
||||
}
|
||||
|
||||
describe('StalkerSearchComponent playback session key', () => {
|
||||
let fixture: ComponentFixture<StalkerSearchComponent>;
|
||||
const playlist = signal({
|
||||
_id: 'playlist|one',
|
||||
title: 'Search portal',
|
||||
portalUrl: 'https://stalker.example',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
});
|
||||
const selectedItem = signal<unknown>(null);
|
||||
const resolveVodPlayback = jest.fn();
|
||||
const snackBar = { open: jest.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
playlist.set({
|
||||
_id: 'playlist|one',
|
||||
title: 'Search portal',
|
||||
portalUrl: 'https://stalker.example',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
});
|
||||
selectedItem.set(null);
|
||||
resolveVodPlayback.mockReset();
|
||||
snackBar.open.mockReset();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerSearchComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
snapshot: {
|
||||
data: {},
|
||||
queryParamMap: convertToParamMap({}),
|
||||
routeConfig: { path: 'search' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provide: Location, useValue: { back: jest.fn() } },
|
||||
{ provide: DataService, useValue: {} },
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: { activePlaylist: playlist },
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: { getPortalFavorites: () => of([]) },
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
selectedItem,
|
||||
setSelectedContentType: jest.fn(),
|
||||
setSelectedItem: jest.fn((item) =>
|
||||
selectedItem.set(item)
|
||||
),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
resolveVodPlayback,
|
||||
},
|
||||
},
|
||||
{ provide: StalkerSessionService, useValue: {} },
|
||||
{ provide: StalkerPortalRepairService, useValue: {} },
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession: signal(null) },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
getPlaybackPosition: jest.fn().mockResolvedValue(null),
|
||||
savePlaybackPosition: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: () => true,
|
||||
openResolvedPlayback: jest.fn(),
|
||||
openExternalPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: MatSnackBar, useValue: snackBar },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(StalkerSearchComponent, {
|
||||
set: {
|
||||
imports: [StubStalkerInlineDetailComponent],
|
||||
template: `
|
||||
@if (showingDetails) {
|
||||
<app-stalker-inline-detail
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[inlinePlayback]="inlinePlayback()"
|
||||
(inlinePlaybackClosed)="closeInlinePlayer()"
|
||||
/>
|
||||
}
|
||||
`,
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
fixture = TestBed.createComponent(StalkerSearchComponent);
|
||||
});
|
||||
|
||||
afterEach(() => fixture?.destroy());
|
||||
|
||||
it('owns the original item identity and threads it unchanged through payload replacement', () => {
|
||||
const first = {
|
||||
id: 'movie|one',
|
||||
cmd: 'ffrt4://movie/one',
|
||||
name: 'Movie One',
|
||||
info: { name: 'Movie One' },
|
||||
};
|
||||
fixture.componentInstance.selectItem(first);
|
||||
fixture.detectChanges();
|
||||
const expected = createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'playlist|one',
|
||||
contentId: 'movie|one',
|
||||
});
|
||||
const detail = fixture.debugElement.query(
|
||||
By.directive(StubStalkerInlineDetailComponent)
|
||||
).componentInstance as StubStalkerInlineDetailComponent;
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
|
||||
expect(detail.playbackSessionKey()).toBe(expected);
|
||||
|
||||
fixture.componentInstance.inlinePlayback.set({
|
||||
streamUrl: 'https://alternative.example/replaced.mkv',
|
||||
title: 'Alternative movie',
|
||||
contentInfo: {
|
||||
playlistId: 'alternative-playlist',
|
||||
contentXtreamId: 999,
|
||||
contentType: 'vod',
|
||||
},
|
||||
});
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
|
||||
|
||||
fixture.componentInstance.selectItem({
|
||||
...first,
|
||||
id: 'movie|two',
|
||||
});
|
||||
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
|
||||
expected
|
||||
);
|
||||
const itemChangedKey = fixture.componentInstance.playbackSessionKey();
|
||||
playlist.set({ ...playlist(), _id: 'playlist|two' });
|
||||
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
|
||||
itemChangedKey
|
||||
);
|
||||
});
|
||||
|
||||
it('clears committed playback when the route playlist owner changes for the same provider id', async () => {
|
||||
const item = {
|
||||
id: 'movie|one',
|
||||
cmd: 'ffrt4://movie/one',
|
||||
name: 'Movie One',
|
||||
info: { name: 'Movie One' },
|
||||
};
|
||||
resolveVodPlayback.mockResolvedValue({
|
||||
streamUrl: 'https://a.example/movie.mpg',
|
||||
});
|
||||
fixture.componentInstance.selectItem(item);
|
||||
fixture.componentInstance.onVodPlay(
|
||||
fixture.componentInstance.vodDetailsItem()!
|
||||
);
|
||||
await fixture.whenStable();
|
||||
expect(fixture.componentInstance.inlinePlayback()).not.toBeNull();
|
||||
|
||||
playlist.set({ ...playlist(), _id: 'playlist|two' });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'playlist|two',
|
||||
contentId: 'movie|one',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('suppresses a pending old-playlist error for the same provider id', async () => {
|
||||
const pending = deferred<ResolvedPortalPlayback>();
|
||||
resolveVodPlayback.mockReturnValue(pending.promise);
|
||||
fixture.componentInstance.selectItem({
|
||||
id: 'movie|one',
|
||||
cmd: 'ffrt4://movie/one',
|
||||
name: 'Movie One',
|
||||
info: { name: 'Movie One' },
|
||||
});
|
||||
fixture.componentInstance.onVodPlay(
|
||||
fixture.componentInstance.vodDetailsItem()!
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
playlist.set({ ...playlist(), _id: 'playlist|two' });
|
||||
fixture.detectChanges();
|
||||
pending.reject(new Error('stale playlist failure'));
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves committed playback when the same canonical owner object refreshes', async () => {
|
||||
const item = {
|
||||
id: 'movie|one',
|
||||
cmd: 'ffrt4://movie/one',
|
||||
name: 'Movie One',
|
||||
info: { name: 'Movie One' },
|
||||
};
|
||||
const playback = { streamUrl: 'https://a.example/movie.mpg' };
|
||||
resolveVodPlayback.mockResolvedValue(playback);
|
||||
fixture.componentInstance.selectItem(item);
|
||||
fixture.componentInstance.onVodPlay(
|
||||
fixture.componentInstance.vodDetailsItem()!
|
||||
);
|
||||
await fixture.whenStable();
|
||||
|
||||
selectedItem.set({ ...item, screenshot_uri: 'refreshed.jpg' });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
inject,
|
||||
resource,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
toggleStalkerVodFavorite,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
interface StalkerFilter {
|
||||
key: StalkerSearchContentType;
|
||||
@@ -103,6 +105,7 @@ export class StalkerSearchComponent {
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly logger = createLogger('StalkerSearch');
|
||||
private currentPlaybackOwnerKey = '';
|
||||
|
||||
readonly filters = signal<Record<StalkerSearchContentType, boolean>>({
|
||||
series: false,
|
||||
@@ -141,6 +144,16 @@ export class StalkerSearchComponent {
|
||||
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
|
||||
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const sourceId = this.currentPlaylist()?._id;
|
||||
const contentId = normalizeStalkerEntityId(this.itemDetails()?.id);
|
||||
return sourceId && contentId
|
||||
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
|
||||
: '';
|
||||
});
|
||||
private readonly playbackOwnerKey = computed(() =>
|
||||
JSON.stringify([this.playbackSessionKey(), this.selectedFilterType()])
|
||||
);
|
||||
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
|
||||
readonly selectedVodPlaybackPosition = computed<number | null>(
|
||||
() => this.selectedVodPosition()?.positionSeconds ?? null
|
||||
@@ -154,6 +167,7 @@ export class StalkerSearchComponent {
|
||||
translateService: this.translateService,
|
||||
logger: this.logger,
|
||||
playbackErrorLogMessage: 'Failed to start search VOD playback',
|
||||
playbackOwnerKey: () => this.playbackOwnerKey(),
|
||||
});
|
||||
|
||||
readonly portalFavorites = createPortalFavoritesResource(
|
||||
@@ -197,16 +211,15 @@ export class StalkerSearchComponent {
|
||||
// executeStalkerRequest owns the portal-mode decision (shared
|
||||
// predicate with URL fallback for legacy rows) and the lazy
|
||||
// portal repair, so search cannot drift from the catalog paths.
|
||||
const response =
|
||||
await executeStalkerRequest<StalkerSearchResponse>(
|
||||
{
|
||||
dataService: this.dataService,
|
||||
stalkerSession: this.stalkerSession,
|
||||
portalRepair: this.portalRepair,
|
||||
},
|
||||
playlist,
|
||||
requestParams
|
||||
);
|
||||
const response = await executeStalkerRequest<StalkerSearchResponse>(
|
||||
{
|
||||
dataService: this.dataService,
|
||||
stalkerSession: this.stalkerSession,
|
||||
portalRepair: this.portalRepair,
|
||||
},
|
||||
playlist,
|
||||
requestParams
|
||||
);
|
||||
const items = response.js?.data || [];
|
||||
return items.map((item: StalkerVodSource) =>
|
||||
this.processItemUrls(item, portalUrl)
|
||||
@@ -217,6 +230,12 @@ export class StalkerSearchComponent {
|
||||
readonly isSelectedVodFavorite = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
this.currentPlaybackOwnerKey = this.playbackOwnerKey();
|
||||
effect(() => {
|
||||
const ownerKey = this.playbackOwnerKey();
|
||||
untracked(() => this.syncPlaybackOwner(ownerKey));
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const routeTerm = this.routeSearchTerm();
|
||||
if (routeTerm !== this.searchTerm()) {
|
||||
@@ -293,7 +312,6 @@ export class StalkerSearchComponent {
|
||||
}
|
||||
|
||||
selectItem(item: StalkerVodSource) {
|
||||
this.closeInlinePlayer();
|
||||
const filterType = this.selectedFilterType();
|
||||
const hasEmbeddedSeries = (item.series?.length ?? 0) > 0;
|
||||
const needsSeriesFetch =
|
||||
@@ -338,6 +356,7 @@ export class StalkerSearchComponent {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.syncPlaybackOwner(this.playbackOwnerKey());
|
||||
}
|
||||
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
@@ -430,6 +449,12 @@ export class StalkerSearchComponent {
|
||||
);
|
||||
}
|
||||
|
||||
private syncPlaybackOwner(ownerKey: string): void {
|
||||
if (ownerKey === this.currentPlaybackOwnerKey) return;
|
||||
this.currentPlaybackOwnerKey = ownerKey;
|
||||
this.closeInlinePlayer();
|
||||
}
|
||||
|
||||
inlineDetail() {
|
||||
return createStalkerInlineDetailState(
|
||||
this.itemDetails(),
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access';
|
||||
import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter';
|
||||
import { createStalkerEpisodePlaybackSessionKey } from './stalker-episode-playback-session-key';
|
||||
|
||||
const episodeState = (
|
||||
episode: Partial<StalkerMappedEpisode>,
|
||||
seasonKey = 'season|1'
|
||||
) => ({
|
||||
seasonKey,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
episode: {
|
||||
id: 'synthesized-32-bit-id',
|
||||
title: 'Episode 2',
|
||||
episode_num: 2,
|
||||
season: 1,
|
||||
...episode,
|
||||
} as StalkerMappedEpisode,
|
||||
previous: null,
|
||||
next: null,
|
||||
});
|
||||
|
||||
describe('createStalkerEpisodePlaybackSessionKey', () => {
|
||||
it.each([
|
||||
STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||
STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
|
||||
])('uses the complete source command for %s episodes', (seriesMode) => {
|
||||
const state = episodeState({ originalCmd: 'ffrt4://cmd|full:2' });
|
||||
const composite = JSON.stringify([
|
||||
seriesMode,
|
||||
'parent|series',
|
||||
state.seasonKey,
|
||||
state.seasonNumber,
|
||||
state.episodeNumber,
|
||||
'ffrt4://cmd|full:2',
|
||||
]);
|
||||
|
||||
expect(
|
||||
createStalkerEpisodePlaybackSessionKey({
|
||||
sourceId: 'playlist|source',
|
||||
parentSeriesId: 'parent|series',
|
||||
seriesMode,
|
||||
episodeState: state,
|
||||
})
|
||||
).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: 'playlist|source',
|
||||
contentId: composite,
|
||||
seriesId: 'parent|series',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the complete provider episode id for lazy VOD episodes', () => {
|
||||
const state = episodeState({ originalId: 'provider|episode:full' });
|
||||
const key = createStalkerEpisodePlaybackSessionKey({
|
||||
sourceId: 'playlist',
|
||||
parentSeriesId: '50001',
|
||||
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
|
||||
episodeState: state,
|
||||
});
|
||||
|
||||
expect(key).toContain('provider|episode:full');
|
||||
expect(key).not.toContain('synthesized-32-bit-id');
|
||||
});
|
||||
|
||||
it('separates parent, mode, season, command, and episode identity without delimiter collisions', () => {
|
||||
const base = {
|
||||
sourceId: 'playlist',
|
||||
parentSeriesId: 'parent|a',
|
||||
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||
episodeState: episodeState({ originalCmd: 'cmd|a' }),
|
||||
} as const;
|
||||
const variants = [
|
||||
{ ...base, parentSeriesId: 'parent|b' },
|
||||
{
|
||||
...base,
|
||||
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
episodeState: episodeState(
|
||||
{ originalCmd: 'cmd|a' },
|
||||
'season|2'
|
||||
),
|
||||
},
|
||||
{ ...base, episodeState: episodeState({ originalCmd: 'cmd|b' }) },
|
||||
{
|
||||
...base,
|
||||
episodeState: {
|
||||
...base.episodeState,
|
||||
episodeNumber: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
const baseKey = createStalkerEpisodePlaybackSessionKey(base);
|
||||
|
||||
expect(
|
||||
variants.map(createStalkerEpisodePlaybackSessionKey)
|
||||
).not.toContain(baseKey);
|
||||
});
|
||||
|
||||
it.each([episodeState({}), episodeState({ originalCmd: '' })])(
|
||||
'fails closed when original episode identity is absent',
|
||||
(state) => {
|
||||
expect(
|
||||
createStalkerEpisodePlaybackSessionKey({
|
||||
sourceId: 'playlist',
|
||||
parentSeriesId: 'series',
|
||||
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||
episodeState: state,
|
||||
})
|
||||
).toBe('');
|
||||
}
|
||||
);
|
||||
});
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import {
|
||||
normalizeStalkerEntityId,
|
||||
type StalkerMappedEpisode,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback';
|
||||
import {
|
||||
STALKER_SERIES_DOWNLOAD_MODES,
|
||||
type StalkerSeriesDownloadMode,
|
||||
} from './stalker-series-download.adapter';
|
||||
|
||||
export interface StalkerEpisodePlaybackSessionKeyOptions {
|
||||
readonly sourceId: string | null | undefined;
|
||||
readonly parentSeriesId: unknown;
|
||||
readonly seriesMode: StalkerSeriesDownloadMode;
|
||||
readonly episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null;
|
||||
}
|
||||
|
||||
export interface StalkerEpisodePlaybackSessionIdentity {
|
||||
readonly sourceId: string;
|
||||
readonly parentSeriesId: string;
|
||||
readonly seriesMode: StalkerSeriesDownloadMode;
|
||||
readonly originalEpisodeIdentity: string | number;
|
||||
readonly seasonKey: string;
|
||||
readonly seasonNumber: number;
|
||||
readonly episodeNumber: number;
|
||||
readonly episodeTrackingId: string | number;
|
||||
readonly sessionKey: string;
|
||||
}
|
||||
|
||||
interface StalkerEpisodeStateLookupOptions {
|
||||
readonly episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>;
|
||||
}
|
||||
|
||||
function present(value: unknown): value is string | number {
|
||||
return (
|
||||
(typeof value === 'string' && value.trim().length > 0) ||
|
||||
(typeof value === 'number' && Number.isFinite(value))
|
||||
);
|
||||
}
|
||||
|
||||
export function captureStalkerEpisodePlaybackSessionIdentity({
|
||||
sourceId,
|
||||
parentSeriesId,
|
||||
seriesMode,
|
||||
episodeState,
|
||||
}: StalkerEpisodePlaybackSessionKeyOptions): StalkerEpisodePlaybackSessionIdentity | null {
|
||||
const normalizedSourceId = sourceId?.trim() ?? '';
|
||||
const seriesId = normalizeStalkerEntityId(parentSeriesId);
|
||||
if (!normalizedSourceId || !seriesId || !episodeState) return null;
|
||||
|
||||
const { episodeNumber, seasonKey, seasonNumber } = episodeState;
|
||||
const episode = episodeState.episode as StalkerMappedEpisode;
|
||||
const originalIdentity =
|
||||
seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod
|
||||
? episode.originalId
|
||||
: episode.originalCmd;
|
||||
if (
|
||||
!present(originalIdentity) ||
|
||||
!seasonKey.trim() ||
|
||||
!Number.isSafeInteger(seasonNumber) ||
|
||||
seasonNumber < 0 ||
|
||||
!Number.isSafeInteger(episodeNumber) ||
|
||||
episodeNumber < 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentId = JSON.stringify([
|
||||
seriesMode,
|
||||
seriesId,
|
||||
seasonKey,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
originalIdentity,
|
||||
]);
|
||||
const sessionKey = createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: normalizedSourceId,
|
||||
contentId,
|
||||
seriesId,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
});
|
||||
return Object.freeze({
|
||||
sourceId: normalizedSourceId,
|
||||
parentSeriesId: seriesId,
|
||||
seriesMode,
|
||||
originalEpisodeIdentity: originalIdentity,
|
||||
seasonKey,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
episodeTrackingId: episodeState.episode.id,
|
||||
sessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function createStalkerEpisodePlaybackSessionKey(
|
||||
options: StalkerEpisodePlaybackSessionKeyOptions
|
||||
): string {
|
||||
return (
|
||||
captureStalkerEpisodePlaybackSessionIdentity(options)?.sessionKey ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSelectedStalkerEpisodeState({
|
||||
episodesBySeason,
|
||||
episode,
|
||||
}: StalkerEpisodeStateLookupOptions & {
|
||||
readonly episode: XtreamSerieEpisode;
|
||||
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
|
||||
const exactState = findStalkerEpisodeState(
|
||||
episodesBySeason,
|
||||
(candidate) => candidate === episode
|
||||
);
|
||||
if (exactState) return exactState;
|
||||
|
||||
const selected = episode as StalkerMappedEpisode;
|
||||
const originalIdentity = selected.originalId ?? selected.originalCmd;
|
||||
const selectedSeasonNumber = Number(episode.season) || 0;
|
||||
if (!present(originalIdentity)) return null;
|
||||
|
||||
return findStalkerEpisodeState(episodesBySeason, (candidate, state) => {
|
||||
const mapped = candidate as StalkerMappedEpisode;
|
||||
return (
|
||||
(mapped.originalId ?? mapped.originalCmd) === originalIdentity &&
|
||||
(!selectedSeasonNumber ||
|
||||
state.seasonNumber === selectedSeasonNumber) &&
|
||||
state.episodeNumber === Number(episode.episode_num)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveStalkerEpisodeStateByIdentity({
|
||||
episodesBySeason,
|
||||
identity,
|
||||
}: StalkerEpisodeStateLookupOptions & {
|
||||
readonly identity: StalkerEpisodePlaybackSessionIdentity;
|
||||
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
|
||||
return findStalkerEpisodeState(
|
||||
{ [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] },
|
||||
(candidate, state) => {
|
||||
const mapped = candidate as StalkerMappedEpisode;
|
||||
const originalIdentity =
|
||||
identity.seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod
|
||||
? mapped.originalId
|
||||
: mapped.originalCmd;
|
||||
return (
|
||||
originalIdentity === identity.originalEpisodeIdentity &&
|
||||
state.seasonNumber === identity.seasonNumber &&
|
||||
state.episodeNumber === identity.episodeNumber
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function findStalkerEpisodeState(
|
||||
episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>,
|
||||
matches: (
|
||||
episode: XtreamSerieEpisode,
|
||||
state: SeriesPlaybackEpisodeState<XtreamSerieEpisode>
|
||||
) => boolean
|
||||
): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
|
||||
for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) {
|
||||
for (
|
||||
let episodeIndex = 0;
|
||||
episodeIndex < episodes.length;
|
||||
episodeIndex++
|
||||
) {
|
||||
const episode = episodes[episodeIndex];
|
||||
const state = {
|
||||
seasonKey,
|
||||
seasonNumber: Number(episode.season) || Number(seasonKey) || 0,
|
||||
episodeNumber: Number(episode.episode_num) || episodeIndex + 1,
|
||||
episode,
|
||||
previous: episodes[episodeIndex - 1] ?? null,
|
||||
next: episodes[episodeIndex + 1] ?? null,
|
||||
};
|
||||
if (matches(episode, state)) return state;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+1
@@ -167,6 +167,7 @@
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
[episodeMetadata]="inlineEpisodeMetadata()"
|
||||
[seriesTitle]="serial.info.name"
|
||||
|
||||
+8
-6
@@ -53,6 +53,7 @@ class StubSeasonContainerComponent {
|
||||
template: '',
|
||||
})
|
||||
class StubPortalInlinePlayerComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly episodeMetadata = input<unknown>(null);
|
||||
readonly seriesTitle = input<string | null>(null);
|
||||
@@ -771,17 +772,17 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(inlinePlayer.episodeMetadata()).toEqual({
|
||||
label: 'S01E01',
|
||||
title: 'Pilot',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
// Prettier expands these compact fixtures enough to breach the spec's
|
||||
// hard 1,200-line lint limit.
|
||||
// prettier-ignore
|
||||
expect(inlinePlayer.episodeMetadata()).toEqual({ label: 'S01E01', title: 'Pilot', seasonNumber: 1, episodeNumber: 1 });
|
||||
expect(inlinePlayer.seriesNavigation()).toEqual({
|
||||
canPrevious: false,
|
||||
canNext: true,
|
||||
autoplayEnabled: true,
|
||||
});
|
||||
const firstEpisodeKey = inlinePlayer.playbackSessionKey();
|
||||
expect(firstEpisodeKey).not.toBe('');
|
||||
|
||||
inlinePlayer.playbackEnded.emit();
|
||||
await fixture.whenStable();
|
||||
@@ -806,6 +807,7 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
canNext: false,
|
||||
autoplayEnabled: true,
|
||||
});
|
||||
expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey);
|
||||
|
||||
inlinePlayer.playbackEnded.emit();
|
||||
await fixture.whenStable();
|
||||
|
||||
+169
-235
@@ -61,7 +61,6 @@ import {
|
||||
getSeriesPlaybackNavigation,
|
||||
type PlaybackFallbackRequest,
|
||||
PortalInlinePlayerComponent,
|
||||
resolveSeriesPlaybackEpisodeState,
|
||||
type SeriesPlaybackEpisodeState,
|
||||
type UpNextRailItem,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -85,6 +84,12 @@ import {
|
||||
createStalkerSeriesDownloadAdapter,
|
||||
STALKER_SERIES_DOWNLOAD_MODES,
|
||||
} from './stalker-series-download.adapter';
|
||||
import {
|
||||
captureStalkerEpisodePlaybackSessionIdentity,
|
||||
resolveSelectedStalkerEpisodeState,
|
||||
resolveStalkerEpisodeStateByIdentity,
|
||||
type StalkerEpisodePlaybackSessionIdentity,
|
||||
} from './stalker-episode-playback-session-key';
|
||||
|
||||
interface SeriesPositionContext {
|
||||
readonly generation: number;
|
||||
@@ -93,46 +98,10 @@ interface SeriesPositionContext {
|
||||
readonly mutationKey: string;
|
||||
}
|
||||
|
||||
function snapshotTextList(value: string | undefined): string[] | undefined {
|
||||
const entries = value
|
||||
?.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
return entries?.length ? entries : undefined;
|
||||
}
|
||||
|
||||
function snapshotNumber(
|
||||
value: string | number | undefined
|
||||
): number | undefined {
|
||||
if (value === undefined || String(value).trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function snapshotYear(value: string | undefined): number | undefined {
|
||||
const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
function snapshotPeople(
|
||||
enriched: TmdbEnrichedCastMember[] | undefined,
|
||||
fallback: string | undefined
|
||||
) {
|
||||
return enriched?.length
|
||||
? enriched.map((person) => ({
|
||||
name: person.name,
|
||||
role: person.character,
|
||||
profileUrl: person.profileUrl ?? undefined,
|
||||
tmdbPersonId: person.tmdbPersonId,
|
||||
}))
|
||||
: snapshotTextList(fallback)?.map((name) => ({ name }));
|
||||
}
|
||||
|
||||
function positiveCoordinate(value: number | string | undefined): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
|
||||
interface StalkerSeriesPlaybackRequestContext {
|
||||
readonly generation: number;
|
||||
readonly usesEmbeddedPlayer: boolean;
|
||||
readonly identity: StalkerEpisodePlaybackSessionIdentity | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,6 +184,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
>();
|
||||
private readonly seriesPositionReloadKeys = new Set<string>();
|
||||
private seriesPositionsLoadGeneration = 0;
|
||||
private seriesPlaybackRequestGeneration = 0;
|
||||
private currentSeriesPlaybackOwnerKey = '';
|
||||
private readonly inlinePlaybackEpisodeState =
|
||||
signal<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(null);
|
||||
private lastSaveTime = 0;
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
readonly openingEpisodeId = signal<number | null>(null);
|
||||
@@ -308,6 +281,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const ownerKey = this.seriesPlaybackOwnerKey();
|
||||
untracked(() => this.syncSeriesPlaybackOwner(ownerKey));
|
||||
});
|
||||
|
||||
// TMDB season fetch, keyed on (tmdb_id, selected season). With season
|
||||
// tabs the first seasonSelected fires immediately when seasons load —
|
||||
// usually BEFORE the async show-level TMDB enrichment has written
|
||||
@@ -426,9 +404,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.episodePlaybackPositions.set(
|
||||
reconciled.positionsByTrackingId
|
||||
);
|
||||
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
|
||||
this.legacyPositionByTrackingId.set(
|
||||
reconciled.legacyPositionByTrackingId
|
||||
);
|
||||
@@ -488,15 +464,14 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
// The facade/runtime already saved this row. Repeat the
|
||||
// idempotent upsert because only this view owns the
|
||||
// scoped-to-legacy cleanup mapping.
|
||||
void this.persistSeriesPosition(
|
||||
playlistId,
|
||||
data
|
||||
).catch((error: unknown) => {
|
||||
this.logger.error(
|
||||
'Failed to persist runtime series position',
|
||||
error
|
||||
);
|
||||
});
|
||||
void this.persistSeriesPosition(playlistId, data).catch(
|
||||
(error: unknown) => {
|
||||
this.logger.error(
|
||||
'Failed to persist runtime series position',
|
||||
error
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
) ?? null;
|
||||
}
|
||||
@@ -534,6 +509,22 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer)
|
||||
);
|
||||
|
||||
readonly seriesMode = computed(() =>
|
||||
this.isVodSeries()
|
||||
? STALKER_SERIES_DOWNLOAD_MODES.LazyVod
|
||||
: this.vodWithSeries()
|
||||
? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod
|
||||
: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries
|
||||
);
|
||||
|
||||
private readonly seriesPlaybackOwnerKey = computed(() => {
|
||||
const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? '';
|
||||
const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id);
|
||||
return sourceId && parentSeriesId
|
||||
? JSON.stringify([sourceId, parentSeriesId, this.seriesMode()])
|
||||
: '';
|
||||
});
|
||||
|
||||
readonly episodeDownloadAdapter = computed(() => {
|
||||
const playlist = this.stalkerStore.currentPlaylist();
|
||||
const item = this.displayItem();
|
||||
@@ -545,11 +536,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
this.translateService.defaultLang ||
|
||||
'en',
|
||||
seriesId: this.toSeriesId(item?.id ?? 0),
|
||||
seriesMode: this.isVodSeries()
|
||||
? STALKER_SERIES_DOWNLOAD_MODES.LazyVod
|
||||
: this.vodWithSeries()
|
||||
? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod
|
||||
: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||
seriesMode: this.seriesMode(),
|
||||
resolveUrl: (command, episodeNumber) =>
|
||||
this.stalkerStore.fetchLinkToPlay(
|
||||
playlist?.portalUrl ?? '',
|
||||
@@ -591,10 +578,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
vodSeriesSeasons: this.vodSeriesSeasons(),
|
||||
});
|
||||
});
|
||||
readonly inlineEpisodeState =
|
||||
computed<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
|
||||
this.getInlineEpisodeState()
|
||||
);
|
||||
readonly inlineEpisodeState = computed(() =>
|
||||
this.inlinePlaybackEpisodeState()
|
||||
);
|
||||
readonly playbackSessionKey = signal('');
|
||||
readonly inlineEpisodeMetadata = computed(() =>
|
||||
getSeriesEpisodeMetadata(this.inlineEpisodeState())
|
||||
);
|
||||
@@ -769,26 +756,32 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
*/
|
||||
onEpisodeClicked(episode: XtreamSerieEpisode) {
|
||||
const mappedEpisode = episode as StalkerMappedEpisode;
|
||||
const item = this.displayItem();
|
||||
const episodeState = resolveSelectedStalkerEpisodeState({
|
||||
episodesBySeason: this.mappedSeasons(),
|
||||
episode,
|
||||
});
|
||||
if (!item || !episodeState) return;
|
||||
this.syncSeriesPlaybackOwner(this.seriesPlaybackOwnerKey());
|
||||
|
||||
if (mappedEpisode.custom_sid === 'vod-series') {
|
||||
// It's a VOD series episode (is_series=1 mode)
|
||||
// Use originalId for playback URL, but use generated id for tracking
|
||||
this.playVodSeriesEpisode({
|
||||
originalId: mappedEpisode.originalId, // For constructing playback URL
|
||||
trackingId: episode.id, // The generated unique ID for position tracking
|
||||
name: episode.title,
|
||||
series_number: episode.episode_num,
|
||||
});
|
||||
} else {
|
||||
// Regular series or vclub mode (VOD with embedded series array)
|
||||
// Use originalCmd for playback, episode.id for tracking
|
||||
const trackingId = Number(episode.id);
|
||||
this.playEpisodeClicked(
|
||||
episode.episode_num,
|
||||
mappedEpisode.originalCmd,
|
||||
trackingId
|
||||
);
|
||||
}
|
||||
const isLazyVod = mappedEpisode.custom_sid === 'vod-series';
|
||||
const command = isLazyVod
|
||||
? `/media/file_${mappedEpisode.originalId ?? ''}.mpg`
|
||||
: mappedEpisode.originalCmd;
|
||||
const title = isLazyVod
|
||||
? `${item.info.name} - ${episode.title || `Episode ${episodeState.episodeNumber}`}`
|
||||
: item.info.name;
|
||||
const trackingId = Number(episode.id);
|
||||
const startTime =
|
||||
this.episodePlaybackPositions().get(trackingId)?.positionSeconds;
|
||||
|
||||
void this.startPlayback(
|
||||
command,
|
||||
title,
|
||||
item.info.movie_image,
|
||||
episodeState,
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
async playQuickStartEpisode(): Promise<void> {
|
||||
@@ -807,69 +800,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play episode - handles regular series and vclub mode
|
||||
*/
|
||||
playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) {
|
||||
const item = this.displayItem();
|
||||
if (!item) return;
|
||||
this.logger.debug('playEpisodeClicked', {
|
||||
episodeNum,
|
||||
cmd,
|
||||
trackingId,
|
||||
seriesId: item.id,
|
||||
});
|
||||
|
||||
const position = trackingId
|
||||
? this.episodePlaybackPositions().get(trackingId)
|
||||
: undefined;
|
||||
const startTime = position?.positionSeconds;
|
||||
void this.startPlayback(
|
||||
cmd,
|
||||
item.info.name,
|
||||
item.info.movie_image,
|
||||
episodeNum,
|
||||
trackingId,
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play VOD series episode
|
||||
*/
|
||||
playVodSeriesEpisode(episode: {
|
||||
originalId?: string;
|
||||
trackingId: string;
|
||||
name: string;
|
||||
series_number: number;
|
||||
}) {
|
||||
const item = this.displayItem();
|
||||
if (!item) return;
|
||||
// Use originalId for playback URL (this is what the Stalker API expects)
|
||||
const cmd = `/media/file_${episode.originalId ?? ''}.mpg`;
|
||||
const episodeName = episode.name || `Episode ${episode.series_number}`;
|
||||
// Use trackingId (generated unique ID) for playback position tracking
|
||||
const trackingId = Number(episode.trackingId);
|
||||
|
||||
this.logger.debug('playVodSeriesEpisode', {
|
||||
originalId: episode.originalId,
|
||||
trackingId,
|
||||
seriesId: item.id,
|
||||
episodeName,
|
||||
});
|
||||
|
||||
const position = this.episodePlaybackPositions().get(trackingId);
|
||||
const startTime = position?.positionSeconds;
|
||||
void this.startPlayback(
|
||||
cmd,
|
||||
`${item.info.name} - ${episodeName}`,
|
||||
item.info.movie_image,
|
||||
episode.series_number,
|
||||
trackingId,
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
openSimilarInPortals(item: CrossPortalSimilarItem): void {
|
||||
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
|
||||
}
|
||||
@@ -907,7 +837,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.seriesPlaybackRequestGeneration += 1;
|
||||
this.inlinePlayback.set(null);
|
||||
this.inlinePlaybackEpisodeState.set(null);
|
||||
this.playbackSessionKey.set('');
|
||||
this.lastSaveTime = 0;
|
||||
}
|
||||
|
||||
@@ -984,13 +917,27 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private async startPlayback(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
episodeNum?: number,
|
||||
episodeId?: number,
|
||||
cmd: string | undefined,
|
||||
title: string | undefined,
|
||||
thumbnail: string | undefined,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode>,
|
||||
startTime?: number
|
||||
): Promise<void> {
|
||||
const generation = ++this.seriesPlaybackRequestGeneration;
|
||||
const episodeNum = episodeState.episodeNumber;
|
||||
const episodeId = Number(episodeState.episode.id);
|
||||
const request: StalkerSeriesPlaybackRequestContext = {
|
||||
generation,
|
||||
usesEmbeddedPlayer: this.portalPlayer.isEmbeddedPlayer(),
|
||||
identity: captureStalkerEpisodePlaybackSessionIdentity({
|
||||
sourceId: this.stalkerStore.currentPlaylist()?._id,
|
||||
parentSeriesId: this.displayItem()?.id,
|
||||
seriesMode: this.seriesMode(),
|
||||
episodeState,
|
||||
}),
|
||||
};
|
||||
if (request.usesEmbeddedPlayer && !request.identity) return;
|
||||
|
||||
try {
|
||||
const playback = await this.stalkerStore.resolveVodPlayback(
|
||||
cmd,
|
||||
@@ -1000,14 +947,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
episodeId,
|
||||
startTime
|
||||
);
|
||||
const episodeState =
|
||||
episodeId === undefined
|
||||
? null
|
||||
: resolveSeriesPlaybackEpisodeState({
|
||||
episodesBySeason: this.mappedSeasons(),
|
||||
currentEpisodeId: episodeId,
|
||||
fallbackEpisodeNumber: episodeNum,
|
||||
});
|
||||
if (!this.isPlaybackRequestCurrent(request)) return;
|
||||
|
||||
const resolvedPlayback =
|
||||
episodeState && playback.contentInfo?.contentType === 'episode'
|
||||
? {
|
||||
@@ -1021,14 +962,19 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
: playback;
|
||||
|
||||
this.lastSaveTime = 0;
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlayback.set(resolvedPlayback);
|
||||
if (request.usesEmbeddedPlayer && request.identity) {
|
||||
this.setInlinePlayback(
|
||||
resolvedPlayback,
|
||||
request.identity.sessionKey,
|
||||
episodeState
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeInlinePlayer();
|
||||
void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true);
|
||||
} catch (error) {
|
||||
if (!this.isPlaybackRequestCurrent(request)) return;
|
||||
this.logger.error('Failed to start inline series playback', error);
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message === 'nothing_to_play'
|
||||
@@ -1042,25 +988,47 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
|
||||
const playback = this.inlinePlayback();
|
||||
const currentEpisodeId = playback?.contentInfo?.contentXtreamId;
|
||||
|
||||
if (
|
||||
playback?.contentInfo?.contentType !== 'episode' ||
|
||||
currentEpisodeId === undefined
|
||||
) {
|
||||
return null;
|
||||
private isPlaybackRequestCurrent(
|
||||
request: StalkerSeriesPlaybackRequestContext
|
||||
): boolean {
|
||||
if (request.generation !== this.seriesPlaybackRequestGeneration) {
|
||||
return false;
|
||||
}
|
||||
if (!request.identity) return true;
|
||||
|
||||
return resolveSeriesPlaybackEpisodeState({
|
||||
const identity = request.identity;
|
||||
const episodeState = resolveStalkerEpisodeStateByIdentity({
|
||||
episodesBySeason: this.mappedSeasons(),
|
||||
currentEpisodeId,
|
||||
identity,
|
||||
});
|
||||
const currentIdentity = captureStalkerEpisodePlaybackSessionIdentity({
|
||||
sourceId: this.stalkerStore.currentPlaylist()?._id,
|
||||
parentSeriesId: this.displayItem()?.id,
|
||||
seriesMode: this.seriesMode(),
|
||||
episodeState,
|
||||
});
|
||||
return currentIdentity?.sessionKey === identity.sessionKey;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribePositionUpdates?.();
|
||||
this.closeInlinePlayer();
|
||||
}
|
||||
|
||||
private setInlinePlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
sessionKey: string,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
|
||||
): void {
|
||||
this.playbackSessionKey.set(sessionKey);
|
||||
this.inlinePlaybackEpisodeState.set(episodeState);
|
||||
this.inlinePlayback.set(playback);
|
||||
}
|
||||
|
||||
private syncSeriesPlaybackOwner(ownerKey: string): void {
|
||||
if (ownerKey === this.currentSeriesPlaybackOwnerKey) return;
|
||||
this.currentSeriesPlaybackOwnerKey = ownerKey;
|
||||
this.closeInlinePlayer();
|
||||
}
|
||||
|
||||
async handlePlaybackToggleRequested(
|
||||
@@ -1072,17 +1040,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
if (request.nextPosition) {
|
||||
await this.persistSeriesPosition(
|
||||
playlistId,
|
||||
request.nextPosition
|
||||
);
|
||||
await this.persistSeriesPosition(playlistId, request.nextPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.clearSeriesPosition(
|
||||
playlistId,
|
||||
request.contentXtreamId
|
||||
);
|
||||
await this.clearSeriesPosition(playlistId, request.contentXtreamId);
|
||||
}
|
||||
|
||||
handlePlaybackToggleRequestedFromUi(
|
||||
@@ -1104,9 +1066,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
const generation = ++this.seriesPositionsLoadGeneration;
|
||||
this.trackPendingSeriesPositionLoad(context, generation);
|
||||
try {
|
||||
await this.waitForSeriesPositionMutations(
|
||||
context.mutationKey
|
||||
);
|
||||
await this.waitForSeriesPositionMutations(context.mutationKey);
|
||||
|
||||
if (
|
||||
generation !== this.seriesPositionsLoadGeneration ||
|
||||
@@ -1155,16 +1115,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
return (
|
||||
activeContext === context &&
|
||||
activeContext.generation === context.generation &&
|
||||
this.stalkerStore.currentPlaylist()?._id ===
|
||||
context.playlistId &&
|
||||
this.stalkerStore.currentPlaylist()?._id === context.playlistId &&
|
||||
this.toSeriesId(this.displayItem()?.id ?? 0) ===
|
||||
context.seriesXtreamId
|
||||
);
|
||||
}
|
||||
|
||||
private waitForSeriesPositionMutations(
|
||||
mutationKey: string
|
||||
): Promise<void> {
|
||||
private waitForSeriesPositionMutations(mutationKey: string): Promise<void> {
|
||||
return (
|
||||
this.seriesPositionMutationQueues.get(mutationKey) ??
|
||||
Promise.resolve()
|
||||
@@ -1176,8 +1133,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
generation: number
|
||||
): void {
|
||||
const generations =
|
||||
this.pendingSeriesPositionLoads.get(context) ??
|
||||
new Set<number>();
|
||||
this.pendingSeriesPositionLoads.get(context) ?? new Set<number>();
|
||||
generations.add(generation);
|
||||
this.pendingSeriesPositionLoads.set(context, generations);
|
||||
}
|
||||
@@ -1186,8 +1142,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
context: SeriesPositionContext,
|
||||
generation: number
|
||||
): void {
|
||||
const generations =
|
||||
this.pendingSeriesPositionLoads.get(context);
|
||||
const generations = this.pendingSeriesPositionLoads.get(context);
|
||||
generations?.delete(generation);
|
||||
if (generations?.size === 0) {
|
||||
this.pendingSeriesPositionLoads.delete(context);
|
||||
@@ -1220,30 +1175,20 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
this.seriesPositionMutationQueues.set(
|
||||
context.mutationKey,
|
||||
barrier
|
||||
);
|
||||
this.seriesPositionMutationQueues.set(context.mutationKey, barrier);
|
||||
void barrier.then(() => {
|
||||
if (
|
||||
this.seriesPositionMutationQueues.get(
|
||||
context.mutationKey
|
||||
) === barrier
|
||||
this.seriesPositionMutationQueues.get(context.mutationKey) ===
|
||||
barrier
|
||||
) {
|
||||
this.seriesPositionMutationQueues.delete(
|
||||
context.mutationKey
|
||||
);
|
||||
this.reloadSeriesPositionsAfterMutations(
|
||||
context.mutationKey
|
||||
);
|
||||
this.seriesPositionMutationQueues.delete(context.mutationKey);
|
||||
this.reloadSeriesPositionsAfterMutations(context.mutationKey);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private reloadSeriesPositionsAfterMutations(
|
||||
mutationKey: string
|
||||
): void {
|
||||
private reloadSeriesPositionsAfterMutations(mutationKey: string): void {
|
||||
if (!this.seriesPositionReloadKeys.delete(mutationKey)) {
|
||||
return;
|
||||
}
|
||||
@@ -1286,10 +1231,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
if (!context) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const legacyPosition =
|
||||
this.legacyPositionByTrackingId().get(
|
||||
position.contentXtreamId
|
||||
);
|
||||
const legacyPosition = this.legacyPositionByTrackingId().get(
|
||||
position.contentXtreamId
|
||||
);
|
||||
return this.enqueueSeriesPositionMutation(context, async () => {
|
||||
let clearedLegacy: boolean;
|
||||
try {
|
||||
@@ -1301,8 +1245,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
StalkerSeriesPositionPartialSaveError &&
|
||||
error instanceof StalkerSeriesPositionPartialSaveError &&
|
||||
this.isSeriesPositionContextActive(context)
|
||||
) {
|
||||
this.publishSavedSeriesPosition(
|
||||
@@ -1329,14 +1272,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
legacyPosition: PlaybackPositionData | undefined,
|
||||
clearedLegacy: boolean
|
||||
): void {
|
||||
const removedTrackingIds = new Set([
|
||||
position.contentXtreamId,
|
||||
]);
|
||||
const removedTrackingIds = new Set([position.contentXtreamId]);
|
||||
if (clearedLegacy && legacyPosition) {
|
||||
removedTrackingIds.add(legacyPosition.contentXtreamId);
|
||||
const legacyPositions = new Map(
|
||||
this.legacyPositionByTrackingId()
|
||||
);
|
||||
const legacyPositions = new Map(this.legacyPositionByTrackingId());
|
||||
legacyPositions.delete(position.contentXtreamId);
|
||||
this.legacyPositionByTrackingId.set(legacyPositions);
|
||||
}
|
||||
@@ -1344,9 +1283,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
this.rawSeriesPositions.set([
|
||||
...this.rawSeriesPositions().filter(
|
||||
(candidate) =>
|
||||
!removedTrackingIds.has(
|
||||
candidate.contentXtreamId
|
||||
)
|
||||
!removedTrackingIds.has(candidate.contentXtreamId)
|
||||
),
|
||||
position,
|
||||
]);
|
||||
@@ -1361,14 +1298,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
if (!context) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const position =
|
||||
this.episodePlaybackPositions().get(contentXtreamId) ?? {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 0,
|
||||
playlistId,
|
||||
seriesXtreamId: context.seriesXtreamId,
|
||||
};
|
||||
const position = this.episodePlaybackPositions().get(
|
||||
contentXtreamId
|
||||
) ?? {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 0,
|
||||
playlistId,
|
||||
seriesXtreamId: context.seriesXtreamId,
|
||||
};
|
||||
const legacyPosition =
|
||||
this.legacyPositionByTrackingId().get(contentXtreamId);
|
||||
return this.enqueueSeriesPositionMutation(context, async () => {
|
||||
@@ -1397,9 +1335,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
const removedTrackingIds = new Set([contentXtreamId]);
|
||||
if (clearedLegacy && legacyPosition) {
|
||||
removedTrackingIds.add(legacyPosition.contentXtreamId);
|
||||
const legacyPositions = new Map(
|
||||
this.legacyPositionByTrackingId()
|
||||
);
|
||||
const legacyPositions = new Map(this.legacyPositionByTrackingId());
|
||||
legacyPositions.delete(contentXtreamId);
|
||||
this.legacyPositionByTrackingId.set(legacyPositions);
|
||||
}
|
||||
@@ -1407,9 +1343,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
this.rawSeriesPositions.set(
|
||||
this.rawSeriesPositions().filter(
|
||||
(candidate) =>
|
||||
!removedTrackingIds.has(
|
||||
candidate.contentXtreamId
|
||||
)
|
||||
!removedTrackingIds.has(candidate.contentXtreamId)
|
||||
)
|
||||
);
|
||||
this.removeEpisodePlaybackPosition(contentXtreamId);
|
||||
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerStore,
|
||||
type StalkerMappedEpisode,
|
||||
type StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { TmdbEnrichmentService } from '@iptvnator/services';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
|
||||
import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter';
|
||||
import { createStalkerEpisodePlaybackSessionKey } from './stalker-episode-playback-session-key';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
|
||||
const selectedContentType = signal<'series' | 'vod'>('series');
|
||||
const selectedItem = signal<StalkerVodSource | null>(null);
|
||||
const serialSeasons = signal<unknown[]>([]);
|
||||
const vodSeasons = signal<unknown[]>([]);
|
||||
const resolveVodPlayback = jest.fn();
|
||||
const currentPlaylist = signal({
|
||||
_id: 'stalker|playlist',
|
||||
title: 'Portal',
|
||||
portalUrl: 'https://stalker.example',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
selectedContentType.set('series');
|
||||
selectedItem.set({
|
||||
id: 'series|parent',
|
||||
info: { name: 'Series', movie_image: 'poster.jpg' },
|
||||
});
|
||||
serialSeasons.set([
|
||||
{
|
||||
id: 'regular-season',
|
||||
name: 'Season 1',
|
||||
cmd: 'ffrt4://regular|full-command',
|
||||
series: [1, 2],
|
||||
},
|
||||
]);
|
||||
vodSeasons.set([]);
|
||||
resolveVodPlayback
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_command: string,
|
||||
title: string,
|
||||
thumbnail: string,
|
||||
_episodeNumber: number,
|
||||
episodeId: number
|
||||
) => ({
|
||||
streamUrl: 'https://resolved.example/temporary.mpg',
|
||||
title,
|
||||
thumbnail,
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: episodeId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 999,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerSeriesViewComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
selectedItem,
|
||||
selectedContentType,
|
||||
currentPlaylist,
|
||||
getSerialSeasonsResource: () => serialSeasons(),
|
||||
getVodSeriesSeasonsResource: () => vodSeasons(),
|
||||
isVodSeriesSeasonsLoading: signal(false),
|
||||
isSerialSeasonsLoading: signal(false),
|
||||
fetchVodSeriesEpisodes: jest.fn(),
|
||||
resolveVodPlayback,
|
||||
fetchLinkToPlay: jest.fn(),
|
||||
clearSelectedItem: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession: signal(null) },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
getSeriesPlaybackPositions: jest
|
||||
.fn()
|
||||
.mockResolvedValue([]),
|
||||
savePlaybackPosition: jest.fn(),
|
||||
clearPlaybackPosition: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: () => true,
|
||||
openResolvedPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: {
|
||||
isEnabled: () => false,
|
||||
getSeason: jest.fn(),
|
||||
getSeasonEpisodes: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
onLangChange: EMPTY,
|
||||
onTranslationChange: EMPTY,
|
||||
onDefaultLangChange: EMPTY,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(StalkerSeriesViewComponent, {
|
||||
set: { template: '' },
|
||||
})
|
||||
.compileComponents();
|
||||
fixture = TestBed.createComponent(StalkerSeriesViewComponent);
|
||||
});
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
async function playFirstEpisode(): Promise<StalkerMappedEpisode> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const episode = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
)[0][0] as StalkerMappedEpisode;
|
||||
fixture.componentInstance.onEpisodeClicked(episode);
|
||||
await fixture.whenStable();
|
||||
return episode;
|
||||
}
|
||||
|
||||
function setLazySeries(parentId: string, episodeId: string): void {
|
||||
selectedContentType.set('vod');
|
||||
selectedItem.set({
|
||||
id: parentId,
|
||||
is_series: true,
|
||||
info: { name: 'Lazy series', movie_image: 'poster.jpg' },
|
||||
});
|
||||
fixture.componentInstance.vodSeriesSeasons.set([
|
||||
{
|
||||
id: 'lazy-season',
|
||||
video_id: parentId,
|
||||
season_number: '2',
|
||||
name: 'Season 2',
|
||||
episodes: [
|
||||
{
|
||||
id: episodeId,
|
||||
series_number: 3,
|
||||
name: 'Replacement episode',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it.each([
|
||||
STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||
STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
|
||||
STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
|
||||
])('owns a collision-safe key for %s playback', async (expectedMode) => {
|
||||
if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod) {
|
||||
fixture.componentRef.setInput('vodWithSeries', {
|
||||
id: 'series|parent',
|
||||
cmd: 'ffrt4://embedded|full-command',
|
||||
series: [1, 2],
|
||||
info: { name: 'Embedded series', movie_image: 'poster.jpg' },
|
||||
});
|
||||
} else if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod) {
|
||||
selectedContentType.set('vod');
|
||||
selectedItem.set({
|
||||
id: 'series|parent',
|
||||
is_series: true,
|
||||
info: { name: 'Lazy series', movie_image: 'poster.jpg' },
|
||||
});
|
||||
vodSeasons.set([]);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.vodSeriesSeasons.set([
|
||||
{
|
||||
id: 'season|provider',
|
||||
video_id: 'series|parent',
|
||||
season_number: '1',
|
||||
name: 'Season | One',
|
||||
episodes: [
|
||||
{
|
||||
id: 'episode|provider:full',
|
||||
series_number: 1,
|
||||
name: 'Pilot',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const episode = await playFirstEpisode();
|
||||
const key = fixture.componentInstance.playbackSessionKey();
|
||||
|
||||
expect(fixture.componentInstance.seriesMode()).toBe(expectedMode);
|
||||
expect(key).not.toBe('');
|
||||
expect(key).not.toContain(String(episode.id));
|
||||
|
||||
fixture.componentInstance.inlinePlayback.set({
|
||||
streamUrl: 'https://alternative.example/replacement.mkv',
|
||||
title: 'Replacement payload',
|
||||
contentInfo: {
|
||||
playlistId: 'alternative-playlist',
|
||||
contentXtreamId: 987654321,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 123,
|
||||
seasonNumber: 9,
|
||||
episodeNumber: 9,
|
||||
},
|
||||
});
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
|
||||
});
|
||||
|
||||
it('changes when the original full episode command changes', async () => {
|
||||
await playFirstEpisode();
|
||||
const firstKey = fixture.componentInstance.playbackSessionKey();
|
||||
serialSeasons.set([
|
||||
{
|
||||
id: 'replacement-season',
|
||||
name: 'Season 1',
|
||||
cmd: 'ffrt4://different|full-command',
|
||||
series: [1],
|
||||
},
|
||||
]);
|
||||
|
||||
await playFirstEpisode();
|
||||
|
||||
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
|
||||
firstKey
|
||||
);
|
||||
});
|
||||
|
||||
it('selects the exact clicked episode when distinct provider commands collide in the tracking hash', async () => {
|
||||
serialSeasons.set([
|
||||
{
|
||||
id: 'collision-a',
|
||||
name: 'Season A',
|
||||
cmd: 'collision-cmd-1000',
|
||||
series: [1],
|
||||
},
|
||||
{
|
||||
id: 'collision-b',
|
||||
name: 'Season B',
|
||||
cmd: 'collision-cmd-425721',
|
||||
series: [1],
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const [episodeA, episodeB] = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
).map((episodes) => episodes[0] as StalkerMappedEpisode);
|
||||
|
||||
expect(episodeA.id).toBe('1511026353');
|
||||
expect(episodeB.id).toBe(episodeA.id);
|
||||
|
||||
fixture.componentInstance.onEpisodeClicked(episodeB);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
|
||||
'collision-cmd-425721',
|
||||
'Series',
|
||||
'poster.jpg',
|
||||
1,
|
||||
1511026353,
|
||||
undefined
|
||||
);
|
||||
const expectedBKey = createStalkerEpisodePlaybackSessionKey({
|
||||
sourceId: 'stalker|playlist',
|
||||
parentSeriesId: 'series|parent',
|
||||
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||
episodeState: {
|
||||
seasonKey: '2',
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
episode: episodeB,
|
||||
previous: null,
|
||||
next: null,
|
||||
},
|
||||
});
|
||||
const wrongAKey = createStalkerEpisodePlaybackSessionKey({
|
||||
sourceId: 'stalker|playlist',
|
||||
parentSeriesId: 'series|parent',
|
||||
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
|
||||
episodeState: {
|
||||
seasonKey: '1',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
episode: episodeA,
|
||||
previous: null,
|
||||
next: null,
|
||||
},
|
||||
});
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||
expectedBKey
|
||||
);
|
||||
expect(expectedBKey).not.toBe(wrongAKey);
|
||||
});
|
||||
|
||||
it.each(['parent', 'mode', 'playlist'] as const)(
|
||||
'clears committed playback when the canonical %s owner changes',
|
||||
async (transition) => {
|
||||
await playFirstEpisode();
|
||||
expect(fixture.componentInstance.inlinePlayback()).not.toBeNull();
|
||||
|
||||
if (transition === 'parent') {
|
||||
selectedItem.set({
|
||||
...selectedItem()!,
|
||||
id: 'replacement-parent',
|
||||
});
|
||||
} else if (transition === 'mode') {
|
||||
selectedContentType.set('vod');
|
||||
selectedItem.set({ ...selectedItem()!, is_series: true });
|
||||
} else {
|
||||
currentPlaylist.set({
|
||||
...currentPlaylist(),
|
||||
_id: 'replacement-playlist',
|
||||
});
|
||||
}
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe('');
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves committed playback across a same-owner object refresh', async () => {
|
||||
await playFirstEpisode();
|
||||
const playback = fixture.componentInstance.inlinePlayback();
|
||||
const key = fixture.componentInstance.playbackSessionKey();
|
||||
|
||||
selectedItem.set({ ...selectedItem()! });
|
||||
currentPlaylist.set({ ...currentPlaylist() });
|
||||
serialSeasons.set(
|
||||
serialSeasons().map((season) => ({
|
||||
...(season as Record<string, unknown>),
|
||||
}))
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
|
||||
});
|
||||
|
||||
it('rejects a completion after its playlist, parent, mode, and episode owner change', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const pending = deferred<ResolvedPortalPlayback>();
|
||||
resolveVodPlayback.mockReturnValueOnce(pending.promise);
|
||||
const episode = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
)[0][0] as StalkerMappedEpisode;
|
||||
fixture.componentInstance.onEpisodeClicked(episode);
|
||||
|
||||
currentPlaylist.set({
|
||||
...currentPlaylist(),
|
||||
_id: 'replacement-playlist',
|
||||
});
|
||||
setLazySeries('replacement-parent', 'replacement-provider-episode');
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/old-episode.mpg',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: episode.id,
|
||||
contentType: 'episode',
|
||||
},
|
||||
});
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the newest episode when owner-scoped requests resolve out of order', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const oldRequest = deferred<ResolvedPortalPlayback>();
|
||||
const newRequest = deferred<ResolvedPortalPlayback>();
|
||||
resolveVodPlayback
|
||||
.mockReturnValueOnce(oldRequest.promise)
|
||||
.mockReturnValueOnce(newRequest.promise);
|
||||
const oldEpisode = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
)[0][0] as StalkerMappedEpisode;
|
||||
fixture.componentInstance.onEpisodeClicked(oldEpisode);
|
||||
|
||||
currentPlaylist.set({ ...currentPlaylist(), _id: 'new-playlist' });
|
||||
setLazySeries('new-parent', 'new-provider-episode');
|
||||
const newEpisode = Object.values(
|
||||
fixture.componentInstance.mappedSeasons()
|
||||
)[0][0] as StalkerMappedEpisode;
|
||||
fixture.componentInstance.onEpisodeClicked(newEpisode);
|
||||
const newestPlayback = {
|
||||
streamUrl: 'https://new.example/episode.mpg',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: newEpisode.id,
|
||||
contentType: 'episode' as const,
|
||||
},
|
||||
};
|
||||
newRequest.resolve(newestPlayback);
|
||||
oldRequest.resolve({
|
||||
streamUrl: 'https://old.example/episode.mpg',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: oldEpisode.id,
|
||||
contentType: 'episode',
|
||||
},
|
||||
});
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(resolveVodPlayback).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.componentInstance.inlinePlayback()?.streamUrl).toBe(
|
||||
newestPlayback.streamUrl
|
||||
);
|
||||
expect(fixture.componentInstance.playbackSessionKey()).not.toBe('');
|
||||
});
|
||||
|
||||
it('preserves a valid mounted episode when original identity is missing', async () => {
|
||||
await playFirstEpisode();
|
||||
const playback = fixture.componentInstance.inlinePlayback();
|
||||
const key = fixture.componentInstance.playbackSessionKey();
|
||||
serialSeasons.set([
|
||||
{
|
||||
id: 'identity-less-season',
|
||||
name: 'Season 1',
|
||||
series: [1],
|
||||
},
|
||||
]);
|
||||
|
||||
await playFirstEpisode();
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,7 @@ function createController() {
|
||||
|
||||
return {
|
||||
controller,
|
||||
inlinePlayback,
|
||||
playbackPositions,
|
||||
selectedVodPosition,
|
||||
};
|
||||
@@ -115,4 +116,31 @@ describe('StalkerVodPlaybackController', () => {
|
||||
expect(selectedVodPosition()?.contentXtreamId).toBe(202);
|
||||
expect(selectedVodPosition()?.positionSeconds).toBe(20);
|
||||
});
|
||||
|
||||
it('does not mount playback that resolves after the detail closes', async () => {
|
||||
const { controller, inlinePlayback } = createController();
|
||||
const pending = createDeferred<{ streamUrl: string }>();
|
||||
const playback = controller.startVodPlayback(() => pending.promise);
|
||||
|
||||
controller.closeInlinePlayer();
|
||||
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
|
||||
await playback;
|
||||
|
||||
expect(inlinePlayback()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the newest VOD request when resolutions finish out of order', async () => {
|
||||
const { controller, inlinePlayback } = createController();
|
||||
const older = createDeferred<{ streamUrl: string }>();
|
||||
const newer = createDeferred<{ streamUrl: string }>();
|
||||
const olderRequest = controller.startVodPlayback(() => older.promise);
|
||||
const newerRequest = controller.startVodPlayback(() => newer.promise);
|
||||
const newestPlayback = { streamUrl: 'https://new.example/movie.mpg' };
|
||||
|
||||
newer.resolve(newestPlayback);
|
||||
older.resolve({ streamUrl: 'https://old.example/movie.mpg' });
|
||||
await Promise.all([olderRequest, newerRequest]);
|
||||
|
||||
expect(inlinePlayback()).toBe(newestPlayback);
|
||||
});
|
||||
});
|
||||
@@ -21,22 +21,30 @@ interface StalkerVodPlaybackControllerConfig {
|
||||
translateService: TranslateService;
|
||||
logger: Logger;
|
||||
playbackErrorLogMessage: string;
|
||||
playbackOwnerKey?: () => string;
|
||||
}
|
||||
|
||||
export class StalkerVodPlaybackController {
|
||||
private lastInlineSaveTime = 0;
|
||||
private loadSelectedVodPositionRequestId = 0;
|
||||
private playbackRequestId = 0;
|
||||
|
||||
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
|
||||
|
||||
async startVodPlayback(
|
||||
resolvePlayback: () => Promise<ResolvedPortalPlayback>
|
||||
): Promise<void> {
|
||||
const requestId = ++this.playbackRequestId;
|
||||
const usesEmbeddedPlayer = this.config.portalPlayer.isEmbeddedPlayer();
|
||||
const playbackOwnerKey = this.config.playbackOwnerKey?.();
|
||||
try {
|
||||
const playback = await resolvePlayback();
|
||||
if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastInlineSaveTime = 0;
|
||||
if (this.config.portalPlayer.isEmbeddedPlayer()) {
|
||||
if (usesEmbeddedPlayer) {
|
||||
this.config.inlinePlayback.set(playback);
|
||||
return;
|
||||
}
|
||||
@@ -44,6 +52,9 @@ export class StalkerVodPlaybackController {
|
||||
this.closeInlinePlayer();
|
||||
void this.config.portalPlayer.openResolvedPlayback(playback, true);
|
||||
} catch (error) {
|
||||
if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) {
|
||||
return;
|
||||
}
|
||||
this.config.logger.error(
|
||||
this.config.playbackErrorLogMessage,
|
||||
error
|
||||
@@ -115,10 +126,22 @@ export class StalkerVodPlaybackController {
|
||||
}
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.playbackRequestId += 1;
|
||||
this.config.inlinePlayback.set(null);
|
||||
this.lastInlineSaveTime = 0;
|
||||
}
|
||||
|
||||
private isPlaybackRequestCurrent(
|
||||
requestId: number,
|
||||
playbackOwnerKey: string | undefined
|
||||
): boolean {
|
||||
return (
|
||||
requestId === this.playbackRequestId &&
|
||||
(this.config.playbackOwnerKey === undefined ||
|
||||
this.config.playbackOwnerKey() === playbackOwnerKey)
|
||||
);
|
||||
}
|
||||
|
||||
showCopyNotification(): void {
|
||||
this.config.snackBar.open(
|
||||
this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'),
|
||||
|
||||
+1
@@ -96,6 +96,7 @@
|
||||
@if (isEmbeddedPlayer) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[streamUrl]="streamUrl"
|
||||
[title]="activePlayback()?.title ?? ''"
|
||||
[playback]="activePlayback()"
|
||||
|
||||
+122
-45
@@ -29,13 +29,21 @@ import {
|
||||
EpgTimelineComponent,
|
||||
EpgTimelineSummary,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
|
||||
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import {
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { GridListComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
|
||||
@@ -78,10 +86,11 @@ class StubGridListComponent {
|
||||
template: '',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
@@ -195,6 +204,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
};
|
||||
const portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(true),
|
||||
openExternalPlayback: jest.fn(),
|
||||
};
|
||||
const settingsStore = {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
@@ -206,6 +216,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentPlaylist.set(playlist);
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(fixedNow);
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
@@ -240,6 +251,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
xtreamUrlService.resolveCatchupUrl.mockClear();
|
||||
portalPlayer.isEmbeddedPlayer.mockReset();
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
portalPlayer.openExternalPlayback.mockClear();
|
||||
|
||||
epgItems.set([]);
|
||||
currentEpgItem.set(null);
|
||||
@@ -590,6 +602,58 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the host-owned live key across URL replacement and changes it with the channel', () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
currentPlaylist.set({ ...playlist, id: 'playlist|one' });
|
||||
const first = { ...sampleChannel, xtream_id: 101 };
|
||||
component.playLive(first);
|
||||
fixture.detectChanges();
|
||||
|
||||
const webPlayer = fixture.debugElement.query(
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
).componentInstance as StubWebPlayerViewComponent;
|
||||
const expected = createPlaybackSessionKey({
|
||||
kind: 'live',
|
||||
sourceId: 'playlist|one',
|
||||
contentId: 101,
|
||||
});
|
||||
expect(webPlayer.playbackSessionKey()).toBe(expected);
|
||||
|
||||
xtreamStore.constructStreamUrl.mockReturnValueOnce(
|
||||
'https://example.com/replaced-timeshift.ts'
|
||||
);
|
||||
component.playLive(first);
|
||||
fixture.detectChanges();
|
||||
expect(webPlayer.playbackSessionKey()).toBe(expected);
|
||||
|
||||
component.playLive({ ...first, xtream_id: 102 });
|
||||
fixture.detectChanges();
|
||||
expect(webPlayer.playbackSessionKey()).not.toBe(expected);
|
||||
});
|
||||
|
||||
it('forwards the exact resolved live playback to external fallback', () => {
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://example.com/fallback.m3u8',
|
||||
title: 'Channel 101',
|
||||
isLive: true,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 101,
|
||||
contentType: 'live',
|
||||
},
|
||||
};
|
||||
component.handleExternalFallbackRequest({
|
||||
player: 'mpv',
|
||||
playback,
|
||||
} as PlaybackFallbackRequest);
|
||||
|
||||
const [forwardedPlayback, forwardedPlayer] =
|
||||
portalPlayer.openExternalPlayback.mock.calls[0];
|
||||
expect(forwardedPlayback).toBe(playback);
|
||||
expect(forwardedPlayer).toBe('mpv');
|
||||
});
|
||||
|
||||
it('updates live root pagination from the header paginator', () => {
|
||||
selectedCategoryId.set(null);
|
||||
selectedTypeContentLoading.set(false);
|
||||
@@ -794,17 +858,44 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the active archive program in the live EPG panel summary', async () => {
|
||||
const archivedProgram: EpgProgram = {
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: 1775296800,
|
||||
stopTimestamp: 1775300400,
|
||||
it('does not attach a pending catchup result to a newer live owner', async () => {
|
||||
let resolveCatchup!: (url: string) => void;
|
||||
xtreamUrlService.resolveCatchupUrl.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveCatchup = resolve;
|
||||
})
|
||||
);
|
||||
component.playLive(sampleChannel);
|
||||
const catchup = component.onProgramActivated({
|
||||
type: 'timeshift',
|
||||
program: buildArchivedProgram(),
|
||||
});
|
||||
const newerChannel = {
|
||||
...sampleChannel,
|
||||
xtream_id: 202,
|
||||
name: 'Channel 202',
|
||||
};
|
||||
xtreamStore.constructStreamUrl.mockReturnValueOnce(
|
||||
'https://example.com/channel-202.ts'
|
||||
);
|
||||
component.playLive(newerChannel);
|
||||
resolveCatchup('https://stale.example/channel-101-timeshift.ts');
|
||||
await catchup;
|
||||
|
||||
expect(component.activePlayback()?.streamUrl).toBe(
|
||||
'https://example.com/channel-202.ts'
|
||||
);
|
||||
expect(component.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'live',
|
||||
sourceId: 'playlist-1',
|
||||
contentId: 202,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the active archive program in the live EPG panel summary', async () => {
|
||||
const archivedProgram = buildArchivedProgram();
|
||||
epgItems.set([
|
||||
buildEpgItem(
|
||||
'archived',
|
||||
@@ -854,16 +945,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
});
|
||||
|
||||
it('returns archive playback to the selected live stream from the panel action', async () => {
|
||||
const archivedProgram: EpgProgram = {
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: 1775296800,
|
||||
stopTimestamp: 1775300400,
|
||||
};
|
||||
const archivedProgram = buildArchivedProgram();
|
||||
currentEpgItem.set(
|
||||
buildEpgItem(
|
||||
'current',
|
||||
@@ -893,7 +975,9 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
isLive: true,
|
||||
})
|
||||
);
|
||||
expect(timeline.componentInstance.summary()?.title).toBe('Current Show');
|
||||
expect(timeline.componentInstance.summary()?.title).toBe(
|
||||
'Current Show'
|
||||
);
|
||||
expect(timeline.componentInstance.summaryLabelKey()).toBe(
|
||||
'EPG.CURRENT_PROGRAM'
|
||||
);
|
||||
@@ -901,16 +985,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
});
|
||||
|
||||
it('publishes the active archive program in remote-control status', async () => {
|
||||
const archivedProgram: EpgProgram = {
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: 1775296800,
|
||||
stopTimestamp: 1775300400,
|
||||
};
|
||||
const archivedProgram = buildArchivedProgram();
|
||||
currentEpgItem.set(
|
||||
buildEpgItem(
|
||||
'current',
|
||||
@@ -942,16 +1017,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
});
|
||||
|
||||
it('passes the active catchup program to the EPG list until live playback resumes', async () => {
|
||||
const archivedProgram: EpgProgram = {
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: 1775296800,
|
||||
stopTimestamp: 1775300400,
|
||||
};
|
||||
const archivedProgram = buildArchivedProgram();
|
||||
epgItems.set([
|
||||
buildEpgItem(
|
||||
'1',
|
||||
@@ -1066,7 +1132,9 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
const timeline = fixture.debugElement.query(
|
||||
By.directive(StubEpgTimelineComponent)
|
||||
);
|
||||
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(true);
|
||||
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
|
||||
@@ -1259,6 +1327,15 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const buildArchivedProgram = (): EpgProgram => ({
|
||||
start: '2026-04-04T10:00:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archived Show',
|
||||
desc: null,
|
||||
category: null,
|
||||
});
|
||||
|
||||
function buildEpgItem(
|
||||
id: string,
|
||||
title: string,
|
||||
|
||||
@@ -70,6 +70,7 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan
|
||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
|
||||
@@ -269,11 +270,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
|
||||
private unsubscribeRemoteChannelChange?: () => void;
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private playbackRequestId = 0;
|
||||
|
||||
readonly usesEmbeddedPlayer = computed(() =>
|
||||
this.portalPlayer.isEmbeddedPlayer()
|
||||
);
|
||||
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
private readonly activeLiveItemId = signal<number | null>(null);
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const sourceId = this.xtreamStore.currentPlaylist()?.id;
|
||||
const contentId = this.activeLiveItemId();
|
||||
return sourceId && contentId
|
||||
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
|
||||
: '';
|
||||
});
|
||||
readonly activeStreamUrl = computed(
|
||||
() => this.activePlayback()?.streamUrl ?? ''
|
||||
);
|
||||
@@ -435,12 +445,14 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
item: XtreamLiveChannelItem,
|
||||
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
|
||||
) {
|
||||
this.playbackRequestId += 1;
|
||||
const streamUrl = this.xtreamStore.constructStreamUrl(item);
|
||||
this.activeCatchupProgram.set(null);
|
||||
// Keep both root/recently-added playback and same-category replays in
|
||||
// sync with the category rail. For already-selected channels this is a
|
||||
// store no-op.
|
||||
this.selectLiveItemCategory(item);
|
||||
this.activeLiveItemId.set(item.xtream_id);
|
||||
this.activePlayback.set({
|
||||
streamUrl,
|
||||
title: item.title ?? item.name ?? '',
|
||||
@@ -541,6 +553,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.playbackRequestId += 1;
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
}
|
||||
@@ -643,6 +656,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++this.playbackRequestId;
|
||||
this.activeLiveItemId.set(item.xtream_id);
|
||||
const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl(
|
||||
playlist.id,
|
||||
{
|
||||
@@ -656,6 +671,13 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
stopTimestamp,
|
||||
playlist.serverTimezone
|
||||
);
|
||||
if (
|
||||
requestId !== this.playbackRequestId ||
|
||||
this.xtreamStore.currentPlaylist()?.id !== playlist.id ||
|
||||
this.activeLiveItemId() !== item.xtream_id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeCatchupProgram.set(program);
|
||||
this.activePlayback.set({
|
||||
|
||||
+15
-2
@@ -71,6 +71,8 @@ export class SerialDetailsPlaybackService {
|
||||
private lastSaveTime = 0;
|
||||
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
readonly inlinePlaybackSessionEpisodeState =
|
||||
signal<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(null);
|
||||
readonly episodePlaybackPositions = this.playbackPositionState.positions;
|
||||
readonly openingEpisodeId = signal<number | null>(null);
|
||||
readonly activeEpisodeId = signal<number | null>(null);
|
||||
@@ -221,7 +223,13 @@ export class SerialDetailsPlaybackService {
|
||||
contentInfo,
|
||||
};
|
||||
|
||||
this.startPlayback(playback);
|
||||
const episodeState = resolveSeriesPlaybackEpisodeState({
|
||||
episodesBySeason: selectedItem.episodes,
|
||||
currentEpisodeId: episode.id,
|
||||
fallbackSeasonNumber: Number(episode.season),
|
||||
fallbackEpisodeNumber: Number(episode.episode_num),
|
||||
});
|
||||
this.startPlayback(playback, episodeState);
|
||||
}
|
||||
|
||||
playQuickStartEpisode(): void {
|
||||
@@ -259,6 +267,7 @@ export class SerialDetailsPlaybackService {
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.inlinePlayback.set(null);
|
||||
this.inlinePlaybackSessionEpisodeState.set(null);
|
||||
this.lastSaveTime = 0;
|
||||
}
|
||||
|
||||
@@ -350,9 +359,13 @@ export class SerialDetailsPlaybackService {
|
||||
});
|
||||
}
|
||||
|
||||
private startPlayback(playback: ResolvedPortalPlayback): void {
|
||||
private startPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
|
||||
): void {
|
||||
this.lastSaveTime = 0;
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlaybackSessionEpisodeState.set(episodeState);
|
||||
this.inlinePlayback.set(playback);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
[episodeMetadata]="inlineEpisodeMetadata()"
|
||||
[seriesTitle]="item.info.name"
|
||||
|
||||
@@ -22,10 +22,11 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { BehaviorSubject, EMPTY, of } from 'rxjs';
|
||||
import { SerialDetailsComponent } from './serial-details.component';
|
||||
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
@@ -54,6 +55,7 @@ class StubSeasonContainerComponent {
|
||||
template: '',
|
||||
})
|
||||
class StubPortalInlinePlayerComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly episodeMetadata = input<unknown>(null);
|
||||
readonly seriesTitle = input<string | null>(null);
|
||||
@@ -108,6 +110,10 @@ describe('SerialDetailsComponent', () => {
|
||||
let seriesResumeTarget: ReturnType<
|
||||
typeof signal<SeriesResumeTarget | null>
|
||||
>;
|
||||
let routeParams: BehaviorSubject<{
|
||||
categoryId: string;
|
||||
serialId: string;
|
||||
}>;
|
||||
|
||||
beforeEach(async () => {
|
||||
window.history.replaceState({}, '', window.location.href);
|
||||
@@ -174,6 +180,10 @@ describe('SerialDetailsComponent', () => {
|
||||
getSeriesPlaybackPositions.mockClear();
|
||||
getSeriesPlaybackPositions.mockResolvedValue([]);
|
||||
seriesResumeTarget = signal<SeriesResumeTarget | null>(null);
|
||||
routeParams = new BehaviorSubject({
|
||||
categoryId: '3',
|
||||
serialId: '103',
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SerialDetailsComponent],
|
||||
@@ -181,10 +191,7 @@ describe('SerialDetailsComponent', () => {
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({
|
||||
categoryId: '3',
|
||||
serialId: '103',
|
||||
}),
|
||||
params: routeParams,
|
||||
snapshot: {
|
||||
params: {
|
||||
categoryId: '3',
|
||||
@@ -846,6 +853,15 @@ describe('SerialDetailsComponent', () => {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
const firstEpisodeKey = createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: 'xtream-1',
|
||||
contentId: 1001,
|
||||
seriesId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
expect(inlinePlayer.playbackSessionKey()).toBe(firstEpisodeKey);
|
||||
expect(inlinePlayer.seriesNavigation()).toEqual({
|
||||
canPrevious: false,
|
||||
canNext: true,
|
||||
@@ -874,6 +890,7 @@ describe('SerialDetailsComponent', () => {
|
||||
canNext: false,
|
||||
autoplayEnabled: true,
|
||||
});
|
||||
expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey);
|
||||
|
||||
inlinePlayer.playbackEnded.emit();
|
||||
fixture.detectChanges();
|
||||
@@ -887,4 +904,59 @@ describe('SerialDetailsComponent', () => {
|
||||
expect.objectContaining({ id: '2001' })
|
||||
);
|
||||
});
|
||||
|
||||
it('owns the parent identity from the route and ignores replacement playback payloads', async () => {
|
||||
isEmbeddedPlayer.mockReturnValue(true);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const item = fixture.componentInstance.selectedItem();
|
||||
const episode = item?.episodes?.['1'][0];
|
||||
if (!item || !episode) {
|
||||
throw new Error('Expected the serial fixture and first episode');
|
||||
}
|
||||
fixture.componentInstance.playEpisode(episode);
|
||||
fixture.detectChanges();
|
||||
const expected = createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: 'xtream-1',
|
||||
contentId: 1001,
|
||||
seriesId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
|
||||
|
||||
fixture.componentInstance.inlinePlayback.set({
|
||||
streamUrl: 'https://alternative.example/replaced.mkv',
|
||||
title: 'Alternative payload',
|
||||
headers: { Authorization: 'Bearer replacement' },
|
||||
contentInfo: {
|
||||
playlistId: 'alternative-playlist',
|
||||
contentXtreamId: 999001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 999,
|
||||
seasonNumber: 9,
|
||||
episodeNumber: 9,
|
||||
},
|
||||
});
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
|
||||
|
||||
selectedItem.set({ ...item, series_id: 999 });
|
||||
routeParams.next({ categoryId: '3', serialId: '104' });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.componentInstance.playEpisode(episode);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId: 'xtream-1',
|
||||
contentId: 1001,
|
||||
seriesId: 104,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
} from '../tmdb-similar.util';
|
||||
import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata';
|
||||
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
|
||||
import { createSerialPlaybackSessionKey } from './serial-playback-session-key';
|
||||
|
||||
@Component({
|
||||
selector: 'app-serial-details',
|
||||
@@ -152,6 +153,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
readonly quickStartAction = this.playback.quickStartAction;
|
||||
readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata;
|
||||
readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation;
|
||||
readonly playbackSessionKey = computed(() =>
|
||||
createSerialPlaybackSessionKey(
|
||||
this.xtreamStore.currentPlaylist()?.id,
|
||||
this.routeParams().serialId,
|
||||
this.playback.inlinePlaybackSessionEpisodeState()
|
||||
)
|
||||
);
|
||||
/** "Up Next" rail entries for the inline player (series only). */
|
||||
readonly upNextEpisodes = computed<UpNextRailItem[]>(() =>
|
||||
buildUpNextRailItems({
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback';
|
||||
|
||||
export function createSerialPlaybackSessionKey(
|
||||
sourceId: string | undefined,
|
||||
seriesId: number | string | undefined,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
|
||||
): string {
|
||||
const normalizedSeriesId = Number(seriesId);
|
||||
if (
|
||||
!sourceId ||
|
||||
!Number.isSafeInteger(normalizedSeriesId) ||
|
||||
normalizedSeriesId <= 0 ||
|
||||
!episodeState
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return createPlaybackSessionKey({
|
||||
kind: 'episode',
|
||||
sourceId,
|
||||
contentId: episodeState.episode.id,
|
||||
seriesId: normalizedSeriesId,
|
||||
seasonNumber: episodeState.seasonNumber,
|
||||
episodeNumber: episodeState.episodeNumber,
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { BehaviorSubject, NEVER, of } from 'rxjs';
|
||||
import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-inline-player',
|
||||
@@ -34,6 +35,7 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
template: '<div data-testid="inline-vod-player"></div>',
|
||||
})
|
||||
class StubPortalInlinePlayerComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly playback = input<unknown>(null);
|
||||
// Multi-source wiring: the real player takes these, so the stub has to
|
||||
// accept them or the template binding fails to compile.
|
||||
@@ -369,6 +371,25 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
thumbnail: 'https://example.com/catalog-poster.jpg',
|
||||
})
|
||||
);
|
||||
const expectedKey = createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'playlist-1',
|
||||
contentId: 650020,
|
||||
});
|
||||
expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey);
|
||||
|
||||
fixture.componentInstance.inlinePlayback.set({
|
||||
streamUrl: 'https://copy.example/movie/9001.mkv',
|
||||
title: 'Catalog movie',
|
||||
headers: { 'User-Agent': 'Copy Provider' },
|
||||
contentInfo: {
|
||||
playlistId: 'copy-playlist',
|
||||
contentXtreamId: 9001,
|
||||
contentType: 'vod',
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey);
|
||||
expect(
|
||||
host.querySelector('app-portal-detail-shell')?.classList
|
||||
).toContain('shell-host--watch');
|
||||
@@ -581,6 +602,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
selectedItem.set(richItem());
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.providerOnly()).toBe(true);
|
||||
const firstSessionKey = fixture.componentInstance.playbackSessionKey();
|
||||
|
||||
window.history.replaceState({}, '', window.location.href);
|
||||
selectedItem.set({
|
||||
@@ -594,6 +616,16 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.providerOnly()).toBe(false);
|
||||
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
|
||||
firstSessionKey
|
||||
);
|
||||
expect(fixture.componentInstance.playbackSessionKey()).toBe(
|
||||
createPlaybackSessionKey({
|
||||
kind: 'vod',
|
||||
sourceId: 'playlist-1',
|
||||
contentId: 650021,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('plays a rich downloaded movie locally without a usable provider source', async () => {
|
||||
|
||||
@@ -254,6 +254,7 @@
|
||||
@if (playableVodItem()) {
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
[alternativeSources]="multiSource.alternatives()"
|
||||
[allSources]="multiSource.sources()"
|
||||
|
||||
@@ -72,6 +72,7 @@ import { VodDetailsDownloadsService } from './vod-details-downloads.service';
|
||||
import { VodDetailsSimilarService } from './vod-details-similar.service';
|
||||
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
|
||||
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
type XtreamVodIdentityItem = XtreamVodDetails & {
|
||||
readonly id?: number | string;
|
||||
@@ -162,6 +163,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly isFavorite = this.xtreamStore.isFavorite;
|
||||
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
|
||||
readonly playbackSessionKey = computed(() => {
|
||||
const sourceId = this.xtreamStore.currentPlaylist()?.id;
|
||||
const contentId = this.selectedVodId();
|
||||
return sourceId && Number.isFinite(contentId) && contentId > 0
|
||||
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
|
||||
: '';
|
||||
});
|
||||
readonly providerOnly = computed(() => {
|
||||
this.routeParams();
|
||||
return isProviderOnlyDetailState(window.history.state);
|
||||
|
||||
@@ -21,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
template: '<div data-test-id="stub-web-player-view"></div>',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
@@ -65,12 +66,10 @@ describe('PortalInlinePlayerComponent sources picker', () => {
|
||||
let component: PortalInlinePlayerComponentInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ PortalInlinePlayerComponent } = await import(
|
||||
'./portal-inline-player.component'
|
||||
));
|
||||
({ WebPlayerViewComponent } = await import(
|
||||
'../web-player-view/web-player-view.component'
|
||||
));
|
||||
({ PortalInlinePlayerComponent } =
|
||||
await import('./portal-inline-player.component'));
|
||||
({ WebPlayerViewComponent } =
|
||||
await import('../web-player-view/web-player-view.component'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -101,6 +100,7 @@ describe('PortalInlinePlayerComponent sources picker', () => {
|
||||
|
||||
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'sources-key');
|
||||
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl: 'https://example.com/movie.mkv',
|
||||
|
||||
@@ -18,6 +18,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
template: '<div data-test-id="stub-web-player-view"></div>',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
@@ -41,12 +42,10 @@ describe('PortalInlinePlayerComponent up next rail', () => {
|
||||
let component: PortalInlinePlayerComponentInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ PortalInlinePlayerComponent } = await import(
|
||||
'./portal-inline-player.component'
|
||||
));
|
||||
({ WebPlayerViewComponent } = await import(
|
||||
'../web-player-view/web-player-view.component'
|
||||
));
|
||||
({ PortalInlinePlayerComponent } =
|
||||
await import('./portal-inline-player.component'));
|
||||
({ WebPlayerViewComponent } =
|
||||
await import('../web-player-view/web-player-view.component'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -77,9 +76,11 @@ describe('PortalInlinePlayerComponent up next rail', () => {
|
||||
|
||||
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'up-next-key');
|
||||
}
|
||||
|
||||
const railEl = () => fixture.nativeElement.querySelector('app-up-next-rail');
|
||||
const railEl = () =>
|
||||
fixture.nativeElement.querySelector('app-up-next-rail');
|
||||
|
||||
const seriesPlayback = {
|
||||
streamUrl: 'https://example.com/episode.mp4',
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
></div>
|
||||
}
|
||||
<app-web-player-view
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[streamUrl]="streamUrl()"
|
||||
[title]="title()"
|
||||
[mediaTitle]="playerMediaTitle()"
|
||||
|
||||
+69
-9
@@ -3,6 +3,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackFallbackRequest } from '@iptvnator/playback/util';
|
||||
import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
@@ -19,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
template: '<div data-test-id="stub-web-player-view"></div>',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
@@ -27,7 +30,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly alternativeSources = input<unknown[]>([]);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
readonly alternativeSourceRequested = output<string>();
|
||||
readonly playbackFailed = output<unknown>();
|
||||
readonly playbackEnded = output<void>();
|
||||
@@ -42,12 +45,10 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
let component: PortalInlinePlayerComponentInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ PortalInlinePlayerComponent } = await import(
|
||||
'./portal-inline-player.component'
|
||||
));
|
||||
({ WebPlayerViewComponent } = await import(
|
||||
'../web-player-view/web-player-view.component'
|
||||
));
|
||||
({ PortalInlinePlayerComponent } =
|
||||
await import('./portal-inline-player.component'));
|
||||
({ WebPlayerViewComponent } =
|
||||
await import('../web-player-view/web-player-view.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -66,6 +67,10 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'playbackSessionKey',
|
||||
'host-owned-session-key'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -94,13 +99,17 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
(
|
||||
component as unknown as {
|
||||
playbackEnded: { subscribe: (fn: () => void) => void };
|
||||
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
|
||||
previousEpisodeRequested: {
|
||||
subscribe: (fn: () => void) => void;
|
||||
};
|
||||
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
|
||||
}
|
||||
).playbackEnded.subscribe(() => events.push('ended'));
|
||||
(
|
||||
component as unknown as {
|
||||
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
|
||||
previousEpisodeRequested: {
|
||||
subscribe: (fn: () => void) => void;
|
||||
};
|
||||
}
|
||||
).previousEpisodeRequested.subscribe(() => events.push('previous'));
|
||||
(
|
||||
@@ -119,6 +128,7 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
).componentInstance as StubWebPlayerViewComponent;
|
||||
expect(webPlayer.seriesNavigation()).toBe(seriesNavigation);
|
||||
expect(webPlayer.playbackSessionKey()).toBe('host-owned-session-key');
|
||||
|
||||
webPlayer.playbackEnded.emit();
|
||||
webPlayer.previousEpisodeRequested.emit();
|
||||
@@ -127,6 +137,48 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
expect(events).toEqual(['ended', 'previous', 'next']);
|
||||
});
|
||||
|
||||
it('forwards the exact resolved playback fallback request unchanged', () => {
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://example.test/live.m3u8',
|
||||
title: 'Header-bearing live stream',
|
||||
isLive: true,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'live',
|
||||
},
|
||||
};
|
||||
const request: PlaybackFallbackRequest = {
|
||||
player: 'mpv',
|
||||
playback,
|
||||
diagnostic: {
|
||||
code: 'network-error',
|
||||
player: 'videojs',
|
||||
source: 'hls',
|
||||
container: '',
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
audioCodecs: [],
|
||||
externalFallbackRecommended: true,
|
||||
},
|
||||
};
|
||||
let forwarded: PlaybackFallbackRequest | undefined;
|
||||
component.externalFallbackRequested.subscribe(
|
||||
(value) => (forwarded = value)
|
||||
);
|
||||
fixture.componentRef.setInput('playback', playback);
|
||||
fixture.detectChanges();
|
||||
|
||||
const webPlayer = fixture.debugElement.query(
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
).componentInstance as StubWebPlayerViewComponent;
|
||||
webPlayer.externalFallbackRequested.emit(request);
|
||||
|
||||
expect(forwarded).toBe(request);
|
||||
expect(forwarded?.playback).toBe(playback);
|
||||
});
|
||||
|
||||
describe('playerMediaTitle', () => {
|
||||
it('uses the series title with the episode label for episode playback', () => {
|
||||
fixture.componentRef.setInput('playback', {
|
||||
@@ -254,6 +306,10 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
|
||||
fixture.componentRef.setInput(
|
||||
'playbackSessionKey',
|
||||
'host-owned-session-key'
|
||||
);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
@@ -308,6 +364,10 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
|
||||
fixture.componentRef.setInput(
|
||||
'playbackSessionKey',
|
||||
'host-owned-session-key'
|
||||
);
|
||||
component = fixture.componentInstance;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ const RAIL_STAGE_GAP = 18;
|
||||
},
|
||||
})
|
||||
export class PortalInlinePlayerComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly episodeMetadata = input<SeriesEpisodeMetadata | null>(null);
|
||||
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
||||
@@ -77,8 +78,7 @@ export class PortalInlinePlayerComponent {
|
||||
readonly title = computed(() =>
|
||||
applyChannelNameStrip(
|
||||
this.playback()?.title,
|
||||
this.playback()?.isLive &&
|
||||
this.settingsStore.stripCountryPrefix?.()
|
||||
this.playback()?.isLive && this.settingsStore.stripCountryPrefix?.()
|
||||
)
|
||||
);
|
||||
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
|
||||
@@ -149,18 +149,15 @@ export class PortalInlinePlayerComponent {
|
||||
return { primary, secondary: metadata?.label ?? null };
|
||||
});
|
||||
|
||||
private readonly stageViewport = viewChild<ElementRef<HTMLElement>>(
|
||||
'stageViewport'
|
||||
);
|
||||
private readonly stageViewport =
|
||||
viewChild<ElementRef<HTMLElement>>('stageViewport');
|
||||
/**
|
||||
* Border-box size of the theater stage, written by a ResizeObserver.
|
||||
* Measuring the border box (not the content box) keeps the value stable
|
||||
* when the rail modifier toggles the stage's own padding, so the gate
|
||||
* below cannot oscillate around its threshold.
|
||||
*/
|
||||
readonly stageSize = signal<{ width: number; height: number } | null>(
|
||||
null
|
||||
);
|
||||
readonly stageSize = signal<{ width: number; height: number } | null>(null);
|
||||
/**
|
||||
* Width the rail would actually get: the stage minus its docked-mode
|
||||
* padding, the 16:9 player sized to the remaining height, and the flex
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
(timeUpdate)="onInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayback()"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
@@ -118,6 +119,10 @@ describe('VodDetailsComponent offline playback', () => {
|
||||
} = {}) => {
|
||||
fixture = TestBed.createComponent(VodDetailsComponent);
|
||||
fixture.componentRef.setInput('item', STALKER_VOD);
|
||||
fixture.componentRef.setInput(
|
||||
'playbackSessionKey',
|
||||
'stalker-host-owned-key'
|
||||
);
|
||||
fixture.componentRef.setInput('playbackPosition', playbackPosition);
|
||||
fixture.componentRef.setInput('externalPlayback', externalPlayback);
|
||||
fixture.componentRef.setInput('providerOnly', providerOnly);
|
||||
@@ -128,6 +133,24 @@ describe('VodDetailsComponent offline playback', () => {
|
||||
await fixture.whenStable();
|
||||
};
|
||||
|
||||
it('passes the host-owned session key unchanged to inline playback', async () => {
|
||||
await render();
|
||||
fixture.componentRef.setInput('inlinePlayback', {
|
||||
streamUrl: 'https://portal.example/movie.mp4',
|
||||
title: 'Catalog Movie',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const inlinePlayer = fixture.debugElement.query(
|
||||
By.css('app-portal-inline-player')
|
||||
).componentInstance as {
|
||||
playbackSessionKey(): string;
|
||||
};
|
||||
expect(inlinePlayer.playbackSessionKey()).toBe(
|
||||
'stalker-host-owned-key'
|
||||
);
|
||||
});
|
||||
|
||||
const completeDownload = (
|
||||
fileAvailability: DownloadItem['fileAvailability'] = 'available'
|
||||
) => {
|
||||
|
||||
@@ -79,6 +79,7 @@ export class VodDetailsComponent {
|
||||
|
||||
/** VOD item with discriminated union type */
|
||||
readonly item = input.required<VodDetailsItem>();
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
|
||||
/** Whether this item is in favorites (managed by parent) */
|
||||
readonly isFavorite = input<boolean>(false);
|
||||
|
||||
+2
@@ -96,6 +96,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
@@ -124,6 +125,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
|
||||
storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs }));
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
@@ -596,6 +597,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
const pendingSettings = new Subject<unknown>();
|
||||
storageMap.get.mockReturnValue(pendingSettings.asObservable());
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
|
||||
@@ -97,6 +97,7 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
|
||||
streamUrl = input.required<string>();
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
title = input('');
|
||||
playback = input<ResolvedPortalPlayback | null>(null);
|
||||
startTime = input<number>(0);
|
||||
@@ -136,7 +137,10 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
|
||||
);
|
||||
readonly hiddenAlternativeCount = computed(() =>
|
||||
Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES)
|
||||
Math.max(
|
||||
0,
|
||||
this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES
|
||||
)
|
||||
);
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
@@ -221,7 +225,9 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
}
|
||||
return { primary: title, secondary: null };
|
||||
});
|
||||
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
|
||||
readonly recordingFolder = computed(
|
||||
() => this.settings()?.recordingFolder ?? ''
|
||||
);
|
||||
|
||||
/** Stream URL the currently configured Electron header override belongs to. */
|
||||
private headerScopeStreamUrl: string | null = null;
|
||||
|
||||
+62
-3
@@ -4,13 +4,17 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
|
||||
import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util';
|
||||
import { GlobalFavoritesService } from './global-favorites.service';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
describe('GlobalFavoritesService', () => {
|
||||
let service: GlobalFavoritesService;
|
||||
let electronApi: {
|
||||
dbGetAppState: jest.Mock;
|
||||
dbReorderGlobalFavorites: jest.Mock;
|
||||
dbSetAppState: jest.Mock;
|
||||
};
|
||||
const storeSelect = jest.fn();
|
||||
const getPlaylistById = jest.fn();
|
||||
|
||||
const makeChannel = (
|
||||
overrides: Partial<UnifiedFavoriteChannel> &
|
||||
@@ -25,7 +29,10 @@ describe('GlobalFavoritesService', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
storeSelect.mockReset().mockReturnValue(of([]));
|
||||
getPlaylistById.mockReset();
|
||||
electronApi = {
|
||||
dbGetAppState: jest.fn().mockResolvedValue(null),
|
||||
dbReorderGlobalFavorites: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true }),
|
||||
@@ -39,9 +46,12 @@ describe('GlobalFavoritesService', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
GlobalFavoritesService,
|
||||
{ provide: Store, useValue: { select: jest.fn() } },
|
||||
{ provide: Store, useValue: { select: storeSelect } },
|
||||
{ provide: DatabaseService, useValue: {} },
|
||||
{ provide: PlaylistsService, useValue: {} },
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: { getPlaylistById },
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
@@ -58,6 +68,52 @@ describe('GlobalFavoritesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps legacy URL UIDs aligned with the persisted M3U order', async () => {
|
||||
const firstUrl = 'https://streams.example/one.ts';
|
||||
const secondUrl = 'https://streams.example/two.ts';
|
||||
storeSelect.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
_id: 'playlist-one',
|
||||
title: 'Playlist',
|
||||
favorites: [firstUrl, secondUrl],
|
||||
},
|
||||
])
|
||||
);
|
||||
getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
playlist: {
|
||||
items: [
|
||||
{
|
||||
id: 'channel-one',
|
||||
name: 'Channel One',
|
||||
url: firstUrl,
|
||||
},
|
||||
{
|
||||
id: 'channel-two',
|
||||
name: 'Channel Two',
|
||||
url: secondUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
electronApi.dbGetAppState.mockResolvedValue(
|
||||
JSON.stringify([
|
||||
`m3u::playlist-one::${secondUrl}`,
|
||||
`m3u::playlist-one::${firstUrl}`,
|
||||
])
|
||||
);
|
||||
|
||||
const channels = await service.getUnifiedLiveFavorites();
|
||||
|
||||
expect(channels.map((channel) => channel.uid)).toEqual([
|
||||
`m3u::playlist-one::${secondUrl}`,
|
||||
`m3u::playlist-one::${firstUrl}`,
|
||||
]);
|
||||
expect(channels.map((channel) => channel.position)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
describe('reorder', () => {
|
||||
it('sends playlist-scoped position updates for Xtream favorites', async () => {
|
||||
// The backend UPDATE filters by (contentId, playlistId); a payload
|
||||
@@ -109,7 +165,10 @@ describe('GlobalFavoritesService', () => {
|
||||
expect(electronApi.dbReorderGlobalFavorites).not.toHaveBeenCalled();
|
||||
expect(electronApi.dbSetAppState).toHaveBeenCalledWith(
|
||||
'global-favorites-channel-order-v1',
|
||||
JSON.stringify(['m3u::playlist-m::url', 'stalker::playlist-s::5'])
|
||||
JSON.stringify([
|
||||
'm3u::playlist-m::url',
|
||||
'stalker::playlist-s::5',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user