fix(playback): use inline player for collection VOD (#928)

* feat(portal): add series quick start CTA
Entire-Checkpoint: f957cd9849e0

* fix(playback): use inline player for collection vod

* fix(playback): address inline collection review feedback

* fix(playback): ignore stale vod position loads
This commit is contained in:
4gray authored and GitHub committed 2026-05-12 19:26:40 +02:00
1 parent ce0e61a92d
commit 6ae1251ad1
55 files changed
+2421 -272

No files matched your search

@@ -541,6 +541,8 @@ test.describe('Electron Favorites', () => {
title: movieTitle,
playlistTitle: portalTitle,
});
await playCurrentDetail(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
@@ -556,6 +558,8 @@ test.describe('Electron Favorites', () => {
title: seriesTitle,
playlistTitle: portalTitle,
});
await playFirstSeriesEpisode(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
@@ -618,6 +622,47 @@ async function expectInlineCollectionDetail(
).toBeVisible({ timeout: 20000 });
}
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
await expect(
page.locator('app-portal-inline-player app-web-player-view').first()
).toBeVisible({ timeout: 20000 });
await expect(
page.locator('mat-dialog-container app-web-player-view')
).toHaveCount(0);
}
async function playCurrentDetail(page: Page): Promise<void> {
const playButton = page.locator('button.play-btn').first();
await expect(playButton).toBeVisible({ timeout: 20000 });
await playButton.click();
}
async function playFirstSeriesEpisode(page: Page): Promise<void> {
const seasonCard = page.locator('.season-card').first();
const episodeCard = page
.locator('.episode-card, .episode-list-item')
.first();
await expect
.poll(
async () =>
(await seasonCard.count()) + (await episodeCard.count()),
{ timeout: 20000 }
)
.toBeGreaterThan(0);
if ((await seasonCard.count()) > 0) {
await seasonCard.scrollIntoViewIfNeeded();
await expect(seasonCard).toBeVisible({ timeout: 20000 });
await seasonCard.click();
}
await episodeCard.scrollIntoViewIfNeeded();
await expect(episodeCard).toBeVisible({ timeout: 20000 });
await episodeCard.click();
}
async function toggleFavoriteForChannel(
page: Page,
title: string
@@ -591,6 +591,8 @@ test.describe('Electron Recently Viewed', () => {
title: movieTitle,
playlistTitle: portalTitle,
});
await playCurrentDetail(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
@@ -606,6 +608,8 @@ test.describe('Electron Recently Viewed', () => {
title: seriesTitle,
playlistTitle: portalTitle,
});
await playFirstSeriesEpisode(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
@@ -677,6 +681,15 @@ async function expectInlineCollectionDetail(
).toBeVisible({ timeout: 20000 });
}
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
await expect(
page.locator('app-portal-inline-player app-web-player-view').first()
).toBeVisible({ timeout: 20000 });
await expect(
page.locator('mat-dialog-container app-web-player-view')
).toHaveCount(0);
}
async function playCurrentDetail(page: Page): Promise<void> {
const playButton = page.locator('button.play-btn').first();
@@ -905,6 +905,16 @@ test.describe('Electron Workspace Search', () => {
await expect(
contentCardByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await contentCardByTitle(app.mainWindow, sample.targetTitle)
.first()
.click();
await expect(
app.mainWindow.locator('app-content-hero')
).toContainText(flexibleTextPattern(sample.targetTitle), {
timeout: 20000,
});
await playCurrentDetail(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
} finally {
await closeElectronApp(app);
}
@@ -1535,6 +1545,22 @@ async function goBackFromDetail(page: Page): Promise<void> {
await backButton.click();
}
async function playCurrentDetail(page: Page): Promise<void> {
const playButton = page.locator('button.play-btn').first();
await expect(playButton).toBeVisible({ timeout: 20000 });
await playButton.click();
}
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
await expect(
page.locator('app-portal-inline-player app-web-player-view').first()
).toBeVisible({ timeout: 20000 });
await expect(
page.locator('mat-dialog-container app-web-player-view')
).toHaveCount(0);
}
async function pickPreferredCategory(categories: Locator): Promise<Locator> {
const count = await categories.count();
let fallback = categories.first();
-2
View File
@@ -24,8 +24,6 @@ export default {
snapshotSerializers: angularEsmPreset.snapshotSerializers,
moduleNameMapper: {
...(angularEsmPreset.moduleNameMapper ?? {}),
'^@iptvnator/portal/xtream/feature$':
'<rootDir>/src/test-stubs/xtream-feature.stub.ts',
tslib: 'tslib/tslib.es6.js',
'^iptv-playlist-parser$':
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
@@ -52,25 +52,14 @@ describe('PlayerService', () => {
expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false);
});
it('uses the dialog fallback for embedded players', async () => {
await service.openResolvedPlayback({
it('does not open a dialog or IPC for embedded players', async () => {
const result = await service.openResolvedPlayback({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
});
expect(dialog.open).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
data: expect.objectContaining({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
playback: expect.objectContaining({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
}),
}),
})
);
expect(result).toBeUndefined();
expect(dialog.open).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
+1 -17
View File
@@ -67,7 +67,6 @@ export class PlayerService {
hideExternalInfoDialog = true
): Promise<ExternalPlayerSession | void> {
const player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
const { streamUrl, title, contentInfo, startTime } = playback;
if (player === VideoPlayer.MPV) {
if (!hideExternalInfoDialog) {
@@ -81,22 +80,7 @@ export class PlayerService {
return await this.openExternalPlayback(playback, 'vlc');
}
return import('@iptvnator/portal/xtream/feature').then(
({ PlayerDialogComponent }) => {
this.dialog.open(PlayerDialogComponent, {
data: {
streamUrl,
title,
contentInfo,
startTime,
playback,
},
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
});
}
);
return;
}
async openExternalPlayback(
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "استئناف",
"RESTART": "البدء من جديد",
"CONTINUE_WATCHING": "متابعة المشاهدة",
"PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى",
"RESUME_EPISODE": "استئناف الحلقة",
"PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية",
"SERIES_WATCHED": "تمت مشاهدة المسلسل",
"WATCHED": "تمت المشاهدة",
"IN_PROGRESS": "قيد التقدم",
"MARK_WATCHED": "وضع علامة كمشاهد",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "كمل",
"RESTART": "بدا من الأول",
"CONTINUE_WATCHING": "كمل المشاهدة",
"PLAY_FIRST_EPISODE": "شغل الحلقة الأولى",
"RESUME_EPISODE": "كمل الحلقة",
"PLAY_NEXT_EPISODE": "شغل الحلقة الجاية",
"SERIES_WATCHED": "السلسلة متشافة",
"WATCHED": "متشاف",
"IN_PROGRESS": "جاري",
"MARK_WATCHED": "علم كمتشاف",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Працягнуць",
"RESTART": "Спачатку",
"CONTINUE_WATCHING": "Працягнуць прагляд",
"PLAY_FIRST_EPISODE": "Прайграць першы эпізод",
"RESUME_EPISODE": "Працягнуць эпізод",
"PLAY_NEXT_EPISODE": "Прайграць наступны эпізод",
"SERIES_WATCHED": "Серыял прагледжаны",
"WATCHED": "Прагледжана",
"IN_PROGRESS": "У працэсе",
"MARK_WATCHED": "Адзначыць як прагледжанае",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Fortsetzen",
"RESTART": "Neu starten",
"CONTINUE_WATCHING": "Weiter schauen",
"PLAY_FIRST_EPISODE": "Erste Folge abspielen",
"RESUME_EPISODE": "Folge fortsetzen",
"PLAY_NEXT_EPISODE": "Nächste Folge abspielen",
"SERIES_WATCHED": "Serie angesehen",
"WATCHED": "Angesehen",
"IN_PROGRESS": "In Wiedergabe",
"MARK_WATCHED": "Als angesehen markieren",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Συνέχιση",
"RESTART": "Από την αρχή",
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου",
"RESUME_EPISODE": "Συνέχιση επεισοδίου",
"PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου",
"SERIES_WATCHED": "Η σειρά προβλήθηκε",
"WATCHED": "Έχει προβληθεί",
"IN_PROGRESS": "Σε εξέλιξη",
"MARK_WATCHED": "Σήμανση ως προβληθέν",
+4
View File
@@ -581,6 +581,10 @@
"RESUME": "Resume",
"RESTART": "Start Over",
"CONTINUE_WATCHING": "Continue Watching",
"PLAY_FIRST_EPISODE": "Play first episode",
"RESUME_EPISODE": "Resume episode",
"PLAY_NEXT_EPISODE": "Play next episode",
"SERIES_WATCHED": "Series watched",
"WATCHED": "Watched",
"IN_PROGRESS": "In Progress",
"MARK_WATCHED": "Mark as Watched",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Continuar",
"RESTART": "Reiniciar",
"CONTINUE_WATCHING": "Continuar viendo",
"PLAY_FIRST_EPISODE": "Reproducir primer episodio",
"RESUME_EPISODE": "Continuar episodio",
"PLAY_NEXT_EPISODE": "Reproducir siguiente episodio",
"SERIES_WATCHED": "Serie vista",
"WATCHED": "Visto",
"IN_PROGRESS": "En progreso",
"MARK_WATCHED": "Marcar como visto",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Reprendre",
"RESTART": "Recommencer",
"CONTINUE_WATCHING": "Reprendre la lecture",
"PLAY_FIRST_EPISODE": "Lire le premier épisode",
"RESUME_EPISODE": "Reprendre l'épisode",
"PLAY_NEXT_EPISODE": "Lire l'épisode suivant",
"SERIES_WATCHED": "Série vue",
"WATCHED": "Vu",
"IN_PROGRESS": "En cours",
"MARK_WATCHED": "Marquer comme vu",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Riprendi",
"RESTART": "Ricomincia",
"CONTINUE_WATCHING": "Continua a guardare",
"PLAY_FIRST_EPISODE": "Riproduci primo episodio",
"RESUME_EPISODE": "Riprendi episodio",
"PLAY_NEXT_EPISODE": "Riproduci episodio successivo",
"SERIES_WATCHED": "Serie vista",
"WATCHED": "Visto",
"IN_PROGRESS": "In corso",
"MARK_WATCHED": "Segna come visto",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "続きから再生",
"RESTART": "最初から再生",
"CONTINUE_WATCHING": "視聴を再開",
"PLAY_FIRST_EPISODE": "第1話を再生",
"RESUME_EPISODE": "エピソードを再開",
"PLAY_NEXT_EPISODE": "次のエピソードを再生",
"SERIES_WATCHED": "シリーズ視聴済み",
"WATCHED": "視聴済み",
"IN_PROGRESS": "視聴中",
"MARK_WATCHED": "視聴済みにする",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "이어 보기",
"RESTART": "처음부터 보기",
"CONTINUE_WATCHING": "이어서 시청",
"PLAY_FIRST_EPISODE": "첫 에피소드 재생",
"RESUME_EPISODE": "에피소드 이어 보기",
"PLAY_NEXT_EPISODE": "다음 에피소드 재생",
"SERIES_WATCHED": "시리즈 시청 완료",
"WATCHED": "시청 완료",
"IN_PROGRESS": "시청 중",
"MARK_WATCHED": "시청한 것으로 표시",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Hervatten",
"RESTART": "Opnieuw beginnen",
"CONTINUE_WATCHING": "Verder kijken",
"PLAY_FIRST_EPISODE": "Eerste aflevering afspelen",
"RESUME_EPISODE": "Aflevering hervatten",
"PLAY_NEXT_EPISODE": "Volgende aflevering afspelen",
"SERIES_WATCHED": "Serie bekeken",
"WATCHED": "Bekeken",
"IN_PROGRESS": "Bezig",
"MARK_WATCHED": "Markeren als bekeken",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Wznów",
"RESTART": "Zacznij od nowa",
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"PLAY_FIRST_EPISODE": "Odtwórz pierwszy odcinek",
"RESUME_EPISODE": "Wznów odcinek",
"PLAY_NEXT_EPISODE": "Odtwórz następny odcinek",
"SERIES_WATCHED": "Serial obejrzany",
"WATCHED": "Obejrzane",
"IN_PROGRESS": "W trakcie",
"MARK_WATCHED": "Oznacz jako obejrzane",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Continuar",
"RESTART": "Recomeçar",
"CONTINUE_WATCHING": "Continuar assistindo",
"PLAY_FIRST_EPISODE": "Reproduzir primeiro episódio",
"RESUME_EPISODE": "Continuar episódio",
"PLAY_NEXT_EPISODE": "Reproduzir próximo episódio",
"SERIES_WATCHED": "Série assistida",
"WATCHED": "Assistido",
"IN_PROGRESS": "Em andamento",
"MARK_WATCHED": "Marcar como assistido",
+4
View File
@@ -579,6 +579,10 @@
"RESUME": "Продолжить",
"RESTART": "С начала",
"CONTINUE_WATCHING": "Продолжить просмотр",
"PLAY_FIRST_EPISODE": "Воспроизвести первую серию",
"RESUME_EPISODE": "Продолжить серию",
"PLAY_NEXT_EPISODE": "Следующая серия",
"SERIES_WATCHED": "Сериал просмотрен",
"WATCHED": "Просмотрено",
"IN_PROGRESS": "В процессе",
"MARK_WATCHED": "Отметить как просмотренное",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "Devam Et",
"RESTART": "Baştan Başla",
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"PLAY_FIRST_EPISODE": "İlk bölümü oynat",
"RESUME_EPISODE": "Bölüme devam et",
"PLAY_NEXT_EPISODE": "Sonraki bölümü oynat",
"SERIES_WATCHED": "Dizi izlendi",
"WATCHED": "İzlendi",
"IN_PROGRESS": "Devam Ediyor",
"MARK_WATCHED": "İzlendi Olarak İşaretle",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "继续观看",
"RESTART": "从头开始",
"CONTINUE_WATCHING": "继续观看",
"PLAY_FIRST_EPISODE": "播放第一集",
"RESUME_EPISODE": "继续播放本集",
"PLAY_NEXT_EPISODE": "播放下一集",
"SERIES_WATCHED": "剧集已看完",
"WATCHED": "已观看",
"IN_PROGRESS": "观看中",
"MARK_WATCHED": "标记为已观看",
+4
View File
@@ -575,6 +575,10 @@
"RESUME": "繼續",
"RESTART": "從頭開始",
"CONTINUE_WATCHING": "繼續觀看",
"PLAY_FIRST_EPISODE": "播放第一集",
"RESUME_EPISODE": "繼續播放本集",
"PLAY_NEXT_EPISODE": "播放下一集",
"SERIES_WATCHED": "劇集已看完",
"WATCHED": "已觀看",
"IN_PROGRESS": "觀看中",
"MARK_WATCHED": "標記為已觀看",
@@ -1 +0,0 @@
export class PlayerDialogComponent {}
+53 -20
View File
@@ -9,23 +9,30 @@ This document records the current contract for embedded playback in portal detai
- External players are `mpv` and `vlc`.
- Flatpak launches external players on the host via `flatpak-spawn --host`.
- Live playback stays inline in dedicated live layouts.
- VOD and series detail playback now also stays inline on canonical detail surfaces.
- Material dialog playback remains only as a fallback for older non-detail callers.
- VOD and series detail playback stays inline on canonical detail, collection,
favorites, recent, and search surfaces.
- Xtream and Stalker series detail heroes expose a quick-start CTA driven by
saved episode playback positions.
- Embedded playback UI is always hosted by the current view. `PlayerService`
launches MPV/VLC only and does not open an embedded-player dialog.
- Browser-player failures are diagnosed client-side and can offer explicit MPV/VLC fallback actions without changing the saved player setting.
## Scope
The first pass is intentionally limited:
Inline embedded playback is required for these VOD/series entry points:
- Xtream VOD detail route
- Xtream series detail route
- Stalker VOD detail view
- Stalker series detail view
- unified favorites collection details
- unified recently viewed collection details
- Stalker advanced search result details
Not migrated in this pass:
- Generic non-detail playback entry points that still call `PlayerService.openPlayer(...)`
- Any collection/search surface that does not host a canonical detail surface of its own
Collection/search VOD surfaces that expose embedded playback must host
`ResolvedPortalPlayback` inline state locally. They must not call
`PlayerService.openPlayer(...)` or `PlayerService.openResolvedPlayback(...)` to
create embedded UI.
## Embedded MPV Harness
@@ -68,7 +75,7 @@ Current limitation:
Shared inline player shell:
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/portal-inline-player/portal-inline-player.component.ts`
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts`
Xtream detail hosts:
@@ -77,13 +84,14 @@ Xtream detail hosts:
Stalker detail hosts:
- `/Users/4gray/Code/iptvnator/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts`
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-series-view/stalker-series-view.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
Fallback dialog path:
- `/Users/4gray/Code/iptvnator/apps/web/src/app/services/player.service.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts`
Embedded playback does not have a fallback dialog path.
`PlayerService.openResolvedPlayback(...)` remains the MPV/VLC external launch
entry point; for embedded players it returns without creating UI.
Diagnostics and fallback UI:
@@ -99,7 +107,30 @@ When a detail view starts playback:
3. If the player is embedded, render the inline player inside the current detail view.
4. If the player is external, hand the same payload to `PlayerService` for MPV/VLC playback.
The detail host owns inline state. `PlayerService` is no longer the primary owner of UI playback state for canonical VOD/series detail screens.
The detail or collection/search host owns inline state. `PlayerService` is not
an owner of embedded UI playback state.
## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in
`/Users/4gray/Code/iptvnator/libs/portal/shared/util/src/lib/series-quick-start.ts`.
The helper flattens the loaded season/episode map, sorts seasons and episodes in
natural order, and returns the hero CTA state.
Current contract:
- the CTA shows the action label plus a compact episode target such as
`S01E02 · Episode title`
- if an episode is in progress, resume the latest updated in-progress episode
with its saved offset
- if no episode is in progress, play the first unwatched episode in season order
- if watched episodes end at a season boundary, play the first episode of the
next loaded season
- if every loaded episode is watched, render a disabled completed state
The click path must continue through each detail host's normal episode playback
method so recent-item updates, inline/external player selection, resume offsets,
and playback-position saving keep the same behavior as manual episode clicks.
## Codec And Container Diagnostics
@@ -175,18 +206,20 @@ Stalker previously resolved playback and opened UI in the same method.
Current contract:
- `resolveVodPlayback(...)` returns a `ResolvedPortalPlayback`
- `createLinkToPlayVod(...)` remains as a compatibility wrapper for untouched callers
- canonical Stalker detail views use the resolver directly and decide inline vs external locally
- `createLinkToPlayVod(...)` remains as a compatibility API but collection,
search, and canonical detail views use the resolver directly
- Stalker detail, collection, and search views decide inline vs external locally
This keeps:
- inline/store-state detail navigation intact
- series and VOD-as-series support intact
- non-detail callers working until they are migrated
- external MPV/VLC launches unchanged
## Playback Position Saving
The old dialog path saved playback positions from inside `PlayerDialogComponent`.
The old dialog path saved playback positions from inside the removed Xtream
player dialog.
The new contract is:
@@ -198,7 +231,7 @@ This avoids coupling inline UI state to a global dialog.
## Future Migration Rule
If a non-detail surface is converted away from dialog playback:
If a non-detail surface needs embedded playback:
- give that surface a canonical inline host
- switch it to `ResolvedPortalPlayback`
+6
View File
@@ -141,6 +141,12 @@ Stalker has multiple real-world data shapes. The current implementation supports
- Treated as series flow from VOD context.
- Seasons are fetched lazily.
- Episodes are fetched on season select.
- The series quick-start CTA can load the first unloaded VOD-series season
before playback, then starts its first episode. If all currently loaded
episodes are watched and more season metadata exists, quick start loads the
next unloaded season instead of showing the completed state.
- For unloaded VOD-series seasons, the CTA target label is derived from season
metadata and rendered as `SxxE01` until episode details are loaded.
- Uses unique generated tracking IDs for episode playback position compatibility.
Core decision logic and normalization are centralized in:
+1
View File
@@ -6,6 +6,7 @@ export * from './lib/portal-catalog-facade';
export * from './lib/portal-external-playback';
export * from './lib/portal-player';
export * from './lib/portal-playback-positions';
export * from './lib/series-quick-start';
export * from './lib/portal-navigation-actions';
export * from './lib/portal-shell-actions';
export * from './lib/portal-collection-context';
@@ -0,0 +1,261 @@
import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces';
import {
SERIES_QUICK_START_ACTION_KIND,
getSeriesQuickStartAction,
getPositiveInteger,
padEpisodePart,
} from './series-quick-start';
function episode(
id: number,
season: number,
episodeNum: number,
title = `Episode ${episodeNum}`
): XtreamSerieEpisode {
return {
id: String(id),
episode_num: episodeNum,
title,
container_extension: 'mp4',
info: [],
custom_sid: '',
added: '',
season,
direct_source: '',
};
}
function position(
contentXtreamId: number,
overrides: Partial<PlaybackPositionData> = {}
): PlaybackPositionData {
return {
contentXtreamId,
contentType: 'episode',
seriesXtreamId: 10,
positionSeconds: 0,
durationSeconds: 100,
...overrides,
};
}
describe('getSeriesQuickStartAction', () => {
it('returns the first episode when there are no playback positions', () => {
const firstEpisode = episode(101, 1, 1);
const action = getSeriesQuickStartAction({
seasons: {
'1': [firstEpisode, episode(102, 1, 2)],
},
playbackPositions: new Map(),
});
expect(action).toEqual({
kind: SERIES_QUICK_START_ACTION_KIND.PlayFirst,
labelKey: 'XTREAM.PLAY_FIRST_EPISODE',
episodeLabel: 'S01E01 · Episode 1',
icon: 'play_arrow',
episode: firstEpisode,
position: null,
startTime: undefined,
disabled: false,
});
});
it('resumes the latest in-progress episode by updatedAt', () => {
const olderEpisode = episode(101, 1, 1);
const latestEpisode = episode(102, 1, 2);
const action = getSeriesQuickStartAction({
seasons: {
'1': [olderEpisode, latestEpisode],
},
playbackPositions: new Map([
[
101,
position(101, {
positionSeconds: 20,
updatedAt: '2026-05-10T10:00:00.000Z',
}),
],
[
102,
position(102, {
positionSeconds: 30,
updatedAt: '2026-05-10T11:00:00.000Z',
}),
],
]),
});
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.Resume);
expect(action?.labelKey).toBe('XTREAM.RESUME_EPISODE');
expect(action?.episodeLabel).toBe('S01E02 · Episode 2');
expect(action?.episode).toBe(latestEpisode);
expect(action?.startTime).toBe(30);
});
it('plays the next unwatched episode after watched episodes', () => {
const nextEpisode = episode(102, 1, 2);
const action = getSeriesQuickStartAction({
seasons: {
'1': [episode(101, 1, 1), nextEpisode],
},
playbackPositions: new Map([
[
101,
position(101, {
positionSeconds: 95,
durationSeconds: 100,
}),
],
]),
});
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext);
expect(action?.labelKey).toBe('XTREAM.PLAY_NEXT_EPISODE');
expect(action?.episodeLabel).toBe('S01E02 · Episode 2');
expect(action?.episode).toBe(nextEpisode);
});
it('plays the first episode of the next season when the current season is watched', () => {
const nextSeasonEpisode = episode(201, 2, 1);
const action = getSeriesQuickStartAction({
seasons: {
'1': [episode(101, 1, 1)],
'2': [nextSeasonEpisode],
},
playbackPositions: new Map([
[
101,
position(101, {
positionSeconds: 100,
durationSeconds: 100,
}),
],
]),
});
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext);
expect(action?.episodeLabel).toBe('S02E01 · Episode 1');
expect(action?.episode).toBe(nextSeasonEpisode);
});
it('returns a disabled completed action when every episode is watched', () => {
const finalEpisode = episode(102, 1, 2);
const action = getSeriesQuickStartAction({
seasons: {
'1': [episode(101, 1, 1), finalEpisode],
},
playbackPositions: new Map([
[
101,
position(101, {
positionSeconds: 100,
durationSeconds: 100,
}),
],
[
102,
position(102, {
positionSeconds: 90,
durationSeconds: 100,
}),
],
]),
});
expect(action).toEqual({
kind: SERIES_QUICK_START_ACTION_KIND.Completed,
labelKey: 'XTREAM.SERIES_WATCHED',
episodeLabel: 'S01E02 · Episode 2',
icon: 'check_circle',
episode: finalEpisode,
position: expect.any(Object),
startTime: undefined,
disabled: true,
});
});
it('sorts nonnumeric season keys naturally', () => {
const seasonTwoEpisode = episode(201, 2, 1);
const action = getSeriesQuickStartAction({
seasons: {
'Season 10': [episode(1001, 10, 1)],
'Season 2': [seasonTwoEpisode],
},
playbackPositions: new Map(),
});
expect(action?.episode).toBe(seasonTwoEpisode);
});
it('falls back to series order when in-progress positions have no updatedAt', () => {
const laterEpisode = episode(102, 1, 2);
const action = getSeriesQuickStartAction({
seasons: {
'1': [episode(101, 1, 1), laterEpisode],
},
playbackPositions: new Map([
[
101,
position(101, {
positionSeconds: 20,
}),
],
[
102,
position(102, {
positionSeconds: 30,
}),
],
]),
});
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.Resume);
expect(action?.episode).toBe(laterEpisode);
});
it('omits the title separator when an episode title is empty', () => {
const firstEpisode = episode(101, 1, 1, '');
const action = getSeriesQuickStartAction({
seasons: {
'1': [firstEpisode],
},
playbackPositions: new Map(),
});
expect(action?.episodeLabel).toBe('S01E01');
});
it('returns null when no playable episodes exist', () => {
const action = getSeriesQuickStartAction({
seasons: {
'1': [],
},
playbackPositions: new Map(),
});
expect(action).toBeNull();
});
});
describe('episode label helpers', () => {
it('normalizes only positive integers', () => {
expect(getPositiveInteger(1)).toBe(1);
expect(getPositiveInteger(0)).toBeNull();
expect(getPositiveInteger(1.5)).toBeNull();
expect(getPositiveInteger(Number.NaN)).toBeNull();
});
it('pads single digit episode label parts', () => {
expect(padEpisodePart(1)).toBe('01');
expect(padEpisodePart(10)).toBe('10');
});
});
@@ -0,0 +1,197 @@
import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces';
import {
isPortalPlaybackInProgress,
isPortalPlaybackWatched,
} from './portal-playback-positions';
export const SERIES_QUICK_START_ACTION_KIND = {
PlayFirst: 'play-first',
Resume: 'resume',
PlayNext: 'play-next',
Completed: 'completed',
} as const;
export type SeriesQuickStartActionKind =
(typeof SERIES_QUICK_START_ACTION_KIND)[keyof typeof SERIES_QUICK_START_ACTION_KIND];
export interface SeriesQuickStartAction {
kind: SeriesQuickStartActionKind;
labelKey: string;
episodeLabel: string;
icon: string;
episode: XtreamSerieEpisode;
position: PlaybackPositionData | null;
startTime?: number;
disabled: boolean;
}
interface SeriesQuickStartRequest {
seasons: Record<string, XtreamSerieEpisode[]>;
playbackPositions: Map<number, PlaybackPositionData>;
}
interface OrderedEpisode {
episode: XtreamSerieEpisode;
position: PlaybackPositionData | null;
order: number;
}
const naturalCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export function getSeriesQuickStartAction(
request: SeriesQuickStartRequest
): SeriesQuickStartAction | null {
const orderedEpisodes = getOrderedEpisodes(request);
if (orderedEpisodes.length === 0) {
return null;
}
const inProgress = orderedEpisodes
.filter(({ position }) => isPortalPlaybackInProgress(position))
.sort(compareInProgressPositions);
const resumeEpisode = inProgress[inProgress.length - 1];
if (resumeEpisode?.position) {
return createQuickStartAction({
kind: SERIES_QUICK_START_ACTION_KIND.Resume,
labelKey: 'XTREAM.RESUME_EPISODE',
icon: 'play_arrow',
episode: resumeEpisode.episode,
position: resumeEpisode.position,
startTime: resumeEpisode.position.positionSeconds,
disabled: false,
});
}
const firstUnwatchedEpisode = orderedEpisodes.find(
({ position }) => !isPortalPlaybackWatched(position)
);
if (firstUnwatchedEpisode) {
const hasWatchedEpisode = orderedEpisodes.some(({ position }) =>
isPortalPlaybackWatched(position)
);
return createQuickStartAction({
kind: hasWatchedEpisode
? SERIES_QUICK_START_ACTION_KIND.PlayNext
: SERIES_QUICK_START_ACTION_KIND.PlayFirst,
labelKey: hasWatchedEpisode
? 'XTREAM.PLAY_NEXT_EPISODE'
: 'XTREAM.PLAY_FIRST_EPISODE',
icon: 'play_arrow',
episode: firstUnwatchedEpisode.episode,
position: firstUnwatchedEpisode.position,
startTime: undefined,
disabled: false,
});
}
const finalEpisode = orderedEpisodes[orderedEpisodes.length - 1];
if (!finalEpisode) {
return null;
}
return createQuickStartAction({
kind: SERIES_QUICK_START_ACTION_KIND.Completed,
labelKey: 'XTREAM.SERIES_WATCHED',
icon: 'check_circle',
episode: finalEpisode.episode,
position: finalEpisode.position,
startTime: undefined,
disabled: true,
});
}
function createQuickStartAction(
action: Omit<SeriesQuickStartAction, 'episodeLabel'>
): SeriesQuickStartAction {
return {
...action,
episodeLabel: getEpisodeLabel(action.episode),
};
}
function getOrderedEpisodes(
request: SeriesQuickStartRequest
): OrderedEpisode[] {
const orderedEpisodes: OrderedEpisode[] = [];
Object.entries(request.seasons)
.sort(([seasonA], [seasonB]) =>
naturalCollator.compare(seasonA, seasonB)
)
.forEach(([, episodes]) => {
[...episodes].sort(compareEpisodes).forEach((episode) => {
orderedEpisodes.push({
episode,
position:
request.playbackPositions.get(Number(episode.id)) ??
null,
order: orderedEpisodes.length,
});
});
});
return orderedEpisodes;
}
function compareEpisodes(
episodeA: XtreamSerieEpisode,
episodeB: XtreamSerieEpisode
): number {
const episodeNumberDelta =
Number(episodeA.episode_num) - Number(episodeB.episode_num);
if (Number.isFinite(episodeNumberDelta) && episodeNumberDelta !== 0) {
return episodeNumberDelta;
}
return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? '');
}
function compareInProgressPositions(
episodeA: OrderedEpisode,
episodeB: OrderedEpisode
): number {
const timeA = getTimestamp(episodeA.position?.updatedAt);
const timeB = getTimestamp(episodeB.position?.updatedAt);
if (timeA !== timeB) {
return timeA - timeB;
}
return episodeA.order - episodeB.order;
}
function getTimestamp(value: string | undefined): number {
if (!value) {
return 0;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : 0;
}
function getEpisodeLabel(episode: XtreamSerieEpisode): string {
const seasonNumber = getPositiveInteger(episode.season) ?? 1;
const episodeNumber = getPositiveInteger(episode.episode_num) ?? 1;
const episodeCode = `S${padEpisodePart(seasonNumber)}E${padEpisodePart(
episodeNumber
)}`;
const title = episode.title.trim();
return title ? `${episodeCode} · ${title}` : episodeCode;
}
export function getPositiveInteger(value: number): number | null {
return Number.isInteger(value) && value > 0 ? value : null;
}
export function padEpisodePart(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
@@ -202,6 +202,49 @@ describe('withStalkerPlayer', () => {
});
});
it('persists regular series recent metadata without marking it as a VOD series', async () => {
store.setSelectedContentType('series');
store.setSelectedItem({
id: '30000',
cmd: '',
title: 'Regular Series',
category_id: 'series',
});
dataService.sendIpcEvent.mockResolvedValueOnce({
js: {
cmd: 'ffmpeg http://cdn.example/episode_30000_1.mpg',
},
});
const playback = await store.resolveVodPlayback(
'ffrt4://series/30000/season/1',
'Episode One',
'series.jpg',
1,
3000001
);
expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith(
PLAYLIST._id,
expect.objectContaining({
id: '30000',
title: 'Episode One',
category_id: 'series',
cover: 'series.jpg',
added_at: expect.any(Number),
})
);
expect(
playlistService.addPortalRecentlyViewed.mock.calls[0][1]
).not.toHaveProperty('is_series');
expect(playback.contentInfo).toEqual({
playlistId: PLAYLIST._id,
contentXtreamId: 3000001,
contentType: 'episode',
seriesXtreamId: 30000,
});
});
it('resolves direct radio stream commands without external player side effects', async () => {
store.setSelectedContentType('radio');
store.setSelectedItem({
@@ -27,7 +27,6 @@ export function getStalkerSeriesRecentMetadata(
selectedContentType: StalkerContentType
): {
category_id?: 'series';
is_series?: true;
} {
if (selectedContentType !== 'series') {
return {};
@@ -35,7 +34,6 @@ export function getStalkerSeriesRecentMetadata(
return {
category_id: 'series',
is_series: true,
};
}
@@ -1,10 +1,22 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
import { Playlist } from 'shared-interfaces';
import {
Playlist,
ResolvedPortalPlayback,
VodDetailsItem,
createStalkerVodItem,
} from 'shared-interfaces';
import { of } from 'rxjs';
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
@@ -27,11 +39,19 @@ class StubStalkerInlineDetailComponent {
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<unknown>(null);
readonly isSeries = input(false);
readonly vodDetailsItem = input<unknown>(null);
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
readonly isFavorite = input(false);
readonly playbackPosition = input<number | null>(null);
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
readonly externalPlayback = input<unknown>(null);
readonly backClicked = output<void>();
readonly playClicked = output<unknown>();
readonly playClicked = output<VodDetailsItem>();
readonly resumeClicked = output<unknown>();
readonly favoriteToggled = output<unknown>();
readonly inlineTimeUpdated = output<unknown>();
readonly inlinePlaybackClosed = output<void>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested = output<unknown>();
}
describe('StalkerCollectionDetailComponent', () => {
@@ -54,6 +74,19 @@ describe('StalkerCollectionDetailComponent', () => {
addToFavorites: jest.Mock;
removeFromFavorites: jest.Mock;
createLinkToPlayVod: jest.Mock;
resolveVodPlayback: jest.Mock;
};
let portalPlayer: {
isEmbeddedPlayer: jest.Mock;
openResolvedPlayback: jest.Mock;
openExternalPlayback: jest.Mock;
};
let playbackPositions: {
savePlaybackPosition: jest.Mock;
getPlaybackPosition: jest.Mock;
getSeriesPlaybackPositions: jest.Mock;
getAllPlaybackPositions: jest.Mock;
clearPlaybackPosition: jest.Mock;
};
const playlist = {
@@ -91,6 +124,19 @@ describe('StalkerCollectionDetailComponent', () => {
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
createLinkToPlayVod: jest.fn(),
resolveVodPlayback: jest.fn(),
};
portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
};
playbackPositions = {
savePlaybackPosition: jest.fn(),
getPlaybackPosition: jest.fn(async () => null),
getSeriesPlaybackPositions: jest.fn(),
getAllPlaybackPositions: jest.fn(),
clearPlaybackPosition: jest.fn(),
};
await TestBed.configureTestingModule({
@@ -100,6 +146,40 @@ describe('StalkerCollectionDetailComponent', () => {
provide: StalkerStore,
useValue: stalkerStore,
},
{
provide: PORTAL_PLAYER,
useValue: portalPlayer,
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: playbackPositions,
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
visibleSession: signal(null),
dismissActiveSession: jest.fn(),
closeSession: 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),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
},
},
{
provide: PlaylistsService,
useValue: {
@@ -111,7 +191,10 @@ describe('StalkerCollectionDetailComponent', () => {
})
.overrideComponent(StalkerCollectionDetailComponent, {
remove: {
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
imports: [
ContentHeroComponent,
StalkerInlineDetailComponent,
],
},
add: {
imports: [
@@ -150,7 +233,9 @@ describe('StalkerCollectionDetailComponent', () => {
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
'vod'
);
expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith('vod');
expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith(
'vod'
);
expect(stalkerStore.setSelectedItem).toHaveBeenLastCalledWith(
expect.objectContaining({
id: '1507',
@@ -227,6 +312,83 @@ describe('StalkerCollectionDetailComponent', () => {
fixture.componentInstance.inlineDetail().seriesItem?.series
).toEqual([1, 2]);
});
it('plays regular VOD collection details inline for embedded players instead of using the legacy play wrapper', async () => {
const sourceItem = {
id: '1701',
title: 'Collection Movie',
category_id: 'vod',
cmd: '/media/file_1701.mpg',
info: {
name: 'Collection Movie',
movie_image: 'movie.jpg',
},
};
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://streams.example.test/movie.mp4',
title: 'Collection Movie',
thumbnail: 'movie.jpg',
};
stalkerStore.resolveVodPlayback.mockResolvedValue(playback);
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'movie',
categoryId: 'vod',
stalkerItem: sourceItem,
})
);
await settleDetail(fixture);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(sourceItem, playlist._id)
);
await settleDetail(fixture);
expect(stalkerStore.resolveVodPlayback).toHaveBeenCalledWith(
'/media/file_1701.mpg',
'Collection Movie',
'movie.jpg'
);
expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled();
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(fixture.componentInstance.inlinePlayback()).toEqual(playback);
});
it('does not load VOD playback position when the playlist id is missing', async () => {
const playlistsService = TestBed.inject(PlaylistsService) as {
getPlaylistById: jest.Mock;
};
playlistsService.getPlaylistById.mockReturnValue(
of({
...playlist,
_id: '',
})
);
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'movie',
categoryId: 'vod',
stalkerItem: {
id: '1702',
title: 'Collection Movie Without Playlist',
category_id: 'vod',
cmd: '/media/file_1702.mpg',
},
})
);
await settleDetail(fixture);
expect(playbackPositions.getPlaybackPosition).not.toHaveBeenCalled();
expect(fixture.componentInstance.selectedVodPlaybackPosition()).toBe(
null
);
});
});
function buildCollectionItem(
@@ -9,9 +9,15 @@ import {
signal,
untracked,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import {
buildStalkerStateItem,
createLogger,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
toStalkerCategoryId,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
@@ -29,10 +35,18 @@ import {
StalkerStore,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { PlaylistsService } from 'services';
import { Playlist, StalkerPortalItem, VodDetailsItem } from 'shared-interfaces';
import {
PlaybackPositionData,
Playlist,
ResolvedPortalPlayback,
StalkerPortalItem,
VodDetailsItem,
} from 'shared-interfaces';
import { firstValueFrom } from 'rxjs';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
interface StalkerCollectionStateSnapshot {
currentPlaylist: Playlist | undefined;
@@ -61,9 +75,19 @@ interface StalkerCollectionDetailMode {
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(backClicked)="closeRequested.emit()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
(inlineExternalFallbackRequested)="
handleExternalFallbackRequest($event)
"
/>
} @else {
<app-content-hero [isLoading]="true" />
@@ -87,11 +111,21 @@ export class StalkerCollectionDetailComponent {
private readonly playlistsService = inject(PlaylistsService);
private readonly stalkerStore = inject(StalkerStore);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerCollectionDetail');
private readonly originalState = this.captureStoreState();
private readonly favoritesRefresh = createRefreshTrigger();
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
readonly isSelectedVodFavorite = signal(false);
readonly detailCategoryOverride = signal<StalkerDetailCategory | null>(
null
@@ -109,8 +143,21 @@ export class StalkerCollectionDetailComponent {
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
private initRequestId = 0;
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start collection VOD playback',
});
constructor() {
effect(() => {
@@ -139,11 +186,12 @@ export class StalkerCollectionDetailComponent {
this.stalkerStore.setSelectedItem(
this.originalState.selectedItem as never
);
this.closeInlinePlayer();
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.stalkerStore.createLinkToPlayVod(
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
@@ -151,6 +199,20 @@ export class StalkerCollectionDetailComponent {
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
@@ -170,6 +232,25 @@ export class StalkerCollectionDetailComponent {
});
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.vodPlayback.handleInlineTimeUpdate(event);
}
closeInlinePlayer(): void {
this.vodPlayback.closeInlinePlayer();
}
showCopyNotification(): void {
this.vodPlayback.showCopyNotification();
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
this.vodPlayback.handleExternalFallbackRequest(request);
}
private async prepareDetail(
item: UnifiedCollectionItem | null
): Promise<void> {
@@ -177,6 +258,7 @@ export class StalkerCollectionDetailComponent {
if (!item) {
this.clearLocalDetailState();
this.closeInlinePlayer();
return;
}
@@ -203,7 +285,9 @@ export class StalkerCollectionDetailComponent {
);
this.detailCategoryOverride.set(detailMode.category);
this.stalkerStore.setSelectedContentType(detailMode.selectedContentType);
this.stalkerStore.setSelectedContentType(
detailMode.selectedContentType
);
this.stalkerStore.setSelectedCategory(
this.resolveSelectedCategory(item, stalkerItem, detailMode)
);
@@ -222,12 +306,17 @@ export class StalkerCollectionDetailComponent {
this.itemDetails.set(detailViewState.itemDetails);
this.vodDetailsItem.set(detailViewState.vodDetailsItem);
this.syncSelectedVodFavorite();
void this.loadSelectedVodPosition(
playlist._id,
Number(detailViewState.itemDetails?.id)
);
return;
}
const cleared = clearStalkerDetailViewState();
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
}
private captureStoreState(): StalkerCollectionStateSnapshot {
@@ -273,11 +362,8 @@ export class StalkerCollectionDetailComponent {
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem
): StalkerCollectionDetailMode {
const hasEmbeddedSeries = Array.isArray(
(stalkerItem as { series?: unknown[] }).series
)
? (stalkerItem as { series?: unknown[] }).series!.length > 0
: false;
const series = (stalkerItem as { series?: unknown[] }).series;
const hasEmbeddedSeries = Array.isArray(series) && series.length > 0;
const isVodSeries = isStalkerSeriesFlag(
(stalkerItem as { is_series?: unknown }).is_series
);
@@ -314,7 +400,9 @@ export class StalkerCollectionDetailComponent {
return 'vod';
}
return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType);
return (
categoryId ?? toStalkerCategoryId(detailMode.selectedContentType)
);
}
private syncSelectedVodFavorite(): void {
@@ -332,5 +420,33 @@ export class StalkerCollectionDetailComponent {
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.detailCategoryOverride.set(null);
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
await this.vodPlayback.startVodPlayback(() =>
startTime === undefined
? this.stalkerStore.resolveVodPlayback(cmd, title, thumbnail)
: this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
)
);
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
}
@@ -2,13 +2,14 @@
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
min-height: 64px;
padding: 0 24px;
font-size: 1rem;
font-weight: 600;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
border-radius: 16px;
cursor: pointer;
transition: all 0.2s ease;
@@ -25,8 +26,8 @@
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
font-size: 24px;
width: 24px;
height: 24px;
}
}
@@ -13,10 +13,19 @@
<app-vod-details
[item]="vodDetailsItem()"
[isFavorite]="isFavorite()"
[externalPlayback]="externalPlayback.activeSession()"
[playbackPosition]="playbackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback()"
(playClicked)="onPlayClicked($event)"
(resumeClicked)="onResumeClicked($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(backClicked)="onBackClicked()"
(inlineTimeUpdated)="onInlineTimeUpdated($event)"
(inlinePlaybackClosed)="onInlinePlaybackClosed()"
(streamUrlCopied)="onStreamUrlCopied()"
(inlineExternalFallbackRequested)="
onInlineExternalFallbackRequested($event)
"
/>
}
} @else if (categoryId() === 'series') {
@@ -1,14 +1,19 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
} from '@angular/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { VodDetailsItem } from 'shared-interfaces';
import {
type PlaybackFallbackRequest,
VodDetailsComponent,
} from '@iptvnator/ui/playback';
import {
ExternalPlayerSession,
ResolvedPortalPlayback,
VodDetailsItem,
} from 'shared-interfaces';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
@Component({
@@ -27,19 +32,33 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie
],
})
export class StalkerInlineDetailComponent {
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
readonly isSeries = input<boolean>(false);
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
readonly isFavorite = input<boolean>(false);
readonly playbackPosition = input<number | null>(null);
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
readonly backClicked = output<void>();
readonly playClicked = output<VodDetailsItem>();
readonly resumeClicked = output<{
item: VodDetailsItem;
positionSeconds: number;
}>();
readonly favoriteToggled = output<{
item: VodDetailsItem;
isFavorite: boolean;
}>();
readonly inlineTimeUpdated = output<{
currentTime: number;
duration: number;
}>();
readonly inlinePlaybackClosed = output<void>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested =
output<PlaybackFallbackRequest>();
onBackClicked() {
this.backClicked.emit();
@@ -49,7 +68,30 @@ export class StalkerInlineDetailComponent {
this.playClicked.emit(item);
}
onResumeClicked(event: {
item: VodDetailsItem;
positionSeconds: number;
}) {
this.resumeClicked.emit(event);
}
onFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }) {
this.favoriteToggled.emit(event);
}
onInlineTimeUpdated(event: { currentTime: number; duration: number }) {
this.inlineTimeUpdated.emit(event);
}
onInlinePlaybackClosed() {
this.inlinePlaybackClosed.emit();
}
onStreamUrlCopied() {
this.streamUrlCopied.emit();
}
onInlineExternalFallbackRequested(request: PlaybackFallbackRequest) {
this.inlineExternalFallbackRequested.emit(request);
}
}
@@ -15,7 +15,15 @@
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
(inlineExternalFallbackRequested)="handleExternalFallbackRequest($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
@@ -9,16 +9,20 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { DataService, PlaylistsService } from 'services';
import {
PlaybackPositionData,
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
VodDetailsItem,
} from 'shared-interfaces';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
@@ -27,6 +31,9 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
isWorkspaceLayoutRoute,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
@@ -45,6 +52,7 @@ import {
isStalkerSeriesFlag,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
interface StalkerFilter {
key: string;
@@ -79,8 +87,13 @@ export class StalkerSearchComponent {
private readonly dataService = inject(DataService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistService = inject(PlaylistsService);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly stalkerStore = inject(StalkerStore);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerSearch');
readonly filters = signal({
@@ -119,6 +132,21 @@ export class StalkerSearchComponent {
itemDetails: StalkerSelectedVodItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start search VOD playback',
});
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
@@ -211,10 +239,6 @@ export class StalkerSearchComponent {
return this.searchResultsResource.value()?.length ?? 0;
}
createLinkToPlayVodItv(cmd?: string, title?: string, thumbnail?: string) {
this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
updateSearchTerm(term: string) {
this.searchTerm.set(term);
}
@@ -234,6 +258,7 @@ export class StalkerSearchComponent {
}
selectItem(item: StalkerVodSource) {
this.closeInlinePlayer();
const hasEmbeddedSeries = item.series?.length > 0;
const needsSeriesFetch =
this.selectedFilterType() === 'vod' &&
@@ -255,10 +280,15 @@ export class StalkerSearchComponent {
this.itemDetails = detailViewState.itemDetails;
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.syncSelectedVodFavorite();
void this.loadSelectedVodPosition(
this.currentPlaylist()?._id ?? '',
Number(detailViewState.itemDetails?.id)
);
} else {
const cleared = clearStalkerDetailViewState();
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
}
break;
case 'series':
@@ -271,7 +301,7 @@ export class StalkerSearchComponent {
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
@@ -279,6 +309,20 @@ export class StalkerSearchComponent {
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
@@ -299,6 +343,27 @@ export class StalkerSearchComponent {
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
this.closeInlinePlayer();
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.vodPlayback.handleInlineTimeUpdate(event);
}
closeInlinePlayer(): void {
this.vodPlayback.closeInlinePlayer();
}
showCopyNotification(): void {
this.vodPlayback.showCopyNotification();
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
this.vodPlayback.handleExternalFallbackRequest(request);
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
@@ -361,4 +426,31 @@ export class StalkerSearchComponent {
return relativePath;
}
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
await this.vodPlayback.startVodPlayback(() =>
startTime === undefined
? this.stalkerStore.resolveVodPlayback(cmd, title, thumbnail)
: this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
)
);
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
}
@@ -51,6 +51,30 @@
<!-- Action Buttons -->
<ng-container hero-actions>
@if (quickStartAction(); as action) {
<button
class="play-btn"
data-testid="series-quick-start"
(click)="playQuickStartEpisode()"
[disabled]="action.disabled"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
action.icon
}}</mat-icon>
</span>
<span class="play-btn__copy">
<span class="play-btn__label">
{{ action.labelKey | translate }}
</span>
@if (action.episodeLabel) {
<span class="play-btn__meta">
{{ action.episodeLabel }}
</span>
}
</span>
</button>
}
<app-favorites-button
[itemId]="serial.id"
[item]="serial"
@@ -0,0 +1,427 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import {
ContentHeroComponent,
SeasonContainerComponent,
} from 'components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { PlaybackPositionData } from 'shared-interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { DownloadsService } from 'services';
import { of } from 'rxjs';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
@Component({
selector: 'app-content-hero',
standalone: true,
template: '<ng-content />',
})
class StubContentHeroComponent {
readonly title = input<string | undefined>(undefined);
readonly description = input<string | undefined>(undefined);
readonly posterUrl = input<string | undefined>(undefined);
readonly backClicked = output<void>();
}
@Component({
selector: 'app-season-container',
standalone: true,
template: '<div data-testid="season-container"></div>',
})
class StubSeasonContainerComponent {
readonly seasons = input<unknown>(null);
readonly seriesId = input<number | string | null>(null);
readonly playlistId = input('');
readonly seriesTitle = input<string | undefined>(undefined);
readonly playbackPositions = input<unknown>(null);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly isLoading = input(false);
readonly seasonSelected = output<string>();
readonly episodeClicked = output<unknown>();
readonly episodeDownloadRequested = output<unknown>();
readonly playbackToggleRequested = output<unknown>();
selectedSeason: string | undefined;
}
@Component({
selector: 'app-portal-inline-player',
standalone: true,
template: '',
})
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<unknown>();
}
@Component({
selector: 'app-favorites-button',
standalone: true,
template: '<button class="favorite-btn">favorite</button>',
})
class StubFavoritesButtonComponent {
readonly itemId = input<string | number | undefined>(undefined);
readonly item = input<unknown>(null);
}
describe('StalkerSeriesViewComponent', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const selectedContentType = signal<'series' | 'vod'>('series');
const selectedItem = signal<StalkerVodSource | null>(null);
const serialSeasonsResource = signal<unknown[]>([]);
const vodSeriesSeasonsResource = signal<unknown[]>([]);
const fetchVodSeriesEpisodes = jest.fn();
const resolveVodPlayback = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
const openResolvedPlayback = jest.fn();
beforeEach(async () => {
selectedContentType.set('series');
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
},
});
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
vodSeriesSeasonsResource.set([]);
fetchVodSeriesEpisodes.mockReset();
resolveVodPlayback.mockReset();
resolveVodPlayback.mockResolvedValue({
streamUrl: 'http://stalker.example/episode.mpg',
title: 'Regular Series',
thumbnail: 'poster.jpg',
});
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
openResolvedPlayback.mockClear();
await TestBed.configureTestingModule({
imports: [StalkerSeriesViewComponent],
providers: [
{
provide: StalkerStore,
useValue: {
selectedItem,
selectedContentType,
currentPlaylist: signal({ _id: 'stalker-1' }),
getSerialSeasonsResource: () =>
serialSeasonsResource(),
getVodSeriesSeasonsResource: () =>
vodSeriesSeasonsResource(),
isVodSeriesSeasonsLoading: signal(false),
isSerialSeasonsLoading: signal(false),
fetchVodSeriesEpisodes,
resolveVodPlayback,
fetchLinkToPlay: jest.fn(),
clearSelectedItem: jest.fn(),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions,
savePlaybackPosition: jest.fn(),
clearPlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback,
},
},
{
provide: Router,
useValue: {
navigateByUrl: jest.fn(),
},
},
{
provide: DownloadsService,
useValue: {
startDownload: 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),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
},
},
],
})
.overrideComponent(StalkerSeriesViewComponent, {
remove: {
imports: [
ContentHeroComponent,
FavoritesButtonComponent,
PortalInlinePlayerComponent,
SeasonContainerComponent,
TranslatePipe,
],
},
add: {
imports: [
StubContentHeroComponent,
StubFavoritesButtonComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerSeriesViewComponent);
});
afterEach(() => {
fixture?.destroy();
});
it('renders quick start for regular series and starts the first episode', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const quickStartButton: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(quickStartButton).not.toBeNull();
expect(quickStartButton?.textContent).toContain(
'XTREAM.PLAY_FIRST_EPISODE'
);
expect(quickStartButton?.textContent).toContain(
'S01E01 · Episode 1'
);
quickStartButton?.click();
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenCalledWith(
'/media/file_30001.mpg',
'Regular Series',
'poster.jpg',
1,
expect.any(Number),
undefined
);
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'http://stalker.example/episode.mpg',
}),
true
);
});
it('loads the first VOD-series season and starts its first episode from quick start', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
]);
fetchVodSeriesEpisodes.mockResolvedValue([
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
]);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const quickStartButton: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(quickStartButton).not.toBeNull();
expect(quickStartButton?.textContent).toContain('S01E01');
quickStartButton?.click();
await fixture.whenStable();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
'50001',
'season-1'
);
expect(resolveVodPlayback).toHaveBeenCalledWith(
'/media/file_episode-1.mpg',
'VOD Flagged Series - Pilot',
'vod-series.jpg',
1,
expect.any(Number),
undefined
);
});
it('loads the next unloaded VOD-series season after the loaded season is watched', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
fetchVodSeriesEpisodes.mockResolvedValue([
{
id: 'episode-2',
series_number: 1,
name: 'Second Season Pilot',
},
]);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
const watchedEpisode = fixture.componentInstance.mappedSeasons()['1'][0];
const watchedPosition: PlaybackPositionData = {
contentXtreamId: Number(watchedEpisode.id),
contentType: 'episode',
seriesXtreamId: 50001,
positionSeconds: 95,
durationSeconds: 100,
};
fixture.componentInstance.episodePlaybackPositions.set(
new Map([[Number(watchedEpisode.id), watchedPosition]])
);
fixture.detectChanges();
const quickStartButton: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(quickStartButton).not.toBeNull();
expect(quickStartButton?.textContent).toContain(
'XTREAM.PLAY_NEXT_EPISODE'
);
expect(quickStartButton?.textContent).toContain('S02E01');
quickStartButton?.click();
await fixture.whenStable();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
'50001',
'season-2'
);
expect(resolveVodPlayback).toHaveBeenCalledWith(
'/media/file_episode-2.mpg',
'VOD Flagged Series - Second Season Pilot',
'vod-series.jpg',
1,
expect.any(Number),
undefined
);
});
});
@@ -8,6 +8,7 @@ import {
output,
signal,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
@@ -26,8 +27,13 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
SERIES_QUICK_START_ACTION_KIND,
SeriesQuickStartAction,
createLogger,
getPositiveInteger,
getSeriesQuickStartAction,
getStalkerReturnToState,
padEpisodePart,
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
@@ -50,6 +56,15 @@ import {
} from '@iptvnator/ui/playback';
import { DownloadsService } from 'services';
interface StalkerQuickStartButton {
labelKey: string;
episodeLabel: string | null;
icon: string;
disabled: boolean;
action: SeriesQuickStartAction | null;
lazySeason: VodSeriesSeasonVm | null;
}
/**
* Component for displaying series/episodes for Stalker portal content.
* Supports three modes:
@@ -67,6 +82,7 @@ import { DownloadsService } from 'services';
PortalInlinePlayerComponent,
TranslatePipe,
SeasonContainerComponent,
MatIcon,
],
})
export class StalkerSeriesViewComponent implements OnDestroy {
@@ -142,14 +158,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const normalizedSeriesId = this.toSeriesId(item.id);
this.logger.debug(
'Loading positions for series',
{
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
}
);
this.logger.debug('Loading positions for series', {
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
});
if (!isNaN(normalizedSeriesId)) {
void this.loadSeriesPositions(
playlist._id,
@@ -258,6 +271,41 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
);
readonly quickStartAction = computed<StalkerQuickStartButton | null>(() => {
const action = getSeriesQuickStartAction({
seasons: this.mappedSeasons(),
playbackPositions: this.episodePlaybackPositions(),
});
const lazySeason = this.getQuickStartLazyVodSeriesSeason(action);
if (lazySeason) {
return {
labelKey:
action?.kind === SERIES_QUICK_START_ACTION_KIND.Completed
? 'XTREAM.PLAY_NEXT_EPISODE'
: 'XTREAM.PLAY_FIRST_EPISODE',
episodeLabel: this.getLazyVodSeriesEpisodeLabel(lazySeason),
icon: 'play_arrow',
disabled: lazySeason.isLoading,
action: null,
lazySeason,
};
}
if (!action) {
return null;
}
return {
labelKey: action.labelKey,
episodeLabel: action.episodeLabel,
icon: action.icon,
disabled: action.disabled,
action,
lazySeason: null,
};
});
/**
* Handles season selection from the container.
* For VOD Series, triggers lazy loading of episodes.
@@ -357,6 +405,22 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
}
async playQuickStartEpisode(): Promise<void> {
const quickStart = this.quickStartAction();
if (!quickStart || quickStart.disabled) {
return;
}
if (quickStart.action) {
this.onEpisodeClicked(quickStart.action.episode);
return;
}
if (quickStart.lazySeason) {
await this.loadAndPlayVodSeriesSeason(quickStart.lazySeason);
}
}
/**
* Play episode - handles regular series and vclub mode
*/
@@ -513,8 +577,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
error instanceof Error &&
error.message === 'nothing_to_play'
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
@@ -589,7 +652,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const posterUrl = episodeInfo?.movie_image;
const seasonNum = Number(episode.season || 1);
const episodeNum = episode.episode_num || 1;
const seriesTitle = item.info?.name || this.displayItem()?.info?.name || 'Series';
const seriesTitle =
item.info?.name || this.displayItem()?.info?.name || 'Series';
const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart(
2,
'0'
@@ -621,10 +685,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
playlistId: string,
seriesXtreamId: number
): Promise<void> {
const positions = await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
positions.forEach((position) => {
positionsMap.set(position.contentXtreamId, position);
@@ -632,7 +697,86 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.episodePlaybackPositions.set(positionsMap);
}
private updateEpisodePlaybackPosition(position: PlaybackPositionData): void {
private async loadAndPlayVodSeriesSeason(
season: VodSeriesSeasonVm
): Promise<void> {
if (season.episodes.length === 0) {
await this.loadEpisodesForSeason(season);
}
const loadedSeason =
this.vodSeriesSeasons().find((item) => item.id === season.id) ??
season;
const seasonKey = getVodSeriesSeasonKey(loadedSeason);
const episode = this.mappedSeasons()[seasonKey]?.[0];
if (episode) {
this.onEpisodeClicked(episode);
}
}
private getQuickStartLazyVodSeriesSeason(
action: SeriesQuickStartAction | null
): VodSeriesSeasonVm | null {
if (!this.isVodSeries()) {
return null;
}
const seasons = this.vodSeriesSeasons();
if (!action) {
return this.getFirstUnloadedVodSeriesSeason(seasons);
}
if (action.kind !== SERIES_QUICK_START_ACTION_KIND.Completed) {
return null;
}
const completedSeasonIndex = this.findVodSeriesSeasonIndexForEpisode(
action.episode,
seasons
);
if (completedSeasonIndex === -1) {
return null;
}
return this.getFirstUnloadedVodSeriesSeason(
seasons.slice(completedSeasonIndex + 1)
);
}
private getFirstUnloadedVodSeriesSeason(
seasons: ReadonlyArray<VodSeriesSeasonVm>
): VodSeriesSeasonVm | null {
return seasons.find((season) => season.episodes.length === 0) ?? null;
}
private findVodSeriesSeasonIndexForEpisode(
episode: XtreamSerieEpisode,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): number {
const mappedSeasons = this.mappedSeasons();
return seasons.findIndex((season) =>
(mappedSeasons[getVodSeriesSeasonKey(season)] ?? []).some(
(mappedEpisode) => mappedEpisode.id === episode.id
)
);
}
private getLazyVodSeriesEpisodeLabel(season: VodSeriesSeasonVm): string {
const seasonIndex = this.vodSeriesSeasons().findIndex(
(item) => item.id === season.id
);
const seasonNumber =
getPositiveInteger(Number(season.season_number)) ??
(seasonIndex >= 0 ? seasonIndex + 1 : 1);
return `S${padEpisodePart(seasonNumber)}E01`;
}
private updateEpisodePlaybackPosition(
position: PlaybackPositionData
): void {
const updated = new Map(this.episodePlaybackPositions());
updated.set(position.contentXtreamId, position);
this.episodePlaybackPositions.set(updated);
@@ -0,0 +1,118 @@
import { signal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type {
Logger,
PortalPlaybackPositions,
PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type { PlaybackPositionData } from 'shared-interfaces';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolver) => {
resolve = resolver;
});
return {
promise,
resolve,
};
}
function createPosition(
contentXtreamId: number,
positionSeconds: number
): PlaybackPositionData {
return {
contentXtreamId,
contentType: 'vod',
positionSeconds,
durationSeconds: 100,
};
}
function createController() {
const inlinePlayback = signal(null);
const selectedVodPosition = signal<PlaybackPositionData | null>(null);
const playbackPositions = {
savePlaybackPosition: jest.fn(),
getPlaybackPosition: jest.fn(),
getSeriesPlaybackPositions: jest.fn(),
getAllPlaybackPositions: jest.fn(),
clearPlaybackPosition: jest.fn(),
} as unknown as jest.Mocked<PortalPlaybackPositions>;
const portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openPlayer: jest.fn(),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
} as unknown as jest.Mocked<PortalPlayer>;
const snackBar = {
open: jest.fn(),
} as unknown as MatSnackBar;
const translateService = {
instant: jest.fn((key: string) => key),
} as unknown as TranslateService;
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as Logger;
const controller = new StalkerVodPlaybackController({
inlinePlayback,
selectedVodPosition,
playbackPositions,
portalPlayer,
snackBar,
translateService,
logger,
playbackErrorLogMessage: 'Playback failed',
});
return {
controller,
playbackPositions,
selectedVodPosition,
};
}
describe('StalkerVodPlaybackController', () => {
it('ignores stale VOD position loads that resolve after a newer selection', async () => {
const { controller, playbackPositions, selectedVodPosition } =
createController();
const olderLoad = createDeferred<PlaybackPositionData | null>();
const newerLoad = createDeferred<PlaybackPositionData | null>();
playbackPositions.getPlaybackPosition.mockImplementation(
(_playlistId, vodId) =>
vodId === 101 ? olderLoad.promise : newerLoad.promise
);
const olderLoadPromise = controller.loadSelectedVodPosition(
'playlist-1',
101
);
const newerLoadPromise = controller.loadSelectedVodPosition(
'playlist-1',
202
);
newerLoad.resolve(createPosition(202, 20));
await newerLoadPromise;
expect(selectedVodPosition()?.contentXtreamId).toBe(202);
olderLoad.resolve(createPosition(101, 10));
await olderLoadPromise;
expect(selectedVodPosition()?.contentXtreamId).toBe(202);
expect(selectedVodPosition()?.positionSeconds).toBe(20);
});
});
@@ -0,0 +1,138 @@
import type { WritableSignal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type {
Logger,
PortalPlaybackPositions,
PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
} from 'shared-interfaces';
interface StalkerVodPlaybackControllerConfig {
inlinePlayback: WritableSignal<ResolvedPortalPlayback | null>;
selectedVodPosition: WritableSignal<PlaybackPositionData | null>;
playbackPositions: PortalPlaybackPositions;
portalPlayer: PortalPlayer;
snackBar: MatSnackBar;
translateService: TranslateService;
logger: Logger;
playbackErrorLogMessage: string;
}
export class StalkerVodPlaybackController {
private lastInlineSaveTime = 0;
private loadSelectedVodPositionRequestId = 0;
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
async startVodPlayback(
resolvePlayback: () => Promise<ResolvedPortalPlayback>
): Promise<void> {
try {
const playback = await resolvePlayback();
this.lastInlineSaveTime = 0;
if (this.config.portalPlayer.isEmbeddedPlayer()) {
this.config.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.config.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.config.logger.error(
this.config.playbackErrorLogMessage,
error
);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.config.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.config.translateService.instant(
'PORTALS.PLAYBACK_ERROR'
);
this.config.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
const requestId = ++this.loadSelectedVodPositionRequestId;
if (!playlistId || !Number.isFinite(vodId)) {
this.config.selectedVodPosition.set(null);
return;
}
const position =
await this.config.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
if (requestId !== this.loadSelectedVodPositionRequestId) {
return;
}
this.config.selectedVodPosition.set(position ?? null);
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.config.inlinePlayback();
if (!playback?.contentInfo) {
return;
}
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) {
return;
}
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.config.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.config.selectedVodPosition.set(position);
}
closeInlinePlayer(): void {
this.config.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.config.snackBar.open(
this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.config.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
}
}
@@ -129,11 +129,11 @@
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 26px;
min-height: 54px;
padding: 10px 24px 10px 18px;
min-height: 64px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
letter-spacing: 0;
background: var(--play-btn-bg);
background-size: 140% 140%;
background-position: 0% 50%;
@@ -221,10 +221,33 @@
box-shadow 220ms ease;
}
&__label {
&__copy {
display: inline-flex;
align-items: center;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
line-height: 1.1;
text-align: left;
}
&__label {
display: block;
min-width: 0;
line-height: 1.15;
}
&__meta {
display: block;
max-width: min(42vw, 360px);
overflow: hidden;
color: var(--play-btn-fg);
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
opacity: 0.72;
text-overflow: ellipsis;
white-space: nowrap;
}
&--state-launching,
@@ -339,6 +362,21 @@
}
}
.favorite-btn,
.download-btn {
min-height: 64px;
padding: 0 24px;
border-radius: 16px;
font-size: 1rem;
font-weight: 600;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
@@ -540,9 +578,14 @@
.download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
.play-btn__meta {
max-width: 100%;
}
.youtube-trailer {
margin-top: 16px;
-1
View File
@@ -3,7 +3,6 @@ export * from './lib/category-management-dialog/category-management-dialog.compo
export * from './lib/global-search-results/global-search-results.component';
export * from './lib/live-stream-layout/live-stream-layout.component';
export * from './lib/loading-overlay.component';
export * from './lib/player-dialog/player-dialog.component';
export * from './lib/portal-channels-list/portal-channels-list.component';
export * from './lib/recently-added/recently-added.component';
export * from './lib/search-results/search-results.component';
@@ -1,25 +0,0 @@
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content class="content">
<app-web-player-view
[streamUrl]="streamUrl"
[title]="title"
[playback]="data.playback ?? null"
[startTime]="data.startTime || 0"
(timeUpdate)="handleTimeUpdate($event)"
(externalFallbackRequested)="handleExternalFallbackRequest($event)"
/>
</mat-dialog-content>
<mat-dialog-actions class="align-actions">
<button
mat-button
[cdkCopyToClipboard]="streamUrl"
(click)="showCopyNotification()"
[matTooltip]="streamUrl"
>
<mat-icon>content_copy</mat-icon>
{{ 'PORTALS.COPY_STREAM_URL' | translate }}
</button>
<button mat-button mat-dialog-close color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -1,16 +0,0 @@
@use '@angular/material' as mat;
.content {
overflow: hidden;
padding: 10px !important;
}
.align-actions {
justify-content: space-between;
}
mat-dialog-content {
.video-js {
height: 500px !important;
}
}
@@ -1,92 +0,0 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { Component, inject, ViewEncapsulation } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { PlayerContentInfo, ResolvedPortalPlayback } from 'shared-interfaces';
import {
type PlaybackFallbackRequest,
WebPlayerViewComponent,
} from 'shared-portals';
export interface PlayerDialogData {
streamUrl: string;
title: string;
contentInfo?: PlayerContentInfo;
startTime?: number;
playback?: ResolvedPortalPlayback;
}
@Component({
templateUrl: './player-dialog.component.html',
imports: [
ClipboardModule,
MatButton,
MatDialogModule,
MatIcon,
MatTooltip,
TranslatePipe,
WebPlayerViewComponent,
],
styleUrl: './player-dialog.component.scss',
encapsulation: ViewEncapsulation.None,
})
export class PlayerDialogComponent {
readonly data = inject<PlayerDialogData>(MAT_DIALOG_DATA);
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly title: string;
readonly streamUrl: string;
private lastSaveTime = 0;
constructor() {
this.streamUrl = this.data.playback?.streamUrl ?? this.data.streamUrl;
this.title = this.data.playback?.title ?? this.data.title;
}
handleTimeUpdate(event: { currentTime: number; duration: number }) {
if (!this.data.contentInfo) return;
const now = Date.now();
// Save every 15 seconds
if (now - this.lastSaveTime > 15000) {
this.lastSaveTime = now;
void this.playbackPositions.savePlaybackPosition(
this.data.contentInfo.playlistId,
{
...this.data.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
}
);
}
}
showCopyNotification() {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
}
}
@@ -46,6 +46,28 @@
</ng-container>
<ng-container hero-actions>
@if (quickStartAction(); as action) {
<button
class="play-btn"
data-testid="series-quick-start"
(click)="playQuickStartEpisode()"
[disabled]="action.disabled"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
action.icon
}}</mat-icon>
</span>
<span class="play-btn__copy">
<span class="play-btn__label">
{{ action.labelKey | translate }}
</span>
<span class="play-btn__meta">
{{ action.episodeLabel }}
</span>
</span>
</button>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
@@ -89,6 +89,9 @@ describe('SerialDetailsComponent', () => {
});
const fetchSerialDetailsWithMetadata = jest.fn();
const checkFavoriteStatus = jest.fn();
const constructEpisodeStreamUrl = jest.fn();
const addRecentItem = jest.fn();
const openResolvedPlayback = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
beforeEach(async () => {
@@ -117,7 +120,14 @@ describe('SerialDetailsComponent', () => {
detailsError.set(null);
fetchSerialDetailsWithMetadata.mockClear();
checkFavoriteStatus.mockClear();
constructEpisodeStreamUrl.mockReset();
constructEpisodeStreamUrl.mockReturnValue(
'http://xtream.example/series/1001.mp4'
);
addRecentItem.mockClear();
openResolvedPlayback.mockClear();
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
await TestBed.configureTestingModule({
imports: [SerialDetailsComponent],
@@ -148,8 +158,8 @@ describe('SerialDetailsComponent', () => {
selectedItem.set(value)
),
toggleFavorite: jest.fn(),
constructEpisodeStreamUrl: jest.fn(),
addRecentItem: jest.fn(),
constructEpisodeStreamUrl,
addRecentItem,
backfillContentBackdrop: jest.fn(),
},
},
@@ -171,7 +181,7 @@ describe('SerialDetailsComponent', () => {
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
openResolvedPlayback,
},
},
{
@@ -267,4 +277,92 @@ describe('SerialDetailsComponent', () => {
],
});
});
it('renders quick start as the first episode action and opens that episode', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const quickStartButton: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(quickStartButton).not.toBeNull();
expect(quickStartButton?.textContent).toContain(
'XTREAM.PLAY_FIRST_EPISODE'
);
expect(quickStartButton?.textContent).toContain(
'S01E01 · Episode 1'
);
quickStartButton?.click();
expect(constructEpisodeStreamUrl).toHaveBeenCalledWith(
expect.objectContaining({ id: '1001' })
);
expect(addRecentItem).toHaveBeenCalledWith({
xtreamId: '103',
contentType: 'series',
playlist: currentPlaylist,
backdropUrl: undefined,
});
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'http://xtream.example/series/1001.mp4',
title: 'Episode 1',
startTime: undefined,
contentInfo: expect.objectContaining({
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
}),
}),
true
);
});
it('resumes quick start from the stored episode position', async () => {
getSeriesPlaybackPositions.mockResolvedValue([
{
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 42,
durationSeconds: 120,
playlistId: 'xtream-1',
updatedAt: '2026-05-10T12:00:00.000Z',
},
]);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const quickStartButton: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(quickStartButton?.textContent).toContain(
'XTREAM.RESUME_EPISODE'
);
expect(quickStartButton?.textContent).toContain(
'S01E01 · Episode 1'
);
quickStartButton?.click();
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
startTime: 42,
contentInfo: expect.objectContaining({
contentXtreamId: 1001,
}),
}),
true
);
});
});
@@ -1,6 +1,7 @@
import { Location, SlicePipe } from '@angular/common';
import {
Component,
computed,
effect,
inject,
OnDestroy,
@@ -21,6 +22,7 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
getSeriesQuickStartAction,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
@@ -92,6 +94,17 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(null);
readonly quickStartAction = computed(() => {
const item = this.selectedItem();
if (!item) {
return null;
}
return getSeriesQuickStartAction({
seasons: item.episodes ?? {},
playbackPositions: this.episodePlaybackPositions(),
});
});
constructor() {
effect(() => {
@@ -260,6 +273,15 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
this.startPlayback(playback);
}
playQuickStartEpisode(): void {
const action = this.quickStartAction();
if (!action || action.disabled) {
return;
}
this.playEpisode(action.episode);
}
toggleFavorite(): void {
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.serialId,
@@ -426,14 +426,15 @@
::ng-deep .play-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
gap: 12px;
min-height: 64px;
padding: 10px 24px 10px 18px;
font-size: 1rem;
font-weight: 600;
background: #fff;
color: #141414;
border: none;
border-radius: 4px;
border-radius: 16px;
cursor: pointer;
transition: all 0.2s ease;
@@ -447,19 +448,50 @@
width: 24px;
height: 24px;
}
.play-btn__copy {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
line-height: 1.1;
text-align: left;
}
.play-btn__label {
display: block;
min-width: 0;
line-height: 1.15;
}
.play-btn__meta {
display: block;
max-width: min(42vw, 360px);
overflow: hidden;
color: currentColor;
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
opacity: 0.72;
text-overflow: ellipsis;
white-space: nowrap;
}
}
::ng-deep .favorite-btn {
::ng-deep .favorite-btn,
::ng-deep .download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
min-height: 64px;
padding: 0 24px;
font-size: 1rem;
font-weight: 600;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
border-radius: 16px;
cursor: pointer;
transition: all 0.2s ease;
@@ -467,6 +499,14 @@
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
::ng-deep .favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
@@ -474,11 +514,31 @@
background: rgba(229, 9, 20, 0.6);
}
}
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
::ng-deep .download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
}
}
@@ -551,11 +611,17 @@
width: 100%;
::ng-deep .play-btn,
::ng-deep .favorite-btn {
::ng-deep .favorite-btn,
::ng-deep .download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
::ng-deep .play-btn__meta {
max-width: 100%;
}
}
}
@@ -129,11 +129,11 @@
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 26px;
min-height: 54px;
padding: 10px 24px 10px 18px;
min-height: 64px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
letter-spacing: 0;
background: var(--play-btn-bg);
background-size: 140% 140%;
background-position: 0% 50%;
@@ -221,10 +221,33 @@
box-shadow 220ms ease;
}
&__label {
&__copy {
display: inline-flex;
align-items: center;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
line-height: 1.1;
text-align: left;
}
&__label {
display: block;
min-width: 0;
line-height: 1.15;
}
&__meta {
display: block;
max-width: min(42vw, 360px);
overflow: hidden;
color: var(--play-btn-fg);
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
opacity: 0.72;
text-overflow: ellipsis;
white-space: nowrap;
}
&--state-launching,
@@ -339,6 +362,21 @@
}
}
.favorite-btn,
.download-btn {
min-height: 64px;
padding: 0 24px;
border-radius: 16px;
font-size: 1rem;
font-weight: 600;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
@@ -538,9 +576,14 @@
.download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
.play-btn__meta {
max-width: 100%;
}
.youtube-trailer {
margin-top: 16px;
@@ -129,11 +129,11 @@
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 26px;
min-height: 54px;
padding: 10px 24px 10px 18px;
min-height: 64px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
letter-spacing: 0;
background: var(--play-btn-bg);
background-size: 140% 140%;
background-position: 0% 50%;
@@ -221,10 +221,33 @@
box-shadow 220ms ease;
}
&__label {
&__copy {
display: inline-flex;
align-items: center;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
line-height: 1.1;
text-align: left;
}
&__label {
display: block;
min-width: 0;
line-height: 1.15;
}
&__meta {
display: block;
max-width: min(42vw, 360px);
overflow: hidden;
color: var(--play-btn-fg);
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
opacity: 0.72;
text-overflow: ellipsis;
white-space: nowrap;
}
&--state-launching,
@@ -339,6 +362,21 @@
}
}
.favorite-btn,
.download-btn {
min-height: 64px;
padding: 0 24px;
border-radius: 16px;
font-size: 1rem;
font-weight: 600;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
@@ -538,9 +576,14 @@
.download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
.play-btn__meta {
max-width: 100%;
}
.youtube-trailer {
margin-top: 16px;