mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
* 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
516 lines
27 KiB
HTML
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>
|