mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(ui): season tabs replace two-level seasons navigation
SeasonTabsComponent renders a pill row (dropdown beyond 6 seasons), an optional season description and a back-to-playing chip. The season container auto-selects a season (inline-playing episode's season → most recent in-progress → first), emits seasonSelected for auto-selections so lazy-load/enrichment hooks keep firing, highlights the inline-playing episode, and loses the seasons grid + 'Back to seasons' button. Download/progress helpers move to pure utils; season strings are now translatable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
50189c27e2
commit
191abe16eb
15 files changed
+992
-445
No files matched your search
@@ -922,6 +922,15 @@
|
||||
"CLOSE_PLAYER": "Close player",
|
||||
"BACK_TO_BROWSE": "Back to details",
|
||||
"ABOUT": "About",
|
||||
"SEASONS_AND_EPISODES": "Seasons and Episodes",
|
||||
"SEASON_TAB": "Season {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Back to playing episode",
|
||||
"GRID_VIEW": "Grid view",
|
||||
"LIST_VIEW": "List view",
|
||||
"NO_EPISODES_AVAILABLE": "No episodes available",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "This series is listed by your provider, but no seasons or episodes are available to play.",
|
||||
"SEASON_EMPTY": "This season is empty",
|
||||
"SEASON_EMPTY_MESSAGE": "This season is listed by your provider, but no playable episodes are available.",
|
||||
"SELECT_CATEGORY": "Select a category",
|
||||
"PAGE": "Page",
|
||||
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
|
||||
|
||||
+4
-1
@@ -146,9 +146,12 @@
|
||||
[playbackPositions]="episodePlaybackPositions()"
|
||||
[openingEpisodeId]="openingEpisodeId()"
|
||||
[activeEpisodeId]="activeEpisodeId()"
|
||||
[playingEpisodeId]="
|
||||
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
|
||||
"
|
||||
[isLoading]="
|
||||
isVodSeriesSeasonsLoading() ||
|
||||
isCurrentSeasonLoading(seasonContainer?.selectedSeason)
|
||||
isCurrentSeasonLoading(seasonContainer?.selectedSeason())
|
||||
"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
(episodeClicked)="onEpisodeClicked($event)"
|
||||
|
||||
+3
-1
@@ -35,12 +35,14 @@ class StubSeasonContainerComponent {
|
||||
readonly playbackPositions = input<unknown>(null);
|
||||
readonly openingEpisodeId = input<number | null>(null);
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
readonly seasonDescriptions = input<unknown>(null);
|
||||
readonly isLoading = input(false);
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly episodeClicked = output<unknown>();
|
||||
readonly episodeDownloadRequested = output<unknown>();
|
||||
readonly playbackToggleRequested = output<unknown>();
|
||||
selectedSeason: string | undefined;
|
||||
readonly selectedSeason = signal<string | undefined>(undefined);
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -154,6 +154,9 @@
|
||||
[xtreamDownloadContext]="xtreamDownloadContext()"
|
||||
[openingEpisodeId]="openingEpisodeId()"
|
||||
[activeEpisodeId]="activeEpisodeId()"
|
||||
[playingEpisodeId]="
|
||||
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
|
||||
"
|
||||
(episodeClicked)="playEpisode($event)"
|
||||
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
|
||||
@@ -35,6 +35,8 @@ class StubSeasonContainerComponent {
|
||||
readonly xtreamDownloadContext = input<unknown>(null);
|
||||
readonly openingEpisodeId = input<number | null>(null);
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
readonly seasonDescriptions = input<unknown>(null);
|
||||
readonly episodeClicked = output<unknown>();
|
||||
readonly playbackToggleRequested = output<unknown>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Pure helpers for mapping episodes (Xtream and Stalker-mapped) to the
|
||||
* download-manager content ids and download requests used by the season
|
||||
* container.
|
||||
*/
|
||||
|
||||
export interface XtreamEpisodeDownloadContext {
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface XtreamEpisodeDownloadRequest {
|
||||
playlistId: string;
|
||||
xtreamId: number;
|
||||
contentType: 'episode';
|
||||
title: string;
|
||||
url: string;
|
||||
posterUrl?: string;
|
||||
seriesXtreamId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
}
|
||||
|
||||
export function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < str.length; index++) {
|
||||
const char = str.charCodeAt(index);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash &= hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
|
||||
const customSid = (episode as { custom_sid?: string }).custom_sid;
|
||||
return customSid === 'vod-series' || customSid === 'regular-series';
|
||||
}
|
||||
|
||||
export function getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
|
||||
const customSid = (episode as { custom_sid?: string }).custom_sid;
|
||||
|
||||
if (customSid === 'regular-series') {
|
||||
const cmd = (episode as { originalCmd?: string }).originalCmd;
|
||||
if (cmd) {
|
||||
const match = cmd.match(/file_(\d+)/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
return hashString(cmd);
|
||||
}
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
if (customSid === 'vod-series') {
|
||||
const originalId = (episode as { originalId?: string | number })
|
||||
.originalId;
|
||||
const numericId = Number(originalId);
|
||||
return Number.isNaN(numericId)
|
||||
? hashString(String(originalId))
|
||||
: numericId;
|
||||
}
|
||||
|
||||
const numericId = Number(episode.id);
|
||||
return Number.isNaN(numericId) ? hashString(String(episode.id)) : numericId;
|
||||
}
|
||||
|
||||
export function buildXtreamEpisodeDownloadRequest(options: {
|
||||
episode: XtreamSerieEpisode;
|
||||
context: XtreamEpisodeDownloadContext;
|
||||
playlistId: string;
|
||||
seriesId: number;
|
||||
seriesTitle: string;
|
||||
fallbackSeasonKey: string | undefined;
|
||||
posterUrl?: string;
|
||||
}): XtreamEpisodeDownloadRequest {
|
||||
const { episode, context, playlistId, seriesId, seriesTitle } = options;
|
||||
const serverUrl = context.serverUrl?.replace(/\/$/, '') || '';
|
||||
const username = context.username || '';
|
||||
const password = context.password || '';
|
||||
const extension = episode.container_extension || 'mp4';
|
||||
const seasonNumber =
|
||||
episode.season || Number(options.fallbackSeasonKey) || 1;
|
||||
const episodeNumber = episode.episode_num || 1;
|
||||
const title = `${seriesTitle || 'Series'} - S${String(seasonNumber).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}E${String(episodeNumber).padStart(2, '0')} - ${episode.title}`;
|
||||
|
||||
return {
|
||||
playlistId,
|
||||
xtreamId: Number(episode.id),
|
||||
contentType: 'episode',
|
||||
title,
|
||||
url: `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`,
|
||||
posterUrl: options.posterUrl,
|
||||
seriesXtreamId: seriesId,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Pure helpers for episode duration parsing and progress-text formatting. */
|
||||
|
||||
export function parseDuration(duration: string | number | undefined): number {
|
||||
if (!duration) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof duration === 'number') {
|
||||
return duration;
|
||||
}
|
||||
|
||||
const parts = duration.split(':').map(Number);
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
return Number(duration) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable in-progress text ("41:12 left" / elapsed time when the
|
||||
* duration is unknown). Returns null for watched or position-less episodes.
|
||||
*/
|
||||
export function formatEpisodePositionText(
|
||||
position: PlaybackPositionData | undefined
|
||||
): string | null {
|
||||
if (isPortalPlaybackWatched(position)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!position || !position.positionSeconds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let seconds = position.positionSeconds;
|
||||
let suffix = '';
|
||||
|
||||
if (position.durationSeconds && position.durationSeconds > 0) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
position.durationSeconds - position.positionSeconds
|
||||
);
|
||||
|
||||
if (remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seconds = remaining;
|
||||
suffix = ' left';
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
const formatted = [hours, minutes, secs]
|
||||
.map((value) => String(value).padStart(2, '0'))
|
||||
.filter((value, index) => (index === 0 ? value !== '00' : true))
|
||||
.join(':');
|
||||
return `${formatted}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildXtreamEpisodeDownloadRequest,
|
||||
getEpisodeDownloadId,
|
||||
hashString,
|
||||
isStalkerEpisode,
|
||||
} from './episode-download.util';
|
||||
import {
|
||||
formatEpisodePositionText,
|
||||
parseDuration,
|
||||
} from './episode-progress.util';
|
||||
|
||||
const episode = (overrides: Record<string, unknown>): XtreamSerieEpisode =>
|
||||
({ id: '10', episode_num: 1, title: 'Ep', ...overrides }) as never;
|
||||
|
||||
describe('episode-download.util', () => {
|
||||
it('detects stalker-mapped episodes', () => {
|
||||
expect(isStalkerEpisode(episode({ custom_sid: 'vod-series' }))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isStalkerEpisode(episode({ custom_sid: 'regular-series' }))
|
||||
).toBe(true);
|
||||
expect(isStalkerEpisode(episode({}))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves download ids per episode source', () => {
|
||||
expect(getEpisodeDownloadId(episode({}))).toBe(10);
|
||||
expect(
|
||||
getEpisodeDownloadId(
|
||||
episode({
|
||||
custom_sid: 'regular-series',
|
||||
originalCmd: '/media/file_777.mpg',
|
||||
})
|
||||
)
|
||||
).toBe(777);
|
||||
expect(
|
||||
getEpisodeDownloadId(
|
||||
episode({ custom_sid: 'vod-series', originalId: '42' })
|
||||
)
|
||||
).toBe(42);
|
||||
expect(
|
||||
getEpisodeDownloadId(
|
||||
episode({ custom_sid: 'vod-series', originalId: 'abc' })
|
||||
)
|
||||
).toBe(hashString('abc'));
|
||||
});
|
||||
|
||||
it('builds an Xtream episode download request', () => {
|
||||
const request = buildXtreamEpisodeDownloadRequest({
|
||||
episode: episode({
|
||||
id: '55',
|
||||
episode_num: 3,
|
||||
season: 2,
|
||||
container_extension: 'mkv',
|
||||
title: 'The One',
|
||||
}),
|
||||
context: {
|
||||
serverUrl: 'http://host/',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
},
|
||||
playlistId: 'pl-1',
|
||||
seriesId: 900,
|
||||
seriesTitle: 'Show',
|
||||
fallbackSeasonKey: '1',
|
||||
});
|
||||
|
||||
expect(request.url).toBe('http://host/series/u/p/55.mkv');
|
||||
expect(request.title).toBe('Show - S02E03 - The One');
|
||||
expect(request.seasonNumber).toBe(2);
|
||||
expect(request.seriesXtreamId).toBe(900);
|
||||
});
|
||||
});
|
||||
|
||||
describe('episode-progress.util', () => {
|
||||
it('parses duration strings', () => {
|
||||
expect(parseDuration('01:00:30')).toBe(3630);
|
||||
expect(parseDuration('45:12')).toBe(2712);
|
||||
expect(parseDuration(120)).toBe(120);
|
||||
expect(parseDuration(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('formats remaining time when duration is known', () => {
|
||||
expect(
|
||||
formatEpisodePositionText({
|
||||
contentXtreamId: 1,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 60,
|
||||
durationSeconds: 360,
|
||||
})
|
||||
).toBe('05:00 left');
|
||||
});
|
||||
|
||||
it('returns null for watched or missing positions', () => {
|
||||
expect(formatEpisodePositionText(undefined)).toBeNull();
|
||||
expect(
|
||||
formatEpisodePositionText({
|
||||
contentXtreamId: 1,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 350,
|
||||
durationSeconds: 360,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,27 @@
|
||||
<section class="seasons-section">
|
||||
<div class="section-header">
|
||||
<div class="section-header__left">
|
||||
<h3 class="section-title">Seasons and Episodes</h3>
|
||||
@if (selectedSeason) {
|
||||
<button class="back-btn" (click)="selectedSeason = undefined">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Back to seasons</span>
|
||||
</button>
|
||||
}
|
||||
<h3 class="section-title">
|
||||
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
|
||||
</h3>
|
||||
</div>
|
||||
@if (selectedSeason && !showSeasonEmptyState()) {
|
||||
@if (hasSeasons() && !showSeasonEmptyState()) {
|
||||
<div class="view-toggle">
|
||||
<mat-button-toggle-group
|
||||
[value]="viewMode()"
|
||||
(change)="setViewMode($event.value)"
|
||||
hideSingleSelectionIndicator
|
||||
>
|
||||
<mat-button-toggle value="grid" [matTooltip]="'Grid view'">
|
||||
<mat-button-toggle
|
||||
value="grid"
|
||||
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>grid_view</mat-icon>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle value="list" [matTooltip]="'List view'">
|
||||
<mat-button-toggle
|
||||
value="list"
|
||||
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>view_list</mat-icon>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
@@ -27,6 +29,19 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (hasSeasons()) {
|
||||
<app-season-tabs
|
||||
[seasonKeys]="sortedSeasonKeys()"
|
||||
[selectedSeason]="selectedSeason()"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonDescription]="selectedSeasonDescription()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="content-container">
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
@@ -37,42 +52,13 @@
|
||||
<div class="empty-state-panel__icon" aria-hidden="true">
|
||||
<mat-icon>video_library</mat-icon>
|
||||
</div>
|
||||
<h4 class="empty-state-panel__title">No episodes available</h4>
|
||||
<h4 class="empty-state-panel__title">
|
||||
{{ 'PORTALS.NO_EPISODES_AVAILABLE' | translate }}
|
||||
</h4>
|
||||
<p class="empty-state-panel__message">
|
||||
This series is listed by your provider, but no seasons or
|
||||
episodes are available to play.
|
||||
{{ 'PORTALS.NO_EPISODES_AVAILABLE_MESSAGE' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
} @else if (!selectedSeason) {
|
||||
<!-- Seasons Grid -->
|
||||
<div class="seasons-grid">
|
||||
@for (
|
||||
season of seasons() | keyvalue: compareSeasons;
|
||||
track $index
|
||||
) {
|
||||
<button
|
||||
class="season-card"
|
||||
(click)="selectSeason(season.key)"
|
||||
[class.season-card--has-episodes]="season.value.length"
|
||||
>
|
||||
<div class="season-card__number">{{ season.key }}</div>
|
||||
<div class="season-card__label">Season</div>
|
||||
@if (season.value.length) {
|
||||
<div class="season-card__episodes">
|
||||
{{ season.value.length }}
|
||||
{{
|
||||
season.value.length === 1
|
||||
? 'Episode'
|
||||
: 'Episodes'
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
<div class="season-card__arrow">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else if (showSeasonEmptyState()) {
|
||||
<div
|
||||
class="empty-state-panel empty-state-panel--season"
|
||||
@@ -81,20 +67,24 @@
|
||||
<div class="empty-state-panel__icon" aria-hidden="true">
|
||||
<mat-icon>video_library</mat-icon>
|
||||
</div>
|
||||
<h4 class="empty-state-panel__title">This season is empty</h4>
|
||||
<h4 class="empty-state-panel__title">
|
||||
{{ 'PORTALS.SEASON_EMPTY' | translate }}
|
||||
</h4>
|
||||
<p class="empty-state-panel__message">
|
||||
This season is listed by your provider, but no playable
|
||||
episodes are available.
|
||||
{{ 'PORTALS.SEASON_EMPTY_MESSAGE' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
@if (viewMode() === 'grid') {
|
||||
<!-- Episodes Grid View -->
|
||||
<div class="episodes-grid">
|
||||
@for (episode of seasons()[selectedSeason]; track $index) {
|
||||
@for (episode of selectedSeasonEpisodes(); track $index) {
|
||||
@let episodeInfo = getEpisodeInfo(episode);
|
||||
<article
|
||||
class="episode-card"
|
||||
[attr.data-episode-id]="
|
||||
getEpisodeContentId(episode)
|
||||
"
|
||||
(click)="selectEpisode(episode)"
|
||||
[class.episode-card--watched]="
|
||||
isEpisodeWatched(episode)
|
||||
@@ -108,6 +98,9 @@
|
||||
[class.episode-card--active-external]="
|
||||
isEpisodeActiveExternal(episode)
|
||||
"
|
||||
[class.episode-card--playing]="
|
||||
isEpisodePlayingInline(episode)
|
||||
"
|
||||
>
|
||||
<div class="episode-card__thumbnail">
|
||||
@if (episodeInfo?.movie_image) {
|
||||
@@ -221,7 +214,10 @@
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
isEpisodeActiveExternal(episode)
|
||||
isEpisodeActiveExternal(
|
||||
episode
|
||||
) ||
|
||||
isEpisodePlayingInline(episode)
|
||||
? 'equalizer'
|
||||
: 'play_arrow'
|
||||
}}</mat-icon>
|
||||
@@ -254,10 +250,13 @@
|
||||
} @else {
|
||||
<!-- Episodes List View -->
|
||||
<div class="episodes-list">
|
||||
@for (episode of seasons()[selectedSeason]; track $index) {
|
||||
@for (episode of selectedSeasonEpisodes(); track $index) {
|
||||
@let episodeInfo = getEpisodeInfo(episode);
|
||||
<article
|
||||
class="episode-list-item"
|
||||
[attr.data-episode-id]="
|
||||
getEpisodeContentId(episode)
|
||||
"
|
||||
(click)="selectEpisode(episode)"
|
||||
[class.episode-list-item--watched]="
|
||||
isEpisodeWatched(episode)
|
||||
@@ -271,6 +270,9 @@
|
||||
[class.episode-list-item--active-external]="
|
||||
isEpisodeActiveExternal(episode)
|
||||
"
|
||||
[class.episode-list-item--playing]="
|
||||
isEpisodePlayingInline(episode)
|
||||
"
|
||||
>
|
||||
<div class="episode-list-item__number">
|
||||
{{ episode.episode_num }}
|
||||
@@ -340,7 +342,8 @@
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
isEpisodeActiveExternal(episode)
|
||||
isEpisodeActiveExternal(episode) ||
|
||||
isEpisodePlayingInline(episode)
|
||||
? 'equalizer'
|
||||
: 'play_arrow'
|
||||
}}</mat-icon>
|
||||
|
||||
@@ -26,33 +26,6 @@
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
mat-button-toggle-group {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -170,140 +143,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Grid Layouts
|
||||
.seasons-grid,
|
||||
// Grid Layout
|
||||
.episodes-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.seasons-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.episodes-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
// Season Card
|
||||
.season-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
|
||||
.season-card__arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-ring {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.progress-ring__bg {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.1);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.progress-ring__fill {
|
||||
fill: none;
|
||||
stroke: var(--accent-color);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dasharray 0.5s ease-out;
|
||||
}
|
||||
|
||||
.season-card__number {
|
||||
position: absolute;
|
||||
font-size: 1rem !important;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
opacity: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 0.9375rem !important;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&__progress-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&__episodes {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
opacity: 0;
|
||||
transform: translateX(-5px);
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-muted);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&--has-episodes {
|
||||
/* background: rgba(var(--accent-color-rgb), 0.1);
|
||||
border-color: rgba(var(--accent-color-rgb), 0.3); */
|
||||
|
||||
.season-card__number {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* .season-card__label {
|
||||
color: var(--accent-color);
|
||||
} */
|
||||
}
|
||||
app-season-tabs {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// Episode Card
|
||||
@@ -352,6 +202,12 @@
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
|
||||
}
|
||||
|
||||
// Playing in the inline player
|
||||
&--playing {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 70%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
// Watched state
|
||||
&--watched {
|
||||
opacity: 0.7;
|
||||
@@ -757,6 +613,11 @@
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent);
|
||||
}
|
||||
|
||||
&--playing {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
// Always show actions on touch devices
|
||||
@media (hover: none) {
|
||||
.episode-list-item__actions {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
@@ -40,6 +40,7 @@ function createEpisode(
|
||||
describe('SeasonContainerComponent', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
let emittedSeasons: string[];
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>,
|
||||
@@ -68,6 +69,10 @@ describe('SeasonContainerComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
emittedSeasons = [];
|
||||
component.seasonSelected.subscribe((seasonKey) =>
|
||||
emittedSeasons.push(seasonKey)
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the series-level placeholder when no seasons are available', () => {
|
||||
@@ -78,12 +83,11 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'No episodes available'
|
||||
'PORTALS.NO_EPISODES_AVAILABLE'
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'This series is listed by your provider, but no seasons or episodes are available to play.'
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('.season-card')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-season-tabs')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the loading state instead of the placeholder while seasons are loading', () => {
|
||||
@@ -91,72 +95,206 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.loading-container mat-spinner')
|
||||
fixture.nativeElement.querySelector(
|
||||
'.loading-container mat-spinner'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the season-level placeholder when the selected season has no episodes', () => {
|
||||
setRequiredInputs({ '1': [] });
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.season-card').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'This season is empty'
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'This season is listed by your provider, but no playable episodes are available.'
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('.view-toggle')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders season cards and episode cards when episodes exist', () => {
|
||||
const episode = createEpisode();
|
||||
|
||||
it('auto-selects the first season, emits seasonSelected, and renders episodes without a click', () => {
|
||||
setRequiredInputs({
|
||||
'1': [episode],
|
||||
'1': [createEpisode()],
|
||||
'2': [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-card').length
|
||||
).toBe(2);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).toBeNull();
|
||||
|
||||
fixture.nativeElement.querySelector('.season-card').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(emittedSeasons).toEqual(['1']);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.episode-card').length
|
||||
).toBe(1);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).toBeNull();
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill').length
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('clears a stale selected season when the input seasons change', () => {
|
||||
const episode = createEpisode();
|
||||
|
||||
setRequiredInputs({ '1': [episode] });
|
||||
it('renders the season-level placeholder when the selected season has no episodes', () => {
|
||||
setRequiredInputs({ '1': [] });
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.season-card').click();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'PORTALS.SEASON_EMPTY'
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('.view-toggle')).toBeNull();
|
||||
});
|
||||
|
||||
it('switches season via tab click and emits the lazy-load hook once per season', () => {
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentRef.setInput('seasons', { '2': [episode] });
|
||||
const pills = fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
);
|
||||
(pills[1] as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason).toBeUndefined();
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(emittedSeasons).toEqual(['1', '2']);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.season-card__number')
|
||||
?.textContent
|
||||
).toContain('2');
|
||||
fixture.nativeElement.querySelector('[data-episode-id="201"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the inline-playing episode season on auto-select', () => {
|
||||
fixture.componentRef.setInput('playingEpisodeId', 201);
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(emittedSeasons).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('prefers the most recently updated in-progress episode season over the first season', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[
|
||||
201,
|
||||
{
|
||||
contentXtreamId: 201,
|
||||
contentType: 'episode' as const,
|
||||
positionSeconds: 500,
|
||||
durationSeconds: 2700,
|
||||
updatedAt: '2026-07-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
});
|
||||
|
||||
it('re-resolves a stale selection when the season set changes', () => {
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
|
||||
fixture.componentRef.setInput('seasons', {
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(emittedSeasons).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('keeps the user selection when episode data mutates without key changes', () => {
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const pills = fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
);
|
||||
(pills[1] as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
|
||||
// Same keys, new object (e.g. TMDB overlay rebuild) — selection sticks
|
||||
fixture.componentRef.setInput('seasons', {
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(emittedSeasons).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('uses a dropdown selector when there are more than six seasons', () => {
|
||||
const seasons: Record<string, XtreamSerieEpisode[]> = {};
|
||||
for (let index = 1; index <= 7; index++) {
|
||||
seasons[String(index)] = [
|
||||
createEpisode({ id: String(100 + index), season: index }),
|
||||
];
|
||||
}
|
||||
setRequiredInputs(seasons);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-dropdown"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill').length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('shows the back-to-playing chip only when the playing episode is in another season', () => {
|
||||
fixture.componentRef.setInput('playingEpisodeId', 101);
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="back-to-playing"]'
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
const pills = fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
);
|
||||
(pills[1] as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="back-to-playing"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('marks the inline-playing episode card', () => {
|
||||
fixture.componentRef.setInput('playingEpisodeId', 101);
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.episode-card--playing')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the season description for the selected season', () => {
|
||||
fixture.componentRef.setInput('seasonDescriptions', {
|
||||
'1': 'Season one overview',
|
||||
});
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-description"]'
|
||||
)?.textContent
|
||||
).toContain('Season one overview');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DoCheck,
|
||||
ElementRef,
|
||||
OnInit,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
@@ -29,6 +31,16 @@ import {
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
|
||||
import { WatchedBadgeComponent } from '../watched-badge/watched-badge.component';
|
||||
import {
|
||||
buildXtreamEpisodeDownloadRequest,
|
||||
getEpisodeDownloadId,
|
||||
isStalkerEpisode,
|
||||
} from './episode-download.util';
|
||||
import {
|
||||
formatEpisodePositionText,
|
||||
parseDuration,
|
||||
} from './episode-progress.util';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
|
||||
type EpisodeViewMode = 'grid' | 'list';
|
||||
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
@@ -44,47 +56,28 @@ export interface SeasonContainerPlaybackToggleRequest {
|
||||
nextPosition: PlaybackPositionData | null;
|
||||
}
|
||||
|
||||
function parseDuration(duration: string | number | undefined): number {
|
||||
if (!duration) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof duration === 'number') {
|
||||
return duration;
|
||||
}
|
||||
|
||||
const parts = duration.split(':').map(Number);
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
return Number(duration) || 0;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
KeyValuePipe,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
ProgressCapsuleComponent,
|
||||
SeasonTabsComponent,
|
||||
TranslateModule,
|
||||
WatchedBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
export class SeasonContainerComponent implements OnInit {
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly logger = createLogger('SeasonContainer');
|
||||
private previousSeasonKeysSignature = '';
|
||||
private lastEmittedSeason: string | undefined;
|
||||
|
||||
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
|
||||
readonly seriesId = input.required<number>();
|
||||
@@ -97,7 +90,12 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
readonly xtreamDownloadContext =
|
||||
input<SeasonContainerXtreamDownloadContext | null>(null);
|
||||
readonly openingEpisodeId = input<number | null>(null);
|
||||
/** Episode currently playing in an EXTERNAL player session. */
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
/** Episode currently playing in the inline player. */
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
/** Per-season descriptions (TMDB/provider), keyed by season key. */
|
||||
readonly seasonDescriptions = input<Record<string, string> | null>(null);
|
||||
|
||||
readonly episodeClicked = output<XtreamSerieEpisode>();
|
||||
readonly episodeDownloadRequested = output<XtreamSerieEpisode>();
|
||||
@@ -107,7 +105,81 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
readonly isElectron = this.downloadsService.isAvailable;
|
||||
readonly viewMode = signal<EpisodeViewMode>('grid');
|
||||
|
||||
selectedSeason: string | undefined;
|
||||
readonly sortedSeasonKeys = computed(() =>
|
||||
Object.keys(this.seasons()).sort((a, b) => Number(a) - Number(b))
|
||||
);
|
||||
|
||||
readonly episodeCounts = computed(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
counts[key] = episodes?.length ?? 0;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
readonly watchedCounts = computed(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
counts[key] = (episodes ?? []).filter((episode) =>
|
||||
this.isEpisodeWatched(episode)
|
||||
).length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
/** Season key of the inline-playing episode, if it is in the loaded set. */
|
||||
readonly playingSeasonKey = computed(() =>
|
||||
this.findSeasonOfEpisode(this.playingEpisodeId())
|
||||
);
|
||||
|
||||
/**
|
||||
* Selected season. Auto-resolves when the season key set changes or when
|
||||
* playback positions first arrive (priority: inline-playing episode's
|
||||
* season → most recently updated in-progress episode's season → first
|
||||
* season); user tab clicks write to it and stick until the auto-select
|
||||
* key changes. Ongoing position saves do not reset the selection — only
|
||||
* the empty→loaded transition of the positions map does.
|
||||
*/
|
||||
readonly selectedSeason = signal<string | undefined>(undefined);
|
||||
|
||||
private readonly autoSelectKey = computed(
|
||||
() =>
|
||||
`${this.sortedSeasonKeys().join('|')}::${
|
||||
this.playbackPositions().size > 0 ? '1' : '0'
|
||||
}`
|
||||
);
|
||||
private lastAutoSelectKey: string | null = null;
|
||||
|
||||
readonly selectedSeasonDescription = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
if (!selected) {
|
||||
return null;
|
||||
}
|
||||
return this.seasonDescriptions()?.[selected] ?? null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const key = this.autoSelectKey();
|
||||
if (key === this.lastAutoSelectKey) {
|
||||
return;
|
||||
}
|
||||
this.lastAutoSelectKey = key;
|
||||
this.selectedSeason.set(
|
||||
untracked(() => this.resolveAutoSeason())
|
||||
);
|
||||
});
|
||||
|
||||
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
|
||||
// with tabs there is no initial "pick a season" click anymore.
|
||||
effect(() => {
|
||||
const selected = this.selectedSeason();
|
||||
if (selected && selected !== this.lastEmittedSeason) {
|
||||
this.lastEmittedSeason = selected;
|
||||
this.seasonSelected.emit(selected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const savedMode = localStorage.getItem(
|
||||
@@ -116,11 +188,6 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
if (savedMode === 'grid' || savedMode === 'list') {
|
||||
this.viewMode.set(savedMode);
|
||||
}
|
||||
this.previousSeasonKeysSignature = this.getSeasonKeys().join('|');
|
||||
}
|
||||
|
||||
ngDoCheck(): void {
|
||||
this.syncSelectedSeason();
|
||||
}
|
||||
|
||||
setViewMode(mode: EpisodeViewMode) {
|
||||
@@ -128,25 +195,44 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
localStorage.setItem(EPISODE_VIEW_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
compareSeasons(a: { key: string }, b: { key: string }): number {
|
||||
return Number(a.key) - Number(b.key);
|
||||
}
|
||||
|
||||
hasSeasons(): boolean {
|
||||
return this.getSeasonKeys().length > 0;
|
||||
return this.sortedSeasonKeys().length > 0;
|
||||
}
|
||||
|
||||
showSeriesEmptyState(): boolean {
|
||||
return !this.selectedSeason && !this.hasSeasons();
|
||||
return !this.hasSeasons();
|
||||
}
|
||||
|
||||
showSeasonEmptyState(): boolean {
|
||||
return Boolean(this.selectedSeason) && !this.selectedSeasonHasEpisodes();
|
||||
const selected = this.selectedSeason();
|
||||
return (
|
||||
Boolean(selected) &&
|
||||
(this.seasons()[selected as string]?.length ?? 0) === 0
|
||||
);
|
||||
}
|
||||
|
||||
selectedSeasonEpisodes(): XtreamSerieEpisode[] {
|
||||
const selected = this.selectedSeason();
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
}
|
||||
|
||||
selectSeason(seasonKey: string) {
|
||||
this.selectedSeason = seasonKey;
|
||||
this.seasonSelected.emit(seasonKey);
|
||||
this.selectedSeason.set(seasonKey);
|
||||
}
|
||||
|
||||
scrollToPlayingEpisode(): void {
|
||||
const playingSeason = this.playingSeasonKey();
|
||||
if (!playingSeason) {
|
||||
return;
|
||||
}
|
||||
this.selectedSeason.set(playingSeason);
|
||||
// Wait a tick so the episode list re-renders for the new season.
|
||||
setTimeout(() => {
|
||||
const target = this.host.nativeElement.querySelector(
|
||||
`[data-episode-id="${this.playingEpisodeId()}"]`
|
||||
);
|
||||
target?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
selectEpisode(episode: XtreamSerieEpisode) {
|
||||
@@ -181,7 +267,9 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: this.seriesId(),
|
||||
seasonNumber: Number(episode.season || this.selectedSeason || 1),
|
||||
seasonNumber: Number(
|
||||
episode.season || this.selectedSeason() || 1
|
||||
),
|
||||
episodeNumber: Number(episode.episode_num || 1),
|
||||
positionSeconds: duration,
|
||||
durationSeconds: duration,
|
||||
@@ -216,70 +304,28 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
return this.activeEpisodeId() === this.getEpisodeContentId(episode);
|
||||
}
|
||||
|
||||
isEpisodePlayingInline(episode: XtreamSerieEpisode): boolean {
|
||||
return this.playingEpisodeId() === this.getEpisodeContentId(episode);
|
||||
}
|
||||
|
||||
getEpisodeProgress(episode: XtreamSerieEpisode): number {
|
||||
return getPortalPlaybackProgressPercent(this.getEpisodePosition(episode));
|
||||
return getPortalPlaybackProgressPercent(
|
||||
this.getEpisodePosition(episode)
|
||||
);
|
||||
}
|
||||
|
||||
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
|
||||
if (this.isEpisodeWatched(episode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = this.getEpisodePosition(episode);
|
||||
if (!position || !position.positionSeconds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let seconds = position.positionSeconds;
|
||||
let suffix = '';
|
||||
|
||||
if (position.durationSeconds && position.durationSeconds > 0) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
position.durationSeconds - position.positionSeconds
|
||||
);
|
||||
|
||||
if (remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seconds = remaining;
|
||||
suffix = ' left';
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
const formatted = [hours, minutes, secs]
|
||||
.map((value) => String(value).padStart(2, '0'))
|
||||
.filter((value, index) => (index === 0 ? value !== '00' : true))
|
||||
.join(':');
|
||||
return `${formatted}${suffix}`;
|
||||
return formatEpisodePositionText(this.getEpisodePosition(episode));
|
||||
}
|
||||
|
||||
getSeasonWatchedCount(seasonKey: string): number {
|
||||
const episodes = this.seasons()[seasonKey];
|
||||
if (!episodes) {
|
||||
return 0;
|
||||
}
|
||||
return episodes.filter((episode) => this.isEpisodeWatched(episode)).length;
|
||||
}
|
||||
|
||||
getSeasonProgressDash(seasonKey: string): string {
|
||||
const episodes = this.seasons()[seasonKey];
|
||||
if (!episodes || episodes.length === 0) {
|
||||
return '0, 100';
|
||||
}
|
||||
|
||||
const watched = this.getSeasonWatchedCount(seasonKey);
|
||||
const percent = (watched / episodes.length) * 100;
|
||||
return `${percent}, 100`;
|
||||
getEpisodeContentId(episode: XtreamSerieEpisode): number {
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.isStalkerEpisode(episode)) {
|
||||
if (isStalkerEpisode(episode)) {
|
||||
this.episodeDownloadRequested.emit(episode);
|
||||
return;
|
||||
}
|
||||
@@ -289,30 +335,17 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverUrl = xtreamDownload.serverUrl?.replace(/\/$/, '') || '';
|
||||
const username = xtreamDownload.username || '';
|
||||
const password = xtreamDownload.password || '';
|
||||
const extension = episode.container_extension || 'mp4';
|
||||
const url = `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`;
|
||||
const episodeInfo = this.getEpisodeInfo(episode);
|
||||
const posterUrl = episodeInfo?.movie_image;
|
||||
const seasonNum = episode.season || Number(this.selectedSeason) || 1;
|
||||
const episodeNum = episode.episode_num || 1;
|
||||
const episodeTitle = `${this.seriesTitle() || 'Series'} - S${String(
|
||||
seasonNum
|
||||
).padStart(2, '0')}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
|
||||
|
||||
await this.downloadsService.startDownload({
|
||||
playlistId: this.playlistId(),
|
||||
xtreamId: Number(episode.id),
|
||||
contentType: 'episode',
|
||||
title: episodeTitle,
|
||||
url,
|
||||
posterUrl,
|
||||
seriesXtreamId: this.seriesId(),
|
||||
seasonNumber: seasonNum,
|
||||
episodeNumber: episodeNum,
|
||||
});
|
||||
await this.downloadsService.startDownload(
|
||||
buildXtreamEpisodeDownloadRequest({
|
||||
episode,
|
||||
context: xtreamDownload,
|
||||
playlistId: this.playlistId(),
|
||||
seriesId: this.seriesId(),
|
||||
seriesTitle: this.seriesTitle(),
|
||||
fallbackSeasonKey: this.selectedSeason(),
|
||||
posterUrl: this.getEpisodeInfo(episode)?.movie_image,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
|
||||
@@ -322,7 +355,7 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isDownloaded(
|
||||
this.getEpisodeDownloadId(episode),
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
@@ -335,7 +368,7 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isDownloading(
|
||||
this.getEpisodeDownloadId(episode),
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
@@ -348,7 +381,7 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
}
|
||||
|
||||
const filePath = this.downloadsService.getDownloadedFilePath(
|
||||
this.getEpisodeDownloadId(episode),
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
@@ -358,89 +391,59 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
}
|
||||
}
|
||||
|
||||
private isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
|
||||
return (
|
||||
(episode as { custom_sid?: string }).custom_sid === 'vod-series' ||
|
||||
(episode as { custom_sid?: string }).custom_sid === 'regular-series'
|
||||
);
|
||||
}
|
||||
|
||||
private getEpisodeContentId(episode: XtreamSerieEpisode): number {
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
|
||||
const customSid = (episode as { custom_sid?: string }).custom_sid;
|
||||
|
||||
if (customSid === 'regular-series') {
|
||||
const cmd = (episode as { originalCmd?: string }).originalCmd;
|
||||
if (cmd) {
|
||||
const match = cmd.match(/file_(\d+)/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
return this.hashString(cmd);
|
||||
}
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
if (customSid === 'vod-series') {
|
||||
const originalId = (episode as { originalId?: string | number })
|
||||
.originalId;
|
||||
const numericId = Number(originalId);
|
||||
return Number.isNaN(numericId)
|
||||
? this.hashString(String(originalId))
|
||||
: numericId;
|
||||
}
|
||||
|
||||
const numericId = Number(episode.id);
|
||||
return Number.isNaN(numericId)
|
||||
? this.hashString(String(episode.id))
|
||||
: numericId;
|
||||
}
|
||||
|
||||
private getEpisodePosition(
|
||||
episode: XtreamSerieEpisode
|
||||
): PlaybackPositionData | undefined {
|
||||
return this.playbackPositions().get(this.getEpisodeContentId(episode));
|
||||
}
|
||||
|
||||
private selectedSeasonHasEpisodes(): boolean {
|
||||
if (!this.selectedSeason) {
|
||||
return false;
|
||||
private findSeasonOfEpisode(episodeId: number | null): string | null {
|
||||
if (episodeId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (this.seasons()[this.selectedSeason]?.length ?? 0) > 0;
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
if (
|
||||
episodes?.some(
|
||||
(episode) => this.getEpisodeContentId(episode) === episodeId
|
||||
)
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getSeasonKeys(): string[] {
|
||||
return Object.keys(this.seasons());
|
||||
}
|
||||
|
||||
private hasSeasonKey(seasonKey: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(this.seasons(), seasonKey);
|
||||
}
|
||||
|
||||
private syncSelectedSeason(): void {
|
||||
const seasonKeysSignature = this.getSeasonKeys().join('|');
|
||||
if (seasonKeysSignature === this.previousSeasonKeysSignature) {
|
||||
return;
|
||||
private resolveAutoSeason(): string | undefined {
|
||||
const keys = this.sortedSeasonKeys();
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.previousSeasonKeysSignature = seasonKeysSignature;
|
||||
|
||||
if (this.selectedSeason && !this.hasSeasonKey(this.selectedSeason)) {
|
||||
this.selectedSeason = undefined;
|
||||
const playingSeason = this.playingSeasonKey();
|
||||
if (playingSeason) {
|
||||
return playingSeason;
|
||||
}
|
||||
|
||||
const resumeSeason = this.findMostRecentInProgressSeason();
|
||||
return resumeSeason ?? keys[0];
|
||||
}
|
||||
|
||||
private hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < str.length; index++) {
|
||||
const char = str.charCodeAt(index);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash &= hash;
|
||||
private findMostRecentInProgressSeason(): string | null {
|
||||
let bestSeason: string | null = null;
|
||||
let bestUpdatedAt = '';
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
for (const episode of episodes ?? []) {
|
||||
const position = this.getEpisodePosition(episode);
|
||||
if (!isPortalPlaybackInProgress(position)) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = position?.updatedAt ?? '';
|
||||
if (updatedAt >= bestUpdatedAt) {
|
||||
bestUpdatedAt = updatedAt;
|
||||
bestSeason = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.abs(hash);
|
||||
return bestSeason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<div class="season-tabs" role="tablist">
|
||||
@if (useDropdown()) {
|
||||
<button
|
||||
class="season-tabs__dropdown"
|
||||
data-testid="season-dropdown"
|
||||
[matMenuTriggerFor]="seasonMenu"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: selectedSeason() ?? '—' }
|
||||
}}
|
||||
</span>
|
||||
<mat-icon>arrow_drop_down</mat-icon>
|
||||
</button>
|
||||
<mat-menu #seasonMenu="matMenu">
|
||||
@for (seasonKey of seasonKeys(); track seasonKey) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[class.season-tabs__menu-item--selected]="
|
||||
seasonKey === selectedSeason()
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: seasonKey }
|
||||
}}
|
||||
</span>
|
||||
<span class="season-tabs__menu-count">
|
||||
{{ episodeCounts()[seasonKey] ?? 0 }}
|
||||
</span>
|
||||
@if (isSeasonCompleted(seasonKey)) {
|
||||
<mat-icon class="season-tabs__done">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
} @else {
|
||||
@for (seasonKey of seasonKeys(); track seasonKey) {
|
||||
<button
|
||||
class="season-tabs__pill"
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="seasonKey === selectedSeason()"
|
||||
[class.season-tabs__pill--active]="
|
||||
seasonKey === selectedSeason()
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
{{ 'PORTALS.SEASON_TAB' | translate: { number: seasonKey } }}
|
||||
@if (isSeasonCompleted(seasonKey)) {
|
||||
<mat-icon class="season-tabs__done">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (showBackToPlaying()) {
|
||||
<button
|
||||
class="season-tabs__back-to-playing"
|
||||
data-testid="back-to-playing"
|
||||
type="button"
|
||||
(click)="backToPlayingRequested.emit()"
|
||||
>
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
{{ 'PORTALS.BACK_TO_PLAYING_EPISODE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (seasonDescription(); as description) {
|
||||
<p class="season-tabs__description" data-testid="season-description">
|
||||
{{ description }}
|
||||
</p>
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.season-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.1));
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 600;
|
||||
outline: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
}
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 10px 8px 16px;
|
||||
border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__menu-count {
|
||||
margin-left: 12px;
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.5));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__done {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--accent-success, #4ade80);
|
||||
}
|
||||
|
||||
&__back-to-playing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #fff);
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-item--selected {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.season-tabs__description {
|
||||
margin: 10px 4px 0;
|
||||
max-width: 760px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
/** Above this count the pill row becomes a dropdown selector. */
|
||||
const MAX_SEASON_PILLS = 6;
|
||||
|
||||
/**
|
||||
* Season selector for the season container: a pill row ("Season 1 · 2 · 3")
|
||||
* for up to 6 seasons, a dropdown beyond that. Shows an optional season
|
||||
* description under the tabs and a "back to playing episode" chip when the
|
||||
* currently playing episode belongs to a different season.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-season-tabs',
|
||||
templateUrl: './season-tabs.component.html',
|
||||
styleUrls: ['./season-tabs.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon, MatMenuModule, TranslateModule],
|
||||
})
|
||||
export class SeasonTabsComponent {
|
||||
/** Season keys, already sorted in display order. */
|
||||
readonly seasonKeys = input.required<string[]>();
|
||||
readonly selectedSeason = input<string | undefined>(undefined);
|
||||
readonly episodeCounts = input<Record<string, number>>({});
|
||||
readonly watchedCounts = input<Record<string, number>>({});
|
||||
/** Description of the selected season (TMDB/provider), if available. */
|
||||
readonly seasonDescription = input<string | null>(null);
|
||||
/** Season key of the episode currently playing inline, if any. */
|
||||
readonly playingSeasonKey = input<string | null>(null);
|
||||
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly backToPlayingRequested = output<void>();
|
||||
|
||||
readonly useDropdown = computed(
|
||||
() => this.seasonKeys().length > MAX_SEASON_PILLS
|
||||
);
|
||||
|
||||
readonly showBackToPlaying = computed(() => {
|
||||
const playing = this.playingSeasonKey();
|
||||
return playing !== null && playing !== this.selectedSeason();
|
||||
});
|
||||
|
||||
isSeasonCompleted(seasonKey: string): boolean {
|
||||
const total = this.episodeCounts()[seasonKey] ?? 0;
|
||||
return total > 0 && (this.watchedCounts()[seasonKey] ?? 0) >= total;
|
||||
}
|
||||
|
||||
selectSeason(seasonKey: string): void {
|
||||
if (seasonKey !== this.selectedSeason()) {
|
||||
this.seasonSelected.emit(seasonKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user