mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat(details): redesigned episode list, section rhythm and hero fade (#1859)
* fix(details): continue the hero artwork under the first section The vertical scrim stopped at the hero's bottom edge, so a bright backdrop ended in a visible band above the Episodes heading. The hero now grows by a 140px tail that the shell pulls the sections up over, and the scrim resolves to the exact page surface behind the heading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): one section header and a 56px rhythm below the hero Episodes, Cast & crew and Similar now share app-detail-section-header: an 18px/600 title, a muted tabular counter and lead/end slots. The Episodes counter adds the watched count ("8 episodes · 3 watched"), and sections sit 56px apart, with 56px after the last one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): flat episode rows with a reserved action slot Each episode is now one app-episode-item, a list row or a grid card: - a number column, a 168px thumbnail with a watched check, a progress bar only for started episodes and a play overlay - title with "46 min · 12 Jan" (time left once started) and a plot clamped to two lines at 74ch - mark watched, download and a "…" menu in a 112px slot that is always reserved and shows on hover or keyboard focus, so nothing shifts - the whole item is one stretched button: Tab focuses it, Enter plays - the playing or last unfinished episode is highlighted The "…" menu holds Episode details and, for a started episode, Play from beginning, which Xtream and Stalker now start at 0. TMDB's episode runtime fills the meta line when the provider sends none. The list is the default view for anyone who has not chosen one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): segmented list/grid switch and a 30px season pill The view toggle becomes a 2px-padded segmented track with 30×26 segments and a neutral checked segment; the season pills and the season dropdown share the section header's 30px pill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): no season synopsis that repeats the series description Providers often send the series plot, or its first sentences, as every season's description, so the same text showed twice a few hundred pixels apart. The season strip now drops a synopsis that equals the hero's description, or is cut short from it, and clamps its own to two lines at 72ch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(details): draw the episode thumbnail hairline as a border An inset shadow over the artwork is invisible to the surface-contrast checks and to forced-colors mode; a 1px border is what both read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): follow the list-first, flat episode items The list is now the default view, rows and cards share .episode-item classes, and grid titles no longer carry the "N." prefix. The surface checks measure the thumbnail hairline instead of a row border, assert that hovering a row does not move its title, and capture list, grid and hover screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(ui): episode list, section rhythm and hero tail contracts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(dashboard): continue the hero artwork under the first rail The dashboard hero's fade ended at its edge, so the artwork stopped just above the Continue Watching heading. Like the details hero, it now grows by a 160px tail that the rails are pulled up over: the art fades behind the first rail's heading and reaches the page colour before its cards, so the rail's scroll-edge fades never show as a box. The narrow layout, whose slide carries its own scrim block, keeps no tail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changes): note the smoother hero fade Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): season chips up to four, a counts menu from five, no season art The season picker drops every poster: chips for up to four seasons, a menu button from five whose rows read the season and "N episodes · M watched". The season synopsis loses its cover, sits on the number column 24px above the list and renders nothing without a plot of its own. The player's episode panel shares the picker and loses its dropdown thumbnails too; its season strip stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): bare rows for seasons without metadata, skeletons while it loads A season whose final episode data has no plot and no usable still (the series poster or season cover repeated as a still counts as none) renders 44px rows: number, title, meta and an inline check or progress bar, the same action slot, no grid toggle. While the provider list or a TMDB lookup that could still fill a bare-looking season is outstanding, the episodes are skeleton rows at the full row's exact geometry instead of a spinner; a season the provider already describes renders at once. Xtream tracks the show match and each season's enrichment in the store; Stalker tracks its show match in the selection state and settled season fetches in its TMDB service, and now reports a regular series' season request as loading instead of an empty series. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(details): loading skeletons laid out like the loaded page The hero skeleton targeted ngx-skeleton-loader's old `.loader` class, so its blocks kept the library's light default fill and margins; `display: block` stacked the chips and buttons into columns; and the details column sat at the top while the loaded one is bottom-aligned, so the title dropped ~150px on load. It is now plain shimmer blocks at the loaded hero's geometry with the stage height kept while loading. The Xtream series page shows an episodes section skeleton under it, and the Stalker series hero holds the Play button's place while seasons load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): the modeled Stalker series renders bare rows Its episodes repeat the series poster and carry no plots, so the shared series surface check now expects 44px bare rows without thumbnails or a grid toggle in both themes. The thumbnail hairline and grid checks stay in the Xtream suite, whose episodes have stills. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(details): format the season picker stylesheet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(details): episode skeletons never outlive their metadata The loading state added for TMDB-enriched episode rows could stick or flicker: - an empty season is never enriched, so its host never settled it and the season showed six skeleton rows forever instead of its empty state; - Xtream re-marked an enriched season pending on every selection write, turning rendered rows back into skeletons (and dropping row focus) for each cache read; - a superseded lookup of a reopened series, or of a Stalker item without an id, could settle a newer lookup's pending state or never clear it; - TMDB requests have no timeout, so a blocked TMDB host held provider episodes back for minutes. Metadata now only holds back a season that has episodes, the wait is capped at 4s per season, only the latest lookup of a key (or selection) settles it, a settled Xtream season stays settled within a visit and a new visit starts its seasons afresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(details): described seasons render at once; room for titles on phones Metadata lookups held every season as skeletons until TMDB answered, even one the provider had already described with plots and stills. The state now waits for metadata only when the current data would render bare; described rows show at once and the metadata lands in place. At a pane width of 480px or less the list row kept the 112px thumbnail and the action slot beside the text, leaving a 320px phone about 18px for the title. The thumbnail shrinks to 96px there and the actions take their own row under the text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): keep phone-width grid cards stacked The phone-width grid-area placements applied to grid cards too, which declare no template areas, so a card's artwork and text overlapped in a narrow pane. The placements are scoped to list rows; the surface check now also asserts a card's text stays under its artwork at 360px. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): a still that only one episode carries is not a repeat The distinct-stills check counted unique image URLs and treated a single one as the series poster repeated, so a season where one episode has a genuine still and the rest have none lost that still and went bare, with its grid toggle. One image on one episode is now a still; only one image on several episodes counts as a repeat. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): phone-width skeleton rows match the rows they precede At a pane width of 480px or less the finished list row uses a 96px thumbnail and an action row under the text, but its skeleton kept the 112px single-row layout, so pictures and text moved when loading ended. The skeleton row now carries an empty 34px action slot and mirrors the phone layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): no air date from a placeholder or an impossible day The Date constructor turned a provider's "0000-00-00" into a day in 1899 and rolled "2025-02-31" into March, so the episode meta line showed dates nobody sent. An ISO day is now validated part by part and an invalid one leaves the date out. The season menu's container colour goes through mat.menu-overrides(), as the UI guidelines require. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * style(details): spinner colours through mat.progress-spinner-overrides() The episode item set the spinner's indicator colour as raw --mat-* declarations; the UI guidelines want Material tokens set through the component's overrides mixin, which rejects unknown names at build time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): unknown season counts, resume position, hero-skeleton spec A lazy Stalker VOD season the portal has not answered yet showed "0 episodes" in the season menu: the picker now takes the per-season load states and shows no count for such a season. A started episode whose duration nobody knows (no provider or TMDB runtime, none saved with the position) lost its saved position from the meta line; it now reads "Resume at 12:34". The downloads offline-detail spec queried the ngx-skeleton-loader the hero skeleton no longer uses; it queries the hero-skeleton test id. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(playback): a reused external player starts each load at its own offset MPV and VLC are launched with a global --start / --start-time for a resumed title, and a reused process applies that to every later load: "Play from beginning", the next episode and a later resume all began at the first launch's offset. The seek sent right after loadfile does not help — mpv rejects it before the file is loaded, and the command sender never reads the reply (verified against a real mpv over IPC). Every reuse load now carries a per-file start (0 unless an offset is requested): mpv's loadfile options and VLC's :start-time input option. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): loading flags follow the detail container, menus know unloaded seasons The fullscreen episode picker reports its season through the same onSeasonSelected as the detail container, so the Stalker view's episode-list and metadata loading flags followed whichever season was picked last: choosing another lazy season in fullscreen turned the detail page's loaded season into skeletons until the portal answered. Both flags now follow the detail container's own selection. The fullscreen panel also shares the season picker but never forwarded its per-season load states, so a lazy season read "0 episodes" in its menu; the states are forwarded now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): Xtream metadata loading follows the detail container too Like the Stalker view, the Xtream seasons service keyed its metadata loading flag off the season selected last, which the fullscreen episode picker also sets: picking another season there could hide the detail page's settled bare rows behind skeletons for up to four seconds. The flag now reads the detail container's own selection; the picker's choice still gets enriched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2dff91c9f2
commit
50d45bc7c0
119 files changed
+5065
-2341
No files matched your search
@@ -19,6 +19,7 @@ export * from './lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component
|
||||
export * from './lib/password-visibility-toggle/password-visibility-toggle.directive';
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/episode-skeleton.component';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
export * from './lib/season-container/season-tabs.component';
|
||||
export * from './lib/season-container/season-watch-presenter';
|
||||
@@ -41,6 +42,8 @@ export * from './lib/detail-ui/detail-credits.component';
|
||||
export * from './lib/detail-ui/detail-icon-button.component';
|
||||
export * from './lib/detail-ui/detail-action-button.component';
|
||||
export * from './lib/detail-ui/detail-rail.component';
|
||||
export * from './lib/detail-ui/detail-action-skeleton.component';
|
||||
export * from './lib/detail-ui/detail-section-header.component';
|
||||
export * from './lib/detail-ui/meta-chip.component';
|
||||
export * from './lib/detail-ui/similar-rail.component';
|
||||
export * from './lib/detail-ui/trailer-dialog.component';
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<section class="hero" [class.hero--compact]="layout() === 'compact'">
|
||||
<!-- The skeleton keeps the stage height: whether the title has a backdrop
|
||||
is unknown until it loads, and most detail pages do. -->
|
||||
<section
|
||||
class="hero"
|
||||
[class.hero--compact]="layout() === 'compact' && !isLoading()"
|
||||
>
|
||||
@if (errorMessage()) {
|
||||
<div class="hero__content hero__error">
|
||||
<mat-icon class="error-icon">cloud_off</mat-icon>
|
||||
@@ -7,81 +12,38 @@
|
||||
<ng-content select="[hero-error-actions]"></ng-content>
|
||||
</div>
|
||||
} @else if (isLoading()) {
|
||||
<div class="hero__content hero__content--skeleton">
|
||||
<!-- Skeleton Poster -->
|
||||
<!-- Placeholders at the loaded hero's geometry, bottom-aligned like
|
||||
it: eyebrow, title, chips, two description lines, the action
|
||||
row and the credits. -->
|
||||
<div
|
||||
class="hero__content hero__content--skeleton"
|
||||
data-test-id="hero-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="poster">
|
||||
<ngx-skeleton-loader
|
||||
count="1"
|
||||
[theme]="{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
'border-radius': '10px',
|
||||
'aspect-ratio': '2/3',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
<span class="skeleton skeleton--poster"></span>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton Details -->
|
||||
<div class="details">
|
||||
<ngx-skeleton-loader
|
||||
count="1"
|
||||
[theme]="{
|
||||
height: '11px',
|
||||
width: '140px',
|
||||
'margin-bottom': '14px',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
<ngx-skeleton-loader
|
||||
count="1"
|
||||
[theme]="{
|
||||
height: '40px',
|
||||
width: '60%',
|
||||
'margin-bottom': '14px',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
<div style="display: flex; gap: 6px; margin-bottom: 14px">
|
||||
<ngx-skeleton-loader
|
||||
count="3"
|
||||
[theme]="{
|
||||
height: '22px',
|
||||
width: '60px',
|
||||
'border-radius': '999px',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<ngx-skeleton-loader
|
||||
count="3"
|
||||
[theme]="{
|
||||
height: '14px',
|
||||
'margin-bottom': '8px',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
<div style="margin-top: 14px; display: flex; gap: 10px">
|
||||
<ngx-skeleton-loader
|
||||
count="1"
|
||||
[theme]="{
|
||||
height: '44px',
|
||||
width: '150px',
|
||||
'border-radius': '10px',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
<ngx-skeleton-loader
|
||||
count="2"
|
||||
[theme]="{
|
||||
height: '44px',
|
||||
width: '44px',
|
||||
'border-radius': '10px',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="skeleton skeleton--kind"></span>
|
||||
<span class="skeleton skeleton--title"></span>
|
||||
<span class="details__tags">
|
||||
<span class="skeleton skeleton--chip"></span>
|
||||
<span class="skeleton skeleton--chip"></span>
|
||||
<span class="skeleton skeleton--chip"></span>
|
||||
</span>
|
||||
<span class="skeleton-lines">
|
||||
<span class="skeleton skeleton--line"></span>
|
||||
<span class="skeleton skeleton--line"></span>
|
||||
</span>
|
||||
<span class="action-buttons">
|
||||
<span class="skeleton skeleton--primary"></span>
|
||||
<span class="skeleton skeleton--icon-button"></span>
|
||||
<span class="skeleton skeleton--icon-button"></span>
|
||||
</span>
|
||||
<span class="skeleton-lines skeleton-lines--meta">
|
||||
<span class="skeleton skeleton--line"></span>
|
||||
<span class="skeleton skeleton--line"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../styles/skeleton-shimmer' as shimmer;
|
||||
|
||||
// ============================================================================
|
||||
// Detail hero — cinematic layout shared by movie and series pages
|
||||
// ============================================================================
|
||||
@@ -50,7 +52,11 @@
|
||||
width: 100%;
|
||||
// Room for a 16:9 backdrop above the content, capped so the episodes or
|
||||
// cast row still starts inside the first screen of a desktop window.
|
||||
min-height: min(480px, 60vh);
|
||||
// `--hero-tail` (set by the detail shell) continues the artwork below
|
||||
// the content, under the first section's heading, so the fade into the
|
||||
// page has room and never ends as a band at the hero's edge.
|
||||
min-height: calc(min(520px, 60vh) + var(--hero-tail, 0px));
|
||||
padding-bottom: var(--hero-tail, 0px);
|
||||
box-sizing: border-box;
|
||||
// `overflow: hidden` clips the scaled blurred backdrop fallback.
|
||||
overflow: hidden;
|
||||
@@ -138,13 +144,15 @@
|
||||
color-mix(in srgb, var(--surface-bg) 45%, transparent) 64%,
|
||||
color-mix(in srgb, var(--surface-bg) 15%, transparent) 100%
|
||||
),
|
||||
// Ends on the exact page surface, not an alpha of it: the
|
||||
// section below starts on the same colour, so there is no seam.
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-bg) 25%, transparent) 0%,
|
||||
transparent 30%,
|
||||
transparent 60%,
|
||||
var(--surface-bg) 100%
|
||||
);
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-bg) 20%, transparent) 0%,
|
||||
transparent 28%,
|
||||
color-mix(in srgb, var(--surface-bg) 55%, transparent) 62%,
|
||||
var(--surface-bg) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&__content {
|
||||
@@ -156,12 +164,8 @@
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 24px;
|
||||
padding: 48px 32px 32px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&--skeleton {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
@@ -483,18 +487,87 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Skeleton Loader Overrides
|
||||
ngx-skeleton-loader {
|
||||
display: block;
|
||||
width: 100%;
|
||||
// ============================================================================
|
||||
// Loading skeleton — the loaded hero's geometry, so nothing moves on load
|
||||
// ============================================================================
|
||||
|
||||
::ng-deep .loader {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 10%,
|
||||
transparent
|
||||
);
|
||||
@include shimmer.keyframes;
|
||||
|
||||
.skeleton {
|
||||
@include shimmer.block(4px);
|
||||
|
||||
flex: none;
|
||||
|
||||
&--poster {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
// 11px eyebrow; the title's own line box (font size × 1.08).
|
||||
&--kind {
|
||||
width: 140px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
&--title {
|
||||
width: min(60%, 420px);
|
||||
height: calc(clamp(28px, 3.2vw, 40px) * 1.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
// Meta chip: 22.8px pill.
|
||||
&--chip {
|
||||
width: 64px;
|
||||
height: 22.8px;
|
||||
border-radius: 999px;
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
&--primary {
|
||||
width: 168px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&--icon-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// Text lines: bars centred in the real line boxes (13.5px × 1.55 for the
|
||||
// description, 21.5px credit rows).
|
||||
.skeleton-lines {
|
||||
--line-box: 20.925px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
.skeleton--line {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
margin-block: calc((var(--line-box) - 10px) / 2);
|
||||
|
||||
& + .skeleton--line {
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
&--meta {
|
||||
--line-box: 21.5px;
|
||||
|
||||
.skeleton--line {
|
||||
width: min(100%, 320px);
|
||||
|
||||
& + .skeleton--line {
|
||||
width: min(70%, 220px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,4 +258,22 @@ describe('ContentHeroComponent cinematic layout', () => {
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
});
|
||||
it('lays the loading skeleton out like the loaded hero: stage height, one row of chips and actions', () => {
|
||||
fixture.componentRef.setInput('isLoading', true);
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
|
||||
const skeleton = root.querySelector('[data-test-id="hero-skeleton"]');
|
||||
expect(skeleton).not.toBeNull();
|
||||
// Nothing is known about a backdrop yet: keep the stage height.
|
||||
expect(root.querySelector('.hero--compact')).toBeNull();
|
||||
// Chips and buttons sit in the same flex rows as the real ones.
|
||||
expect(
|
||||
skeleton?.querySelectorAll('.details__tags > .skeleton--chip')
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
skeleton?.querySelectorAll('.action-buttons > .skeleton')
|
||||
).toHaveLength(3);
|
||||
expect(root.querySelector('ngx-skeleton-loader')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
|
||||
import { HeroTrailerBackdropComponent } from '../detail-ui/hero-trailer-backdrop.component';
|
||||
|
||||
/** `stage` keeps room for a 16:9 backdrop; `compact` is sized by the content. */
|
||||
@@ -30,7 +29,6 @@ export type ContentHeroLayout = 'stage' | 'compact';
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
NgxSkeletonLoaderComponent,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './content-hero.component.html',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@use '../styles/skeleton-shimmer' as shimmer;
|
||||
|
||||
@include shimmer.keyframes;
|
||||
|
||||
// The primary action's footprint: a 44px pill about as wide as "Play" with
|
||||
// its episode line.
|
||||
:host {
|
||||
@include shimmer.block(10px);
|
||||
|
||||
width: 168px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Placeholder for the hero's primary action while the host cannot build it
|
||||
* yet (a Stalker series before its seasons load): keeps the action row's
|
||||
* width, so the favourite and "…" buttons do not jump right when the Play
|
||||
* button arrives.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-action-skeleton',
|
||||
template: '',
|
||||
styleUrl: './detail-action-skeleton.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'aria-hidden': 'true',
|
||||
'data-test-id': 'detail-action-skeleton',
|
||||
},
|
||||
})
|
||||
export class DetailActionSkeletonComponent {}
|
||||
@@ -1,16 +1,17 @@
|
||||
<section class="rail">
|
||||
<header class="rail__header">
|
||||
<h3 class="rail__title" [attr.id]="headingId()">{{ title() }}</h3>
|
||||
@if (count() !== null) {
|
||||
<span class="rail__count">{{ count() }}</span>
|
||||
}
|
||||
<ng-content select="[rail-header]" />
|
||||
<app-detail-section-header
|
||||
[title]="title()"
|
||||
[count]="count()"
|
||||
[headingId]="headingId()"
|
||||
>
|
||||
@if (overflowing()) {
|
||||
<div class="rail__arrows">
|
||||
<div class="rail__arrows" section-header-end>
|
||||
<button
|
||||
type="button"
|
||||
class="rail__arrow"
|
||||
[attr.aria-label]="'PORTALS.DETAIL.SCROLL_PREVIOUS' | translate"
|
||||
[attr.aria-label]="
|
||||
'PORTALS.DETAIL.SCROLL_PREVIOUS' | translate
|
||||
"
|
||||
(click)="scrollBy(-1)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">chevron_left</mat-icon>
|
||||
@@ -25,7 +26,7 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
</app-detail-section-header>
|
||||
<div #scroller class="rail__scroller">
|
||||
<ng-content />
|
||||
</div>
|
||||
|
||||
@@ -3,30 +3,9 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rail__title {
|
||||
margin: 0;
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rail__count {
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rail__arrows {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rail__arrow {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { DetailSectionHeaderComponent } from './detail-section-header.component';
|
||||
|
||||
const SCROLL_STEP_PX = 450;
|
||||
|
||||
@@ -21,7 +22,7 @@ const SCROLL_STEP_PX = 450;
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-rail',
|
||||
imports: [MatIcon, TranslatePipe],
|
||||
imports: [DetailSectionHeaderComponent, MatIcon, TranslatePipe],
|
||||
templateUrl: './detail-rail.component.html',
|
||||
styleUrl: './detail-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// One heading scale for every details-page section, so Episodes, Cast & crew
|
||||
// and Similar read as siblings.
|
||||
:host {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
min-height: 34px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header__title {
|
||||
margin: 0;
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.section-header__count {
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 12.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-header__end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DetailSectionHeaderComponent } from './detail-section-header.component';
|
||||
|
||||
@Component({
|
||||
imports: [DetailSectionHeaderComponent],
|
||||
template: `
|
||||
<app-detail-section-header
|
||||
title="Episodes"
|
||||
titleTestId="episodes-heading"
|
||||
[count]="count"
|
||||
>
|
||||
<button section-header-lead class="lead">Season 1</button>
|
||||
<span section-header-end class="end">toggle</span>
|
||||
</app-detail-section-header>
|
||||
`,
|
||||
})
|
||||
class HostComponent {
|
||||
count: string | number | null = '8 episodes · 3 watched';
|
||||
}
|
||||
|
||||
describe('DetailSectionHeaderComponent', () => {
|
||||
function render(count: string | number | null) {
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.componentInstance.count = count;
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
it('orders title, lead slot, counter and the end slot', () => {
|
||||
const host = render('8 episodes · 3 watched');
|
||||
const parts = [
|
||||
...host.querySelectorAll('h3, .lead, .section-header__count, .end'),
|
||||
].map((node) => node.textContent?.trim());
|
||||
|
||||
expect(parts).toEqual([
|
||||
'Episodes',
|
||||
'Season 1',
|
||||
'8 episodes · 3 watched',
|
||||
'toggle',
|
||||
]);
|
||||
expect(host.querySelector('h3')?.getAttribute('data-test-id')).toBe(
|
||||
'episodes-heading'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders no counter without a count', () => {
|
||||
expect(render(null).querySelector('.section-header__count')).toBeNull();
|
||||
expect(render('').querySelector('.section-header__count')).toBeNull();
|
||||
expect(
|
||||
render(0).querySelector('.section-header__count')?.textContent
|
||||
).toBe('0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* The heading row every details-page section shares (Episodes, Cast & crew,
|
||||
* Similar): an 18px title, an optional muted counter, and two projection
|
||||
* slots — `[section-header-lead]` right after the title (the season picker)
|
||||
* and `[section-header-end]` pushed to the far edge (view toggle, arrows).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-section-header',
|
||||
template: `
|
||||
<h3
|
||||
class="section-header__title"
|
||||
[attr.id]="headingId()"
|
||||
[attr.data-test-id]="titleTestId()"
|
||||
>
|
||||
{{ title() }}
|
||||
</h3>
|
||||
<ng-content select="[section-header-lead]" />
|
||||
@if (count() !== null && count() !== '') {
|
||||
<span class="section-header__count">{{ count() }}</span>
|
||||
}
|
||||
<div class="section-header__end">
|
||||
<ng-content select="[section-header-end]" />
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './detail-section-header.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailSectionHeaderComponent {
|
||||
readonly title = input.required<string>();
|
||||
readonly count = input<number | string | null>(null);
|
||||
readonly headingId = input<string | null>(null);
|
||||
readonly titleTestId = input<string | null>(null);
|
||||
}
|
||||
@@ -50,14 +50,22 @@
|
||||
// ============================================================================
|
||||
|
||||
.shell__hero {
|
||||
// The hero's artwork runs on under the first section's heading: the hero
|
||||
// grows by the tail and the sections below are pulled up over it, so the
|
||||
// fade into the page surface ends behind the heading, not at a hard edge.
|
||||
--hero-tail: 140px;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
margin-bottom: calc(-1 * var(--hero-tail));
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease;
|
||||
opacity 250ms ease,
|
||||
margin-bottom 300ms ease;
|
||||
|
||||
&--collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
margin-bottom: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
@@ -66,6 +74,7 @@
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease,
|
||||
margin-bottom 300ms ease,
|
||||
visibility 0s linear 300ms;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +82,16 @@
|
||||
.shell__hero-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
// Keeps the hero's layered scrim and content in their own stacking
|
||||
// context, so the sections pulled up over the tail paint above them.
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.shell__player,
|
||||
.shell__episodes,
|
||||
.shell__about,
|
||||
.shell__extras {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -106,17 +125,33 @@
|
||||
animation: shell-fade-in 250ms ease 100ms both;
|
||||
}
|
||||
|
||||
// Section rhythm: 56px between sections (Episodes → Cast & crew → Similar)
|
||||
// and after the last one, 16px between a section's header and its content
|
||||
// (app-detail-section-header).
|
||||
.shell__extras {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 8px 32px 40px;
|
||||
padding: 0 32px 56px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// The hosts project one wrapper with their rails; a rail without items
|
||||
// renders nothing and must not leave a 56px hole.
|
||||
::ng-deep > [detail-extras] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 56px;
|
||||
|
||||
> :empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shell__episodes:not(:empty) ~ .shell__extras {
|
||||
margin-top: 56px;
|
||||
}
|
||||
|
||||
@keyframes shell-fade-in {
|
||||
@@ -140,7 +175,7 @@
|
||||
}
|
||||
|
||||
.shell__extras {
|
||||
padding: 0 20px 32px;
|
||||
padding: 0 20px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
@let item = episode();
|
||||
@let details = info();
|
||||
<!-- The whole item plays: a click anywhere outside the actions, or Enter
|
||||
once Tab has landed here. -->
|
||||
<button
|
||||
type="button"
|
||||
class="episode-item__target"
|
||||
data-testid="episode-play-target"
|
||||
[attr.aria-label]="
|
||||
('XTREAM.PLAY_EPISODE' | translate: { episode: item.episode_num }) +
|
||||
': ' +
|
||||
item.title
|
||||
"
|
||||
[attr.aria-disabled]="launching() || null"
|
||||
(click)="play()"
|
||||
></button>
|
||||
|
||||
@if (layout() === 'list') {
|
||||
<span class="episode-item__number" aria-hidden="true">
|
||||
<!-- A bare row has no play disc: the number column shows the start. -->
|
||||
@if (bare() && launching()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="16" />
|
||||
} @else if (bare() && playing()) {
|
||||
<mat-icon class="episode-item__number-icon">equalizer</mat-icon>
|
||||
} @else {
|
||||
{{ item.episode_num }}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (!bare()) {
|
||||
<div
|
||||
class="episode-item__thumb episode-card__thumbnail"
|
||||
[class.episode-item__thumb--fallback]="!showStill() || !stillUrl()"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@if (stillUrl(); as url) {
|
||||
<img
|
||||
[src]="url"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
(error)="onStillError(url)"
|
||||
/>
|
||||
}
|
||||
@if (layout() === 'grid') {
|
||||
<span class="episode-item__chip">{{ item.episode_num }}</span>
|
||||
}
|
||||
@if (watched()) {
|
||||
<span
|
||||
class="episode-item__badge"
|
||||
data-testid="episode-watched-badge"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
</span>
|
||||
}
|
||||
@if (progressPercent() > 0) {
|
||||
<span class="episode-item__progress">
|
||||
<i [style.width.%]="progressPercent()"></i>
|
||||
</span>
|
||||
}
|
||||
<span class="episode-item__play">
|
||||
<span class="episode-item__play-disc">
|
||||
@if (launching()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
playing() ? 'equalizer' : 'play_arrow'
|
||||
}}</mat-icon>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="episode-item__text">
|
||||
<div class="episode-item__title-row">
|
||||
<h4 class="episode-item__title">{{ item.title }}</h4>
|
||||
@if (timeLabel() || airDate()) {
|
||||
<small class="episode-item__meta">
|
||||
@if (timeLabel(); as time) {
|
||||
{{ time.key | translate: time.params }}
|
||||
}
|
||||
@if (timeLabel() && airDate()) {
|
||||
·
|
||||
}
|
||||
{{ airDate() }}
|
||||
</small>
|
||||
}
|
||||
@if (bare()) {
|
||||
<!-- No thumbnail to carry the state: it follows the meta. -->
|
||||
@if (watched()) {
|
||||
<mat-icon
|
||||
class="episode-item__inline-check"
|
||||
data-testid="episode-watched-badge"
|
||||
>check</mat-icon
|
||||
>
|
||||
} @else if (progressPercent() > 0) {
|
||||
<span class="episode-item__inline-progress">
|
||||
<i [style.width.%]="progressPercent()"></i>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (!bare() && details?.plot) {
|
||||
<p class="episode-item__description">{{ details?.plot }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="episode-item__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="episode-item__action episode-item__watched-toggle"
|
||||
data-testid="episode-watched-toggle"
|
||||
[class.episode-item__watched-toggle--watched]="watched()"
|
||||
[class.episode-card__watched-toggle--watched]="watched()"
|
||||
[attr.aria-pressed]="watched()"
|
||||
[attr.aria-label]="
|
||||
(watched() ? 'XTREAM.MARK_UNWATCHED' : 'XTREAM.MARK_WATCHED')
|
||||
| translate
|
||||
"
|
||||
[matTooltip]="
|
||||
(watched() ? 'XTREAM.MARK_UNWATCHED' : 'XTREAM.MARK_WATCHED')
|
||||
| translate
|
||||
"
|
||||
(click)="watchedToggled.emit($event)"
|
||||
>
|
||||
<mat-icon>{{
|
||||
watched() ? 'check_circle' : 'check_circle_outline'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
@if (downloadPresenter.presentationVisible()) {
|
||||
@let download = downloadPresenter.rowFor(item).presentation;
|
||||
@let downloadLabel =
|
||||
download.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL' | translate) + ': ' + item.title
|
||||
: (download.ariaKey | translate: { title: item.title });
|
||||
<button
|
||||
type="button"
|
||||
class="episode-item__action download-btn"
|
||||
[class.episode-item__action--success]="download.action === 'play'"
|
||||
[class.download-btn--completed]="download.action === 'play'"
|
||||
[class.download-btn--paused]="download.action === 'resume'"
|
||||
[class.download-btn--downloading]="download.disabled"
|
||||
[disabled]="download.disabled"
|
||||
[attr.data-test-id]="'episode-download-' + item.id"
|
||||
[attr.aria-label]="downloadLabel"
|
||||
[matTooltip]="downloadLabel"
|
||||
(click)="downloadPresenter.runEpisodeAction($event, item)"
|
||||
>
|
||||
<mat-icon>{{ download.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="episode-item__action"
|
||||
data-testid="episode-more-button"
|
||||
[attr.aria-label]="'PORTALS.DETAIL.MORE_ACTIONS' | translate"
|
||||
[matTooltip]="'PORTALS.DETAIL.MORE_ACTIONS' | translate"
|
||||
[matMenuTriggerFor]="episodeMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>more_horiz</mat-icon>
|
||||
</button>
|
||||
<mat-menu #episodeMenu="matMenu" xPosition="before">
|
||||
@if (inProgress()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
data-testid="episode-play-from-beginning"
|
||||
(click)="restartRequested.emit()"
|
||||
>
|
||||
<mat-icon>replay</mat-icon>
|
||||
<span>{{
|
||||
'PORTALS.DETAIL.PLAY_FROM_BEGINNING' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-menu-item
|
||||
data-testid="episode-info-button"
|
||||
(click)="infoRequested.emit($event)"
|
||||
>
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>{{ 'PORTALS.EPISODE_INFO' | translate }}</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
@@ -0,0 +1,551 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
// ============================================================================
|
||||
// Episode item — list row and grid card of the season container
|
||||
// ============================================================================
|
||||
//
|
||||
// Page chrome reads the app's theme tokens, so both themes work. The badge,
|
||||
// progress track and play disc sit over the artwork and keep fixed colours,
|
||||
// like the player overlay.
|
||||
|
||||
:host {
|
||||
--ep-hover-bg: var(--app-widget-bg, #1e2330);
|
||||
--ep-action-hover-bg: var(--app-card-hover-bg, #252b3a);
|
||||
--ep-thumb-bg: color-mix(in srgb, var(--app-on-surface) 8%, transparent);
|
||||
--ep-hairline: color-mix(in srgb, var(--app-on-surface) 12%, transparent);
|
||||
--ep-text-primary: var(--app-heading-color, #d8dce8);
|
||||
--ep-text-secondary: var(--app-body-color, #8b93a8);
|
||||
--ep-text-tertiary: var(--app-eyebrow-color, #6b7494);
|
||||
// Between the secondary and the primary ink: a long plot stays readable
|
||||
// without competing with the title.
|
||||
--ep-description: color-mix(
|
||||
in srgb,
|
||||
var(--app-heading-color, #d8dce8) 55%,
|
||||
var(--app-body-color, #8b93a8)
|
||||
);
|
||||
--ep-accent: var(--app-progress-color, #78adff);
|
||||
--ep-success: var(--app-success-color, #4ade80);
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
border-radius: 10px;
|
||||
color: var(--ep-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// The stretched play target covers the item; the actions sit above it.
|
||||
.episode-item__target {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
cursor: progress;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Thumbnail: still (or dimmed fallback), watched badge, progress, play disc
|
||||
// ============================================================================
|
||||
|
||||
.episode-item__thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
// A hairline keeps the artwork's edge visible on the light theme too.
|
||||
border: 1px solid var(--ep-hairline);
|
||||
border-radius: 6px;
|
||||
background: var(--ep-thumb-bg);
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
// No distinct still (or the provider repeats the series poster): the
|
||||
// image stays as a dimmed texture, never as a bright duplicate poster.
|
||||
&--fallback img {
|
||||
filter: saturate(0.7);
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-item__badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: rgba(5, 7, 11, 0.7);
|
||||
color: #4ade80;
|
||||
|
||||
mat-icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
font-size: 11px;
|
||||
line-height: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-item__progress {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ep-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.episode-item__play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(5, 7, 11, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.episode-item__play-disc {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: rgba(242, 244, 248, 0.95);
|
||||
color: #0a0e14;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
mat-progress-spinner {
|
||||
@include mat.progress-spinner-overrides(
|
||||
(
|
||||
active-indicator-color: #0a0e14,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
:host(:hover),
|
||||
:host(:focus-within),
|
||||
:host(.episode-item--launching),
|
||||
:host(.episode-item--playing) {
|
||||
.episode-item__play {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text
|
||||
// ============================================================================
|
||||
|
||||
.episode-item__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.episode-item__title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.episode-item__title {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.episode-item__meta {
|
||||
flex: none;
|
||||
color: var(--ep-text-tertiary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.episode-item__description {
|
||||
display: -webkit-box;
|
||||
max-width: 74ch;
|
||||
margin: 4px 0 0;
|
||||
overflow: hidden;
|
||||
color: var(--ep-description);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
text-wrap: pretty;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
:host(.episode-item--watched) {
|
||||
.episode-item__title {
|
||||
color: var(--ep-text-secondary);
|
||||
}
|
||||
|
||||
.episode-item__description {
|
||||
color: var(--ep-text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Actions: always reserved, shown on hover and keyboard focus
|
||||
// ============================================================================
|
||||
|
||||
.episode-item__actions {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
:host(:hover),
|
||||
:host(:focus-within) {
|
||||
.episode-item__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-item__action {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--ep-text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--ep-action-hover-bg);
|
||||
color: var(--ep-text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&--success,
|
||||
&.episode-item__watched-toggle--watched {
|
||||
color: var(--ep-success);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// List row
|
||||
// ============================================================================
|
||||
|
||||
:host(.episode-list-item) {
|
||||
grid-template-columns: 36px 168px minmax(0, 1fr) 112px;
|
||||
column-gap: 16px;
|
||||
align-items: center;
|
||||
padding: 10px 12px 10px 8px;
|
||||
}
|
||||
|
||||
:host(.episode-list-item:hover),
|
||||
:host(.episode-list-item:focus-within) {
|
||||
background: var(--ep-hover-bg);
|
||||
}
|
||||
|
||||
:host(.episode-list-item.episode-item--current) {
|
||||
background: color-mix(in srgb, var(--ep-accent) 6%, transparent);
|
||||
|
||||
.episode-item__number,
|
||||
.episode-item__meta {
|
||||
color: var(--ep-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.episode-item__number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ep-text-tertiary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
|
||||
mat-progress-spinner {
|
||||
@include mat.progress-spinner-overrides(
|
||||
(
|
||||
active-indicator-color: var(--ep-accent),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.episode-item__number-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--ep-accent);
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
// Bare row: a season without stills or plots. No thumbnail or description,
|
||||
// a 44px line with the watched check or the progress inline.
|
||||
:host(.episode-list-item.episode-item--bare) {
|
||||
grid-template-columns: 36px minmax(0, 1fr) 112px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px 0 8px;
|
||||
|
||||
.episode-item__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-item__inline-check {
|
||||
flex: none;
|
||||
align-self: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: var(--ep-success);
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.episode-item__inline-progress {
|
||||
flex: none;
|
||||
align-self: center;
|
||||
width: 72px;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ep-accent);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid card
|
||||
// ============================================================================
|
||||
|
||||
:host(.episode-card) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-content: start;
|
||||
|
||||
.episode-item__thumb {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.episode-item__text {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.episode-item__title-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.episode-item__title {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.episode-item__meta {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.episode-item__description {
|
||||
color: var(--ep-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// The actions ride on the artwork's top-right corner in a dark pill.
|
||||
.episode-item__actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(5, 7, 11, 0.72);
|
||||
}
|
||||
|
||||
.episode-item__action {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: rgba(242, 244, 248, 0.86);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--success,
|
||||
&.episode-item__watched-toggle--watched {
|
||||
color: #4ade80;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-item__badge {
|
||||
// The action pill covers the top-right corner while it is shown.
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
:host(.episode-card:hover),
|
||||
:host(.episode-card:focus-within) {
|
||||
.episode-item__badge {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:host(.episode-card.episode-item--watched) {
|
||||
.episode-item__title,
|
||||
.episode-item__description {
|
||||
color: var(--ep-text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
:host(.episode-card.episode-item--current) .episode-item__thumb {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ep-accent) 60%, transparent);
|
||||
}
|
||||
|
||||
.episode-item__chip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
background: rgba(5, 7, 11, 0.72);
|
||||
color: #e7ecf3;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.episode-item__play,
|
||||
.episode-item__actions,
|
||||
:host(.episode-card) .episode-item__badge {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Touch screens have no hover: keep the actions reachable.
|
||||
@media (hover: none) {
|
||||
.episode-item__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Narrow details pane (beside the categories panel, phones).
|
||||
@container detail (max-width: 640px) {
|
||||
:host(.episode-list-item) {
|
||||
grid-template-columns: 24px 112px minmax(0, 1fr) auto;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
:host(.episode-list-item.episode-item--bare) {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.episode-item__title-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0;
|
||||
}
|
||||
|
||||
.episode-item__description {
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Phone width: the thumbnail shrinks and the actions take a row of their
|
||||
// own under the text, so the title keeps the width instead of the slot.
|
||||
@container detail (max-width: 480px) {
|
||||
:host(.episode-list-item) {
|
||||
grid-template-columns: 24px 96px minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'number thumb text'
|
||||
'number thumb actions';
|
||||
row-gap: 2px;
|
||||
padding: 8px 8px 8px 4px;
|
||||
}
|
||||
|
||||
:host(.episode-list-item.episode-item--bare) {
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'number text'
|
||||
'number actions';
|
||||
}
|
||||
|
||||
// Only the list row declares these areas; a grid card keeps its
|
||||
// stacked layout (thumbnail, text, actions pinned on the artwork).
|
||||
:host(.episode-list-item) {
|
||||
.episode-item__number {
|
||||
grid-area: number;
|
||||
}
|
||||
|
||||
.episode-item__thumb {
|
||||
grid-area: thumb;
|
||||
}
|
||||
|
||||
.episode-item__text {
|
||||
grid-area: text;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.episode-item__actions {
|
||||
grid-area: actions;
|
||||
justify-content: flex-start;
|
||||
margin-left: -8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EpisodeItemComponent } from './episode-item.component';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
|
||||
const episode = (info: Record<string, unknown> = {}): XtreamSerieEpisode =>
|
||||
({
|
||||
id: '101',
|
||||
episode_num: 3,
|
||||
title: 'Episode 3',
|
||||
container_extension: 'mp4',
|
||||
info: { plot: 'A plot', duration_secs: 2760, ...info },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
}) as XtreamSerieEpisode;
|
||||
|
||||
const position = (positionSeconds: number): PlaybackPositionData => ({
|
||||
contentXtreamId: 101,
|
||||
contentType: 'episode',
|
||||
positionSeconds,
|
||||
durationSeconds: 2760,
|
||||
});
|
||||
|
||||
describe('EpisodeItemComponent', () => {
|
||||
let fixture: ComponentFixture<EpisodeItemComponent>;
|
||||
const el = () => fixture.nativeElement as HTMLElement;
|
||||
const q = (selector: string) => el().querySelector(selector);
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
EpisodeItemComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: SeasonDownloadPresenter,
|
||||
useValue: { presentationVisible: () => false },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(EpisodeItemComponent);
|
||||
fixture.componentRef.setInput('episode', episode());
|
||||
});
|
||||
|
||||
it('renders an unstarted list row: number, runtime, no badge, no bar', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el().classList).toContain('episode-list-item');
|
||||
expect(q('.episode-item__number')?.textContent?.trim()).toBe('3');
|
||||
expect(q('.episode-item__meta')?.textContent).toContain(
|
||||
'PORTALS.DETAIL.DURATION_MINUTES'
|
||||
);
|
||||
expect(q('.episode-item__badge')).toBeNull();
|
||||
expect(q('.episode-item__progress')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the bar and the time left for a started episode', () => {
|
||||
fixture.componentRef.setInput('position', position(900));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el().classList).toContain('episode-item--in-progress');
|
||||
expect(
|
||||
(q('.episode-item__progress i') as HTMLElement).style.width
|
||||
).toBe('33%');
|
||||
expect(q('.episode-item__meta')?.textContent).toContain(
|
||||
'WORKSPACE.DASHBOARD.REMAINING_MINUTES'
|
||||
);
|
||||
expect(q('.episode-item__badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks a watched episode with the badge and without a bar', () => {
|
||||
fixture.componentRef.setInput('position', position(2700));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el().classList).toContain('episode-item--watched');
|
||||
expect(q('[data-testid="episode-watched-badge"]')).not.toBeNull();
|
||||
expect(q('.episode-item__progress')).toBeNull();
|
||||
const toggle = q('[data-testid="episode-watched-toggle"]');
|
||||
expect(toggle?.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(toggle?.classList).toContain(
|
||||
'episode-item__watched-toggle--watched'
|
||||
);
|
||||
});
|
||||
|
||||
it('plays from the stretched target, but not while launching', () => {
|
||||
const played = jest.fn();
|
||||
fixture.componentInstance.played.subscribe(played);
|
||||
fixture.detectChanges();
|
||||
|
||||
const target = q('[data-testid="episode-play-target"]') as HTMLElement;
|
||||
expect(target.tagName).toBe('BUTTON');
|
||||
target.click();
|
||||
expect(played).toHaveBeenCalledTimes(1);
|
||||
|
||||
fixture.componentRef.setInput('launching', true);
|
||||
fixture.detectChanges();
|
||||
target.click();
|
||||
expect(played).toHaveBeenCalledTimes(1);
|
||||
expect(target.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('emits the watched toggle without playing', () => {
|
||||
const played = jest.fn();
|
||||
const toggled = jest.fn();
|
||||
fixture.componentInstance.played.subscribe(played);
|
||||
fixture.componentInstance.watchedToggled.subscribe(toggled);
|
||||
fixture.detectChanges();
|
||||
|
||||
(q('[data-testid="episode-watched-toggle"]') as HTMLElement).click();
|
||||
|
||||
expect(toggled).toHaveBeenCalledTimes(1);
|
||||
expect(played).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the actions in the DOM for every row (reserved slot)', () => {
|
||||
fixture.detectChanges();
|
||||
expect(q('.episode-item__actions')).not.toBeNull();
|
||||
expect(q('[data-testid="episode-more-button"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders a grid card with a number chip instead of the number column', () => {
|
||||
fixture.componentRef.setInput('layout', 'grid');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el().classList).toContain('episode-card');
|
||||
expect(q('.episode-item__number')).toBeNull();
|
||||
expect(q('.episode-item__chip')?.textContent?.trim()).toBe('3');
|
||||
});
|
||||
|
||||
it('dims the still when the season has no distinct stills', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'episode',
|
||||
episode({ movie_image: 'poster.jpg' })
|
||||
);
|
||||
fixture.componentRef.setInput('showStill', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(q('.episode-item__thumb--fallback img')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a failed still out', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'episode',
|
||||
episode({ movie_image: 'broken.jpg' })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
q('.episode-item__thumb img')?.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(q('.episode-item__thumb img')).toBeNull();
|
||||
expect(q('.episode-item__thumb--fallback')).not.toBeNull();
|
||||
});
|
||||
describe('bare row', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput(
|
||||
'episode',
|
||||
episode({ plot: '', movie_image: '' })
|
||||
);
|
||||
fixture.componentRef.setInput('bare', true);
|
||||
});
|
||||
|
||||
it('drops the thumbnail and the description', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'episode',
|
||||
episode({ plot: 'A plot' })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el().classList).toContain('episode-item--bare');
|
||||
expect(q('.episode-item__thumb')).toBeNull();
|
||||
expect(q('.episode-item__description')).toBeNull();
|
||||
expect(q('.episode-item__number')?.textContent?.trim()).toBe('3');
|
||||
expect(q('[data-testid="episode-more-button"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('puts the watched check inline, without a bar', () => {
|
||||
fixture.componentRef.setInput('position', position(2760));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
q('.episode-item__title-row .episode-item__inline-check')
|
||||
).not.toBeNull();
|
||||
expect(q('.episode-item__inline-progress')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts a 72px bar inline for a started episode', () => {
|
||||
fixture.componentRef.setInput('position', position(900));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(q('.episode-item__inline-progress i') as HTMLElement).style
|
||||
.width
|
||||
).toBe('33%');
|
||||
expect(q('.episode-item__inline-check')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the start in the number column while launching', () => {
|
||||
fixture.componentRef.setInput('launching', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
q('.episode-item__number mat-progress-spinner')
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
getPortalPlaybackProgressPercent,
|
||||
isPortalPlaybackInProgress,
|
||||
isPortalPlaybackWatched,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { episodeTimeLabel, formatEpisodeAirDate } from './episode-meta.util';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
import { resolveEpisodeInfo } from './season-watch-toggle.util';
|
||||
|
||||
export type EpisodeItemLayout = 'list' | 'grid';
|
||||
|
||||
/**
|
||||
* One episode of the selected season, as a list row or a grid card.
|
||||
*
|
||||
* The whole item is one stretched button: a click or Enter plays (resuming
|
||||
* from the saved position), Tab lands on it and reveals the actions through
|
||||
* `:focus-within`. The actions — mark watched, download, "…" — sit in a
|
||||
* reserved slot above that button, so showing them never moves the text.
|
||||
* Watched episodes carry a check on the thumbnail and muted text, started
|
||||
* ones a progress bar and the time left; there is no bar on a watched one.
|
||||
* A season without any stills or plots renders `bare` rows: no thumbnail,
|
||||
* no description, the check or a 72px bar inline after the meta.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-episode-item',
|
||||
templateUrl: './episode-item.component.html',
|
||||
styleUrl: './episode-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
MatProgressSpinner,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
host: {
|
||||
class: 'episode-item',
|
||||
'[class.episode-list-item]': "layout() === 'list'",
|
||||
'[class.episode-card]': "layout() === 'grid'",
|
||||
'[class.episode-item--bare]': 'bare()',
|
||||
'[class.episode-item--watched]': 'watched()',
|
||||
'[class.episode-item--in-progress]': 'inProgress()',
|
||||
'[class.episode-item--current]': 'current()',
|
||||
'[class.episode-item--launching]': 'launching()',
|
||||
'[class.episode-item--playing]': 'playing()',
|
||||
'[attr.data-episode-id]': 'episode().id',
|
||||
},
|
||||
})
|
||||
export class EpisodeItemComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
readonly downloadPresenter = inject(SeasonDownloadPresenter);
|
||||
|
||||
readonly episode = input.required<XtreamSerieEpisode>();
|
||||
readonly layout = input<EpisodeItemLayout>('list');
|
||||
readonly position = input<PlaybackPositionData | undefined>(undefined);
|
||||
/** Distinct per-episode still available; otherwise a dimmed fallback. */
|
||||
readonly showStill = input(true);
|
||||
readonly launching = input(false);
|
||||
/** Playing inline or in an external player session. */
|
||||
readonly playing = input(false);
|
||||
/** The "now playing / continue" row of the season. */
|
||||
readonly current = input(false);
|
||||
/**
|
||||
* The season has neither stills nor plots: a 44px row without a
|
||||
* thumbnail or description, the watched check or progress inline.
|
||||
*/
|
||||
readonly bare = input(false);
|
||||
|
||||
readonly played = output<void>();
|
||||
readonly restartRequested = output<void>();
|
||||
readonly watchedToggled = output<Event>();
|
||||
readonly infoRequested = output<Event>();
|
||||
|
||||
readonly info = computed(() => resolveEpisodeInfo(this.episode()));
|
||||
readonly watched = computed(() => isPortalPlaybackWatched(this.position()));
|
||||
readonly inProgress = computed(() =>
|
||||
isPortalPlaybackInProgress(this.position())
|
||||
);
|
||||
/** Bar only for a started, unfinished episode. */
|
||||
readonly progressPercent = computed(() =>
|
||||
this.watched() ? 0 : getPortalPlaybackProgressPercent(this.position())
|
||||
);
|
||||
readonly timeLabel = computed(() =>
|
||||
episodeTimeLabel(this.info(), this.position())
|
||||
);
|
||||
|
||||
private readonly failedStill = signal<string | null>(null);
|
||||
readonly stillUrl = computed(() => {
|
||||
const url = this.info()?.movie_image;
|
||||
return url && url !== this.failedStill() ? url : null;
|
||||
});
|
||||
|
||||
/** Read on every check: follows a language switch with the pipes. */
|
||||
airDate(): string {
|
||||
return formatEpisodeAirDate(
|
||||
this.info()?.releasedate,
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
}
|
||||
|
||||
onStillError(url: string): void {
|
||||
this.failedStill.set(url);
|
||||
}
|
||||
|
||||
play(): void {
|
||||
if (!this.launching()) {
|
||||
this.played.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
resolveEpisodeMetaState,
|
||||
usableStillUrl,
|
||||
} from './episode-meta-state.util';
|
||||
|
||||
const SERIES_POSTER = 'https://img.test/series.jpg';
|
||||
const SEASON_COVER = 'https://img.test/season-1.jpg';
|
||||
|
||||
const episode = (
|
||||
id: number,
|
||||
info: { plot?: string; movie_image?: string } = {}
|
||||
): XtreamSerieEpisode =>
|
||||
({
|
||||
id: String(id),
|
||||
episode_num: id,
|
||||
title: `Episode ${id}`,
|
||||
container_extension: 'mp4',
|
||||
info,
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
}) as XtreamSerieEpisode;
|
||||
|
||||
const context = (distinctStills = true) => ({
|
||||
distinctStills,
|
||||
posterUrls: [SERIES_POSTER, SEASON_COVER],
|
||||
});
|
||||
|
||||
describe('usableStillUrl', () => {
|
||||
it('keeps an episode still of its own', () => {
|
||||
expect(
|
||||
usableStillUrl(
|
||||
episode(1, { movie_image: 'https://img.test/e1.jpg' }),
|
||||
context()
|
||||
)
|
||||
).toBe('https://img.test/e1.jpg');
|
||||
});
|
||||
|
||||
it('treats the series poster or the season cover as no still', () => {
|
||||
expect(
|
||||
usableStillUrl(
|
||||
episode(1, { movie_image: SERIES_POSTER }),
|
||||
context()
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
usableStillUrl(
|
||||
episode(1, { movie_image: ` ${SEASON_COVER} ` }),
|
||||
context()
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats one image repeated across the season as no still', () => {
|
||||
expect(
|
||||
usableStillUrl(
|
||||
episode(1, { movie_image: 'https://img.test/same.jpg' }),
|
||||
context(false)
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEpisodeMetaState', () => {
|
||||
const listLoading = { list: true, metadata: false };
|
||||
const metadataLoading = { list: false, metadata: true };
|
||||
const settled = { list: false, metadata: false };
|
||||
|
||||
it('stays loading while the provider list is outstanding, whatever it holds', () => {
|
||||
expect(
|
||||
resolveEpisodeMetaState([episode(1)], listLoading, context())
|
||||
).toBe('loading');
|
||||
expect(resolveEpisodeMetaState([], listLoading, context())).toBe(
|
||||
'loading'
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for metadata only when the data would render bare', () => {
|
||||
const bare = [episode(1, { movie_image: SERIES_POSTER })];
|
||||
const described = [
|
||||
episode(1, { plot: 'Plot', movie_image: 'https://img.test/1.jpg' }),
|
||||
];
|
||||
|
||||
expect(resolveEpisodeMetaState(bare, metadataLoading, context())).toBe(
|
||||
'loading'
|
||||
);
|
||||
expect(
|
||||
resolveEpisodeMetaState(described, metadataLoading, context())
|
||||
).toBe('full');
|
||||
expect(resolveEpisodeMetaState([], metadataLoading, context())).toBe(
|
||||
'full'
|
||||
);
|
||||
});
|
||||
|
||||
it('is bare when no episode has a plot or a usable still', () => {
|
||||
const episodes = [
|
||||
episode(1, { movie_image: SERIES_POSTER }),
|
||||
episode(2, { plot: ' ' }),
|
||||
episode(3),
|
||||
];
|
||||
expect(resolveEpisodeMetaState(episodes, settled, context())).toBe(
|
||||
'bare'
|
||||
);
|
||||
});
|
||||
|
||||
it('is full when only some episodes lack a still or a plot', () => {
|
||||
expect(
|
||||
resolveEpisodeMetaState(
|
||||
[episode(1, { plot: 'Something happens.' }), episode(2)],
|
||||
settled,
|
||||
context()
|
||||
)
|
||||
).toBe('full');
|
||||
expect(
|
||||
resolveEpisodeMetaState(
|
||||
[
|
||||
episode(1, { movie_image: 'https://img.test/e1.jpg' }),
|
||||
episode(2),
|
||||
],
|
||||
settled,
|
||||
context()
|
||||
)
|
||||
).toBe('full');
|
||||
});
|
||||
|
||||
it('is full for an empty season (the empty state owns that case)', () => {
|
||||
expect(resolveEpisodeMetaState([], settled, context())).toBe('full');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { resolveEpisodeInfo } from './season-watch-toggle.util';
|
||||
|
||||
/**
|
||||
* How the selected season's episodes render:
|
||||
* - `loading`: metadata is still on its way — skeleton rows at final height;
|
||||
* - `full`: rows with thumbnails and plots (a missing still falls back to
|
||||
* a dimmed tile, a missing plot leaves its line out);
|
||||
* - `bare`: no episode has a plot or a usable still — 44px text rows.
|
||||
*/
|
||||
export type EpisodeMetaState = 'loading' | 'full' | 'bare';
|
||||
|
||||
export interface EpisodeStillContext {
|
||||
/**
|
||||
* False when every episode of the season repeats one image: providers
|
||||
* often send the series poster as each episode's picture.
|
||||
*/
|
||||
readonly distinctStills: boolean;
|
||||
/** Series poster and season cover: a "still" equal to either is none. */
|
||||
readonly posterUrls: readonly (string | null | undefined)[];
|
||||
}
|
||||
|
||||
/** The episode's own still, or null when it has none worth showing. */
|
||||
export function usableStillUrl(
|
||||
episode: XtreamSerieEpisode,
|
||||
context: EpisodeStillContext
|
||||
): string | null {
|
||||
const image = resolveEpisodeInfo(episode)?.movie_image?.trim();
|
||||
if (!image || !context.distinctStills) {
|
||||
return null;
|
||||
}
|
||||
return context.posterUrls.some((poster) => poster?.trim() === image)
|
||||
? null
|
||||
: image;
|
||||
}
|
||||
|
||||
function hasPlot(episode: XtreamSerieEpisode): boolean {
|
||||
return !!resolveEpisodeInfo(episode)?.plot?.trim();
|
||||
}
|
||||
|
||||
export interface EpisodeLoadingState {
|
||||
/** The provider's season or episode list is still on its way. */
|
||||
readonly list: boolean;
|
||||
/** A TMDB lookup that could still fill the episodes is outstanding. */
|
||||
readonly metadata: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decided from the data, never from timing alone: `loading` while the
|
||||
* provider list is outstanding; once it is in, `bare` only when no episode
|
||||
* has a plot and none has a usable still — and `loading` instead of `bare`
|
||||
* while metadata that could still fill those rows is outstanding. A season
|
||||
* the provider already describes renders at once; the metadata then lands
|
||||
* in place.
|
||||
*/
|
||||
export function resolveEpisodeMetaState(
|
||||
episodes: readonly XtreamSerieEpisode[],
|
||||
loading: EpisodeLoadingState,
|
||||
context: EpisodeStillContext
|
||||
): EpisodeMetaState {
|
||||
if (loading.list) {
|
||||
return 'loading';
|
||||
}
|
||||
if (episodes.length === 0) {
|
||||
return 'full';
|
||||
}
|
||||
const bare = episodes.every(
|
||||
(episode) => !hasPlot(episode) && !usableStillUrl(episode, context)
|
||||
);
|
||||
if (!bare) {
|
||||
return 'full';
|
||||
}
|
||||
return loading.metadata ? 'loading' : 'bare';
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { episodeTimeLabel, formatEpisodeAirDate } from './episode-meta.util';
|
||||
|
||||
const position = (
|
||||
positionSeconds: number,
|
||||
durationSeconds = 2700
|
||||
): PlaybackPositionData => ({
|
||||
contentXtreamId: 1,
|
||||
contentType: 'episode',
|
||||
positionSeconds,
|
||||
durationSeconds,
|
||||
});
|
||||
|
||||
describe('episodeTimeLabel', () => {
|
||||
it('shows the runtime for an unstarted or a watched episode', () => {
|
||||
expect(episodeTimeLabel({ duration_secs: 2760 }, undefined)).toEqual({
|
||||
key: 'PORTALS.DETAIL.DURATION_MINUTES',
|
||||
params: { minutes: 46 },
|
||||
});
|
||||
expect(
|
||||
episodeTimeLabel({ duration_secs: 2760 }, position(2700, 2760))
|
||||
).toEqual({
|
||||
key: 'PORTALS.DETAIL.DURATION_MINUTES',
|
||||
params: { minutes: 46 },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the time left for a started episode', () => {
|
||||
expect(
|
||||
episodeTimeLabel({ duration_secs: 2760 }, position(840))
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
|
||||
params: { minutes: 31 },
|
||||
});
|
||||
});
|
||||
|
||||
it('is null when the provider sent no runtime', () => {
|
||||
expect(episodeTimeLabel({}, undefined)).toBeNull();
|
||||
expect(episodeTimeLabel(undefined, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows where a started episode resumes when nobody knows its duration', () => {
|
||||
const unknownDuration: PlaybackPositionData = {
|
||||
contentXtreamId: 1,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 754,
|
||||
};
|
||||
expect(episodeTimeLabel({}, unknownDuration)).toEqual({
|
||||
key: 'PORTALS.DETAIL.RESUME_AT',
|
||||
params: { time: '12:34' },
|
||||
});
|
||||
expect(
|
||||
episodeTimeLabel({}, { ...unknownDuration, positionSeconds: 3725 })
|
||||
).toEqual({
|
||||
key: 'PORTALS.DETAIL.RESUME_AT',
|
||||
params: { time: '1:02:05' },
|
||||
});
|
||||
// A known runtime still wins over the bare position.
|
||||
expect(
|
||||
episodeTimeLabel({ duration_secs: 2760 }, unknownDuration)
|
||||
).toEqual({
|
||||
key: 'PORTALS.DETAIL.DURATION_MINUTES',
|
||||
params: { minutes: 46 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEpisodeAirDate', () => {
|
||||
const now = new Date(2026, 9, 8);
|
||||
|
||||
it('drops the year for a date in the current year', () => {
|
||||
expect(formatEpisodeAirDate('2026-01-12', 'en', now)).toBe('Jan 12');
|
||||
});
|
||||
|
||||
it('keeps the year for an older date', () => {
|
||||
expect(formatEpisodeAirDate('2019-01-12', 'en', now)).toBe(
|
||||
'Jan 12, 2019'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads an ISO day as a local calendar day', () => {
|
||||
const label = formatEpisodeAirDate('2026-03-01', 'en', now);
|
||||
expect(label).toBe('Mar 1');
|
||||
});
|
||||
|
||||
it('maps app locale codes', () => {
|
||||
expect(formatEpisodeAirDate('2026-01-12', 'ru', now)).toMatch(
|
||||
/^12 янв/
|
||||
);
|
||||
});
|
||||
|
||||
it('is empty for missing or unparseable dates', () => {
|
||||
expect(formatEpisodeAirDate(undefined, 'en', now)).toBe('');
|
||||
expect(formatEpisodeAirDate(' ', 'en', now)).toBe('');
|
||||
expect(formatEpisodeAirDate('soon', 'en', now)).toBe('');
|
||||
});
|
||||
|
||||
it('is empty for a provider placeholder or an impossible day', () => {
|
||||
// Providers send "0000-00-00" for "no date"; the Date constructor
|
||||
// would make a day in 1899 of it and roll Feb 31 into March.
|
||||
expect(formatEpisodeAirDate('0000-00-00', 'en', now)).toBe('');
|
||||
expect(formatEpisodeAirDate('0000-00-00 00:00:00', 'en', now)).toBe('');
|
||||
expect(formatEpisodeAirDate('2025-02-31', 'en', now)).toBe('');
|
||||
expect(formatEpisodeAirDate('2025-13-01', 'en', now)).toBe('');
|
||||
expect(formatEpisodeAirDate('2024-02-29', 'en', now)).toBe(
|
||||
'Feb 29, 2024'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { formatWithIntl } from '@iptvnator/pipes/date-format';
|
||||
import {
|
||||
formatDurationLabel,
|
||||
formatRemainingLabel,
|
||||
isPortalPlaybackInProgress,
|
||||
type RemainingTimeLabel,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisodeInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { episodeRuntimeSeconds } from './episode-progress.util';
|
||||
|
||||
/** An i18n key with its parameters, as the meta line's time part. */
|
||||
export interface EpisodeTimeLabel {
|
||||
readonly key: string;
|
||||
readonly params: Record<string, number | string>;
|
||||
}
|
||||
|
||||
/** "5:00" / "1:05:00" from a number of seconds. */
|
||||
function formatClock(totalSeconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const rest = seconds % 60;
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return hours > 0
|
||||
? `${hours}:${pad(minutes)}:${pad(rest)}`
|
||||
: `${minutes}:${pad(rest)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The time part of an episode's meta line: the time left for a started
|
||||
* episode, otherwise its runtime. A started episode whose duration nobody
|
||||
* knows (no provider or TMDB runtime, no duration saved with the position)
|
||||
* still shows where it resumes. Null when there is nothing to say.
|
||||
*/
|
||||
export function episodeTimeLabel(
|
||||
info: XtreamSerieEpisodeInfo | undefined,
|
||||
position: PlaybackPositionData | undefined
|
||||
): EpisodeTimeLabel | null {
|
||||
if (isPortalPlaybackInProgress(position)) {
|
||||
const remaining: RemainingTimeLabel | null =
|
||||
formatRemainingLabel(position);
|
||||
if (remaining) {
|
||||
return remaining;
|
||||
}
|
||||
}
|
||||
const runtime = formatDurationLabel(episodeRuntimeSeconds(info));
|
||||
if (runtime) {
|
||||
return runtime;
|
||||
}
|
||||
return position && isPortalPlaybackInProgress(position)
|
||||
? {
|
||||
key: 'PORTALS.DETAIL.RESUME_AT',
|
||||
params: { time: formatClock(position.positionSeconds) },
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/;
|
||||
|
||||
/**
|
||||
* A local calendar day from an ISO day, or null when the parts do not name
|
||||
* one: the `Date` constructor would otherwise turn a provider's
|
||||
* "0000-00-00" placeholder into a day in 1899 and roll "2025-02-31" over
|
||||
* into March.
|
||||
*/
|
||||
function parseIsoDay(match: RegExpExecArray): Date | null {
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(year, month - 1, day);
|
||||
return date.getFullYear() === year &&
|
||||
date.getMonth() === month - 1 &&
|
||||
date.getDate() === day
|
||||
? date
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "12 Jan" for an air date in the current year, "12 Jan 2019" otherwise.
|
||||
* An ISO day ("2019-01-12", the Xtream and TMDB form) is read as a local
|
||||
* calendar day, so it never shifts by one west of UTC. Empty when the
|
||||
* provider sent nothing parseable, a placeholder or an impossible day.
|
||||
*/
|
||||
export function formatEpisodeAirDate(
|
||||
releaseDate: string | undefined | null,
|
||||
locale: string | undefined | null,
|
||||
now: Date = new Date()
|
||||
): string {
|
||||
const value = releaseDate?.trim();
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const iso = ISO_DAY.exec(value);
|
||||
const date = iso ? parseIsoDay(iso) : new Date(value);
|
||||
if (!date || Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return formatWithIntl(date, {
|
||||
locale,
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
...(date.getFullYear() === now.getFullYear()
|
||||
? {}
|
||||
: { year: 'numeric' }),
|
||||
});
|
||||
}
|
||||
@@ -1,57 +1,11 @@
|
||||
import {
|
||||
isPortalPlaybackWatched,
|
||||
parseDurationSeconds,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { parseDurationSeconds } from '@iptvnator/portal/shared/util';
|
||||
|
||||
/** Pure helpers for episode duration parsing and progress-text formatting. */
|
||||
/** Pure helpers for episode duration parsing. */
|
||||
|
||||
export function parseDuration(duration: string | number | undefined): number {
|
||||
return parseDurationSeconds(duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
/** The episode runtime: the provider's `duration_secs` first, then its formatted `duration`. */
|
||||
export function episodeRuntimeSeconds(
|
||||
info: { duration_secs?: number; duration?: string } | null | undefined
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
@use '../styles/skeleton-shimmer' as shimmer;
|
||||
|
||||
@include shimmer.keyframes;
|
||||
|
||||
// Geometry mirrors episode-item.component.scss: change both together.
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host(.episodes-list) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
:host(.episodes-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(236px, 1fr));
|
||||
gap: 20px 16px;
|
||||
}
|
||||
|
||||
.episode-skeleton {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 36px 168px minmax(0, 1fr) 112px;
|
||||
column-gap: 16px;
|
||||
align-items: center;
|
||||
padding: 10px 12px 10px 8px;
|
||||
|
||||
// Same hairline as the real rows, inset past the number column.
|
||||
& + &::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 12px;
|
||||
left: 56px;
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--app-on-surface) 7%, transparent);
|
||||
}
|
||||
|
||||
&__number {
|
||||
justify-self: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@include shimmer.block(4px);
|
||||
}
|
||||
|
||||
// The real thumbnail is 16:9 inside a 1px hairline.
|
||||
&__thumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 1px solid transparent;
|
||||
@include shimmer.block(6px);
|
||||
}
|
||||
|
||||
// Starts the fourth column empty: the reserved action slot.
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// One title line (14.5px × 1.35) with the meta beside it.
|
||||
&__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 19.575px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
width: 90px;
|
||||
height: 12px;
|
||||
@include shimmer.block(4px);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
width: 110px;
|
||||
height: 10px;
|
||||
@include shimmer.block(4px);
|
||||
}
|
||||
|
||||
// Two description lines of 12.5px × 1.45 (18.125px each), 4px under the
|
||||
// title; each bar sits centred in its line box.
|
||||
&__line {
|
||||
--line-box: 18.125px;
|
||||
--bar: 9px;
|
||||
|
||||
width: min(100%, 74ch);
|
||||
height: var(--bar);
|
||||
margin-block: calc((var(--line-box) - var(--bar)) / 2);
|
||||
@include shimmer.block(4px);
|
||||
|
||||
.episode-skeleton__title-row + & {
|
||||
margin-top: calc(4px + (var(--line-box) - var(--bar)) / 2);
|
||||
}
|
||||
|
||||
&--short {
|
||||
width: min(70%, 52ch);
|
||||
}
|
||||
}
|
||||
|
||||
// Grid card: the artwork on top, title and two lines under it.
|
||||
&--card {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
padding: 0;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.episode-skeleton__thumb {
|
||||
@include shimmer.block(10px);
|
||||
}
|
||||
|
||||
.episode-skeleton__text {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.episode-skeleton__title-row {
|
||||
height: 18.225px;
|
||||
}
|
||||
|
||||
.episode-skeleton__line {
|
||||
--line-box: 17.4px;
|
||||
--bar: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The reserved action slot: empty at the real row's 34px button height.
|
||||
.episode-skeleton__actions {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
// Narrow details pane: the real row's 24px / 112px columns.
|
||||
@container detail (max-width: 640px) {
|
||||
.episode-skeleton:not(.episode-skeleton--card) {
|
||||
grid-template-columns: 24px 112px minmax(0, 1fr) auto;
|
||||
column-gap: 12px;
|
||||
|
||||
.episode-skeleton__line--short {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phone width: the real row's 96px thumbnail and the action row under the
|
||||
// text, so finished rows land exactly where the placeholders were.
|
||||
@container detail (max-width: 480px) {
|
||||
.episode-skeleton:not(.episode-skeleton--card) {
|
||||
grid-template-columns: 24px 96px minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'number thumb text'
|
||||
'number thumb actions';
|
||||
row-gap: 2px;
|
||||
padding: 8px 8px 8px 4px;
|
||||
|
||||
.episode-skeleton__number {
|
||||
grid-area: number;
|
||||
}
|
||||
|
||||
.episode-skeleton__thumb {
|
||||
grid-area: thumb;
|
||||
}
|
||||
|
||||
.episode-skeleton__text {
|
||||
grid-area: text;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.episode-skeleton__actions {
|
||||
grid-area: actions;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { EpisodeItemLayout } from './episode-item.component';
|
||||
|
||||
/** Rows shown while the provider has not said how many episodes there are. */
|
||||
const DEFAULT_SKELETON_ROWS = 6;
|
||||
|
||||
/**
|
||||
* Loading placeholders for the season container's episodes, at the exact
|
||||
* geometry of the full-density rows (or grid cards) they precede, so the
|
||||
* real items swap in without moving anything: a 168×94 thumbnail, a 90px
|
||||
* title and 110px meta line, two description lines. No hover state, no
|
||||
* actions (the slot is reserved, and at phone width it is the row the
|
||||
* real actions take under the text). Bare mode is never anticipated — it
|
||||
* is decided once the data is final.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-episode-skeleton',
|
||||
template: `
|
||||
@for (row of rows(); track row) {
|
||||
<div
|
||||
class="episode-skeleton"
|
||||
[class.episode-skeleton--card]="layout() === 'grid'"
|
||||
data-testid="episode-skeleton"
|
||||
>
|
||||
@if (layout() === 'list') {
|
||||
<span class="episode-skeleton__number"></span>
|
||||
}
|
||||
<span class="episode-skeleton__thumb"></span>
|
||||
<span class="episode-skeleton__text">
|
||||
<span class="episode-skeleton__title-row">
|
||||
<span class="episode-skeleton__title"></span>
|
||||
<span class="episode-skeleton__meta"></span>
|
||||
</span>
|
||||
<span class="episode-skeleton__line"></span>
|
||||
<span
|
||||
class="episode-skeleton__line episode-skeleton__line--short"
|
||||
></span>
|
||||
</span>
|
||||
@if (layout() === 'list') {
|
||||
<span class="episode-skeleton__actions"></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styleUrl: './episode-skeleton.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'aria-hidden': 'true',
|
||||
'[class.episodes-list]': "layout() === 'list'",
|
||||
'[class.episodes-grid]': "layout() === 'grid'",
|
||||
},
|
||||
})
|
||||
export class EpisodeSkeletonComponent {
|
||||
readonly layout = input<EpisodeItemLayout>('list');
|
||||
/** Episode count when known (TMDB pending), else a default screenful. */
|
||||
readonly count = input<number | null>(null);
|
||||
|
||||
readonly rows = computed(() =>
|
||||
Array.from(
|
||||
{ length: this.count() || DEFAULT_SKELETON_ROWS },
|
||||
(_, index) => index
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
formatDurationLabel,
|
||||
formatRemainingLabel,
|
||||
isPortalPlaybackWatched,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisodeInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
episodeRuntimeSeconds,
|
||||
formatEpisodePositionText,
|
||||
} from './episode-progress.util';
|
||||
|
||||
/**
|
||||
* The line under an episode card's title, built from the episode's runtime
|
||||
* and its saved playback position:
|
||||
* "42 min · 18m left", "42 min · watched", "42 min" — or null.
|
||||
*/
|
||||
export function buildEpisodeSubline(
|
||||
info: XtreamSerieEpisodeInfo | undefined,
|
||||
position: PlaybackPositionData | undefined,
|
||||
translate: Pick<TranslateService, 'instant'>
|
||||
): string | null {
|
||||
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
|
||||
const remaining = formatRemainingLabel(position);
|
||||
const parts = [
|
||||
duration ? translate.instant(duration.key, duration.params) : null,
|
||||
isPortalPlaybackWatched(position)
|
||||
? translate.instant('PORTALS.DETAIL.WATCHED')
|
||||
: remaining
|
||||
? translate.instant(remaining.key, remaining.params)
|
||||
: formatEpisodePositionText(position),
|
||||
].filter((part): part is string => !!part);
|
||||
return parts.length ? parts.join(' · ') : null;
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
episodeRuntimeSeconds,
|
||||
formatEpisodePositionText,
|
||||
parseDuration,
|
||||
} from './episode-progress.util';
|
||||
import { episodeRuntimeSeconds, parseDuration } from './episode-progress.util';
|
||||
|
||||
describe('episodeRuntimeSeconds', () => {
|
||||
it('prefers the provider seconds over the formatted string', () => {
|
||||
@@ -30,27 +26,4 @@ describe('episode-progress.util', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
type Signal,
|
||||
computed,
|
||||
effect,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
|
||||
/**
|
||||
* How long episode rows wait as skeletons for TMDB metadata before the
|
||||
* provider's own data renders anyway. TMDB requests carry no timeout, and a
|
||||
* blocked or throttled TMDB host must not hold back episodes the provider
|
||||
* has already delivered.
|
||||
*/
|
||||
export const EPISODE_METADATA_WAIT_MS = 4000;
|
||||
|
||||
/**
|
||||
* `waiting`, capped: true while the host reports pending metadata, until
|
||||
* `EPISODE_METADATA_WAIT_MS` has passed for the same `scope` (the selected
|
||||
* season). A new scope, or the wait ending and starting again, re-arms it.
|
||||
* Must be created in an injection context.
|
||||
*/
|
||||
export function createCappedMetadataWait(
|
||||
waiting: Signal<boolean>,
|
||||
scope: Signal<unknown>,
|
||||
timeoutMs = EPISODE_METADATA_WAIT_MS
|
||||
): Signal<boolean> {
|
||||
const expired = signal(false);
|
||||
effect((onCleanup) => {
|
||||
scope();
|
||||
const isWaiting = waiting();
|
||||
untracked(() => expired.set(false));
|
||||
if (!isWaiting) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => expired.set(true), timeoutMs);
|
||||
onCleanup(() => clearTimeout(timer));
|
||||
});
|
||||
return computed(() => waiting() && !expired());
|
||||
}
|
||||
@@ -1,35 +1,45 @@
|
||||
<section class="seasons-section">
|
||||
<!-- "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" data-test-id="episodes-heading">
|
||||
{{ 'PORTALS.DETAIL.EPISODES' | translate }}
|
||||
</h3>
|
||||
<!-- "Episodes · Season 1 · 8 episodes · 3 watched": the season picker
|
||||
sits in the shared section header; season and series actions live
|
||||
in the hero's menu. -->
|
||||
@let episodeCount = selectedSeasonEpisodes().length;
|
||||
@let watchedCount = watchedCounts()[selectedSeason() ?? ''] ?? 0;
|
||||
<app-detail-section-header
|
||||
class="episodes-header"
|
||||
titleTestId="episodes-heading"
|
||||
[title]="'PORTALS.DETAIL.EPISODES' | translate"
|
||||
[count]="
|
||||
!hasSeasons() || !episodeCount
|
||||
? null
|
||||
: ((episodeCount === 1
|
||||
? 'PORTALS.EPISODE_COUNT_ONE'
|
||||
: 'PORTALS.EPISODE_COUNT_OTHER'
|
||||
) | translate: { count: episodeCount }) +
|
||||
(watchedCount
|
||||
? ' · ' +
|
||||
('PORTALS.EPISODES_WATCHED_COUNT'
|
||||
| translate: { count: watchedCount })
|
||||
: '')
|
||||
"
|
||||
>
|
||||
@if (hasSeasons()) {
|
||||
<app-season-tabs
|
||||
section-header-lead
|
||||
class="episodes-header__tabs"
|
||||
[seasonKeys]="sortedSeasonKeys()"
|
||||
[selectedSeason]="selectedSeason()"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[seasonLoadStates]="seasonLoadStates()"
|
||||
[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">
|
||||
@if (
|
||||
hasSeasons() && !showSeasonEmptyState() && metaState() !== 'bare'
|
||||
) {
|
||||
<div class="view-toggle" section-header-end>
|
||||
<mat-button-toggle-group
|
||||
[value]="viewMode()"
|
||||
(change)="setViewMode($event.value)"
|
||||
@@ -52,44 +62,25 @@
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
</app-detail-section-header>
|
||||
|
||||
@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-strip__cover"
|
||||
data-testid="season-cover"
|
||||
[src]="posterUrl"
|
||||
[alt]="
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: selectedSeason() }
|
||||
"
|
||||
loading="lazy"
|
||||
(error)="onSeasonPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
@if (selectedSeasonDescription(); as description) {
|
||||
<app-expandable-text
|
||||
class="season-strip__description"
|
||||
data-testid="season-description"
|
||||
[text]="description"
|
||||
[clampLines]="3"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (hasSeasons() && selectedSeasonDescription(); as description) {
|
||||
<!-- The selected season's own synopsis, aligned with the number
|
||||
column. No cover beside it, and no row at all without one. -->
|
||||
<app-expandable-text
|
||||
class="season-description"
|
||||
data-testid="season-description"
|
||||
[text]="description"
|
||||
[clampLines]="2"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="content-container">
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
@if (metaState() === 'loading') {
|
||||
<app-episode-skeleton
|
||||
[layout]="effectiveViewMode()"
|
||||
[count]="selectedSeasonEpisodes().length"
|
||||
/>
|
||||
} @else if (showSeriesEmptyState()) {
|
||||
<div class="empty-state-panel" role="status">
|
||||
<div class="empty-state-panel__icon" aria-hidden="true">
|
||||
@@ -118,420 +109,33 @@
|
||||
</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) {
|
||||
<span
|
||||
class="episode-card__progress"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i
|
||||
[style.width.%]="
|
||||
getEpisodeProgress(episode)
|
||||
"
|
||||
></i>
|
||||
</span>
|
||||
}
|
||||
<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 (
|
||||
downloadPresenter.presentationVisible()
|
||||
) {
|
||||
@let downloadRow =
|
||||
downloadPresenter.rowFor(episode);
|
||||
@let downloadState =
|
||||
downloadRow.presentation;
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn"
|
||||
[class.download-btn--completed]="
|
||||
downloadState.action === 'play'
|
||||
"
|
||||
[class.download-btn--paused]="
|
||||
downloadState.action ===
|
||||
'resume'
|
||||
"
|
||||
[class.download-btn--downloading]="
|
||||
downloadState.disabled
|
||||
"
|
||||
[disabled]="downloadState.disabled"
|
||||
[attr.data-test-id]="
|
||||
'episode-download-' + episode.id
|
||||
"
|
||||
[attr.aria-label]="
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
[matTooltip]="
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
downloadPresenter.runEpisodeAction(
|
||||
$event,
|
||||
episode
|
||||
)
|
||||
"
|
||||
>
|
||||
<mat-icon>{{
|
||||
downloadState.icon
|
||||
}}</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>
|
||||
<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 (getEpisodeSubline(episode); as subline) {
|
||||
<small class="episode-card__subline">
|
||||
{{ subline }}
|
||||
</small>
|
||||
}
|
||||
</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 (downloadPresenter.presentationVisible()) {
|
||||
@let downloadRow =
|
||||
downloadPresenter.rowFor(episode);
|
||||
@let downloadState =
|
||||
downloadRow.presentation;
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-btn"
|
||||
[class.action-btn--success]="
|
||||
downloadState.action === 'play'
|
||||
"
|
||||
[disabled]="downloadState.disabled"
|
||||
[attr.data-test-id]="
|
||||
'episode-download-' + episode.id
|
||||
"
|
||||
[attr.aria-label]="
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
[matTooltip]="
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
downloadPresenter.runEpisodeAction(
|
||||
$event,
|
||||
episode
|
||||
)
|
||||
"
|
||||
>
|
||||
<mat-icon>{{
|
||||
downloadState.icon
|
||||
}}</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
|
||||
[class.episodes-grid]="effectiveViewMode() === 'grid'"
|
||||
[class.episodes-list]="effectiveViewMode() === 'list'"
|
||||
[class.episodes-list--bare]="metaState() === 'bare'"
|
||||
>
|
||||
@for (episode of selectedSeasonEpisodes(); track $index) {
|
||||
<app-episode-item
|
||||
[episode]="episode"
|
||||
[layout]="effectiveViewMode()"
|
||||
[bare]="metaState() === 'bare'"
|
||||
[position]="getEpisodePosition(episode)"
|
||||
[showStill]="hasUsableStill(episode)"
|
||||
[launching]="isEpisodeLaunching(episode)"
|
||||
[playing]="
|
||||
isEpisodeActiveExternal(episode) ||
|
||||
isEpisodePlayingInline(episode)
|
||||
"
|
||||
[current]="
|
||||
currentEpisodeId() === getEpisodeContentId(episode)
|
||||
"
|
||||
(played)="selectEpisode(episode)"
|
||||
(restartRequested)="restartEpisode(episode)"
|
||||
(watchedToggled)="toggleWatched($event, episode)"
|
||||
(infoRequested)="openEpisodeInfo($event, episode)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,21 +1,5 @@
|
||||
.seasons-section {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.episode-card,
|
||||
.episode-card__thumbnail img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 30px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-state-panel {
|
||||
@@ -92,768 +76,130 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Grid Layout
|
||||
.episodes-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
}
|
||||
|
||||
// "Episodes" header: title, season pills, count and the view toggle on one
|
||||
// line; the pills wrap first when the pane is narrow.
|
||||
// Episodes header: the shared section header with the season picker after
|
||||
// the title and the view toggle at the far edge.
|
||||
.episodes-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
&__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;
|
||||
flex: 0 0 auto;
|
||||
width: calc(var(--season-cover-width, 120px) * 0.5);
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
// Segmented list/grid switch: a 2px-padded track with 30×26 segments;
|
||||
// the checked one is a neutral raised segment with full-contrast ink.
|
||||
.view-toggle mat-button-toggle-group {
|
||||
gap: 2px;
|
||||
height: auto;
|
||||
padding: 2px;
|
||||
overflow: visible;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
border-radius: 8px;
|
||||
background: var(--app-widget-bg);
|
||||
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);
|
||||
::ng-deep {
|
||||
.mat-button-toggle {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--app-eyebrow-color);
|
||||
|
||||
.mat-button-toggle-button {
|
||||
width: 30px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mat-button-toggle-label-content {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:hover:not(.mat-button-toggle-checked) {
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
&.mat-button-toggle-checked {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle + .mat-button-toggle {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Episode Card — flat: the thumbnail and the text sit on the page, no box.
|
||||
.episode-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&--launching,
|
||||
&--active-external {
|
||||
.episode-card__thumbnail {
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&--launching {
|
||||
.episode-card__overlay {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.56);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
// Watched state — the sole indicator is the watched toggle on the
|
||||
// thumbnail (plus the dimmed card); no extra checkmarks elsewhere.
|
||||
&--watched {
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Watched indicator + toggle in one control, top-left on the artwork.
|
||||
// Watched: always visible, green (the old badge look). Unwatched: ghost
|
||||
// circle revealed on card hover.
|
||||
&__watched-toggle {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 2;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--watched {
|
||||
opacity: 1;
|
||||
color: white;
|
||||
background: rgba(70, 211, 105, 0.9);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: rgba(70, 211, 105, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .episode-card__watched-toggle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.episode-card__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.episode-card__play {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.episode-card__thumbnail img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
&__thumbnail {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
// A hairline keeps the artwork's edge visible on the light theme too.
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// 3px watched share at the bottom of the artwork
|
||||
&__progress {
|
||||
position: absolute;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
color: var(--text-muted);
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.download-btn {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
line-height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: rgba(25, 118, 210, 0.8);
|
||||
}
|
||||
|
||||
.download-btn--completed {
|
||||
color: var(--mat-sys-tertiary) !important;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary) 80%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn--downloading {
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.download-btn--paused {
|
||||
color: #fbbf24 !important;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, #fbbf24 20%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.episode-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__play {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
transform: scale(0.8);
|
||||
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
padding: 8px 2px 0;
|
||||
}
|
||||
|
||||
&__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// "About the episode" lives with the text, not on the artwork: a quiet
|
||||
// ghost button at the end of the title row, revealed on card hover.
|
||||
&__info-btn {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
margin: -1px -2px 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// The default 48px MDC touch target would swallow clicks on the
|
||||
// title around this tiny button (card click = play).
|
||||
::ng-deep .mat-mdc-button-touch-target {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .episode-card__info-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__plot {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__subline {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
// The selected season's own synopsis under the header, offset past the
|
||||
// 36px number column and its gap so it lines up with the episode numbers.
|
||||
// One that repeats the hero's series description is dropped by the
|
||||
// container, and without a synopsis nothing renders.
|
||||
.season-description {
|
||||
display: block;
|
||||
max-width: 72ch;
|
||||
margin: 4px 0 24px;
|
||||
padding-left: 52px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// Episodes List View
|
||||
// ============================================================================
|
||||
// Episodes: list rows and grid cards (app-episode-item)
|
||||
// ============================================================================
|
||||
|
||||
.episodes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(236px, 1fr));
|
||||
gap: 20px 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Borderless rows separated by a hairline inset past the number column. The
|
||||
// hairline gives way around a hovered, focused or highlighted row, so the
|
||||
// row's fill never sits against a line.
|
||||
.episodes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.episode-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--app-on-surface) 3%, transparent);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
|
||||
&:hover {
|
||||
background: var(--app-card-hover-bg);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 22%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.episode-list-item__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--watched {
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--in-progress {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 40%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 60%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 8%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
background: var(--app-hover-overlay);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Compact 16:9 episode still (only when the season has distinct
|
||||
// per-episode art); the episode number rides on the image.
|
||||
&__thumb {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 96px;
|
||||
height: 54px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
&__thumb-number {
|
||||
> app-episode-item + app-episode-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
top: -2px;
|
||||
right: 12px;
|
||||
left: 56px;
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--app-on-surface) 7%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
// Bare rows have no thumbnail: the hairline starts past the number.
|
||||
&--bare > app-episode-item + app-episode-item::before {
|
||||
left: 48px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.watched-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--mat-sys-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__progress-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--mat-sys-primary);
|
||||
padding: 2px 8px;
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 15%, transparent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__progress-bar {
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--app-progress-color);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
> app-episode-item:hover::before,
|
||||
> app-episode-item:focus-within::before,
|
||||
> app-episode-item.episode-item--current::before,
|
||||
> app-episode-item:hover + app-episode-item::before,
|
||||
> app-episode-item:focus-within + app-episode-item::before,
|
||||
> app-episode-item.episode-item--current + app-episode-item::before {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.play-btn {
|
||||
color: var(--accent-color);
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-color-rgb), 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
color: var(--mat-sys-tertiary);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--launching,
|
||||
&--active-external {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary) 46%,
|
||||
transparent
|
||||
);
|
||||
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 {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,10 +267,10 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'.loading-container mat-spinner'
|
||||
)
|
||||
).not.toBeNull();
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'[data-testid="episode-skeleton"]'
|
||||
).length
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).toBeNull();
|
||||
@@ -286,7 +286,7 @@ describe('SeasonContainerComponent', () => {
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(emittedSeasons).toEqual(['1']);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.episode-card').length
|
||||
fixture.nativeElement.querySelectorAll('.episode-item').length
|
||||
).toBe(1);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill').length
|
||||
@@ -301,7 +301,7 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.episode-card').length
|
||||
fixture.nativeElement.querySelectorAll('.episode-item').length
|
||||
).toBe(1);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
@@ -414,9 +414,9 @@ describe('SeasonContainerComponent', () => {
|
||||
expect(emittedSeasons).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('uses a dropdown selector when there are more than six seasons', () => {
|
||||
it('uses a dropdown selector from five seasons on', () => {
|
||||
const seasons: Record<string, XtreamSerieEpisode[]> = {};
|
||||
for (let index = 1; index <= 7; index++) {
|
||||
for (let index = 1; index <= 5; index++) {
|
||||
seasons[String(index)] = [
|
||||
createEpisode({ id: String(100 + index), season: index }),
|
||||
];
|
||||
@@ -460,14 +460,16 @@ describe('SeasonContainerComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('marks the inline-playing episode card', () => {
|
||||
it('marks the inline-playing episode as playing and current', () => {
|
||||
fixture.componentRef.setInput('playingEpisodeId', 101);
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.episode-card--playing')
|
||||
).not.toBeNull();
|
||||
const item = fixture.nativeElement.querySelector(
|
||||
'.episode-item'
|
||||
) as HTMLElement;
|
||||
expect(item.classList).toContain('episode-item--playing');
|
||||
expect(item.classList).toContain('episode-item--current');
|
||||
});
|
||||
|
||||
it('opens the episode info dialog and plays on the dialog play action', () => {
|
||||
@@ -479,7 +481,13 @@ describe('SeasonContainerComponent', () => {
|
||||
component.episodeClicked.subscribe((episode) => played.push(episode));
|
||||
fixture.detectChanges();
|
||||
|
||||
const infoButton = fixture.nativeElement.querySelector(
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="episode-more-button"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
const infoButton = document.querySelector(
|
||||
'[data-testid="episode-info-button"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(infoButton).toBeTruthy();
|
||||
@@ -492,47 +500,43 @@ describe('SeasonContainerComponent', () => {
|
||||
expect(played.length).toBe(1);
|
||||
});
|
||||
|
||||
it('hides the episode info button when the episode has no plot', () => {
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode({ info: { duration: '45 min' } as never })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="episode-info-button"]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows list thumbnails only for distinct episode images', () => {
|
||||
it('dims the thumbnails unless the episodes have distinct stills', () => {
|
||||
const withImages = (a: string, b: string) => ({
|
||||
'1': [
|
||||
createEpisode({ info: { movie_image: a } as never }),
|
||||
createEpisode({
|
||||
info: { movie_image: a, plot: 'One.' } as never,
|
||||
}),
|
||||
createEpisode({
|
||||
id: '102',
|
||||
episode_num: 2,
|
||||
info: { movie_image: b } as never,
|
||||
info: { movie_image: b, plot: 'Two.' } as never,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const query = (selector: string) =>
|
||||
fixture.nativeElement.querySelectorAll(selector).length;
|
||||
|
||||
// Distinct stills → thumbnails replace the number square
|
||||
// Distinct stills → bright thumbnails beside the number column
|
||||
setRequiredInputs(withImages('still-1.jpg', 'still-2.jpg'));
|
||||
component.setViewMode('list');
|
||||
fixture.detectChanges();
|
||||
expect(component.listThumbnailsEnabled()).toBe(true);
|
||||
expect(query('.episode-list-item__thumb')).toBe(2);
|
||||
expect(query('.episode-list-item__number')).toBe(0);
|
||||
expect(component.distinctStills()).toBe(true);
|
||||
expect(query('.episode-item__thumb')).toBe(2);
|
||||
expect(query('.episode-item__thumb--fallback')).toBe(0);
|
||||
expect(query('.episode-item__number')).toBe(2);
|
||||
|
||||
// Same poster on every episode → number squares stay
|
||||
// One episode's own still, the other without any → the lone still
|
||||
// is kept; only a repeated image counts as none
|
||||
setRequiredInputs(withImages('still-1.jpg', ''));
|
||||
fixture.detectChanges();
|
||||
expect(component.distinctStills()).toBe(true);
|
||||
expect(query('.episode-item__thumb--fallback')).toBe(1);
|
||||
|
||||
// Same poster on every episode → dimmed fallback tiles (the plots
|
||||
// keep the rows full; without them the season would render bare)
|
||||
setRequiredInputs(withImages('poster.jpg', 'poster.jpg'));
|
||||
fixture.detectChanges();
|
||||
expect(component.listThumbnailsEnabled()).toBe(false);
|
||||
expect(query('.episode-list-item__thumb')).toBe(0);
|
||||
expect(query('.episode-list-item__number')).toBe(2);
|
||||
expect(component.distinctStills()).toBe(false);
|
||||
expect(query('.episode-item__thumb--fallback')).toBe(2);
|
||||
});
|
||||
|
||||
it('renders the season description for the selected season', () => {
|
||||
|
||||
@@ -13,36 +13,44 @@ 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, TranslateService } from '@ngx-translate/core';
|
||||
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
createLogger,
|
||||
getPortalPlaybackProgressPercent,
|
||||
isPortalPlaybackInProgress,
|
||||
isPortalPlaybackWatched,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
XtreamSerieEpisodeInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DetailSectionHeaderComponent } from '../detail-ui/detail-section-header.component';
|
||||
import { ExpandableTextComponent } from '../expandable-text/expandable-text.component';
|
||||
import {
|
||||
EPISODE_INFO_PLAY,
|
||||
EpisodeInfoDialogComponent,
|
||||
buildEpisodeInfoDialogData,
|
||||
} from './episode-info-dialog.component';
|
||||
import { formatEpisodePositionText } from './episode-progress.util';
|
||||
import { buildEpisodeSubline } from './episode-subline.util';
|
||||
import { EpisodeItemComponent } from './episode-item.component';
|
||||
import {
|
||||
type EpisodeMetaState,
|
||||
resolveEpisodeMetaState,
|
||||
usableStillUrl,
|
||||
} from './episode-meta-state.util';
|
||||
import { EpisodeSkeletonComponent } from './episode-skeleton.component';
|
||||
import { createCappedMetadataWait } from './metadata-wait.state';
|
||||
import {
|
||||
type SeasonAutoSelectState,
|
||||
createSeasonAutoSelectState,
|
||||
findSeasonOfEpisode,
|
||||
} from './season-auto-select.state';
|
||||
import { repeatsSeriesDescription } from './season-description.util';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
import {
|
||||
type SeasonCountLoadState,
|
||||
SeasonTabsComponent,
|
||||
} from './season-tabs.component';
|
||||
import { SeasonWatchPresenter } from './season-watch-presenter';
|
||||
import {
|
||||
type SeasonContainerPlaybackToggleRequest,
|
||||
@@ -56,6 +64,17 @@ const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
|
||||
export type EpisodeViewMode = 'grid' | 'list';
|
||||
|
||||
/** The viewer's saved list/grid choice for episodes; list by default. */
|
||||
export function readSavedEpisodeViewMode(): EpisodeViewMode {
|
||||
try {
|
||||
return localStorage.getItem(EPISODE_VIEW_MODE_KEY) === 'grid'
|
||||
? 'grid'
|
||||
: 'list';
|
||||
} catch {
|
||||
return 'list';
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
@@ -63,11 +82,13 @@ export type EpisodeViewMode = 'grid' | 'list';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [SeasonDownloadPresenter, SeasonWatchPresenter],
|
||||
imports: [
|
||||
DetailSectionHeaderComponent,
|
||||
EpisodeItemComponent,
|
||||
EpisodeSkeletonComponent,
|
||||
ExpandableTextComponent,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
SeasonTabsComponent,
|
||||
TranslateModule,
|
||||
@@ -87,7 +108,18 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly seriesId = input.required<number>();
|
||||
readonly playlistId = input.required<string>();
|
||||
readonly seriesTitle = input<string>('');
|
||||
/** The hero's description: a season synopsis repeating it is not shown again. */
|
||||
readonly seriesDescription = input<string | null | undefined>(null);
|
||||
/** True while the provider's season or episode list is on its way. */
|
||||
readonly isLoading = input<boolean>(false);
|
||||
/**
|
||||
* True while the selected season's episode metadata is still being
|
||||
* fetched after the provider list (TMDB enrichment): its rows stay
|
||||
* skeletons so they do not change height when the data lands.
|
||||
*/
|
||||
readonly metadataLoading = input<boolean>(false);
|
||||
/** The series poster: an episode "still" that repeats it counts as none. */
|
||||
readonly seriesPosterUrl = input<string | null | undefined>(null);
|
||||
readonly playbackPositions = input<Map<number, PlaybackPositionData>>(
|
||||
new Map()
|
||||
);
|
||||
@@ -102,7 +134,8 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly seasonDescriptions = input<Record<string, string> | null>(null);
|
||||
/**
|
||||
* Per-season poster URLs (TMDB season poster, provider season cover),
|
||||
* keyed by season key. Rendered as the season cover beside the tabs.
|
||||
* keyed by season key. Never rendered here: an episode "still" that is
|
||||
* only the season cover again counts as no still.
|
||||
*/
|
||||
readonly seasonPosters = input<Record<string, string> | null>(null);
|
||||
/** True while a host is persisting a season-level watched toggle. */
|
||||
@@ -113,6 +146,10 @@ export class SeasonContainerComponent implements OnInit {
|
||||
* count from the series action label.
|
||||
*/
|
||||
readonly hasUnloadedSeasons = input(false);
|
||||
/** Per-season load state of those lazy seasons; their menu row shows no count. */
|
||||
readonly seasonLoadStates = input<Readonly<
|
||||
Record<string, SeasonCountLoadState>
|
||||
> | null>(null);
|
||||
/**
|
||||
* The seasons whose lists are not loaded yet (Stalker lazy-VOD), so the
|
||||
* auto-selection can tell them from seasons the portal answered empty.
|
||||
@@ -120,6 +157,8 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly unloadedSeasonKeys = input<readonly string[]>([]);
|
||||
|
||||
readonly episodeClicked = output<XtreamSerieEpisode>();
|
||||
/** "Play from beginning" in an episode's menu: start at 0, not the saved position. */
|
||||
readonly episodeRestartRequested = output<XtreamSerieEpisode>();
|
||||
readonly playbackToggleRequested =
|
||||
output<SeasonContainerPlaybackToggleRequest>();
|
||||
readonly seasonPlaybackToggleRequested =
|
||||
@@ -127,7 +166,7 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly seriesPlaybackToggleRequested =
|
||||
output<SeasonContainerSeriesPlaybackToggleRequest>();
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly viewMode = signal<EpisodeViewMode>('grid');
|
||||
readonly viewMode = signal<EpisodeViewMode>('list');
|
||||
|
||||
readonly sortedSeasonKeys = computed(() =>
|
||||
Object.keys(this.seasons()).sort((a, b) => Number(a) - Number(b))
|
||||
@@ -179,20 +218,81 @@ export class SeasonContainerComponent implements OnInit {
|
||||
});
|
||||
|
||||
/**
|
||||
* Show thumbnails in the list view only when episodes have genuinely
|
||||
* distinct stills (TMDB or per-episode provider art). When every episode
|
||||
* carries the same image (providers often repeat the series poster) a
|
||||
* column of identical pictures is worse than the plain number square.
|
||||
* True when the season's episodes carry genuinely distinct stills (TMDB
|
||||
* or per-episode provider art). When one image repeats across episodes
|
||||
* (providers often send the series poster as every still) the
|
||||
* thumbnails fall back to a dimmed tile instead of a column of identical
|
||||
* bright posters. A still that only one episode carries is its own, not
|
||||
* a repeat: the other episodes simply have none.
|
||||
*/
|
||||
readonly listThumbnailsEnabled = computed(() => {
|
||||
const episodes = this.selectedSeasonEpisodes();
|
||||
const images = episodes
|
||||
.map((episode) => this.getEpisodeInfo(episode)?.movie_image)
|
||||
readonly distinctStills = computed(() => {
|
||||
const images = this.selectedSeasonEpisodes()
|
||||
.map((episode) => resolveEpisodeInfo(episode)?.movie_image)
|
||||
.filter((image): image is string => !!image);
|
||||
if (images.length === 0) {
|
||||
return false;
|
||||
// One image on one episode is a still; one image on several is a
|
||||
// repeat; two or more different images are stills.
|
||||
return images.length === 1 || new Set(images).size > 1;
|
||||
});
|
||||
|
||||
private readonly stillContext = computed(() => ({
|
||||
distinctStills: this.distinctStills(),
|
||||
posterUrls: [
|
||||
this.seriesPosterUrl(),
|
||||
this.seasonPosters()?.[this.selectedSeason() ?? ''],
|
||||
],
|
||||
}));
|
||||
|
||||
/**
|
||||
* Metadata only holds rows back when there are rows that would render
|
||||
* bare: a season without episodes is never enriched, so its host never
|
||||
* reports it settled and it must reach the empty state. The wait is
|
||||
* also capped in time.
|
||||
*/
|
||||
private readonly metadataPending = createCappedMetadataWait(
|
||||
computed(
|
||||
() =>
|
||||
this.metadataLoading() &&
|
||||
this.selectedSeasonEpisodes().length > 0
|
||||
),
|
||||
this.selectedSeason
|
||||
);
|
||||
|
||||
/** Skeleton, full rows or bare rows; see `resolveEpisodeMetaState`. */
|
||||
readonly metaState = computed<EpisodeMetaState>(() =>
|
||||
resolveEpisodeMetaState(
|
||||
this.selectedSeasonEpisodes(),
|
||||
{ list: this.isLoading(), metadata: this.metadataPending() },
|
||||
this.stillContext()
|
||||
)
|
||||
);
|
||||
|
||||
/** Bare rows have no grid form: the view toggle hides and lists show. */
|
||||
readonly effectiveViewMode = computed<EpisodeViewMode>(() =>
|
||||
this.metaState() === 'bare' ? 'list' : this.viewMode()
|
||||
);
|
||||
|
||||
/**
|
||||
* The highlighted "now playing / continue" episode of the season: the
|
||||
* one playing (inline or external), else the most recently watched
|
||||
* episode that is not finished.
|
||||
*/
|
||||
readonly currentEpisodeId = computed<number | null>(() => {
|
||||
const playing = this.playingEpisodeId() ?? this.activeEpisodeId();
|
||||
if (playing !== null) {
|
||||
return playing;
|
||||
}
|
||||
return episodes.length === 1 || new Set(images).size > 1;
|
||||
let best: { id: number; updatedAt: string } | null = null;
|
||||
for (const episode of this.selectedSeasonEpisodes()) {
|
||||
const position = this.getEpisodePosition(episode);
|
||||
if (!isPortalPlaybackInProgress(position)) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = position?.updatedAt ?? '';
|
||||
if (!best || updatedAt > best.updatedAt) {
|
||||
best = { id: this.getEpisodeContentId(episode), updatedAt };
|
||||
}
|
||||
}
|
||||
return best?.id ?? null;
|
||||
});
|
||||
|
||||
readonly selectedSeasonDescription = computed(() => {
|
||||
@@ -200,27 +300,11 @@ export class SeasonContainerComponent implements OnInit {
|
||||
if (!selected) {
|
||||
return null;
|
||||
}
|
||||
return this.seasonDescriptions()?.[selected] ?? null;
|
||||
});
|
||||
|
||||
/** Poster URLs whose image request failed; the cover column then folds. */
|
||||
private readonly failedSeasonPosters = signal<ReadonlySet<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
/**
|
||||
* The selected season's cover. Withheld for one-season items — that
|
||||
* poster is the show poster again, a few hundred pixels below the hero —
|
||||
* and for a URL whose image failed, so a dead provider link never leaves
|
||||
* a broken-image frame beside the tabs.
|
||||
*/
|
||||
readonly selectedSeasonPosterUrl = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
if (!selected || this.sortedSeasonKeys().length < 2) {
|
||||
return null;
|
||||
}
|
||||
const url = this.seasonPosters()?.[selected] ?? null;
|
||||
return url && !this.failedSeasonPosters().has(url) ? url : null;
|
||||
const description = this.seasonDescriptions()?.[selected] ?? null;
|
||||
return description &&
|
||||
!repeatsSeriesDescription(description, this.seriesDescription())
|
||||
? description
|
||||
: null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
@@ -273,12 +357,7 @@ export class SeasonContainerComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const savedMode = localStorage.getItem(
|
||||
EPISODE_VIEW_MODE_KEY
|
||||
) as EpisodeViewMode;
|
||||
if (savedMode === 'grid' || savedMode === 'list') {
|
||||
this.viewMode.set(savedMode);
|
||||
}
|
||||
this.viewMode.set(readSavedEpisodeViewMode());
|
||||
}
|
||||
|
||||
setViewMode(mode: EpisodeViewMode) {
|
||||
@@ -306,10 +385,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
this.selectedSeason.set(seasonKey);
|
||||
}
|
||||
|
||||
onSeasonPosterError(url: string): void {
|
||||
this.failedSeasonPosters.update((failed) => new Set(failed).add(url));
|
||||
}
|
||||
|
||||
scrollToPlayingEpisode(): void {
|
||||
const playingSeason = this.playingSeasonKey();
|
||||
if (!playingSeason) {
|
||||
@@ -329,6 +404,10 @@ export class SeasonContainerComponent implements OnInit {
|
||||
this.episodeClicked.emit(episode);
|
||||
}
|
||||
|
||||
restartEpisode(episode: XtreamSerieEpisode) {
|
||||
this.episodeRestartRequested.emit(episode);
|
||||
}
|
||||
|
||||
openEpisodeInfo(event: Event, episode: XtreamSerieEpisode) {
|
||||
event.stopPropagation();
|
||||
this.dialog
|
||||
@@ -377,20 +456,14 @@ export class SeasonContainerComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
getEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
return resolveEpisodeInfo(episode);
|
||||
hasUsableStill(episode: XtreamSerieEpisode): boolean {
|
||||
return usableStillUrl(episode, this.stillContext()) !== null;
|
||||
}
|
||||
|
||||
isEpisodeWatched(episode: XtreamSerieEpisode): boolean {
|
||||
return isPortalPlaybackWatched(this.getEpisodePosition(episode));
|
||||
}
|
||||
|
||||
isEpisodeInProgress(episode: XtreamSerieEpisode): boolean {
|
||||
return isPortalPlaybackInProgress(this.getEpisodePosition(episode));
|
||||
}
|
||||
|
||||
isEpisodeLaunching(episode: XtreamSerieEpisode): boolean {
|
||||
return this.openingEpisodeId() === this.getEpisodeContentId(episode);
|
||||
}
|
||||
@@ -403,30 +476,11 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return this.playingEpisodeId() === this.getEpisodeContentId(episode);
|
||||
}
|
||||
|
||||
getEpisodeProgress(episode: XtreamSerieEpisode): number {
|
||||
return getPortalPlaybackProgressPercent(
|
||||
this.getEpisodePosition(episode)
|
||||
);
|
||||
}
|
||||
|
||||
/** "42 min · 18m left", "42 min · watched", "42 min" — or null. */
|
||||
getEpisodeSubline(episode: XtreamSerieEpisode): string | null {
|
||||
return buildEpisodeSubline(
|
||||
this.getEpisodeInfo(episode),
|
||||
this.getEpisodePosition(episode),
|
||||
this.translate
|
||||
);
|
||||
}
|
||||
|
||||
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
|
||||
return formatEpisodePositionText(this.getEpisodePosition(episode));
|
||||
}
|
||||
|
||||
getEpisodeContentId(episode: XtreamSerieEpisode): number {
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
private getEpisodePosition(
|
||||
getEpisodePosition(
|
||||
episode: XtreamSerieEpisode
|
||||
): PlaybackPositionData | undefined {
|
||||
return this.playbackPositions().get(this.getEpisodeContentId(episode));
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
|
||||
// The episode list of the selected season: the "now playing / continue"
|
||||
// highlight, the header counter, the episode menu and the season
|
||||
// description. The main season-container spec sits at the max-lines cap.
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: { duration: '45 min', plot: 'Pilot episode' },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
describe('SeasonContainerComponent episode list', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>
|
||||
) => {
|
||||
fixture.componentRef.setInput('seasons', seasons);
|
||||
fixture.componentRef.setInput('seriesId', 20);
|
||||
fixture.componentRef.setInput('playlistId', 'playlist-1');
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem('iptvnator_episode_view_mode');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
SeasonContainerComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
hasAuthoritativeDownloadList: signal(false),
|
||||
hasLoadedDownloads: signal(false),
|
||||
downloads: signal([]),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
SeasonDownloadCoordinator,
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem('iptvnator_episode_view_mode');
|
||||
});
|
||||
|
||||
it('defaults to the list view', () => {
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.viewMode()).toBe('list');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'.episodes-list .episode-list-item'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('counts the episodes and the watched ones in the header', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[
|
||||
102,
|
||||
{
|
||||
contentXtreamId: 102,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 2700,
|
||||
durationSeconds: 2700,
|
||||
} as PlaybackPositionData,
|
||||
],
|
||||
])
|
||||
);
|
||||
setRequiredInputs({
|
||||
'1': [
|
||||
createEpisode(),
|
||||
createEpisode({ id: '102', episode_num: 2 }),
|
||||
],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.section-header__count')
|
||||
?.textContent.trim()
|
||||
).toBe('PORTALS.EPISODE_COUNT_OTHER · PORTALS.EPISODES_WATCHED_COUNT');
|
||||
});
|
||||
|
||||
it('highlights the most recently watched unfinished episode when nothing plays', () => {
|
||||
const position = (
|
||||
contentXtreamId: number,
|
||||
positionSeconds: number,
|
||||
updatedAt: string
|
||||
): PlaybackPositionData => ({
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
positionSeconds,
|
||||
durationSeconds: 2700,
|
||||
updatedAt,
|
||||
});
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[101, position(101, 600, '2026-10-01T10:00:00Z')],
|
||||
[102, position(102, 900, '2026-10-02T10:00:00Z')],
|
||||
[103, position(103, 2690, '2026-10-03T10:00:00Z')],
|
||||
])
|
||||
);
|
||||
setRequiredInputs({
|
||||
'1': [
|
||||
createEpisode(),
|
||||
createEpisode({ id: '102', episode_num: 2 }),
|
||||
createEpisode({ id: '103', episode_num: 3 }),
|
||||
],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.currentEpisodeId()).toBe(102);
|
||||
const current = fixture.nativeElement.querySelectorAll(
|
||||
'.episode-item--current'
|
||||
);
|
||||
expect(current.length).toBe(1);
|
||||
expect(current[0].getAttribute('data-episode-id')).toBe('102');
|
||||
});
|
||||
|
||||
it('plays from the beginning through the episode menu', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[
|
||||
101,
|
||||
{
|
||||
contentXtreamId: 101,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 600,
|
||||
durationSeconds: 2700,
|
||||
} as PlaybackPositionData,
|
||||
],
|
||||
])
|
||||
);
|
||||
const restarted: XtreamSerieEpisode[] = [];
|
||||
component.episodeRestartRequested.subscribe((episode) =>
|
||||
restarted.push(episode)
|
||||
);
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="episode-more-button"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
(
|
||||
document.querySelector(
|
||||
'[data-testid="episode-play-from-beginning"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
|
||||
expect(restarted.map((episode) => episode.id)).toEqual(['101']);
|
||||
});
|
||||
it('drops a season description that repeats the series description', () => {
|
||||
const series = 'The series plot. It goes on.';
|
||||
fixture.componentRef.setInput('seriesDescription', series);
|
||||
fixture.componentRef.setInput('seasonDescriptions', {
|
||||
'1': 'The series plot.',
|
||||
'2': 'A season of its own.',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201' })],
|
||||
});
|
||||
component.selectSeason('1');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeasonDescription()).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-description"]'
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
component.selectSeason('2');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-description"]'
|
||||
)?.textContent
|
||||
).toContain('A season of its own.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
|
||||
// Loading skeleton, full rows and bare rows of the season container,
|
||||
// decided from the episode data (see episode-meta-state.util.ts).
|
||||
|
||||
const SERIES_POSTER = 'https://img.test/series.jpg';
|
||||
|
||||
function createEpisode(
|
||||
id: number,
|
||||
info: Record<string, unknown> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: String(id),
|
||||
episode_num: id,
|
||||
title: `Episode ${id}`,
|
||||
container_extension: 'mp4',
|
||||
info,
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
/** A Stalker-like season: every "still" is the series poster, no plots. */
|
||||
const bareSeason = () => ({
|
||||
'1': [1, 2, 3].map((id) =>
|
||||
createEpisode(id, { movie_image: SERIES_POSTER })
|
||||
),
|
||||
});
|
||||
|
||||
describe('SeasonContainerComponent metadata state', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>
|
||||
) => {
|
||||
fixture.componentRef.setInput('seasons', seasons);
|
||||
fixture.componentRef.setInput('seriesId', 20);
|
||||
fixture.componentRef.setInput('playlistId', 'playlist-1');
|
||||
};
|
||||
const all = (selector: string): HTMLElement[] =>
|
||||
Array.from(fixture.nativeElement.querySelectorAll(selector));
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
SeasonContainerComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
hasAuthoritativeDownloadList: signal(false),
|
||||
hasLoadedDownloads: signal(false),
|
||||
downloads: signal([]),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
SeasonDownloadCoordinator,
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('seriesPosterUrl', SERIES_POSTER);
|
||||
});
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('renders bare rows without thumbnails and hides the grid toggle', () => {
|
||||
setRequiredInputs(bareSeason());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.metaState()).toBe('bare');
|
||||
expect(all('app-episode-item.episode-item--bare')).toHaveLength(3);
|
||||
expect(all('.episode-item__thumb')).toHaveLength(0);
|
||||
expect(all('.view-toggle')).toHaveLength(0);
|
||||
expect(all('.episodes-list--bare')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps bare rows in list form even when grid was the saved view', () => {
|
||||
component.setViewMode('grid');
|
||||
setRequiredInputs(bareSeason());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.effectiveViewMode()).toBe('list');
|
||||
expect(all('.episodes-grid')).toHaveLength(0);
|
||||
expect(all('app-episode-item.episode-list-item')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('treats a still that repeats the season cover as missing', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [
|
||||
createEpisode(1, {
|
||||
movie_image: 'https://img.test/season-1.jpg',
|
||||
}),
|
||||
createEpisode(2, { movie_image: SERIES_POSTER }),
|
||||
],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.metaState()).toBe('bare');
|
||||
});
|
||||
|
||||
it('keeps full rows with a dimmed tile when only some stills are missing', () => {
|
||||
setRequiredInputs({
|
||||
'1': [
|
||||
createEpisode(1, { movie_image: 'https://img.test/e1.jpg' }),
|
||||
createEpisode(2, { movie_image: SERIES_POSTER }),
|
||||
],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.metaState()).toBe('full');
|
||||
expect(all('.episode-item__thumb')).toHaveLength(2);
|
||||
expect(all('.episode-item__thumb--fallback')).toHaveLength(1);
|
||||
expect(all('.view-toggle')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps full rows and the grid toggle for a lone episode still', () => {
|
||||
// No plots, one genuine still among three episodes: the UI contract
|
||||
// keeps full rows when only some stills are missing.
|
||||
setRequiredInputs({
|
||||
'1': [
|
||||
createEpisode(1, { movie_image: 'https://img.test/e1.jpg' }),
|
||||
createEpisode(2),
|
||||
createEpisode(3),
|
||||
],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.metaState()).toBe('full');
|
||||
expect(all('.episode-item__thumb')).toHaveLength(3);
|
||||
expect(all('.episode-item__thumb--fallback')).toHaveLength(2);
|
||||
expect(all('.view-toggle')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a season the provider already describes at once, metadata or not', () => {
|
||||
fixture.componentRef.setInput('metadataLoading', true);
|
||||
setRequiredInputs({
|
||||
'1': [1, 2].map((id) =>
|
||||
createEpisode(id, {
|
||||
plot: `Plot ${id}`,
|
||||
movie_image: `https://img.test/e${id}.jpg`,
|
||||
})
|
||||
),
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.metaState()).toBe('full');
|
||||
expect(all('[data-testid="episode-skeleton"]')).toHaveLength(0);
|
||||
expect(all('app-episode-item')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows one skeleton row per episode while the metadata loads, then the rows', () => {
|
||||
fixture.componentRef.setInput('metadataLoading', true);
|
||||
setRequiredInputs(bareSeason());
|
||||
fixture.detectChanges();
|
||||
|
||||
// Not judged bare from the provider list alone.
|
||||
expect(component.metaState()).toBe('loading');
|
||||
expect(all('[data-testid="episode-skeleton"]')).toHaveLength(3);
|
||||
expect(all('app-episode-item')).toHaveLength(0);
|
||||
|
||||
fixture.componentRef.setInput('seasons', {
|
||||
'1': [1, 2, 3].map((id) =>
|
||||
createEpisode(id, {
|
||||
plot: `Plot ${id}`,
|
||||
movie_image: `https://img.test/e${id}.jpg`,
|
||||
})
|
||||
),
|
||||
});
|
||||
fixture.componentRef.setInput('metadataLoading', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.metaState()).toBe('full');
|
||||
expect(all('[data-testid="episode-skeleton"]')).toHaveLength(0);
|
||||
expect(all('app-episode-item')).toHaveLength(3);
|
||||
expect(all('.episode-item__thumb--fallback')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('falls back to bare only after the metadata answered with nothing', () => {
|
||||
fixture.componentRef.setInput('metadataLoading', true);
|
||||
setRequiredInputs(bareSeason());
|
||||
fixture.detectChanges();
|
||||
expect(component.metaState()).toBe('loading');
|
||||
|
||||
fixture.componentRef.setInput('metadataLoading', false);
|
||||
fixture.detectChanges();
|
||||
expect(component.metaState()).toBe('bare');
|
||||
});
|
||||
|
||||
it('reserves the action row only in skeleton list rows', () => {
|
||||
fixture.componentRef.setInput('metadataLoading', true);
|
||||
setRequiredInputs(bareSeason());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(all('.episode-skeleton__actions')).toHaveLength(3);
|
||||
|
||||
component.setViewMode('grid');
|
||||
fixture.detectChanges();
|
||||
expect(all('.episode-skeleton__actions')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('follows the saved grid view with skeleton cards', () => {
|
||||
component.setViewMode('grid');
|
||||
fixture.componentRef.setInput('metadataLoading', true);
|
||||
setRequiredInputs(bareSeason());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(all('app-episode-skeleton.episodes-grid')).toHaveLength(1);
|
||||
expect(all('.episode-skeleton--card')).toHaveLength(3);
|
||||
});
|
||||
it('shows an empty season instead of skeletons while metadata is pending', () => {
|
||||
// A host never enriches (and so never settles) a season without
|
||||
// episodes: its pending flag would otherwise hold skeletons forever.
|
||||
fixture.componentRef.setInput('metadataLoading', true);
|
||||
setRequiredInputs({ '1': [] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.metaState()).not.toBe('loading');
|
||||
expect(all('[data-testid="episode-skeleton"]')).toHaveLength(0);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'PORTALS.SEASON_EMPTY'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops waiting for metadata after the cap and renders the provider rows', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
fixture.componentRef.setInput('metadataLoading', true);
|
||||
setRequiredInputs(bareSeason());
|
||||
fixture.detectChanges();
|
||||
expect(component.metaState()).toBe('loading');
|
||||
|
||||
jest.advanceTimersByTime(3999);
|
||||
fixture.detectChanges();
|
||||
expect(component.metaState()).toBe('loading');
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
fixture.detectChanges();
|
||||
expect(component.metaState()).toBe('bare');
|
||||
expect(all('[data-testid="episode-skeleton"]')).toHaveLength(0);
|
||||
expect(all('.episode-item')).toHaveLength(3);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
+12
-9
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Watch progress in app chrome reads one theme token in both season view
|
||||
// modes; the list rows draw their own fill rather than the grid's capsule
|
||||
// (see the UI guidelines' Progress Bars section).
|
||||
// modes: the episode item draws its own 3px bar on the thumbnail (see the UI
|
||||
// guidelines' Progress Bars section).
|
||||
const DIR = resolve(
|
||||
process.cwd(),
|
||||
'libs/ui/components/src/lib/season-container'
|
||||
@@ -14,14 +14,17 @@ function read(file: string): string {
|
||||
}
|
||||
|
||||
describe('season container watch-progress colour', () => {
|
||||
it('fills list rows with the app watch-progress token', () => {
|
||||
const styles = read('season-container.component.scss');
|
||||
const listItem = styles.slice(styles.indexOf('.episode-list-item {'));
|
||||
const fill = listItem.match(/&__progress-fill\s*\{([^}]*)\}/)?.[1];
|
||||
it('fills the episode progress bar with the app watch-progress token', () => {
|
||||
const styles = read('episode-item.component.scss');
|
||||
const bar = styles.slice(styles.indexOf('.episode-item__progress {'));
|
||||
const fill = bar.match(/\bi\s*\{([^}]*)\}/)?.[1];
|
||||
|
||||
expect(read('season-container.component.html')).toContain(
|
||||
'class="episode-list-item__progress-fill"'
|
||||
expect(read('episode-item.component.html')).toContain(
|
||||
'class="episode-item__progress"'
|
||||
);
|
||||
expect(fill).toMatch(/background:\s*var\(--ep-accent\);/);
|
||||
expect(styles).toMatch(
|
||||
/--ep-accent:\s*var\(--app-progress-color, #78adff\);/
|
||||
);
|
||||
expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/);
|
||||
});
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
|
||||
// Coverage for the season cover beside the season tabs and the season
|
||||
// thumbnails in the >6-seasons dropdown (both fed by the `seasonPosters`
|
||||
// input). Lives beside season-container.component.spec.ts, which sits at the
|
||||
// max-lines cap.
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: { duration: '45 min' },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
describe('SeasonContainerComponent season cover', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>
|
||||
) => {
|
||||
fixture.componentRef.setInput('seasons', seasons);
|
||||
fixture.componentRef.setInput('seriesId', 20);
|
||||
fixture.componentRef.setInput('playlistId', 'playlist-1');
|
||||
};
|
||||
|
||||
const cover = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-cover"]'
|
||||
) as HTMLImageElement | null;
|
||||
|
||||
const coverColumnRendered = () =>
|
||||
fixture.nativeElement.querySelector('.season-strip__cover') !== null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
SeasonContainerComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
hasAuthoritativeDownloadList: signal(false),
|
||||
hasLoadedDownloads: signal(false),
|
||||
downloads: signal([]),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
SeasonDownloadCoordinator,
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('renders the selected season cover and swaps it with the tab', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cover()?.src).toBe('https://img.test/season-1.jpg');
|
||||
expect(coverColumnRendered()).toBe(true);
|
||||
|
||||
const tabs = fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
) as NodeListOf<HTMLButtonElement>;
|
||||
tabs[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(cover()?.src).toBe('https://img.test/season-2.jpg');
|
||||
});
|
||||
|
||||
it('folds the cover column when the image request fails', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// A dead image link folds the cover column instead of leaving a
|
||||
// broken-image frame beside the tabs; the tabs stay in place.
|
||||
cover()?.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(cover()).toBeNull();
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill')
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('withholds the season cover for a one-season item', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
});
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cover()).toBeNull();
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
});
|
||||
|
||||
describe('dropdown thumbnails (more than six seasons)', () => {
|
||||
const sevenSeasons = (): Record<string, XtreamSerieEpisode[]> => {
|
||||
const seasons: Record<string, XtreamSerieEpisode[]> = {};
|
||||
for (let index = 1; index <= 7; index++) {
|
||||
seasons[String(index)] = [
|
||||
createEpisode({
|
||||
id: String(100 + index),
|
||||
season: index,
|
||||
}),
|
||||
];
|
||||
}
|
||||
return seasons;
|
||||
};
|
||||
const trigger = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-dropdown"]'
|
||||
) as HTMLButtonElement | null;
|
||||
const triggerThumb = () =>
|
||||
trigger()?.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-dropdown-thumb"]'
|
||||
) ?? null;
|
||||
const openMenu = (): HTMLButtonElement[] => {
|
||||
trigger()?.click();
|
||||
fixture.detectChanges();
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.mat-mdc-menu-panel .mat-mdc-menu-item'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('renders a thumbnail per menu row that has a poster and none where a season has no poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'3': 'https://img.test/season-3.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
// The pill row is untouched: the dropdown replaces it.
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill')
|
||||
).toHaveLength(0);
|
||||
const items = openMenu();
|
||||
expect(items).toHaveLength(7);
|
||||
const thumbOf = (item: HTMLButtonElement) =>
|
||||
item.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-menu-thumb"]'
|
||||
);
|
||||
expect(thumbOf(items[0])?.src).toBe(
|
||||
'https://img.test/season-1.jpg'
|
||||
);
|
||||
// Projected into the leading slot, ahead of Material's text span.
|
||||
expect(thumbOf(items[0])?.parentElement).toBe(items[0]);
|
||||
expect(thumbOf(items[1])).toBeNull();
|
||||
expect(thumbOf(items[2])?.src).toBe(
|
||||
'https://img.test/season-3.jpg'
|
||||
);
|
||||
// Rows without a poster carry no placeholder either.
|
||||
expect(items[1].querySelector('.season-tabs__thumb')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the selected season poster in the closed trigger and swaps it with the selection', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(triggerThumb()?.src).toBe('https://img.test/season-1.jpg');
|
||||
expect(
|
||||
trigger()?.classList.contains(
|
||||
'season-tabs__dropdown--with-thumb'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
openMenu()[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(triggerThumb()?.src).toBe('https://img.test/season-2.jpg');
|
||||
|
||||
// A season without a poster leaves the trigger text-only.
|
||||
openMenu()[2].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('3');
|
||||
expect(triggerThumb()).toBeNull();
|
||||
expect(
|
||||
trigger()?.classList.contains(
|
||||
'season-tabs__dropdown--with-thumb'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a thumbnail whose image request fails instead of leaving a broken frame', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
triggerThumb()?.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(triggerThumb()).toBeNull();
|
||||
// The same URL is dropped from the menu row too.
|
||||
expect(
|
||||
openMenu()[0].querySelector('[data-testid="season-menu-thumb"]')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('withholds the cover when the selected season has no poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(cover()).toBeNull();
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-season-tabs')
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
|
||||
// The episodes header's season picker (chips up to four seasons, a menu
|
||||
// with "N episodes · M watched" rows from five) and the season synopsis
|
||||
// under it. No season poster renders in either, even when the host passes
|
||||
// `seasonPosters`. Lives beside season-container.component.spec.ts, which
|
||||
// sits at the max-lines cap.
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: { duration: '45 min' },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
/** `count` seasons of `perSeason` episodes; ids are season*100 + episode. */
|
||||
function seasonsOf(
|
||||
count: number,
|
||||
perSeason = 1
|
||||
): Record<string, XtreamSerieEpisode[]> {
|
||||
const seasons: Record<string, XtreamSerieEpisode[]> = {};
|
||||
for (let season = 1; season <= count; season++) {
|
||||
seasons[String(season)] = Array.from(
|
||||
{ length: perSeason },
|
||||
(_, index) =>
|
||||
createEpisode({
|
||||
id: String(season * 100 + index + 1),
|
||||
episode_num: index + 1,
|
||||
season,
|
||||
})
|
||||
);
|
||||
}
|
||||
return seasons;
|
||||
}
|
||||
|
||||
function postersFor(count: number): Record<string, string> {
|
||||
const posters: Record<string, string> = {};
|
||||
for (let season = 1; season <= count; season++) {
|
||||
posters[String(season)] = `https://img.test/season-${season}.jpg`;
|
||||
}
|
||||
return posters;
|
||||
}
|
||||
|
||||
const watchedPosition = (contentXtreamId: number) => ({
|
||||
contentXtreamId,
|
||||
contentType: 'episode' as const,
|
||||
seriesXtreamId: 20,
|
||||
positionSeconds: 100,
|
||||
durationSeconds: 100,
|
||||
playlistId: 'playlist-1',
|
||||
});
|
||||
|
||||
describe('SeasonContainerComponent season picker', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>
|
||||
) => {
|
||||
fixture.componentRef.setInput('seasons', seasons);
|
||||
fixture.componentRef.setInput('seriesId', 20);
|
||||
fixture.componentRef.setInput('playlistId', 'playlist-1');
|
||||
};
|
||||
|
||||
const query = (selector: string): HTMLElement | null =>
|
||||
fixture.nativeElement.querySelector(selector);
|
||||
const chips = () =>
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
) as NodeListOf<HTMLButtonElement>;
|
||||
const trigger = () =>
|
||||
query('[data-testid="season-dropdown"]') as HTMLButtonElement | null;
|
||||
const openMenu = (): HTMLButtonElement[] => {
|
||||
trigger()?.click();
|
||||
fixture.detectChanges();
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.season-tabs-menu .mat-mdc-menu-item'
|
||||
)
|
||||
);
|
||||
};
|
||||
/** Any season artwork in the header, the synopsis or an open menu. */
|
||||
const seasonImages = () => [
|
||||
...Array.from(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'app-detail-section-header img, [data-testid="season-description"] img'
|
||||
)
|
||||
),
|
||||
...Array.from(document.querySelectorAll('.season-tabs-menu img')),
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
SeasonContainerComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
hasAuthoritativeDownloadList: signal(false),
|
||||
hasLoadedDownloads: signal(false),
|
||||
downloads: signal([]),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
SeasonDownloadCoordinator,
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
PORTALS: {
|
||||
SEASON_TAB: 'Season {{number}}',
|
||||
EPISODE_COUNT_ONE: '1 episode',
|
||||
EPISODE_COUNT_OTHER: '{{count}} episodes',
|
||||
EPISODES_WATCHED_COUNT: '{{count}} watched',
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('shows chips for a three-season show and no season poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', postersFor(3));
|
||||
fixture.componentRef.setInput('seasonDescriptions', {
|
||||
'1': 'A season of its own.',
|
||||
});
|
||||
setRequiredInputs(seasonsOf(3));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(chips()).toHaveLength(3);
|
||||
expect(trigger()).toBeNull();
|
||||
expect(query('[data-testid="season-description"]')).not.toBeNull();
|
||||
expect(seasonImages()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps chips at four seasons and switches to the menu at five', () => {
|
||||
setRequiredInputs(seasonsOf(4));
|
||||
fixture.detectChanges();
|
||||
expect(chips()).toHaveLength(4);
|
||||
expect(trigger()).toBeNull();
|
||||
|
||||
setRequiredInputs(seasonsOf(5));
|
||||
fixture.detectChanges();
|
||||
expect(chips()).toHaveLength(0);
|
||||
expect(trigger()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('lists "N episodes · M watched" per season in the menu of a twelve-season show', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', postersFor(12));
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[101, watchedPosition(101)],
|
||||
[102, watchedPosition(102)],
|
||||
])
|
||||
);
|
||||
setRequiredInputs(seasonsOf(12, 3));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(trigger()?.textContent).toContain('Season 1');
|
||||
const items = openMenu();
|
||||
expect(items).toHaveLength(12);
|
||||
|
||||
const countOf = (item: HTMLButtonElement) =>
|
||||
item
|
||||
.querySelector('[data-testid="season-menu-count"]')
|
||||
?.textContent?.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
expect(countOf(items[0])).toBe('3 episodes · 2 watched');
|
||||
// Nothing watched: the second half is left out, not "0 watched".
|
||||
expect(countOf(items[1])).toBe('3 episodes');
|
||||
expect(
|
||||
items[0].classList.contains('season-tabs__menu-item--selected')
|
||||
).toBe(true);
|
||||
expect(
|
||||
items[1].classList.contains('season-tabs__menu-item--selected')
|
||||
).toBe(false);
|
||||
expect(seasonImages()).toHaveLength(0);
|
||||
|
||||
items[4].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('5');
|
||||
});
|
||||
|
||||
it('shows no count for a lazy season the portal has not answered yet', () => {
|
||||
// Stalker VOD seasons start as empty lists and load on request: an
|
||||
// unfetched season is unknown, not "0 episodes".
|
||||
const seasons = seasonsOf(6, 2);
|
||||
seasons['5'] = [];
|
||||
seasons['6'] = [];
|
||||
fixture.componentRef.setInput('hasUnloadedSeasons', true);
|
||||
fixture.componentRef.setInput('seasonLoadStates', {
|
||||
'5': 'unloaded',
|
||||
'6': 'loading',
|
||||
});
|
||||
setRequiredInputs(seasons);
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = openMenu();
|
||||
const countOf = (item: HTMLButtonElement) =>
|
||||
item.querySelector('[data-testid="season-menu-count"]');
|
||||
expect(countOf(items[0])?.textContent).toContain('2 episodes');
|
||||
expect(countOf(items[4])).toBeNull();
|
||||
expect(countOf(items[5])).toBeNull();
|
||||
expect(items[4].textContent).toContain('Season 5');
|
||||
});
|
||||
|
||||
it('renders no synopsis row for a season without a plot', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', postersFor(2));
|
||||
fixture.componentRef.setInput('seasonDescriptions', {
|
||||
'2': 'A season of its own.',
|
||||
});
|
||||
setRequiredInputs(seasonsOf(2));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(query('[data-testid="season-description"]')).toBeNull();
|
||||
expect(query('.season-strip')).toBeNull();
|
||||
expect(seasonImages()).toHaveLength(0);
|
||||
|
||||
chips()[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
query('[data-testid="season-description"]')?.textContent
|
||||
).toContain('A season of its own.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { repeatsSeriesDescription } from './season-description.util';
|
||||
|
||||
const SERIES =
|
||||
'Two researchers find a cure. The family fights over it. The eldest son steps in.';
|
||||
|
||||
describe('repeatsSeriesDescription', () => {
|
||||
it('matches the same text after trimming and whitespace folding', () => {
|
||||
expect(repeatsSeriesDescription(` ${SERIES}\n`, SERIES)).toBe(true);
|
||||
expect(
|
||||
repeatsSeriesDescription(SERIES.replace(/ /g, ' '), SERIES)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches a season text that is the series text cut short', () => {
|
||||
expect(
|
||||
repeatsSeriesDescription(
|
||||
'Two researchers find a cure. The family fights over it.',
|
||||
SERIES
|
||||
)
|
||||
).toBe(true);
|
||||
expect(repeatsSeriesDescription(`${SERIES} More.`, SERIES)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a season synopsis of its own', () => {
|
||||
expect(
|
||||
repeatsSeriesDescription('The second season moves abroad.', SERIES)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never hides a description against a missing series description', () => {
|
||||
expect(repeatsSeriesDescription(SERIES, null)).toBe(false);
|
||||
expect(repeatsSeriesDescription(SERIES, ' ')).toBe(false);
|
||||
expect(repeatsSeriesDescription('', SERIES)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Whitespace-insensitive form of a synopsis for comparison. */
|
||||
function normalize(text: string | null | undefined): string {
|
||||
return (text ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a season synopsis only repeats the series description shown a
|
||||
* few hundred pixels above in the hero: the same text, or one of the two
|
||||
* cut short from the other (providers often send the series plot, or its
|
||||
* first sentences, as every season's description).
|
||||
*/
|
||||
export function repeatsSeriesDescription(
|
||||
seasonDescription: string | null | undefined,
|
||||
seriesDescription: string | null | undefined
|
||||
): boolean {
|
||||
const season = normalize(seasonDescription);
|
||||
const series = normalize(seriesDescription);
|
||||
if (!season || !series) {
|
||||
return false;
|
||||
}
|
||||
return series.startsWith(season) || season.startsWith(series);
|
||||
}
|
||||
@@ -3,64 +3,58 @@
|
||||
<button
|
||||
class="season-tabs__dropdown"
|
||||
data-testid="season-dropdown"
|
||||
[class.season-tabs__dropdown--with-thumb]="selectedPosterUrl()"
|
||||
[matMenuTriggerFor]="seasonMenu"
|
||||
type="button"
|
||||
>
|
||||
@if (selectedPosterUrl(); as posterUrl) {
|
||||
<img
|
||||
class="season-tabs__thumb"
|
||||
data-testid="season-dropdown-thumb"
|
||||
[src]="posterUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
(error)="onPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: selectedSeason() ?? '—' }
|
||||
}}
|
||||
</span>
|
||||
<mat-icon>arrow_drop_down</mat-icon>
|
||||
<mat-icon>expand_more</mat-icon>
|
||||
</button>
|
||||
<mat-menu #seasonMenu="matMenu">
|
||||
<mat-menu #seasonMenu="matMenu" class="season-tabs-menu">
|
||||
@for (seasonKey of seasonKeys(); track seasonKey) {
|
||||
@let episodeCount = episodeCounts()[seasonKey] ?? 0;
|
||||
@let watchedCount = watchedCounts()[seasonKey] ?? 0;
|
||||
<button
|
||||
mat-menu-item
|
||||
class="season-tabs__menu-item"
|
||||
data-testid="season-menu-item"
|
||||
[class.season-tabs__menu-item--selected]="
|
||||
seasonKey === selectedSeason()
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
<!-- Projected into the item's leading slot so the row
|
||||
stays a flex line: thumbnail, name, count, check. -->
|
||||
@if (posterUrlOf(seasonKey); as posterUrl) {
|
||||
<img
|
||||
matMenuItemIcon
|
||||
class="season-tabs__thumb season-tabs__thumb--menu"
|
||||
data-testid="season-menu-thumb"
|
||||
[src]="posterUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
(error)="onPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: seasonKey }
|
||||
}}
|
||||
<span class="season-tabs__menu-row">
|
||||
<span class="season-tabs__menu-name">
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: seasonKey }
|
||||
}}
|
||||
</span>
|
||||
@if (isCountKnown(seasonKey)) {
|
||||
<small
|
||||
class="season-tabs__menu-count"
|
||||
data-testid="season-menu-count"
|
||||
>
|
||||
{{
|
||||
(episodeCount === 1
|
||||
? 'PORTALS.EPISODE_COUNT_ONE'
|
||||
: 'PORTALS.EPISODE_COUNT_OTHER'
|
||||
) | translate: { count: episodeCount }
|
||||
}}
|
||||
@if (watchedCount) {
|
||||
·
|
||||
{{
|
||||
'PORTALS.EPISODES_WATCHED_COUNT'
|
||||
| translate: { count: watchedCount }
|
||||
}}
|
||||
}
|
||||
</small>
|
||||
}
|
||||
</span>
|
||||
<span class="season-tabs__menu-count">
|
||||
{{ episodeCounts()[seasonKey] }}
|
||||
</span>
|
||||
@if (isSeasonCompleted(seasonKey)) {
|
||||
<mat-icon class="season-tabs__done">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
@@ -7,15 +9,18 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
|
||||
// Season pills and the dropdown share the section header's 30px pill.
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
font-size: 13px;
|
||||
@@ -30,61 +35,48 @@
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.1));
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 14%,
|
||||
transparent
|
||||
);
|
||||
background: var(--app-widget-bg);
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 600;
|
||||
outline: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
&__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));
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 10px 0 12px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
background: var(--app-widget-bg);
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
// The trigger hugs its thumbnail: less inset on the picture side and
|
||||
// vertically, so the pill grows only as much as the 42px poster needs.
|
||||
&--with-thumb {
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
> mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
}
|
||||
}
|
||||
|
||||
// Season thumbnail in the dropdown (trigger + menu rows). Poster ratio at
|
||||
// 28×42 — small enough to keep the 48px menu row height Material sets.
|
||||
&__thumb {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 42px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
|
||||
&--menu {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__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);
|
||||
color: var(--app-success-color, #4ade80);
|
||||
}
|
||||
|
||||
&__back-to-playing {
|
||||
@@ -112,8 +104,71 @@
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-item--selected {
|
||||
font-weight: 700;
|
||||
// The season menu: two columns per row, the season name and its
|
||||
// "N episodes · M watched" count; the selected season gets a selection tint.
|
||||
// The panel lives in the overlay container, outside this host.
|
||||
::ng-deep .season-tabs-menu.mat-mdc-menu-panel {
|
||||
@include mat.menu-overrides(
|
||||
(
|
||||
container-color: var(--mat-sys-surface-container-high),
|
||||
)
|
||||
);
|
||||
min-width: 280px;
|
||||
max-width: min(360px, calc(100vw - 24px));
|
||||
border: 1px solid color-mix(in srgb, var(--app-on-surface) 10%, transparent);
|
||||
border-radius: 10px;
|
||||
|
||||
.mat-mdc-menu-content {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.season-tabs__menu-item {
|
||||
min-height: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
|
||||
// Material wraps the projected text in a span that must take the row.
|
||||
::ng-deep .mat-mdc-menu-item-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 10%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.season-tabs__menu-count {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.season-tabs__menu-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.season-tabs__menu-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--app-heading-color);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.season-tabs__menu-count {
|
||||
flex: none;
|
||||
color: var(--app-muted-color);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -4,29 +4,27 @@ import {
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} 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;
|
||||
/** Up to this many seasons the picker is a row of chips, beyond it a menu. */
|
||||
const MAX_SEASON_CHIPS = 4;
|
||||
|
||||
/** A season whose episodes are not in yet: `loading` now, `unloaded` until asked. */
|
||||
export type SeasonCountLoadState = 'loading' | 'unloaded';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Season selector for the season container: a chip row ("Season 1 · 2 · 3")
|
||||
* for up to 4 seasons, a menu button beyond that, and a "back to playing
|
||||
* episode" chip when the currently playing episode belongs to a different
|
||||
* season. The threshold is the season count, not the available width.
|
||||
*
|
||||
* The dropdown carries season thumbnails (`seasonPosters`): a small poster at
|
||||
* the start of each menu row and, for the selected season, in the closed
|
||||
* trigger — a long menu of "Season 7 … Season 14" rows reads far faster with
|
||||
* a picture per row. The pill row deliberately stays text-only: a thumbnail
|
||||
* per pill turns a compact tab strip into a second poster rail. A season
|
||||
* without a poster simply has no thumbnail (no placeholder tile), and a
|
||||
* poster whose image request fails is dropped rather than left as a
|
||||
* broken-image frame.
|
||||
* Neither form shows a season poster: the chips are a compact tab strip and
|
||||
* the menu rows carry "N episodes · M watched" instead, which tells a long
|
||||
* run of "Season 7 … Season 14" apart better than a column of near-identical
|
||||
* thumbnails.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-season-tabs',
|
||||
@@ -41,22 +39,22 @@ export class SeasonTabsComponent {
|
||||
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. */
|
||||
/**
|
||||
* Seasons whose episode list is still a portal request away (lazy
|
||||
* Stalker VOD): their count is unknown, not zero, so the menu row shows
|
||||
* none until the portal answers.
|
||||
*/
|
||||
readonly seasonLoadStates = input<Readonly<
|
||||
Record<string, SeasonCountLoadState>
|
||||
> | null>(null);
|
||||
/** Season key of the episode currently playing inline, if any. */
|
||||
readonly playingSeasonKey = input<string | null>(null);
|
||||
/**
|
||||
* Per-season poster URLs keyed by season key, rendered as thumbnails in
|
||||
* the dropdown only (menu rows + the selected season's trigger).
|
||||
*/
|
||||
readonly seasonPosters = input<Readonly<Record<string, string>> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly backToPlayingRequested = output<void>();
|
||||
|
||||
readonly useDropdown = computed(
|
||||
() => this.seasonKeys().length > MAX_SEASON_PILLS
|
||||
() => this.seasonKeys().length > MAX_SEASON_CHIPS
|
||||
);
|
||||
|
||||
readonly showBackToPlaying = computed(() => {
|
||||
@@ -64,22 +62,9 @@ export class SeasonTabsComponent {
|
||||
return playing !== null && playing !== this.selectedSeason();
|
||||
});
|
||||
|
||||
/** Poster URLs whose image request failed; their thumbnail is dropped. */
|
||||
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
/** Thumbnail shown in the closed dropdown trigger. */
|
||||
readonly selectedPosterUrl = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
return selected === undefined ? null : this.posterUrlOf(selected);
|
||||
});
|
||||
|
||||
posterUrlOf(seasonKey: string): string | null {
|
||||
const url = this.seasonPosters()?.[seasonKey];
|
||||
return url && !this.failedPosters().has(url) ? url : null;
|
||||
}
|
||||
|
||||
onPosterError(url: string): void {
|
||||
this.failedPosters.update((failed) => new Set(failed).add(url));
|
||||
/** False while the season's episode list is still on its way. */
|
||||
isCountKnown(seasonKey: string): boolean {
|
||||
return !this.seasonLoadStates()?.[seasonKey];
|
||||
}
|
||||
|
||||
isSeasonCompleted(seasonKey: string): boolean {
|
||||
|
||||
@@ -42,9 +42,7 @@ export interface SeasonWatchPresenterSources {
|
||||
@Injectable()
|
||||
export class SeasonWatchPresenter {
|
||||
private readonly logger = createLogger('SeasonWatchPresenter');
|
||||
private readonly sources = signal<SeasonWatchPresenterSources | null>(
|
||||
null
|
||||
);
|
||||
private readonly sources = signal<SeasonWatchPresenterSources | null>(null);
|
||||
|
||||
connect(sources: SeasonWatchPresenterSources): void {
|
||||
this.sources.set(sources);
|
||||
@@ -199,9 +197,7 @@ export class SeasonWatchPresenter {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.loadedEpisodes().length > 0 || sources.hasUnloadedSeasons()
|
||||
);
|
||||
return this.loadedEpisodes().length > 0 || sources.hasUnloadedSeasons();
|
||||
});
|
||||
|
||||
readonly seriesActionDisabled = computed(() => {
|
||||
|
||||
@@ -15,8 +15,7 @@ export interface SeasonContainerSeriesPlaybackToggleRequest {
|
||||
requests: SeasonContainerPlaybackToggleRequest[];
|
||||
}
|
||||
|
||||
export interface SeasonContainerSeasonPlaybackToggleRequest
|
||||
extends SeasonContainerSeriesPlaybackToggleRequest {
|
||||
export interface SeasonContainerSeasonPlaybackToggleRequest extends SeasonContainerSeriesPlaybackToggleRequest {
|
||||
seasonKey: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Placeholder blocks for detail-page loading states (hero, episode rows).
|
||||
//
|
||||
// A block is a flat fill a step above the page surface with a moving
|
||||
// highlight a step higher, both mixed from the theme's on-surface ink so the
|
||||
// placeholders read on the light and the dark theme alike. Under
|
||||
// `prefers-reduced-motion` the highlight stays put.
|
||||
//
|
||||
// Include `keyframes` once in a component stylesheet that uses `block`:
|
||||
// Angular scopes keyframes to the component that declares them.
|
||||
|
||||
$duration: 1.4s;
|
||||
|
||||
@mixin keyframes {
|
||||
@keyframes skeleton-shimmer {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin block($radius: 4px) {
|
||||
--skeleton-base: color-mix(in srgb, var(--app-on-surface) 6%, transparent);
|
||||
--skeleton-highlight: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 11%,
|
||||
transparent
|
||||
);
|
||||
|
||||
display: block;
|
||||
border-radius: $radius;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--skeleton-base) 25%,
|
||||
var(--skeleton-highlight) 50%,
|
||||
var(--skeleton-base) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer $duration ease-in-out infinite;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
background: var(--skeleton-base);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user