feat(portal): add series quick start CTA

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-11 22:11:12 +02:00
1 parent f922dd2d00
commit c45274be0e
34 files changed
+1520 -38

No files matched your search

+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": "標記為已觀看",
@@ -10,6 +10,8 @@ This document records the current contract for embedded playback in portal detai
- 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.
- Xtream and Stalker series detail heroes expose a quick-start CTA driven by
saved episode playback positions.
- Material dialog playback remains only as a fallback for older non-detail callers.
- Browser-player failures are diagnosed client-side and can offer explicit MPV/VLC fallback actions without changing the saved player setting.
@@ -101,6 +103,28 @@ When a detail view starts playback:
The detail host owns inline state. `PlayerService` is no longer the primary owner of UI playback state for canonical VOD/series detail screens.
## 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
The shared `WebPlayerViewComponent` is the central browser-player viewport for M3U, Xtream, and Stalker inline playback. Video.js, HTML5, and ArtPlayer report native media errors, HLS.js errors, mpegts.js errors, and HLS manifest codec metadata into the shared diagnostics classifier.
+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,245 @@
import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces';
import {
SERIES_QUICK_START_ACTION_KIND,
getSeriesQuickStartAction,
} 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();
});
});
@@ -0,0 +1,195 @@
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;
}
function getPositiveInteger(value: number): number | null {
return Number.isInteger(value) && value > 0 ? value : null;
}
function padEpisodePart(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
@@ -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;
}
}
@@ -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,7 +27,10 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
SERIES_QUICK_START_ACTION_KIND,
SeriesQuickStartAction,
createLogger,
getSeriesQuickStartAction,
getStalkerReturnToState,
} from '@iptvnator/portal/shared/util';
import {
@@ -50,6 +54,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 +80,7 @@ import { DownloadsService } from 'services';
PortalInlinePlayerComponent,
TranslatePipe,
SeasonContainerComponent,
MatIcon,
],
})
export class StalkerSeriesViewComponent implements OnDestroy {
@@ -258,6 +272,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 +406,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
*/
@@ -632,6 +697,83 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.episodePlaybackPositions.set(positionsMap);
}
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);
@@ -693,3 +835,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return Math.abs(hash);
}
}
function getPositiveInteger(value: number): number | null {
return Number.isInteger(value) && value > 0 ? value : null;
}
function padEpisodePart(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
@@ -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;
@@ -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;