feat(portals): cinematic series details with the episodes grid in the first screen

Series pages take the same hero as movies: the provider's season marker
moves out of the title into a "Season N" chip, the status chip is localized,
the primary reads Continue / "S02E03 · 18m left" or Play / the next episode,
Trailer opens the modal, and favorites plus a "…" menu complete the row.
The menu carries mark season/series watched, reset progress, open in
external player, copy the next episode's URL, download season, show in
category and hide from Continue Watching — rows a provider cannot serve
are left out. The season container's header is "Episodes" with the season
pills and count; the season actions it used to show live in the menu;
episode cards are flat with a watched bar on the artwork, the plot and
"42 min · 18m left". Cast & crew and Similar follow the episodes. The
Electron and web E2E flows and the season container specs drive the new
menu and presenters; legacy detail styles are removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-02 08:49:36 +02:00
1 parent 075c056ba1
commit c2f5d67120
42 files changed
+1969 -1893

No files matched your search

+1
View File
@@ -46,3 +46,4 @@ export * from './lib/detail-ui/trailer-dialog.component';
export * from './lib/detail-ui/trailer-dialog.service';
export * from './lib/detail-ui/vod-more-menu.component';
export * from './lib/detail-ui/vod-more-menu.model';
export * from './lib/detail-ui/series-hero.state';
@@ -9,8 +9,6 @@
// light theme gets a light scrim with dark text and the dark theme the
// reverse, with the same geometry.
@use '../../../../styles/detail-view-actions' as detail-actions;
:host {
--poster-width: 190px;
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);
@@ -107,7 +105,7 @@
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 80px 32px 28px;
padding: 72px 32px 24px;
// Reserved by the detail shell while its sticky Back control shows.
padding-inline-start: max(32px, var(--detail-back-lane, 0px));
box-sizing: border-box;
@@ -245,43 +243,6 @@
}
}
// Legacy `.details__tag` projected by hosts not yet on `app-meta-chip`.
::ng-deep .details__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
background: var(--tag-bg);
border: 0;
border-radius: 999px;
color: var(--text-primary);
&--rating {
color: var(--app-rating-color, #ffcf5c);
font-weight: 600;
}
&--clickable {
appearance: none;
cursor: pointer;
font-family: inherit;
letter-spacing: inherit;
transition: background 0.15s ease;
&:hover {
background: var(--tag-border);
}
&:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
}
}
&__description {
margin: 0;
font-size: 13.5px;
@@ -408,31 +369,6 @@
&:empty {
display: none;
}
// Legacy projected buttons (series hosts until they move to the shared
// pieces) take the cinematic surfaces too.
::ng-deep .play-btn {
@include detail-actions.light-primary-button;
height: 44px;
@include detail-actions.play-button-copy(var(--app-cta-meta-fg, #5a6274));
}
::ng-deep .favorite-btn,
::ng-deep .download-btn {
@include detail-actions.ghost-text-button;
height: 44px;
}
::ng-deep .favorite-btn {
@include detail-actions.favorite-button-state;
}
::ng-deep .download-btn {
@include detail-actions.download-button-state;
}
}
// ============================================================================
@@ -11,6 +11,13 @@ import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
import { buildCastCrewEntries, type CastCrewEntry } from './cast-crew.util';
import { DetailRailComponent } from './detail-rail.component';
/** Scrolls the page to the Cast & crew row ("and more" in the credits). */
export function scrollToCastCrewRow(): void {
document
.getElementById('detail-cast-crew')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/**
* "Cast & crew" row under the hero: 72px round avatars with the full name
* and the character (or "Director"), the director first, initials when a
@@ -0,0 +1,300 @@
import { computed, type Signal } from '@angular/core';
import type { TranslateService } from '@ngx-translate/core';
import {
formatRemainingLabel,
shortCountryList,
shortCountryName,
splitSeasonSuffix,
type SeriesQuickStartActionKind,
} from '@iptvnator/portal/shared/util';
import {
seriesStatusLabelKey,
type PlaybackPositionData,
type TmdbCountryFacet,
type TmdbEnrichedCastMember,
type TmdbGenreFacet,
type TmdbSeriesStatus,
} from '@iptvnator/shared/interfaces';
import { castMembersFromNames, splitPeopleNames } from './cast-crew.util';
import type { VodMoreMenuSection } from './vod-more-menu.model';
/** The next episode to play, as the quick-start helpers describe it. */
export interface SeriesHeroQuickStart {
readonly labelKey: string;
readonly labelParams?: Record<string, number>;
readonly episodeLabel: string | null;
readonly icon: string;
readonly disabled: boolean;
readonly kind: SeriesQuickStartActionKind | null;
readonly position: PlaybackPositionData | null;
/** "S02E03" of the episode the button acts on. */
readonly episodeCode: string | null;
}
export interface SeriesHeroFacetChip<F> {
readonly label: string;
readonly facet: F | null;
}
export interface SeriesHeroPrimaryAction {
readonly label: string;
readonly meta: string | null;
readonly icon: string;
readonly disabled: boolean;
}
export interface SeriesHeroStateDeps {
readonly title: Signal<string | undefined>;
readonly sourceLabel: Signal<string | null | undefined>;
readonly status: Signal<TmdbSeriesStatus | undefined | null>;
readonly year: Signal<string | null | undefined>;
readonly tmdbGenres: Signal<readonly TmdbGenreFacet[] | undefined>;
readonly genre: Signal<string | undefined>;
readonly tmdbCountries: Signal<readonly TmdbCountryFacet[] | undefined>;
readonly country?: Signal<string | undefined>;
readonly tmdbCast: Signal<readonly TmdbEnrichedCastMember[] | undefined>;
readonly cast: Signal<string | undefined>;
readonly tmdbDirectors: Signal<readonly TmdbEnrichedCastMember[] | undefined>;
readonly director: Signal<string | undefined>;
readonly quickStart: Signal<SeriesHeroQuickStart | null>;
readonly translate: Pick<TranslateService, 'instant'>;
}
/**
* Everything the series hero shows: the title without its season suffix,
* the chips (season, status, year, genres, countries), the credits and the
* primary button's two lines ("Continue" / "S02E03 · 18m left").
*/
export function createSeriesHeroState(deps: SeriesHeroStateDeps) {
const translate = (key: string, params?: Record<string, unknown>) =>
deps.translate.instant(key, params);
const titleParts = computed(() => splitSeasonSuffix(deps.title()));
const castMembers = computed<TmdbEnrichedCastMember[]>(() =>
deps.tmdbCast()?.length
? [...(deps.tmdbCast() ?? [])]
: castMembersFromNames(splitPeopleNames(deps.cast()))
);
const directorMembers = computed<TmdbEnrichedCastMember[]>(() =>
deps.tmdbDirectors()?.length
? [...(deps.tmdbDirectors() ?? [])]
: castMembersFromNames(splitPeopleNames(deps.director()))
);
return {
kindLabel: computed(() => {
const kind = translate('WORKSPACE.DASHBOARD.TYPE_SERIES');
const source = deps.sourceLabel()?.trim();
return source ? `${kind} · ${source}` : kind;
}),
title: computed(() => titleParts().title),
seasonChip: computed(() => titleParts().season),
year: computed(() => deps.year() ?? null),
statusLabel: computed(() => {
const status = deps.status();
return status ? translate(seriesStatusLabelKey(status)) : null;
}),
genreChips: computed<SeriesHeroFacetChip<TmdbGenreFacet>[]>(() => {
const facets = deps.tmdbGenres();
if (facets?.length) {
return facets.map((facet) => ({ label: facet.name, facet }));
}
return (deps.genre() ?? '')
.split(/[,/|]/)
.map((genre) => genre.trim())
.filter((genre) => genre.length > 0)
.map((label) => ({ label, facet: null }));
}),
countryChips: computed<SeriesHeroFacetChip<TmdbCountryFacet>[]>(() => {
const facets = deps.tmdbCountries();
if (facets?.length) {
return facets.map((facet) => ({
label: shortCountryName(facet.name, facet.code),
facet,
}));
}
return shortCountryList(deps.country?.()).map((label) => ({
label,
facet: null,
}));
}),
castMembers,
directorMembers,
castNames: computed(() => castMembers().map((member) => member.name)),
directorNames: computed(() =>
directorMembers().map((member) => member.name)
),
primaryAction: computed<SeriesHeroPrimaryAction | null>(() => {
const quickStart = deps.quickStart();
if (!quickStart) {
return null;
}
if (quickStart.kind === 'completed') {
return {
label: translate(quickStart.labelKey, quickStart.labelParams),
meta: quickStart.episodeLabel,
icon: quickStart.icon,
disabled: true,
};
}
if (quickStart.kind === 'resume') {
const remaining = formatRemainingLabel(quickStart.position);
const parts = [
quickStart.episodeCode,
remaining ? translate(remaining.key, remaining.params) : null,
].filter((part): part is string => !!part);
return {
label: translate('WORKSPACE.DASHBOARD.HERO_CONTINUE'),
meta: parts.length ? parts.join(' · ') : quickStart.episodeLabel,
icon: quickStart.icon,
disabled: quickStart.disabled,
};
}
return {
label: translate('XTREAM.PLAY'),
meta: quickStart.episodeLabel,
icon: quickStart.icon,
disabled: quickStart.disabled,
};
}),
};
}
export const SERIES_MENU_ACTION = {
SeasonWatched: 'season-watched',
SeriesWatched: 'series-watched',
ResetProgress: 'reset-progress',
Sources: 'sources',
ExternalPlayer: 'external-player',
CopyUrl: 'copy-url',
DownloadSeason: 'download-season',
ShowInCategory: 'show-in-category',
HideFromContinueWatching: 'hide-continue-watching',
} as const;
export interface SeriesMenuInput {
readonly seasonWatchVisible: boolean;
readonly seasonFullyWatched: boolean;
readonly seasonEligibleCount: number;
readonly seasonActionDisabled: boolean;
readonly seriesMenuVisible: boolean;
readonly seriesFullyWatched: boolean;
readonly seriesEligibleCount: number;
readonly seriesCountKnown: boolean;
readonly seriesActionDisabled: boolean;
readonly hasProgress: boolean;
readonly sourcesCount: number;
readonly externalPlayerHint: 'MPV' | 'VLC' | null;
readonly copyUrlEpisodeCode: string | null;
readonly downloadVisible: boolean;
readonly downloadCount: number;
readonly downloadDisabled: boolean;
readonly downloadBusy: boolean;
readonly categoryName: string | null;
readonly inContinueWatching: boolean;
}
/** Rows of the series "…" menu. Rows a provider cannot serve are left out. */
export function buildSeriesMenuSections(input: SeriesMenuInput): VodMoreMenuSection[] {
const watching: VodMoreMenuSection['items'][number][] = [];
if (input.seasonWatchVisible) {
watching.push({
id: SERIES_MENU_ACTION.SeasonWatched,
labelKey: input.seasonFullyWatched
? 'XTREAM.MARK_SEASON_UNWATCHED'
: 'XTREAM.MARK_SEASON_WATCHED',
labelParams: { count: input.seasonEligibleCount },
icon: input.seasonFullyWatched ? 'remove_done' : 'done_all',
disabled: input.seasonActionDisabled,
testId: 'toggle-season-watched',
});
}
if (input.seriesMenuVisible) {
watching.push({
id: SERIES_MENU_ACTION.SeriesWatched,
labelKey: input.seriesFullyWatched
? 'XTREAM.MARK_SERIES_UNWATCHED'
: input.seriesCountKnown
? 'XTREAM.MARK_SERIES_WATCHED'
: 'XTREAM.MARK_SERIES_WATCHED_ALL',
labelParams: { count: input.seriesEligibleCount },
icon: input.seriesFullyWatched ? 'remove_done' : 'done_all',
disabled: input.seriesActionDisabled,
testId: 'toggle-series-watched',
});
}
if (input.hasProgress && !input.seriesFullyWatched) {
watching.push({
id: SERIES_MENU_ACTION.ResetProgress,
labelKey: 'PORTALS.DETAIL.RESET_PROGRESS',
icon: 'history_toggle_off',
disabled: input.seriesActionDisabled,
testId: 'vod-menu-reset-progress',
});
}
const source: VodMoreMenuSection['items'][number][] = [];
if (input.sourcesCount > 1) {
source.push({
id: SERIES_MENU_ACTION.Sources,
labelKey: 'PORTALS.DETAIL.OTHER_SOURCES',
icon: 'video_library',
hint: input.sourcesCount,
kind: 'sources',
testId: 'vod-menu-sources',
});
}
if (input.externalPlayerHint) {
source.push({
id: SERIES_MENU_ACTION.ExternalPlayer,
labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER',
icon: 'open_in_new',
hint: input.externalPlayerHint,
testId: 'vod-menu-external',
});
}
if (input.copyUrlEpisodeCode) {
source.push({
id: SERIES_MENU_ACTION.CopyUrl,
labelKey: 'PORTALS.COPY_STREAM_URL',
icon: 'link',
hint: input.copyUrlEpisodeCode,
testId: 'vod-menu-copy-url',
});
}
const download: VodMoreMenuSection['items'][number][] = [];
if (input.downloadVisible) {
download.push({
id: SERIES_MENU_ACTION.DownloadSeason,
labelKey: input.downloadBusy
? 'DOWNLOADS.ADDING_TO_QUEUE'
: 'DOWNLOADS.DOWNLOAD_SEASON',
labelParams: { count: input.downloadCount },
icon: 'download',
disabled: input.downloadDisabled,
testId: 'download-season',
});
}
const place: VodMoreMenuSection['items'][number][] = [];
if (input.categoryName) {
place.push({
id: SERIES_MENU_ACTION.ShowInCategory,
labelKey: 'PORTALS.DETAIL.SHOW_IN_CATEGORY',
icon: 'folder_open',
hint: input.categoryName,
testId: 'vod-menu-show-in-category',
});
}
if (input.inContinueWatching) {
place.push({
id: SERIES_MENU_ACTION.HideFromContinueWatching,
labelKey: 'PORTALS.DETAIL.HIDE_FROM_CONTINUE_WATCHING',
icon: 'visibility_off',
testId: 'vod-menu-hide-continue',
});
}
return [
{ labelKey: 'PORTALS.DETAIL.GROUP_WATCHING', items: watching },
{ labelKey: 'PORTALS.DETAIL.GROUP_SOURCE', items: source },
{ items: download },
{ items: place },
];
}
@@ -1,37 +1,66 @@
<section class="seasons-section">
<app-season-header
[downloadVisible]="downloadPresenter.presentationVisible()"
[batchRunning]="downloadPresenter.batchRunning()"
[seasonDisabled]="downloadPresenter.seasonDisabled()"
[eligibleEpisodeCount]="downloadPresenter.eligibleEpisodeCount()"
[watchToggleVisible]="watchPresenter.seasonWatchToggleVisible()"
[watchBatchRunning]="seasonWatchBatchRunning()"
[seasonFullyWatched]="watchPresenter.selectedSeasonFullyWatched()"
[watchEligibleCount]="watchPresenter.seasonWatchEligibleCount()"
[seriesMenuVisible]="watchPresenter.seriesMenuVisible()"
[seriesActionDisabled]="watchPresenter.seriesActionDisabled()"
[seriesFullyWatched]="watchPresenter.seriesFullyWatched()"
[seriesEligibleCount]="watchPresenter.seriesWatchEligibleCount()"
[seriesCountKnown]="watchPresenter.seriesCountKnown()"
[viewToggleVisible]="hasSeasons() && !showSeasonEmptyState()"
[viewMode]="viewMode()"
(downloadSeason)="downloadPresenter.enqueueSeason()"
(toggleSeasonWatched)="watchPresenter.toggleSeasonWatched()"
(toggleSeriesWatched)="watchPresenter.toggleSeriesWatched()"
(viewModeChange)="setViewMode($event)"
/>
<!-- "Episodes · Season 1 · Season 2 · 8 episodes": the season pills sit
in the header; season and series actions live in the hero's menu. -->
<header class="episodes-header">
<h3 class="episodes-header__title">
{{ 'PORTALS.DETAIL.EPISODES' | translate }}
</h3>
@if (hasSeasons()) {
<app-season-tabs
class="episodes-header__tabs"
[seasonKeys]="sortedSeasonKeys()"
[selectedSeason]="selectedSeason()"
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonPosters]="seasonPosters()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="selectSeason($event)"
(backToPlayingRequested)="scrollToPlayingEpisode()"
/>
@if (selectedSeasonEpisodes().length; as count) {
<span class="episodes-header__count">
{{
(count === 1
? 'PORTALS.EPISODE_COUNT_ONE'
: 'PORTALS.EPISODE_COUNT_OTHER'
) | translate: { count }
}}
</span>
}
}
@if (hasSeasons() && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
value="grid"
[aria-label]="'PORTALS.GRID_VIEW' | translate"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="list"
[aria-label]="'PORTALS.LIST_VIEW' | translate"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</header>
@if (hasSeasons()) {
<!-- Season card: the selected season's own cover beside the tabs and
description. Without a cover the column is not rendered at all,
so the tabs sit exactly where they did before season posters. -->
<div
class="season-card"
[class.season-card--with-cover]="selectedSeasonPosterUrl()"
>
@if (hasSeasons() && (selectedSeasonPosterUrl() || selectedSeasonDescription())) {
<!-- The selected season's own cover and synopsis, compact, under
the header. Without either the row is not rendered at all. -->
<div class="season-strip">
@if (selectedSeasonPosterUrl(); as posterUrl) {
<img
class="season-card__cover"
class="season-strip__cover"
data-testid="season-cover"
[src]="posterUrl"
[alt]="
@@ -42,17 +71,14 @@
(error)="onSeasonPosterError(posterUrl)"
/>
}
<app-season-tabs
[seasonKeys]="sortedSeasonKeys()"
[selectedSeason]="selectedSeason()"
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonDescription]="selectedSeasonDescription()"
[seasonPosters]="seasonPosters()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="selectSeason($event)"
(backToPlayingRequested)="scrollToPlayingEpisode()"
/>
@if (selectedSeasonDescription(); as description) {
<app-expandable-text
class="season-strip__description"
data-testid="season-description"
[text]="description"
[clampLines]="3"
/>
}
</div>
}
@@ -133,19 +159,16 @@
</div>
}
@if (getEpisodeProgress(episode) > 0) {
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (
getEpisodePositionText(episode);
as time
) {
<div
class="episode-card__position-text"
>
{{ time }}
</div>
}
<span
class="episode-card__progress"
aria-hidden="true"
>
<i
[style.width.%]="
getEpisodeProgress(episode)
"
></i>
</span>
}
<button
mat-icon-button
@@ -248,9 +271,6 @@
}
</div>
</div>
<div class="episode-card__number">
{{ episode.episode_num }}
</div>
</div>
<div class="episode-card__info">
<div class="episode-card__title-row">
@@ -280,10 +300,10 @@
{{ episodeInfo?.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo?.duration }}
</span>
@if (getEpisodeSubline(episode); as subline) {
<small class="episode-card__subline">
{{ subline }}
</small>
}
</div>
</article>
@@ -1,6 +1,13 @@
.seasons-section {
width: 100%;
margin-top: 24px;
margin-top: 4px;
}
@media (prefers-reduced-motion: reduce) {
.episode-card,
.episode-card__thumbnail img {
transition: none;
}
}
.loading-container {
@@ -72,82 +79,144 @@
// Grid Layout
.episodes-grid {
display: grid;
gap: 12px;
gap: 16px;
width: 100%;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
}
// Season card: an optional cover column (--season-cover-width follows
// Settings.coverSize: 96 / 120 / 144px, tokens in apps/web/src/_cover-size.scss)
// next to the tabs + description. Without a cover the grid is one column and
// the tabs render exactly as they did before season posters existed.
.season-card {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
align-items: start;
margin-bottom: 16px;
// "Episodes" header: title, season pills, count and the view toggle on one
// line; the pills wrap first when the pane is narrow.
.episodes-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
margin-bottom: 14px;
&--with-cover {
grid-template-columns: var(--season-cover-width, 120px) minmax(0, 1fr);
&__title {
margin: 0 6px 0 0;
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
line-height: 1.3;
}
&__tabs {
display: block;
min-width: 0;
}
&__count {
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
}
.view-toggle {
margin-left: auto;
mat-button-toggle-group {
border: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
border-radius: 6px;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-on-surface) 3%,
transparent
);
::ng-deep {
.mat-button-toggle {
background: transparent;
.mat-button-toggle-button {
padding: 0 8px;
height: 30px;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--text-secondary);
}
&.mat-button-toggle-checked {
background: var(--app-selection-surface);
mat-icon {
color: var(--app-selection-color);
}
}
&:hover:not(.mat-button-toggle-checked) {
background: var(--app-hover-overlay);
}
}
.mat-button-toggle-appearance-standard {
.mat-button-toggle-label-content {
line-height: 30px;
padding: 0;
}
}
.mat-button-toggle + .mat-button-toggle {
border-left: 1px solid
color-mix(
in srgb,
var(--app-on-surface) 14%,
transparent
);
}
}
}
}
}
// The selected season's cover (--season-cover-width follows
// Settings.coverSize) and synopsis under the header.
.season-strip {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 14px;
&__cover {
display: block;
width: 100%;
flex: 0 0 auto;
width: calc(var(--season-cover-width, 120px) * 0.5);
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--app-widget-bg);
box-shadow: var(--poster-shadow, 0 8px 40px rgba(0, 0, 0, 0.5));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
&__description {
flex: 1 1 auto;
min-width: 0;
max-width: 760px;
font-size: 12.5px;
line-height: 1.55;
color: var(--text-secondary);
}
}
app-season-tabs {
display: block;
min-width: 0;
}
@media (max-width: 640px) {
.season-card--with-cover {
grid-template-columns: 88px minmax(0, 1fr);
gap: 14px;
}
}
// Episode Card
// Episode Card — flat: the thumbnail and the text sit on the page, no box.
.episode-card {
background: var(--app-widget-bg);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid color-mix(in srgb, var(--app-on-surface) 14%, transparent);
position: relative;
// In-progress state
&--in-progress {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-primary) 10%, transparent) 0%,
transparent 50%
);
pointer-events: none;
z-index: 1;
}
}
cursor: pointer;
border-radius: 10px;
transition: opacity 0.2s ease;
&--launching,
&--active-external {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
.episode-card__thumbnail {
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
}
}
&--launching {
@@ -157,14 +226,15 @@ app-season-tabs {
}
}
&--active-external {
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
// Playing in the inline player
&--playing .episode-card__thumbnail {
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--mat-sys-primary) 60%, 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);
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
// Watched state — the sole indicator is the watched toggle on the
@@ -235,10 +305,6 @@ app-season-tabs {
}
&:hover {
transform: translateY(-2px);
background: var(--app-card-hover-bg);
border-color: color-mix(in srgb, var(--app-on-surface) 22%, transparent);
.episode-card__overlay {
opacity: 1;
}
@@ -246,32 +312,42 @@ app-season-tabs {
.episode-card__play {
transform: scale(1);
}
.episode-card__thumbnail img {
transform: scale(1.03);
}
}
&__thumbnail {
position: relative;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 10px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
}
&__position-text {
// 3px watched share at the bottom of the artwork
&__progress {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: rgba(255, 255, 255, 0.15);
z-index: 2;
i {
display: block;
height: 100%;
background: var(--app-selection-color, #98b8ff);
}
}
&__placeholder {
@@ -388,34 +464,22 @@ app-season-tabs {
}
}
&__number {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
}
&__info {
padding: 12px;
padding: 8px 2px 0;
}
&__title-row {
display: flex;
align-items: flex-start;
gap: 4px;
margin-bottom: 4px;
margin-bottom: 2px;
}
&__title {
flex: 1;
min-width: 0;
font-size: 0.875rem;
font-weight: 600;
font-size: 13px;
font-weight: 500;
margin: 0;
color: var(--text-primary);
line-height: 1.4;
@@ -469,9 +533,9 @@ app-season-tabs {
}
&__plot {
font-size: 0.75rem;
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 8px 0;
margin: 0 0 4px 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -479,13 +543,10 @@ app-season-tabs {
overflow: hidden;
}
&__duration {
font-size: 0.6875rem;
&__subline {
display: block;
font-size: 11.5px;
color: var(--text-muted);
display: inline-block;
padding: 1px 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
}
@@ -161,6 +161,9 @@ describe('SeasonContainerComponent', () => {
let emittedSeasons: string[];
const snackBarOpen = jest.fn();
/** The season download, as the hero "…" menu drives it through the presenter. */
const seasonDownload = () => component.downloadPresenter;
const setRequiredInputs = (
seasons: Record<string, XtreamSerieEpisode[]>,
isLoading = false
@@ -593,19 +596,11 @@ describe('SeasonContainerComponent', () => {
setRequiredInputs({ '1': [paused, eligible] });
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(button.textContent).toContain('Download season (1)');
expect(button.getAttribute('aria-label')).toBe(
'Download season, 1 episodes available'
);
expect(
button.querySelector('.season-download-button__label')?.textContent
).toContain('Download season (1)');
expect(button.nextElementSibling?.classList).toContain('view-toggle');
expect(seasonDownload().presentationVisible()).toBe(true);
expect(seasonDownload().eligibleEpisodeCount()).toBe(1);
expect(seasonDownload().seasonDisabled()).toBe(false);
button.click();
await seasonDownload().enqueueSeason();
await fixture.whenStable();
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
@@ -628,14 +623,11 @@ describe('SeasonContainerComponent', () => {
fixture.detectChanges();
const action = episodeAction(episode.id);
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(action.disabled).toBe(true);
expect(action.querySelector('mat-icon')?.textContent).toContain(
'block'
);
expect(seasonButton.disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
expect(downloadsServiceStub.startDownload).not.toHaveBeenCalled();
});
@@ -654,15 +646,12 @@ describe('SeasonContainerComponent', () => {
fixture.detectChanges();
const action = episodeAction(episode.id);
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(action.disabled).toBe(false);
expect(action.querySelector('mat-icon')?.textContent).toContain(
'download'
);
expect(seasonButton.disabled).toBe(false);
expect(seasonButton.textContent).toContain('Download season (1)');
expect(seasonDownload().seasonDisabled()).toBe(false);
expect(seasonDownload().eligibleEpisodeCount()).toBe(1);
});
it('gives the grid and list view radios localized accessible names', () => {
@@ -694,38 +683,34 @@ describe('SeasonContainerComponent', () => {
setRequiredInputs({ '1': [first] });
fixture.detectChanges();
const seasonButton = () =>
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
expect(downloadsServiceStub.hasLoadedDownloads()).toBe(true);
expect(episodeAction(first.id).disabled).toBe(true);
downloadsServiceStub.hasAuthoritativeDownloadList.set(true);
fixture.componentRef.setInput('isLoading', true);
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
fixture.componentRef.setInput('isLoading', false);
fixture.componentRef.setInput('seasons', { '1': [] });
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
fixture.componentRef.setInput('seasons', { '1': [first] });
downloadsServiceStub.downloads.set([
createDownload(first, { status: 'paused' }),
]);
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
downloadsServiceStub.downloads.set([]);
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
fixture.detectChanges();
seasonButton().click();
void seasonDownload().enqueueSeason();
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
expect(seasonDownload().seasonDisabled()).toBe(true);
start.resolve({ success: true });
await fixture.whenStable();
@@ -740,33 +725,22 @@ describe('SeasonContainerComponent', () => {
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
const seasonButton = () =>
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton().click();
void seasonDownload().enqueueSeason();
fixture.detectChanges();
expect(seasonButton().textContent).toContain('Adding to queue…');
expect(seasonButton().getAttribute('aria-label')).toBe(
'Adding to queue…'
);
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
expect(seasonDownload().batchRunning()).toBe(true);
start.resolve({ success: true });
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
expect(seasonDownload().batchRunning()).toBe(true);
refresh.resolve(undefined);
await fixture.whenStable();
fixture.detectChanges();
expect(seasonButton().hasAttribute('aria-busy')).toBe(false);
expect(seasonButton().textContent).toContain('Download season (1)');
expect(seasonButton().getAttribute('aria-label')).toBe(
'Download season, 1 episodes available'
);
expect(seasonDownload().batchRunning()).toBe(false);
expect(seasonDownload().eligibleEpisodeCount()).toBe(1);
});
it('hides all download presentation on web, in provider-only mode, or without an adapter', () => {
@@ -774,11 +748,7 @@ describe('SeasonContainerComponent', () => {
setRequiredInputs({ '1': [first] });
fixture.componentRef.setInput('downloadAdapter', downloadAdapter);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
expect(seasonDownload().presentationVisible()).toBe(false);
downloadsServiceStub.isAvailable.set(true);
downloadsServiceStub.hasLoadedDownloads.set(true);
@@ -789,20 +759,12 @@ describe('SeasonContainerComponent', () => {
'[data-test-id^="episode-download-"]'
)
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
expect(seasonDownload().presentationVisible()).toBe(false);
fixture.componentRef.setInput('downloadsEnabled', true);
fixture.componentRef.setInput('downloadAdapter', null);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
expect(seasonDownload().presentationVisible()).toBe(false);
expect(
fixture.nativeElement.querySelector(
'[data-test-id^="episode-download-"]'
@@ -999,12 +961,8 @@ describe('SeasonContainerComponent', () => {
enableDownloads();
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton.click();
seasonButton.click();
void seasonDownload().enqueueSeason();
void seasonDownload().enqueueSeason();
await Promise.resolve();
await Promise.resolve();
@@ -1082,10 +1040,7 @@ describe('SeasonContainerComponent', () => {
component.selectSeason('1');
fixture.detectChanges();
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton.click();
void seasonDownload().enqueueSeason();
component.selectSeason('2');
fixture.componentRef.setInput('seasons', {
'1': [createEpisode({ id: '103', title: 'Replacement' })],
@@ -1119,11 +1074,7 @@ describe('SeasonContainerComponent', () => {
});
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement
).click();
await seasonDownload().enqueueSeason();
await fixture.whenStable();
expect(snackBarOpen).toHaveBeenCalledWith(
@@ -1151,10 +1102,28 @@ describe('SeasonContainerComponent', () => {
playlistId: 'playlist-1',
});
const toggleButton = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="toggle-season-watched"]'
);
/**
* The season toggle as the hero "…" menu presents it: the presenter
* owns label, count, disabled state and the click.
*/
const toggleButton = () => {
const presenter = component.watchPresenter;
if (!presenter.seasonWatchToggleVisible()) {
return null;
}
const fullyWatched = presenter.selectedSeasonFullyWatched();
const count = presenter.seasonWatchEligibleCount();
return {
textContent: fullyWatched
? 'Mark season as unwatched'
: `Mark season as watched (${count})`,
icon: fullyWatched ? 'remove_done' : 'done_all',
disabled:
component.seasonWatchBatchRunning() ||
(!fullyWatched && count === 0),
click: () => presenter.toggleSeasonWatched(),
};
};
const threeEpisodes = () => ({
'1': [
@@ -1239,9 +1208,7 @@ describe('SeasonContainerComponent', () => {
const button = toggleButton();
expect(button?.textContent).toContain('Mark season as unwatched');
expect(button?.querySelector('mat-icon')?.textContent).toContain(
'remove_done'
);
expect(button?.icon).toBe('remove_done');
button?.click();
expect(emitted).toHaveLength(1);
@@ -1299,8 +1266,7 @@ describe('SeasonContainerComponent', () => {
const button = toggleButton();
expect(button?.disabled).toBe(true);
expect(button?.getAttribute('aria-busy')).toBe('true');
expect(button?.querySelector('mat-spinner')).not.toBeNull();
expect(component.seasonWatchBatchRunning()).toBe(true);
});
it('hides the toggle without a playlist id and on empty seasons', () => {
@@ -12,24 +12,28 @@ import {
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
createLogger,
formatDurationLabel,
formatRemainingLabel,
getPortalPlaybackProgressPercent,
isPortalPlaybackInProgress,
isPortalPlaybackWatched,
parseDurationSeconds,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
import { ExpandableTextComponent } from '../expandable-text/expandable-text.component';
import {
EPISODE_INFO_PLAY,
EpisodeInfoDialogComponent,
@@ -38,10 +42,6 @@ import {
import { formatEpisodePositionText } from './episode-progress.util';
import { resolveAutoSelectedSeason } from './season-auto-select.util';
import { SeasonDownloadPresenter } from './season-download-presenter';
import {
type EpisodeViewMode,
SeasonHeaderComponent,
} from './season-header.component';
import { SeasonTabsComponent } from './season-tabs.component';
import { SeasonWatchPresenter } from './season-watch-presenter';
import {
@@ -54,6 +54,8 @@ import {
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
export type EpisodeViewMode = 'grid' | 'list';
@Component({
selector: 'app-season-container',
templateUrl: './season-container.component.html',
@@ -61,18 +63,19 @@ const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [SeasonDownloadPresenter, SeasonWatchPresenter],
imports: [
ExpandableTextComponent,
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
ProgressCapsuleComponent,
SeasonHeaderComponent,
SeasonTabsComponent,
TranslateModule,
],
})
export class SeasonContainerComponent implements OnInit {
private readonly dialog = inject(MatDialog);
private readonly translate = inject(TranslateService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly logger = createLogger('SeasonContainer');
private lastEmittedSeason: string | undefined;
@@ -427,6 +430,26 @@ export class SeasonContainerComponent implements OnInit {
);
}
/** "42 min · 18m left", "42 min · watched", "42 min" — or null. */
getEpisodeSubline(episode: XtreamSerieEpisode): string | null {
const info = this.getEpisodeInfo(episode);
const seconds = parseDurationSeconds(info?.duration);
const duration = formatDurationLabel(seconds);
const position = this.playbackPositions().get(Number(episode.id));
const remaining = formatRemainingLabel(position);
const parts = [
duration
? this.translate.instant(duration.key, duration.params)
: null,
this.isEpisodeWatched(episode)
? this.translate.instant('PORTALS.DETAIL.WATCHED')
: remaining
? this.translate.instant(remaining.key, remaining.params)
: this.getEpisodePositionText(episode),
].filter((part): part is string => !!part);
return parts.length ? parts.join(' · ') : null;
}
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
return formatEpisodePositionText(this.getEpisodePosition(episode));
}
@@ -49,8 +49,7 @@ describe('SeasonContainerComponent season cover', () => {
) as HTMLImageElement | null;
const coverColumnRendered = () =>
fixture.nativeElement.querySelector('.season-card--with-cover') !==
null;
fixture.nativeElement.querySelector('.season-strip__cover') !== null;
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -76,21 +76,27 @@ describe('SeasonContainerComponent series watched toggle', () => {
fixture.componentRef.setInput('playlistId', playlistId);
};
const menuTrigger = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="series-watch-menu"]'
);
/** The series row of the hero "…" menu exists only when the presenter says so. */
const menuTrigger = (): object | null =>
component.watchPresenter.seriesMenuVisible() ? {} : null;
const openSeriesMenuItem = (): HTMLButtonElement => {
const trigger = menuTrigger();
expect(trigger).not.toBeNull();
trigger?.click();
fixture.detectChanges();
const item = document.querySelector(
'[data-test-id="toggle-series-watched"]'
) as HTMLButtonElement | null;
expect(item).not.toBeNull();
return item as HTMLButtonElement;
/** The series row as the hero "…" menu presents it, from the presenter. */
const openSeriesMenuItem = () => {
expect(menuTrigger()).not.toBeNull();
const presenter = component.watchPresenter;
const translate = TestBed.inject(TranslateService);
const labelKey = presenter.seriesFullyWatched()
? 'XTREAM.MARK_SERIES_UNWATCHED'
: presenter.seriesCountKnown()
? 'XTREAM.MARK_SERIES_WATCHED'
: 'XTREAM.MARK_SERIES_WATCHED_ALL';
return {
textContent: translate.instant(labelKey, {
count: presenter.seriesWatchEligibleCount(),
}) as string,
disabled: presenter.seriesActionDisabled(),
click: () => presenter.toggleSeriesWatched(),
};
};
beforeEach(async () => {
@@ -1,155 +0,0 @@
<div class="section-header">
<div class="section-header__left">
<h3 class="section-title">
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
</h3>
</div>
@if (
downloadVisible() ||
watchToggleVisible() ||
viewToggleVisible() ||
seriesMenuVisible()
) {
<div class="section-header__actions">
@if (watchToggleVisible()) {
<button
mat-stroked-button
class="season-watch-toggle-button"
data-test-id="toggle-season-watched"
[disabled]="
watchBatchRunning() ||
(!seasonFullyWatched() && watchEligibleCount() === 0)
"
[attr.aria-label]="
(seasonFullyWatched()
? 'XTREAM.MARK_SEASON_UNWATCHED'
: 'XTREAM.MARK_SEASON_WATCHED'
) | translate: { count: watchEligibleCount() }
"
[attr.aria-busy]="watchBatchRunning() ? 'true' : null"
(click)="toggleSeasonWatched.emit()"
>
@if (watchBatchRunning()) {
<mat-spinner
diameter="18"
aria-hidden="true"
></mat-spinner>
} @else {
<mat-icon>{{
seasonFullyWatched() ? 'remove_done' : 'done_all'
}}</mat-icon>
}
<span class="season-watch-toggle-button__label">
{{
(seasonFullyWatched()
? 'XTREAM.MARK_SEASON_UNWATCHED'
: 'XTREAM.MARK_SEASON_WATCHED'
) | translate: { count: watchEligibleCount() }
}}
</span>
</button>
}
@if (downloadVisible()) {
<button
mat-stroked-button
class="season-download-button"
data-test-id="download-season"
[disabled]="seasonDisabled()"
[attr.aria-label]="
batchRunning()
? ('DOWNLOADS.ADDING_TO_QUEUE' | translate)
: ('DOWNLOADS.DOWNLOAD_SEASON_ARIA'
| translate: { count: eligibleEpisodeCount() })
"
[attr.aria-busy]="batchRunning() ? 'true' : null"
(click)="downloadSeason.emit()"
>
@if (batchRunning()) {
<mat-spinner
diameter="18"
aria-hidden="true"
></mat-spinner>
} @else {
<mat-icon>download</mat-icon>
}
<span class="season-download-button__label">
@if (batchRunning()) {
{{ 'DOWNLOADS.ADDING_TO_QUEUE' | translate }}
} @else {
{{
'DOWNLOADS.DOWNLOAD_SEASON'
| translate
: { count: eligibleEpisodeCount() }
}}
}
</span>
</button>
}
@if (viewToggleVisible()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="viewModeChange.emit($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
value="grid"
[aria-label]="'PORTALS.GRID_VIEW' | translate"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="list"
[aria-label]="'PORTALS.LIST_VIEW' | translate"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
@if (seriesMenuVisible()) {
<button
mat-icon-button
class="series-watch-menu-trigger"
data-test-id="series-watch-menu"
[matMenuTriggerFor]="seriesWatchMenu"
[attr.aria-label]="'XTREAM.SERIES_WATCH_MENU' | translate"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #seriesWatchMenu="matMenu">
<button
mat-menu-item
class="series-watch-menu-item"
data-test-id="toggle-series-watched"
[disabled]="seriesActionDisabled()"
[attr.aria-busy]="watchBatchRunning() ? 'true' : null"
(click)="toggleSeriesWatched.emit()"
>
@if (watchBatchRunning()) {
<mat-spinner
diameter="18"
aria-hidden="true"
></mat-spinner>
} @else {
<mat-icon>{{
seriesFullyWatched()
? 'remove_done'
: 'done_all'
}}</mat-icon>
}
<span>
{{
seriesActionLabelKey()
| translate
: { count: seriesEligibleCount() }
}}
</span>
</button>
</mat-menu>
}
</div>
}
</div>
@@ -1,132 +0,0 @@
:host {
display: block;
}
.section-header {
display: flex;
// The actions move onto their own row before the title has to wrap. The
// pane is narrower than the window (context panel, Back lane), so a
// viewport breakpoint cannot tell when the two stop fitting side by side.
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
min-height: 32px;
gap: 16px;
&__left {
display: flex;
align-items: center;
gap: 16px;
flex: 1 1 auto;
}
// Beside the title the header's space-between keeps the actions at the
// end; once they move to their own row they start under the title.
&__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.view-toggle {
mat-button-toggle-group {
border: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
border-radius: 6px;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-on-surface) 3%,
transparent
);
::ng-deep {
.mat-button-toggle {
background: transparent;
.mat-button-toggle-button {
padding: 0 8px;
height: 32px;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--text-secondary);
}
&.mat-button-toggle-checked {
background: var(--app-selection-surface);
mat-icon {
color: var(--app-selection-color);
}
}
&:hover:not(.mat-button-toggle-checked) {
background: var(--app-hover-overlay);
}
}
.mat-button-toggle-appearance-standard {
.mat-button-toggle-label-content {
line-height: 32px;
padding: 0;
}
}
.mat-button-toggle + .mat-button-toggle {
border-left: 1px solid
color-mix(
in srgb,
var(--app-on-surface) 14%,
transparent
);
}
}
}
}
}
.season-download-button,
.season-watch-toggle-button {
min-height: 36px;
mat-spinner {
display: inline-block;
margin-inline-end: 8px;
}
}
.series-watch-menu-trigger {
flex-shrink: 0;
}
.series-watch-menu-item mat-spinner {
display: inline-block;
margin-inline-end: 16px;
vertical-align: middle;
}
@media (max-width: 560px) {
.section-header {
align-items: flex-start;
&__actions {
width: 100%;
justify-content: space-between;
}
}
}
@@ -1,63 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
export type EpisodeViewMode = 'grid' | 'list';
@Component({
selector: 'app-season-header',
templateUrl: './season-header.component.html',
styleUrls: ['./season-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatMenuModule,
MatProgressSpinnerModule,
MatTooltipModule,
TranslateModule,
],
})
export class SeasonHeaderComponent {
readonly downloadVisible = input.required<boolean>();
readonly batchRunning = input.required<boolean>();
readonly seasonDisabled = input.required<boolean>();
readonly eligibleEpisodeCount = input.required<number>();
readonly watchToggleVisible = input.required<boolean>();
readonly watchBatchRunning = input.required<boolean>();
readonly seasonFullyWatched = input.required<boolean>();
readonly watchEligibleCount = input.required<number>();
readonly seriesMenuVisible = input.required<boolean>();
readonly seriesActionDisabled = input.required<boolean>();
readonly seriesFullyWatched = input.required<boolean>();
readonly seriesEligibleCount = input.required<number>();
/** False while unloaded seasons make the series count a guess. */
readonly seriesCountKnown = input.required<boolean>();
readonly viewToggleVisible = input.required<boolean>();
readonly viewMode = input.required<EpisodeViewMode>();
readonly downloadSeason = output<void>();
readonly toggleSeasonWatched = output<void>();
readonly toggleSeriesWatched = output<void>();
readonly viewModeChange = output<EpisodeViewMode>();
seriesActionLabelKey(): string {
if (this.seriesFullyWatched()) {
return 'XTREAM.MARK_SERIES_UNWATCHED';
}
return this.seriesCountKnown()
? 'XTREAM.MARK_SERIES_WATCHED'
: 'XTREAM.MARK_SERIES_WATCHED_ALL';
}
}
@@ -96,12 +96,3 @@
</button>
}
</div>
@if (seasonDescription(); as description) {
<app-expandable-text
class="season-tabs__description"
data-testid="season-description"
[text]="description"
[clampLines]="3"
/>
}
@@ -117,12 +117,3 @@
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));
// Clamping + the show more/less toggle live in app-expandable-text.
}
@@ -9,7 +9,6 @@ import {
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { TranslateModule } from '@ngx-translate/core';
import { ExpandableTextComponent } from '../expandable-text/expandable-text.component';
/** Above this count the pill row becomes a dropdown selector. */
const MAX_SEASON_PILLS = 6;
@@ -34,7 +33,7 @@ const MAX_SEASON_PILLS = 6;
templateUrl: './season-tabs.component.html',
styleUrls: ['./season-tabs.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ExpandableTextComponent, MatIcon, MatMenuModule, TranslateModule],
imports: [MatIcon, MatMenuModule, TranslateModule],
})
export class SeasonTabsComponent {
/** Season keys, already sorted in display order. */
@@ -43,7 +42,6 @@ export class SeasonTabsComponent {
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);
/**
@@ -219,6 +219,25 @@ export class SeasonWatchPresenter {
);
});
/**
* "Reset progress": clear every loaded episode's saved position. The
* host persists it through the same series toggle request as the menu.
*/
buildResetRequest(): SeasonContainerSeriesPlaybackToggleRequest | null {
const sources = this.sources();
const playlistId = sources?.playlistId();
if (!sources || !playlistId || sources.batchRunning()) {
return null;
}
return buildSeriesWatchToggleRequest({
seasons: sources.seasons(),
seriesId: sources.seriesId(),
playlistId,
isEpisodeWatched: sources.isEpisodeWatched,
markWatched: false,
});
}
toggleSeriesWatched(): void {
const sources = this.sources();
if (!sources) {