Files
iptvnator/libs/ui/components/src/lib/season-container/season-container.component.html
T
4gray 760099358b feat(downloads): redesign download manager (#1313)
* docs(downloads): specify manager MVP redesign

* docs(downloads): plan manager MVP implementation

* docs(downloads): tighten manager validation plan

* fix(downloads): keep renderer download state global

* fix(downloads): make active count accessible

* feat(downloads): derive queue and library view model

* test(downloads): close view model coverage gaps

* fix(downloads): stabilize malformed view model data

* refactor(downloads): isolate library navigation

* fix(downloads): report library navigation failures

* feat(downloads): add ready-to-watch library

* feat(downloads): add active download queue

* feat(downloads): finish manager MVP

* docs(downloads): clarify detail-first offline behavior

* docs(downloads): plan detail navigation follow-up

* fix(downloads): open completed movies in details

* test(downloads): cover pending series navigation

* fix(downloads): honor the global cover size

* fix(downloads): prefer local playback in shared details

* fix(downloads): preserve external launch priority

* fix(downloads): prefer local playback in Xtream details

* test(downloads): cover offline detail journey

* docs(downloads): document offline detail behavior

* docs(downloads): format detail navigation plan

* fix(downloads): open Stalker items in provider details

* docs(downloads): clarify Stalker navigation fallback

* fix(xtream): isolate reused detail identities

* fix(xtream): ignore stale VOD positions

* fix(downloads): keep offline Xtream playback available

* docs(downloads): clarify provider playback availability

* docs(downloads): design missing-file recovery

* docs(downloads): plan missing-file recovery

* feat(downloads): derive completed file availability

* feat(downloads): recover missing completed files

* feat(downloads): refresh missing local files

* feat(downloads): separate missing files from ready media

* feat(downloads): surface missing files for recovery

* refactor(downloads): simplify ready cards

* test(downloads): cover missing-file and series journeys

* feat(downloads): finish missing-file recovery

* docs(downloads): design offline detail views

* docs(downloads): plan offline detail views

* feat(downloads): persist offline metadata snapshots

* fix(downloads): complete metadata snapshot bridge contract

* feat(downloads): manage offline metadata snapshots

* fix(downloads): harden metadata snapshot updates

* fix(downloads): restrict snapshot artwork

* fix(downloads): guard restart artwork URL

* fix(downloads): refine artwork URL checks

* feat(downloads): expose offline metadata updates

* fix(downloads): keep metadata service change focused

* fix(downloads): preserve metadata error conventions

* feat(downloads): derive offline detail content

* fix(downloads): preserve unknown episode coordinates

* feat(downloads): add focused offline detail routes

* fix(downloads): ignore fragments in shell route state

* fix(downloads): normalize fragments before queries

* feat(downloads): open ready cards in offline details

* fix(downloads): use native disabled card styles

* feat(downloads): enrich offline detail metadata

* fix(downloads): harden offline metadata resolution

* fix(downloads): preserve stalker provider titles

* fix(downloads): distinguish stalker metadata seeds

* fix(downloads): stabilize offline metadata refresh

* fix(downloads): throttle sparse metadata refreshes

* fix(downloads): type metadata language settings

* feat(downloads): render offline movie and series details

* fix(downloads): harden offline detail interactions

* fix(downloads): close offline detail edge cases

* feat(downloads): hand off to provider-only details

* fix(downloads): preserve stalker provider handoff

* feat(downloads): capture metadata at download time

* fix(downloads): preserve snapshot source semantics

* fix(downloads): preserve episode snapshot identity

* docs(downloads): document offline details flow

* docs(downloads): clarify stalker provider fallback

* test(downloads): cover offline detail journeys

* test(downloads): stabilize offline detail selectors

* style(downloads): format changed files

* docs(downloads): clean design spec formatting

* fix(downloads): preserve offline library ownership

* test(downloads): fix Windows workspace navigation

* test(database): preserve Electron tsconfig resolution

* perf(downloads): avoid blocking file availability probes
2026-08-01 18:09:31 +02:00

516 lines
27 KiB
HTML

<section class="seasons-section">
<div class="section-header">
<div class="section-header__left">
<h3 class="section-title">
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
</h3>
</div>
@if (hasSeasons() && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<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]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</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">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else if (showSeriesEmptyState()) {
<div class="empty-state-panel" role="status">
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
<h4 class="empty-state-panel__title">
{{ 'PORTALS.NO_EPISODES_AVAILABLE' | translate }}
</h4>
<p class="empty-state-panel__message">
{{ 'PORTALS.NO_EPISODES_AVAILABLE_MESSAGE' | translate }}
</p>
</div>
} @else if (showSeasonEmptyState()) {
<div
class="empty-state-panel empty-state-panel--season"
role="status"
>
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
<h4 class="empty-state-panel__title">
{{ 'PORTALS.SEASON_EMPTY' | translate }}
</h4>
<p class="empty-state-panel__message">
{{ 'PORTALS.SEASON_EMPTY_MESSAGE' | translate }}
</p>
</div>
} @else {
@if (viewMode() === 'grid') {
<!-- Episodes Grid View -->
<div class="episodes-grid">
@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)
"
[class.episode-card--in-progress]="
isEpisodeInProgress(episode)
"
[class.episode-card--launching]="
isEpisodeLaunching(episode)
"
[class.episode-card--active-external]="
isEpisodeActiveExternal(episode)
"
[class.episode-card--playing]="
isEpisodePlayingInline(episode)
"
>
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
<img
[src]="episodeInfo?.movie_image ?? ''"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="Episode thumbnail"
loading="lazy"
/>
} @else {
<div class="episode-card__placeholder">
<mat-icon>play_circle_outline</mat-icon>
</div>
}
@if (getEpisodeProgress(episode) > 0) {
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (
getEpisodePositionText(episode);
as time
) {
<div
class="episode-card__position-text"
>
{{ time }}
</div>
}
}
<button
mat-icon-button
class="episode-card__watched-toggle"
[class.episode-card__watched-toggle--watched]="
isEpisodeWatched(episode)
"
data-testid="episode-watched-toggle"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED'
) | translate
"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
<div class="episode-card__actions">
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="
playFromLocal(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="download-btn download-btn--paused"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME'
| translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (
isEpisodeDownloading(episode)
) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="download-btn"
(click)="
downloadEpisode(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD'
| translate
"
>
<mat-icon>download</mat-icon>
</button>
}
}
</div>
<div class="episode-card__overlay">
<div class="episode-card__play">
@if (isEpisodeLaunching(episode)) {
<mat-spinner
diameter="26"
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(
episode
) ||
isEpisodePlayingInline(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
}
</div>
</div>
<div class="episode-card__number">
{{ episode.episode_num }}
</div>
</div>
<div class="episode-card__info">
<div class="episode-card__title-row">
<h4 class="episode-card__title">
{{ episode.episode_num }}.
{{ episode.title }}
</h4>
@if (episodeInfo?.plot) {
<button
mat-icon-button
class="episode-card__info-btn"
data-testid="episode-info-button"
(click)="
openEpisodeInfo($event, episode)
"
[matTooltip]="
'PORTALS.EPISODE_INFO'
| translate
"
>
<mat-icon>info_outline</mat-icon>
</button>
}
</div>
@if (episodeInfo?.plot) {
<p class="episode-card__plot">
{{ episodeInfo?.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo?.duration }}
</span>
}
</div>
</article>
}
</div>
} @else {
<!-- Episodes List View -->
<div class="episodes-list">
@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)
"
[class.episode-list-item--in-progress]="
isEpisodeInProgress(episode)
"
[class.episode-list-item--launching]="
isEpisodeLaunching(episode)
"
[class.episode-list-item--active-external]="
isEpisodeActiveExternal(episode)
"
[class.episode-list-item--playing]="
isEpisodePlayingInline(episode)
"
>
@if (listThumbnailsEnabled()) {
<div class="episode-list-item__thumb">
@if (episodeInfo?.movie_image) {
<img
[src]="
episodeInfo?.movie_image ?? ''
"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="Episode thumbnail"
loading="lazy"
/>
} @else {
<mat-icon
class="episode-list-item__thumb-placeholder"
>play_circle_outline</mat-icon
>
}
<span
class="episode-list-item__thumb-number"
>{{ episode.episode_num }}</span
>
</div>
} @else {
<div class="episode-list-item__number">
{{ episode.episode_num }}
</div>
}
<div class="episode-list-item__content">
<div class="episode-list-item__header">
<h4 class="episode-list-item__title">
{{ episode.title }}
@if (isEpisodeWatched(episode)) {
<mat-icon class="watched-icon"
>check_circle</mat-icon
>
}
</h4>
<div class="episode-list-item__meta">
@if (episodeInfo?.duration) {
<span
class="episode-list-item__duration"
>
{{ episodeInfo?.duration }}
</span>
}
@if (
getEpisodePositionText(episode);
as time
) {
<span
class="episode-list-item__progress-text"
>
{{ time }}
</span>
}
</div>
</div>
@if (episodeInfo?.plot) {
<p class="episode-list-item__description">
{{ episodeInfo?.plot }}
</p>
}
@if (getEpisodeProgress(episode) > 0) {
<div
class="episode-list-item__progress-bar"
>
<div
class="episode-list-item__progress-fill"
[style.width.%]="
getEpisodeProgress(episode)
"
></div>
</div>
}
</div>
<div class="episode-list-item__actions">
<button
mat-icon-button
class="action-btn play-btn"
(click)="
selectEpisode(episode);
$event.stopPropagation()
"
[matTooltip]="'Play'"
[disabled]="isEpisodeLaunching(episode)"
>
@if (isEpisodeLaunching(episode)) {
<mat-spinner
diameter="18"
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode) ||
isEpisodePlayingInline(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
}
</button>
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="action-btn action-btn--success"
(click)="
playFromLocal($event, episode)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="action-btn"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="action-btn"
disabled
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="action-btn"
(click)="
downloadEpisode($event, episode)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD' | translate
"
>
<mat-icon>download</mat-icon>
</button>
}
}
@if (episodeInfo?.plot) {
<button
mat-icon-button
class="action-btn"
data-testid="episode-info-button"
(click)="
openEpisodeInfo($event, episode)
"
[matTooltip]="
'PORTALS.EPISODE_INFO' | translate
"
>
<mat-icon>info_outline</mat-icon>
</button>
}
<button
mat-icon-button
class="action-btn"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED'
) | translate
"
[class.action-btn--success]="
isEpisodeWatched(episode)
"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
</div>
</article>
}
</div>
}
}
</div>
</section>