mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
feat(ui): cinematic movie and series details pages and dashboard hero (#1792)
This commit is contained in:
1 parent
07c5dffab0
commit
ab8460338a
213 files changed
+12494
-3930
No files matched your search
@@ -34,3 +34,19 @@ export * from './lib/window-controls/window-controls.component';
|
||||
|
||||
export * from './lib/channel-scroll-focus/channel-scroll-focus.directive';
|
||||
export * from './lib/category-lock-menu/category-lock-menu.component';
|
||||
|
||||
export * from './lib/detail-ui/cast-crew.util';
|
||||
export * from './lib/detail-ui/cast-crew-row.component';
|
||||
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/meta-chip.component';
|
||||
export * from './lib/detail-ui/similar-rail.component';
|
||||
export * from './lib/detail-ui/trailer-dialog.component';
|
||||
export * from './lib/detail-ui/trailer-dialog.service';
|
||||
export * from './lib/detail-ui/trailer-dialog-state';
|
||||
export * from './lib/detail-ui/vod-more-menu.component';
|
||||
export * from './lib/detail-ui/vod-more-menu.model';
|
||||
export * from './lib/detail-ui/series-hero.state';
|
||||
export * from './lib/detail-ui/hero-trailer-backdrop.component';
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<section class="hero">
|
||||
<section class="hero" [class.hero--compact]="layout() === 'compact'">
|
||||
@if (errorMessage()) {
|
||||
<div class="hero__content hero__error">
|
||||
<mat-icon class="error-icon">cloud_off</mat-icon>
|
||||
@@ -7,7 +7,7 @@
|
||||
<ng-content select="[hero-error-actions]"></ng-content>
|
||||
</div>
|
||||
} @else if (isLoading()) {
|
||||
<div class="hero__content">
|
||||
<div class="hero__content hero__content--skeleton">
|
||||
<!-- Skeleton Poster -->
|
||||
<div class="poster">
|
||||
<ngx-skeleton-loader
|
||||
@@ -15,7 +15,7 @@
|
||||
[theme]="{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
'border-radius': '8px',
|
||||
'border-radius': '10px',
|
||||
'aspect-ratio': '2/3',
|
||||
display: 'block',
|
||||
}"
|
||||
@@ -24,59 +24,60 @@
|
||||
|
||||
<!-- Skeleton Details -->
|
||||
<div class="details">
|
||||
<!-- Title -->
|
||||
<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': '16px',
|
||||
'margin-bottom': '14px',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Tags -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px">
|
||||
<div style="display: flex; gap: 6px; margin-bottom: 14px">
|
||||
<ngx-skeleton-loader
|
||||
count="3"
|
||||
[theme]="{
|
||||
height: '24px',
|
||||
height: '22px',
|
||||
width: '60px',
|
||||
'border-radius': '999px',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<ngx-skeleton-loader
|
||||
count="3"
|
||||
[theme]="{
|
||||
height: '16px',
|
||||
height: '14px',
|
||||
'margin-bottom': '8px',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Meta -->
|
||||
<div style="margin-top: 20px; display: flex; gap: 32px">
|
||||
<div style="margin-top: 14px; display: flex; gap: 10px">
|
||||
<ngx-skeleton-loader
|
||||
count="2"
|
||||
count="1"
|
||||
[theme]="{
|
||||
height: '40px',
|
||||
width: '100px',
|
||||
height: '44px',
|
||||
width: '150px',
|
||||
'border-radius': '10px',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div style="margin-top: 24px; display: flex; gap: 12px">
|
||||
<ngx-skeleton-loader
|
||||
count="2"
|
||||
[theme]="{
|
||||
height: '48px',
|
||||
width: '140px',
|
||||
height: '44px',
|
||||
width: '44px',
|
||||
'border-radius': '10px',
|
||||
display: 'block',
|
||||
}"
|
||||
/>
|
||||
@@ -103,7 +104,42 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Gradient Vignette Overlay -->
|
||||
@if (trailerBackdropUrl(); as trailerUrl) {
|
||||
<app-hero-trailer-backdrop
|
||||
#trailer
|
||||
class="hero__trailer"
|
||||
[embedUrl]="trailerUrl"
|
||||
[title]="formattedTitle()"
|
||||
/>
|
||||
<!-- Above the content layer, or the content box would take the clicks -->
|
||||
@if (trailer.playing()) {
|
||||
<button
|
||||
type="button"
|
||||
class="hero__trailer-mute"
|
||||
[matTooltip]="
|
||||
(trailer.muted()
|
||||
? 'PORTALS.DETAIL.TRAILER_UNMUTE'
|
||||
: 'PORTALS.DETAIL.TRAILER_MUTE'
|
||||
) | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
(trailer.muted()
|
||||
? 'PORTALS.DETAIL.TRAILER_UNMUTE'
|
||||
: 'PORTALS.DETAIL.TRAILER_MUTE'
|
||||
) | translate
|
||||
"
|
||||
[attr.aria-pressed]="!trailer.muted()"
|
||||
data-test-id="trailer-backdrop-mute"
|
||||
(click)="trailer.toggleMute()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
trailer.muted() ? 'volume_off' : 'volume_up'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Scrim: strong on the text side, fading into the page below -->
|
||||
<div class="hero__vignette"></div>
|
||||
|
||||
<!-- Hero Content -->
|
||||
@@ -133,6 +169,12 @@
|
||||
</div>
|
||||
|
||||
<div class="details">
|
||||
@if (kindLabel(); as kind) {
|
||||
<p class="details__kind" data-test-id="detail-kind">
|
||||
{{ kind }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<h1 class="details__title">{{ formattedTitle() }}</h1>
|
||||
|
||||
<!-- Tags Slot -->
|
||||
@@ -175,15 +217,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Metadata Grid Slot -->
|
||||
<div class="details__meta">
|
||||
<ng-content select="[hero-meta]"></ng-content>
|
||||
</div>
|
||||
@if (progress() !== null) {
|
||||
<span
|
||||
class="details__progress"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="progress()"
|
||||
data-test-id="detail-progress"
|
||||
>
|
||||
<i [style.width.%]="progress()"></i>
|
||||
</span>
|
||||
}
|
||||
|
||||
<!-- Action Buttons Slot -->
|
||||
<div class="action-buttons">
|
||||
<ng-content select="[hero-actions]"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Credits / metadata slot -->
|
||||
<div class="details__meta">
|
||||
<ng-content select="[hero-meta]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
// ============================================================================
|
||||
// Netflix-style Detail View - Cinematic Streaming Design
|
||||
// Detail hero — cinematic layout shared by movie and series pages
|
||||
// ============================================================================
|
||||
|
||||
@use '../../../../styles/detail-view-actions' as detail-actions;
|
||||
//
|
||||
// The backdrop fills the hero; a strong scrim on the text side and a fade
|
||||
// into the page background keep the copy readable. Without a 16:9 backdrop
|
||||
// (or when the provider sent the poster twice) the blurred poster takes its
|
||||
// place. The page surface and all text colours are the app theme's, so the
|
||||
// light theme gets a light scrim with dark text and the dark theme the
|
||||
// reverse, with the same geometry.
|
||||
|
||||
:host {
|
||||
--poster-width: 220px;
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
|
||||
--poster-width: 190px;
|
||||
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);
|
||||
--content-max-width: 1400px;
|
||||
|
||||
// Light theme colors (default - no .dark-theme class on body)
|
||||
--hero-gradient-start: rgba(255, 250, 245, 0);
|
||||
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
|
||||
--hero-gradient-end: rgba(255, 250, 245, 1);
|
||||
--surface-bg: #fffaf5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.5);
|
||||
--accent-color: #b81d24;
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
--surface-bg: var(--app-content-bg, #161a22);
|
||||
--text-primary: var(--app-heading-color, #e7ecf3);
|
||||
--text-secondary: var(--app-body-color, #9aa3b2);
|
||||
--text-muted: var(--app-muted-color, #6b7384);
|
||||
--accent-color: var(--app-selection-color, #78adff);
|
||||
--tag-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 10%,
|
||||
transparent
|
||||
);
|
||||
--tag-border: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 14%,
|
||||
transparent
|
||||
);
|
||||
|
||||
width: 100%;
|
||||
display: block;
|
||||
@@ -27,19 +35,8 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
// Dark theme overrides - cinematic dark mode
|
||||
:host-context(.dark-theme) {
|
||||
--hero-gradient-start: rgba(20, 20, 20, 0);
|
||||
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
|
||||
--hero-gradient-end: rgba(20, 20, 20, 1);
|
||||
--surface-bg: #141414;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--accent-color: #e50914;
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -48,23 +45,26 @@
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
// Natural height: the page scroll container lives outside the hero
|
||||
// (PortalDetailShellComponent). `overflow: hidden` clips the scaled
|
||||
// blurred backdrop fallback.
|
||||
// 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);
|
||||
box-sizing: border-box;
|
||||
// `overflow: hidden` clips the scaled blurred backdrop fallback.
|
||||
overflow: hidden;
|
||||
background: var(
|
||||
--surface-bg
|
||||
); // Solid background prevents any bleed-through
|
||||
background: var(--surface-bg);
|
||||
|
||||
// Only the poster: nothing to show above the content, so the hero is
|
||||
// sized by its content column over the blurred wash.
|
||||
&--compact {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// Backdrop image layer - fixed at top, never repeats
|
||||
&__backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: auto; // Only use height, not bottom
|
||||
height: 60%;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center 20%;
|
||||
background-repeat: no-repeat;
|
||||
@@ -79,77 +79,91 @@
|
||||
object-position: center 20%;
|
||||
}
|
||||
|
||||
// Fallback blur effect when using poster as backdrop
|
||||
// Fallback: the poster, blurred and scaled past its edges.
|
||||
&--blurred {
|
||||
filter: blur(40px) saturate(1.3);
|
||||
transform: scale(1.4);
|
||||
height: 65%;
|
||||
filter: blur(22px) saturate(1.2);
|
||||
transform: scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient overlays for text legibility
|
||||
// The muted trailer sits over the still backdrop and under the scrim.
|
||||
&__trailer {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
// Its sound toggle lives above the content box (z-index 2), which would
|
||||
// otherwise take the clicks; the top band is clear of the content.
|
||||
&__trailer-mute {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--surface-bg) 70%, transparent);
|
||||
color: var(--app-heading-color);
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Two scrim layers: a horizontal one for the text column, a vertical one
|
||||
// that dissolves the artwork into the page.
|
||||
&__vignette {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0; // Full height to cover everything
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent 35%,
|
||||
var(--surface-bg) 55%,
|
||||
var(--surface-bg) 100%
|
||||
);
|
||||
|
||||
// Top area gradient for text legibility over backdrop
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60%;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--hero-gradient-end) 0%,
|
||||
var(--hero-gradient-mid) 50%,
|
||||
var(--hero-gradient-start) 100%
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--surface-bg) 97%, transparent) 0%,
|
||||
color-mix(in srgb, var(--surface-bg) 90%, transparent) 34%,
|
||||
color-mix(in srgb, var(--surface-bg) 45%, transparent) 64%,
|
||||
color-mix(in srgb, var(--surface-bg) 15%, transparent) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-bg) 25%, transparent) 0%,
|
||||
transparent 30%,
|
||||
transparent 60%,
|
||||
var(--surface-bg) 100%
|
||||
);
|
||||
}
|
||||
|
||||
// Side fade for poster area
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 50%;
|
||||
width: 40%;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--hero-gradient-mid) 0%,
|
||||
var(--hero-gradient-start) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Content area (page scroll lives in the surrounding detail shell)
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
padding: 140px 40px 60px;
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 24px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&--skeleton {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
// Error state
|
||||
&__error {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -175,39 +189,37 @@
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Poster Card - Floating with shadow
|
||||
// Poster
|
||||
// ============================================================================
|
||||
|
||||
.poster {
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 auto;
|
||||
width: var(--poster-width);
|
||||
align-self: flex-start;
|
||||
aspect-ratio: 2 / 3;
|
||||
align-self: flex-end;
|
||||
|
||||
&:hover .poster__image {
|
||||
transform: scale(1.02);
|
||||
&__image,
|
||||
&__placeholder {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--poster-shadow);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--poster-shadow);
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
box-shadow: var(--poster-shadow);
|
||||
background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
|
||||
overflow: hidden;
|
||||
|
||||
&-icon {
|
||||
@@ -233,21 +245,35 @@
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Details Section - Title, metadata, description
|
||||
// Details column - kind, title, chips, description, actions, credits
|
||||
// ============================================================================
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding-bottom: 24px;
|
||||
max-width: 620px;
|
||||
|
||||
&__kind {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.4px;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: clamp(1.75rem, 4vw, 3rem);
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 3.2vw, 40px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
margin: 0 0 16px 0;
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.5px;
|
||||
text-wrap: balance;
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@@ -258,70 +284,29 @@
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
|
||||
// These styles are for Projected Content, we need to ensure they apply
|
||||
// using ::ng-deep or just assume the global styles will handle them?
|
||||
// Actually, since we project content, the styles defined here might NOT apply to the projected content
|
||||
// unless we use ::ng-deep or the content itself has these styles.
|
||||
// However, since we are moving the "shell", the container classes (.details__tags) are here.
|
||||
// The inner items (.details__tag) will be in the projected content.
|
||||
// So we need ::ng-deep here for .details__tag.
|
||||
|
||||
::ng-deep .details__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: var(--tag-bg);
|
||||
border: 1px solid var(--tag-border);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&--rating {
|
||||
color: #ffd700;
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
// Rendered as a <button> so the facet is keyboard-operable; the
|
||||
// UA's own font and appearance have to be neutralized to keep it
|
||||
// visually identical to the informational chips beside it.
|
||||
&--clickable {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--tag-border);
|
||||
border-color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--text-primary, #fff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
max-width: 600px;
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--text-primary) 78%,
|
||||
var(--text-secondary)
|
||||
);
|
||||
text-wrap: pretty;
|
||||
max-width: 100%;
|
||||
|
||||
// Truncate long descriptions
|
||||
// Three lines, then "More"
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -335,40 +320,29 @@
|
||||
&__description-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 4px 0;
|
||||
gap: 2px;
|
||||
margin: -8px 0 0;
|
||||
padding: 2px 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
transition:
|
||||
color 180ms ease,
|
||||
transform 180ms ease;
|
||||
transition: color 180ms ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
|
||||
mat-icon {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-expanded='true']:hover mat-icon {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -378,14 +352,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__progress {
|
||||
display: block;
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
height: 3px;
|
||||
margin-bottom: -4px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 18%,
|
||||
transparent
|
||||
);
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 32px;
|
||||
margin-bottom: 24px;
|
||||
gap: 12px 32px;
|
||||
max-width: 100%;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Similar issue for meta items
|
||||
// Legacy `.details__meta-item` projected by hosts not yet on credits.
|
||||
::ng-deep .details__meta-item {
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -405,162 +405,52 @@
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action Buttons - Play, Favorite
|
||||
// Action row - never wraps on a desktop window
|
||||
// ============================================================================
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 100%;
|
||||
|
||||
// Use ::ng-deep to style the projected buttons
|
||||
::ng-deep .play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 64px;
|
||||
padding: 10px 24px 10px 18px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
color: #141414;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@include detail-actions.play-button-copy;
|
||||
}
|
||||
|
||||
::ng-deep .favorite-btn,
|
||||
::ng-deep .download-btn,
|
||||
// The sources chip is a peer secondary action, so it wears the same
|
||||
// surface, height and radius. Without this it reads as a link floating
|
||||
// between three solid buttons.
|
||||
::ng-deep app-vod-sources-chip .sources-chip {
|
||||
@include detail-actions.secondary-action-button;
|
||||
}
|
||||
|
||||
// Match the chip to its neighbouring icon buttons' tag surfaces. Its own
|
||||
// pressed state (.sources-chip--open) is more specific and still wins.
|
||||
::ng-deep app-vod-sources-chip .sources-chip {
|
||||
background: var(--tag-bg);
|
||||
border-color: var(--tag-border);
|
||||
|
||||
&:hover {
|
||||
background: var(--tag-border);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .favorite-btn {
|
||||
@include detail-actions.favorite-button-state;
|
||||
}
|
||||
|
||||
::ng-deep .download-btn {
|
||||
@include detail-actions.download-button-state;
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Responsive - Option A: Keep poster grid layout
|
||||
// Narrow details column (beside the categories panel, phones)
|
||||
// ============================================================================
|
||||
// At a 1200px window the details pane beside the categories panel is about
|
||||
// 815px wide and must still carry the poster; the poster goes below that.
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:host {
|
||||
--poster-width: 120px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
&__backdrop {
|
||||
height: 45%;
|
||||
|
||||
&--blurred {
|
||||
height: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__vignette {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent 25%,
|
||||
var(--surface-bg) 45%,
|
||||
var(--surface-bg) 100%
|
||||
);
|
||||
|
||||
&::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 60px 20px 40px;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@container detail (max-width: 760px) {
|
||||
.poster {
|
||||
// Mobile poster adjustments - none needed
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero__content {
|
||||
padding: 40px 20px 24px;
|
||||
}
|
||||
|
||||
.details {
|
||||
&__title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
-webkit-line-clamp: 3;
|
||||
|
||||
&--expanded {
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
gap: 12px 20px;
|
||||
}
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
|
||||
@include detail-actions.projected-mobile-action-buttons;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// A phone cannot carry the poster and the details side by side: the details
|
||||
// column drops to ~200px, and the action buttons are then squeezed below
|
||||
// their own labels ("Play" clipped down to its icon). Stack them instead.
|
||||
@media (max-width: 640px) {
|
||||
.hero__content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 140px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.details__title {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,7 +461,7 @@
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -580,7 +470,17 @@
|
||||
}
|
||||
|
||||
.hero__content {
|
||||
animation: fadeInUp 0.6s ease-out 0.2s both;
|
||||
animation: fadeInUp 0.4s ease-out 0.1s both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero__content {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.details__description-toggle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Skeleton Loader Overrides
|
||||
@@ -588,35 +488,13 @@ ngx-skeleton-loader {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
// Ensure the loader inside poster takes up the correct aspect ratio space
|
||||
::ng-deep .loader {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.dark-theme) {
|
||||
ngx-skeleton-loader {
|
||||
::ng-deep .loader {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.poster {
|
||||
// ... existing styles ...
|
||||
// Ensure poster container has height/ratio for skeleton
|
||||
min-height: 330px; // Fallback
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
// After the rule above so it wins the cascade at equal specificity: that
|
||||
// 330px skeleton fallback is sized for the desktop poster width, while the
|
||||
// stacked phone hero renders the poster 140px wide — its aspect-ratio height
|
||||
// is ~210px, and the leftover minimum was pure empty space pushing the title
|
||||
// and actions ~120px down on every loaded detail page.
|
||||
@media (max-width: 640px) {
|
||||
.poster {
|
||||
min-height: 0;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 10%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { HeroTrailerBackdropComponent } from '../detail-ui/hero-trailer-backdrop.component';
|
||||
import { ContentHeroComponent } from './content-hero.component';
|
||||
|
||||
describe('ContentHeroComponent', () => {
|
||||
@@ -107,3 +109,153 @@ describe('ContentHeroComponent', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentHeroComponent cinematic layout', () => {
|
||||
let fixture: ComponentFixture<ContentHeroComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContentHeroComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(ContentHeroComponent);
|
||||
});
|
||||
|
||||
function host(): HTMLElement {
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
it('renders the kind label above the title and the resume bar', () => {
|
||||
fixture.componentRef.setInput('title', 'Black Harbor');
|
||||
fixture.componentRef.setInput('kindLabel', 'Movie · Demo');
|
||||
fixture.componentRef.setInput('progress', 63);
|
||||
fixture.detectChanges();
|
||||
|
||||
const kind = host().querySelector('[data-test-id="detail-kind"]');
|
||||
expect(kind?.textContent?.trim()).toBe('Movie · Demo');
|
||||
const title = host().querySelector('.details__title');
|
||||
expect(kind?.compareDocumentPosition(title as Node)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
const bar = host().querySelector('[data-test-id="detail-progress"]');
|
||||
expect(bar?.getAttribute('aria-valuenow')).toBe('63');
|
||||
expect(
|
||||
(bar?.querySelector('i') as HTMLElement | null)?.style.width
|
||||
).toBe('63%');
|
||||
});
|
||||
|
||||
it('hides the resume bar and kind label when absent', () => {
|
||||
fixture.componentRef.setInput('title', 'Black Harbor');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('[data-test-id="detail-kind"]')).toBeNull();
|
||||
expect(
|
||||
host().querySelector('[data-test-id="detail-progress"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('blurs the poster as backdrop when the backdrop is missing or is the poster', () => {
|
||||
fixture.componentRef.setInput('posterUrl', 'poster.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
host().querySelector(
|
||||
'.hero__backdrop--blurred img[src="poster.jpg"]'
|
||||
)
|
||||
).toBeTruthy();
|
||||
|
||||
fixture.componentRef.setInput('backdropUrl', 'poster.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero__backdrop--blurred')).toBeTruthy();
|
||||
|
||||
fixture.componentRef.setInput('backdropUrl', 'wide.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero__backdrop--blurred')).toBeNull();
|
||||
expect(
|
||||
host().querySelector('img.hero__backdrop-image[src="wide.jpg"]')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps room for a real backdrop and sizes the hero by content without one', () => {
|
||||
fixture.componentRef.setInput('title', 'Poster only');
|
||||
fixture.componentRef.setInput('posterUrl', 'poster.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero--compact')).toBeTruthy();
|
||||
|
||||
fixture.componentRef.setInput('title', 'With backdrop');
|
||||
fixture.componentRef.setInput('backdropUrl', 'wide.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero--compact')).toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('title', 'Poster twice');
|
||||
fixture.componentRef.setInput('backdropUrl', 'poster.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero--compact')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not grow when enrichment adds a backdrop to the same title', () => {
|
||||
fixture.componentRef.setInput('title', 'Late backdrop');
|
||||
fixture.componentRef.setInput('posterUrl', 'poster.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero--compact')).toBeTruthy();
|
||||
|
||||
fixture.componentRef.setInput('backdropUrl', 'tmdb-wide.jpg');
|
||||
fixture.componentRef.setInput('posterUrl', 'tmdb-poster.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero--compact')).toBeTruthy();
|
||||
expect(
|
||||
host().querySelector(
|
||||
'img.hero__backdrop-image[src="tmdb-wide.jpg"]'
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the trailer sound toggle above the content once the trailer plays', () => {
|
||||
fixture.componentRef.setInput('title', 'Black Harbor');
|
||||
fixture.componentRef.setInput(
|
||||
'trailerBackdropUrl',
|
||||
'https://www.youtube-nocookie.com/embed/abc123'
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero__trailer-mute')).toBeNull();
|
||||
|
||||
const trailer = fixture.debugElement.query(
|
||||
By.directive(HeroTrailerBackdropComponent)
|
||||
).componentInstance as HeroTrailerBackdropComponent;
|
||||
trailer.playing.set(true);
|
||||
fixture.detectChanges();
|
||||
const mute = host().querySelector<HTMLButtonElement>(
|
||||
'.hero__trailer-mute'
|
||||
);
|
||||
expect(mute?.getAttribute('aria-pressed')).toBe('false');
|
||||
mute?.click();
|
||||
fixture.detectChanges();
|
||||
expect(trailer.muted()).toBe(false);
|
||||
expect(mute?.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('keys the layout on the content identity when enrichment renames the title', () => {
|
||||
fixture.componentRef.setInput('contentKey', 'm3u:42');
|
||||
fixture.componentRef.setInput('title', 'Channel name');
|
||||
fixture.componentRef.setInput('posterUrl', 'poster.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero--compact')).toBeTruthy();
|
||||
|
||||
// TMDB replaces the title and brings a backdrop: same content.
|
||||
fixture.componentRef.setInput('title', 'Proper Title (2021)');
|
||||
fixture.componentRef.setInput('backdropUrl', 'wide.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero--compact')).toBeTruthy();
|
||||
|
||||
fixture.componentRef.setInput('contentKey', 'm3u:43');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('.hero--compact')).toBeNull();
|
||||
});
|
||||
|
||||
it('places the actions before the credits slot', () => {
|
||||
fixture.componentRef.setInput('title', 'Black Harbor');
|
||||
fixture.detectChanges();
|
||||
const actions = host().querySelector('.action-buttons');
|
||||
const meta = host().querySelector('.details__meta');
|
||||
expect(actions?.compareDocumentPosition(meta as Node)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
@@ -13,15 +14,22 @@ import {
|
||||
} from '@angular/core';
|
||||
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. */
|
||||
export type ContentHeroLayout = 'stage' | 'compact';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-hero',
|
||||
standalone: true,
|
||||
imports: [
|
||||
HeroTrailerBackdropComponent,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
NgxSkeletonLoaderComponent,
|
||||
TranslateModule,
|
||||
],
|
||||
@@ -34,9 +42,20 @@ export class ContentHeroComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly title = input<string>();
|
||||
/** "Movie · playlist name" eyebrow above the title. */
|
||||
readonly kindLabel = input<string | null>(null);
|
||||
readonly description = input<string>();
|
||||
readonly posterUrl = input<string>();
|
||||
readonly backdropUrl = input<string>();
|
||||
/**
|
||||
* Stable identity of the shown title (provider + id). The layout is
|
||||
* decided once per identity; enrichment may replace the title itself.
|
||||
*/
|
||||
readonly contentKey = input<string | null>(null);
|
||||
/** 0–100 watched share; renders the resume bar above the actions. */
|
||||
readonly progress = input<number | null>(null);
|
||||
/** With the setting on, this trailer plays muted behind the details. */
|
||||
readonly trailerBackdropUrl = input<string | null>(null);
|
||||
readonly isLoading = input(false);
|
||||
readonly errorMessage = input<string>();
|
||||
|
||||
@@ -52,9 +71,35 @@ export class ContentHeroComponent {
|
||||
readonly backdropImageUrl = computed(() =>
|
||||
this.backdropError() ? undefined : this.backdropSourceUrl()
|
||||
);
|
||||
readonly usesPosterBackdrop = computed(
|
||||
() => !this.backdropUrl() && !!this.posterUrl() && !this.backdropError()
|
||||
);
|
||||
/**
|
||||
* No 16:9 backdrop, or the provider sent the poster twice: the poster,
|
||||
* blurred and scaled, becomes the backdrop (the sharp copy stays on the
|
||||
* left). Common for Xtream and Stalker portals.
|
||||
*/
|
||||
readonly usesPosterBackdrop = computed(() => {
|
||||
const poster = this.posterUrl();
|
||||
const backdrop = this.backdropUrl();
|
||||
return (
|
||||
!!poster &&
|
||||
(!backdrop || backdrop === poster) &&
|
||||
!this.backdropError()
|
||||
);
|
||||
});
|
||||
private readonly hasRealBackdrop = computed(() => {
|
||||
const backdrop = this.backdropUrl();
|
||||
return !!backdrop && backdrop !== this.posterUrl();
|
||||
});
|
||||
/**
|
||||
* Decided once per title: a backdrop that TMDB enrichment adds a moment
|
||||
* later fills the compact hero instead of growing it under the user's
|
||||
* cursor. Keyed by `contentKey`, falling back to the title for hosts
|
||||
* without one.
|
||||
*/
|
||||
readonly layout = linkedSignal<string | undefined, ContentHeroLayout>({
|
||||
source: () => this.contentKey() ?? this.title(),
|
||||
computation: () =>
|
||||
untracked(() => this.hasRealBackdrop()) ? 'stage' : 'compact',
|
||||
});
|
||||
|
||||
readonly descriptionEl =
|
||||
viewChild<ElementRef<HTMLElement>>('descriptionEl');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
@if (entries().length) {
|
||||
<app-detail-rail
|
||||
[title]="'PORTALS.DETAIL.CAST_AND_CREW' | translate"
|
||||
[count]="entries().length"
|
||||
[headingId]="headingId()"
|
||||
data-test-id="detail-cast-crew"
|
||||
>
|
||||
@for (entry of entries(); track entry.key) {
|
||||
<div
|
||||
class="person"
|
||||
[class.person--clickable]="isClickable(entry)"
|
||||
[attr.role]="isClickable(entry) ? 'button' : null"
|
||||
[attr.tabindex]="isClickable(entry) ? 0 : null"
|
||||
(click)="select(entry)"
|
||||
(keydown.enter)="select(entry)"
|
||||
(keydown.space)="$event.preventDefault(); select(entry)"
|
||||
>
|
||||
@if (entry.profileUrl; as photo) {
|
||||
<img
|
||||
class="person__avatar"
|
||||
[src]="photo"
|
||||
[alt]="entry.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="person__avatar person__avatar--initials"
|
||||
aria-hidden="true"
|
||||
>{{ entry.initials }}</span
|
||||
>
|
||||
}
|
||||
<p class="person__name">{{ entry.name }}</p>
|
||||
<small class="person__role">{{ entry.role ?? ' ' }}</small>
|
||||
</div>
|
||||
}
|
||||
</app-detail-rail>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.person {
|
||||
flex: 0 0 96px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .person__avatar {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.person__avatar {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--app-widget-bg, #1d2533);
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&--initials {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--app-body-color, #9aa3b2);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.person__name {
|
||||
margin: 8px 0 2px;
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.3;
|
||||
text-wrap: balance;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.person__role {
|
||||
display: block;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
|
||||
import { CastCrewRowComponent } from './cast-crew-row.component';
|
||||
|
||||
const ACTOR: TmdbEnrichedCastMember = {
|
||||
name: 'Ada Vance',
|
||||
character: 'Captain',
|
||||
tmdbPersonId: 501,
|
||||
} as TmdbEnrichedCastMember;
|
||||
|
||||
describe('CastCrewRowComponent', () => {
|
||||
let fixture: ComponentFixture<CastCrewRowComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CastCrewRowComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(CastCrewRowComponent);
|
||||
fixture.componentRef.setInput('cast', [ACTOR]);
|
||||
});
|
||||
|
||||
function person(): HTMLElement {
|
||||
const element = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.person'
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element as HTMLElement;
|
||||
}
|
||||
|
||||
it('offers a person with a TMDB id as a button that opens the actor page', () => {
|
||||
const personSelected = jest.fn();
|
||||
fixture.componentInstance.personSelected.subscribe(personSelected);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(person().getAttribute('role')).toBe('button');
|
||||
expect(person().getAttribute('tabindex')).toBe('0');
|
||||
person().click();
|
||||
expect(personSelected).toHaveBeenCalledWith(ACTOR);
|
||||
});
|
||||
|
||||
it('renders plain entries for a host without an actor page', () => {
|
||||
const personSelected = jest.fn();
|
||||
fixture.componentInstance.personSelected.subscribe(personSelected);
|
||||
fixture.componentRef.setInput('interactive', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
// A focusable button that does nothing would mislead mouse and
|
||||
// keyboard users alike.
|
||||
expect(person().getAttribute('role')).toBeNull();
|
||||
expect(person().getAttribute('tabindex')).toBeNull();
|
||||
expect(person().classList.contains('person--clickable')).toBe(false);
|
||||
person().click();
|
||||
expect(personSelected).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
|
||||
import { buildCastCrewEntries, type CastCrewEntry } from './cast-crew.util';
|
||||
import { DetailRailComponent } from './detail-rail.component';
|
||||
|
||||
/** Scrolls the page to the Cast & crew row ("and more" in the credits). */
|
||||
export function scrollToCastCrewRow(): void {
|
||||
document
|
||||
.getElementById('detail-cast-crew')
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Cast & crew" row under the hero: 72px round avatars with the full name
|
||||
* and the character (or "Director"), the director first, initials when a
|
||||
* person has no photo. A click on a person with a TMDB id opens the actor
|
||||
* page through the host; a host without one renders plain entries.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-cast-crew-row',
|
||||
imports: [DetailRailComponent, TranslatePipe],
|
||||
templateUrl: './cast-crew-row.component.html',
|
||||
styleUrl: './cast-crew-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CastCrewRowComponent {
|
||||
readonly cast = input<readonly TmdbEnrichedCastMember[]>([]);
|
||||
readonly directors = input<readonly TmdbEnrichedCastMember[]>([]);
|
||||
readonly headingId = input<string | null>('detail-cast-crew');
|
||||
/** False when the host has no actor page: nobody becomes a button that does nothing. */
|
||||
readonly interactive = input(true);
|
||||
|
||||
readonly personSelected = output<TmdbEnrichedCastMember>();
|
||||
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly entries = computed<CastCrewEntry[]>(() =>
|
||||
buildCastCrewEntries(
|
||||
this.cast(),
|
||||
this.directors(),
|
||||
this.translate.instant('XTREAM.DIRECTOR')
|
||||
)
|
||||
);
|
||||
|
||||
isClickable(entry: CastCrewEntry): boolean {
|
||||
return this.interactive() && !!entry.member.tmdbPersonId;
|
||||
}
|
||||
|
||||
select(entry: CastCrewEntry): void {
|
||||
if (this.isClickable(entry)) {
|
||||
this.personSelected.emit(entry.member);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
buildCastCrewEntries,
|
||||
castMembersFromNames,
|
||||
personInitials,
|
||||
splitPeopleNames,
|
||||
} from './cast-crew.util';
|
||||
|
||||
describe('splitPeopleNames', () => {
|
||||
it('splits on commas and semicolons and drops duplicates', () => {
|
||||
expect(splitPeopleNames('Mara Venn, Elias Shore; mara venn, ')).toEqual([
|
||||
'Mara Venn',
|
||||
'Elias Shore',
|
||||
]);
|
||||
expect(splitPeopleNames(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCastCrewEntries', () => {
|
||||
it('lists the director first and dedupes a director who also acts', () => {
|
||||
const entries = buildCastCrewEntries(
|
||||
[
|
||||
{ name: 'Russell Crowe', character: 'Henry Murray', profileUrl: 'a.jpg', tmdbPersonId: 1 },
|
||||
{ name: 'Janus Metz', profileUrl: null },
|
||||
{ name: 'Mark Camacho', profileUrl: null },
|
||||
],
|
||||
[{ name: 'Janus Metz', profileUrl: null, tmdbPersonId: 7 }],
|
||||
'Director'
|
||||
);
|
||||
expect(entries.map((entry) => [entry.name, entry.role])).toEqual([
|
||||
['Janus Metz', 'Director'],
|
||||
['Russell Crowe', 'Henry Murray'],
|
||||
['Mark Camacho', null],
|
||||
]);
|
||||
expect(entries[0].key).toBe('p7');
|
||||
expect(entries[2].initials).toBe('MC');
|
||||
});
|
||||
|
||||
it('wraps provider names without photos', () => {
|
||||
expect(castMembersFromNames(['A B'])).toEqual([
|
||||
{ name: 'A B', profileUrl: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('personInitials', () => {
|
||||
it('takes the first and last initials', () => {
|
||||
expect(personInitials('Aleksandr Samoylenko Jr.')).toBe('AJ');
|
||||
expect(personInitials('Cher')).toBe('C');
|
||||
expect(personInitials('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** One avatar of the "Cast & crew" row. */
|
||||
export interface CastCrewEntry {
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
/** Character for actors, a translated "Director" for crew, else null. */
|
||||
readonly role: string | null;
|
||||
readonly profileUrl: string | null;
|
||||
readonly initials: string;
|
||||
readonly member: TmdbEnrichedCastMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Mara Venn, Elias Shore; Tomas Bell" → names. Providers join the cast with
|
||||
* commas (Xtream) or semicolons (some Stalker portals); either way a name
|
||||
* never contains the separator.
|
||||
*/
|
||||
export function splitPeopleNames(value: string | null | undefined): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
for (const part of value.split(/[,;]/)) {
|
||||
const name = part.trim();
|
||||
const key = normalizePersonName(name);
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Plain names (no TMDB data) as cast members without photo or character. */
|
||||
export function castMembersFromNames(
|
||||
names: readonly string[]
|
||||
): TmdbEnrichedCastMember[] {
|
||||
return names.map((name) => ({ name, profileUrl: null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Director first, then the cast, one entry per person: a director who also
|
||||
* acts keeps the single "Director" avatar.
|
||||
*/
|
||||
export function buildCastCrewEntries(
|
||||
cast: readonly TmdbEnrichedCastMember[],
|
||||
directors: readonly TmdbEnrichedCastMember[],
|
||||
directorRole: string
|
||||
): CastCrewEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const entries: CastCrewEntry[] = [];
|
||||
const push = (member: TmdbEnrichedCastMember, role: string | null) => {
|
||||
const key = normalizePersonName(member.name);
|
||||
if (!key || seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
entries.push({
|
||||
key: member.tmdbPersonId ? `p${member.tmdbPersonId}` : `n:${key}`,
|
||||
name: member.name.trim(),
|
||||
role,
|
||||
profileUrl: member.profileUrl ?? null,
|
||||
initials: personInitials(member.name),
|
||||
member,
|
||||
});
|
||||
};
|
||||
directors.forEach((member) => push(member, directorRole));
|
||||
cast.forEach((member) => push(member, member.character?.trim() || null));
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function personInitials(name: string): string {
|
||||
const parts = name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((part) => part.length > 0);
|
||||
if (parts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const first = parts[0][0] ?? '';
|
||||
const last = parts.length > 1 ? (parts[parts.length - 1][0] ?? '') : '';
|
||||
return `${first}${last}`.toUpperCase();
|
||||
}
|
||||
|
||||
function normalizePersonName(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<button
|
||||
type="button"
|
||||
class="play-btn"
|
||||
[class.play-btn--secondary]="variant() === 'secondary'"
|
||||
[class.play-btn--launching]="state() === 'launching'"
|
||||
[class.play-btn--stop]="state() === 'stop'"
|
||||
[class.play-btn--two-line]="!!meta()"
|
||||
[disabled]="disabled()"
|
||||
[attr.data-testid]="testId()"
|
||||
[attr.aria-busy]="state() === 'launching' ? 'true' : null"
|
||||
(click)="pressed.emit()"
|
||||
>
|
||||
<mat-icon class="play-btn__icon" aria-hidden="true">{{ icon() }}</mat-icon>
|
||||
<span class="play-btn__copy">
|
||||
<span class="play-btn__label">{{ label() }}</span>
|
||||
@if (meta(); as metaLine) {
|
||||
<span class="play-btn__meta">{{ metaLine }}</span>
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
@@ -0,0 +1,106 @@
|
||||
@use '../../../../styles/detail-view-actions' as actions;
|
||||
|
||||
:host {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
@include actions.light-primary-button;
|
||||
|
||||
height: 44px;
|
||||
max-width: 100%;
|
||||
|
||||
&--secondary {
|
||||
@include actions.ghost-text-button;
|
||||
|
||||
height: 44px;
|
||||
gap: 8px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
&__copy {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: block;
|
||||
max-width: min(42vw, 320px);
|
||||
overflow: hidden;
|
||||
margin-top: 1px;
|
||||
color: var(--app-cta-meta-fg, #5a6274);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// External player feedback: a warm tint while the player starts, a red
|
||||
// one while the button stops it. Tints, not glows.
|
||||
&--launching {
|
||||
background: #ffd36f;
|
||||
color: #241103;
|
||||
|
||||
.play-btn__meta {
|
||||
color: rgba(36, 17, 3, 0.7);
|
||||
}
|
||||
|
||||
.play-btn__icon {
|
||||
animation: play-btn-launch 1.15s cubic-bezier(0.55, 0, 0.45, 1)
|
||||
infinite;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #ffdd8a;
|
||||
}
|
||||
}
|
||||
|
||||
&--stop {
|
||||
background: #ff9f8d;
|
||||
color: #321012;
|
||||
|
||||
.play-btn__meta {
|
||||
color: rgba(50, 16, 18, 0.7);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #ffb3a5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes play-btn-launch {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(18deg) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.play-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.play-btn,
|
||||
.play-btn__icon {
|
||||
animation: none !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
/** External-player launch feedback carried by the primary button. */
|
||||
export type DetailActionButtonState = 'idle' | 'launching' | 'stop';
|
||||
|
||||
/**
|
||||
* A text action of the details row. `primary` is the light call to action:
|
||||
* play icon plus a two-line label ("Continue" / "S02E03 · 18m left"), the
|
||||
* same surface as the dashboard hero's primary (`light-primary-button`
|
||||
* mixin). `secondary` is the ghost text button beside it ("Trailer").
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-action-button',
|
||||
imports: [MatIcon],
|
||||
templateUrl: './detail-action-button.component.html',
|
||||
styleUrl: './detail-action-button.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailActionButtonComponent {
|
||||
readonly variant = input<'primary' | 'secondary'>('primary');
|
||||
readonly label = input.required<string>();
|
||||
/** Second, smaller line: episode code, time left, duration. */
|
||||
readonly meta = input<string | null>(null);
|
||||
readonly icon = input('play_arrow');
|
||||
readonly state = input<DetailActionButtonState>('idle');
|
||||
readonly disabled = input(false);
|
||||
readonly testId = input<string | null>(null);
|
||||
|
||||
readonly pressed = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@if (cast().length) {
|
||||
<div class="credits__row" data-test-id="detail-credits-cast">
|
||||
<span class="credits__label">{{
|
||||
'PORTALS.DETAIL.STARRING' | translate
|
||||
}}</span>
|
||||
<span class="credits__names">{{ leadCast() }}</span>
|
||||
@if (hasMore()) {
|
||||
<button
|
||||
type="button"
|
||||
class="credits__more"
|
||||
(click)="moreRequested.emit()"
|
||||
>
|
||||
{{ 'PORTALS.DETAIL.AND_MORE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (directors().length) {
|
||||
<div class="credits__row" data-test-id="detail-credits-director">
|
||||
<span class="credits__label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
<span class="credits__names">{{ directorNames() }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
}
|
||||
|
||||
.credits__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 8px;
|
||||
}
|
||||
|
||||
.credits__label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 64px;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.credits__names {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.credits__more {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--app-body-color, #9aa3b2);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { DetailCreditsComponent } from './detail-credits.component';
|
||||
|
||||
describe('DetailCreditsComponent', () => {
|
||||
let fixture: ComponentFixture<DetailCreditsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DetailCreditsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
PORTALS: { DETAIL: { STARRING: 'Starring', AND_MORE: 'and more' } },
|
||||
XTREAM: { DIRECTOR: 'Director' },
|
||||
});
|
||||
translate.use('en');
|
||||
fixture = TestBed.createComponent(DetailCreditsComponent);
|
||||
});
|
||||
|
||||
it('shows three lead names, an "and more" link and the director', () => {
|
||||
fixture.componentRef.setInput('cast', ['A', 'B', 'C', 'D']);
|
||||
fixture.componentRef.setInput('directors', ['Janus Metz']);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const castLine = host.querySelector(
|
||||
'[data-test-id="detail-credits-cast"]'
|
||||
)?.textContent;
|
||||
expect(castLine).toContain('A, B, C');
|
||||
expect(castLine).not.toContain('A, B, C, D');
|
||||
const more = host.querySelector<HTMLButtonElement>('.credits__more')!;
|
||||
const spy = jest.fn();
|
||||
fixture.componentInstance.moreRequested.subscribe(spy);
|
||||
more.click();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(
|
||||
host.querySelector('[data-test-id="detail-credits-director"]')
|
||||
?.textContent
|
||||
).toContain('Janus Metz');
|
||||
});
|
||||
|
||||
it('hides the link when three or fewer names exist and renders nothing when empty', () => {
|
||||
fixture.componentRef.setInput('cast', ['A', 'B']);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.querySelector('.credits__more')).toBeNull();
|
||||
expect(host.querySelector('[data-test-id="detail-credits-director"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
const LEAD_CAST_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* Two text lines of credits inside the details hero: "Starring" with the
|
||||
* first three names and an "and more" link to the Cast & crew row, then
|
||||
* "Director". Nothing renders for an empty list.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-credits',
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './detail-credits.component.html',
|
||||
styleUrl: './detail-credits.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailCreditsComponent {
|
||||
readonly cast = input<readonly string[]>([]);
|
||||
readonly directors = input<readonly string[]>([]);
|
||||
|
||||
/** "and more" was activated: the host scrolls to the Cast & crew row. */
|
||||
readonly moreRequested = output<void>();
|
||||
|
||||
readonly leadCast = computed(() =>
|
||||
this.cast().slice(0, LEAD_CAST_LIMIT).join(', ')
|
||||
);
|
||||
readonly hasMore = computed(() => this.cast().length > LEAD_CAST_LIMIT);
|
||||
readonly directorNames = computed(() => this.directors().join(', '));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
[class.is-active]="active()"
|
||||
[class.is-pulsing]="pulsing()"
|
||||
[class.icon-btn--favorite]="tone() === 'favorite'"
|
||||
[class.icon-btn--watched]="tone() === 'watched'"
|
||||
[class.icon-btn--done]="tone() === 'done'"
|
||||
[disabled]="disabled()"
|
||||
[matTooltip]="label()"
|
||||
[attr.aria-label]="label()"
|
||||
[attr.aria-pressed]="pressedState()"
|
||||
[attr.data-testid]="testId()"
|
||||
(click)="pressed.emit()"
|
||||
>
|
||||
<ng-content select="[glyph]" />
|
||||
@if (icon(); as glyph) {
|
||||
<mat-icon aria-hidden="true">{{ glyph }}</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@@ -0,0 +1,59 @@
|
||||
@use '../../../../styles/detail-view-actions' as actions;
|
||||
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@include actions.ghost-action-surface;
|
||||
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
// Favorited: the glyph fills and turns red, the surface stays a ghost.
|
||||
&--favorite.is-active {
|
||||
color: var(--app-live-color, #ff5b6e);
|
||||
}
|
||||
|
||||
&--favorite.is-pulsing mat-icon {
|
||||
animation: icon-btn-pulse 180ms ease;
|
||||
}
|
||||
|
||||
&--watched.is-active,
|
||||
&--done {
|
||||
color: #4ade80;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-btn-pulse {
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.icon-btn mat-icon {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
|
||||
/** Which accent an active icon button takes. */
|
||||
export type DetailIconButtonTone = 'favorite' | 'watched' | 'done' | 'none';
|
||||
|
||||
/**
|
||||
* 44px ghost icon button of the details action row (Favorites, Mark as
|
||||
* watched, Download, More). The label is both the tooltip and the accessible
|
||||
* name. A projected `[glyph]` (the download progress ring) replaces the icon.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-icon-button',
|
||||
imports: [MatIcon, MatTooltip],
|
||||
templateUrl: './detail-icon-button.component.html',
|
||||
styleUrl: './detail-icon-button.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailIconButtonComponent {
|
||||
readonly label = input.required<string>();
|
||||
readonly icon = input<string | null>(null);
|
||||
readonly active = input(false);
|
||||
readonly tone = input<DetailIconButtonTone>('none');
|
||||
/** Set for toggles; `null` leaves `aria-pressed` off a plain action. */
|
||||
readonly pressedState = input<boolean | null>(null);
|
||||
readonly disabled = input(false);
|
||||
readonly pulsing = input(false);
|
||||
readonly testId = input<string | null>(null);
|
||||
|
||||
readonly pressed = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<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]" />
|
||||
@if (overflowing()) {
|
||||
<div class="rail__arrows">
|
||||
<button
|
||||
type="button"
|
||||
class="rail__arrow"
|
||||
[attr.aria-label]="'PORTALS.DETAIL.SCROLL_PREVIOUS' | translate"
|
||||
(click)="scrollBy(-1)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">chevron_left</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rail__arrow"
|
||||
[attr.aria-label]="'PORTALS.DETAIL.SCROLL_NEXT' | translate"
|
||||
(click)="scrollBy(1)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
<div #scroller class="rail__scroller">
|
||||
<ng-content />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,80 @@
|
||||
:host {
|
||||
display: block;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 50%;
|
||||
background: var(--app-widget-bg, #0f141c);
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--app-card-hover-bg, #1d2533);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.rail__scroller {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rail__scroller {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
const SCROLL_STEP_PX = 450;
|
||||
|
||||
/**
|
||||
* A details-page row: heading, optional count, previous/next buttons and a
|
||||
* horizontal scroller with a hidden scrollbar. Hosts project the cards.
|
||||
* The arrows only render while the content overflows.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-rail',
|
||||
imports: [MatIcon, TranslatePipe],
|
||||
templateUrl: './detail-rail.component.html',
|
||||
styleUrl: './detail-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailRailComponent {
|
||||
readonly title = input.required<string>();
|
||||
readonly count = input<number | string | null>(null);
|
||||
readonly headingId = input<string | null>(null);
|
||||
|
||||
readonly overflowing = signal(false);
|
||||
|
||||
private readonly scroller =
|
||||
viewChild.required<ElementRef<HTMLElement>>('scroller');
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => this.observeOverflow());
|
||||
}
|
||||
|
||||
scrollBy(direction: -1 | 1): void {
|
||||
this.scroller().nativeElement.scrollBy({
|
||||
left: direction * SCROLL_STEP_PX,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
private observeOverflow(): void {
|
||||
const element = this.scroller().nativeElement;
|
||||
const measure = () =>
|
||||
this.overflowing.set(element.scrollWidth > element.clientWidth + 1);
|
||||
measure();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
const mutations = new MutationObserver(measure);
|
||||
mutations.observe(element, { childList: true, subtree: true });
|
||||
this.destroyRef.onDestroy(() => {
|
||||
observer.disconnect();
|
||||
mutations.disconnect();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@if (playing()) {
|
||||
<iframe
|
||||
#frame
|
||||
class="trailer-backdrop__frame"
|
||||
[src]="frameUrl()"
|
||||
[title]="title()"
|
||||
allow="autoplay; encrypted-media"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
></iframe>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 600ms ease;
|
||||
|
||||
&.is-playing {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// A 16:9 frame that covers the hero whatever its aspect: oversized and
|
||||
// centred, like `object-fit: cover` for an iframe.
|
||||
.trailer-backdrop__frame {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: max(100%, calc(100vh * 16 / 9));
|
||||
height: max(100%, calc(100vw * 9 / 16));
|
||||
transform: translate(-50%, -50%);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:host {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
buildLoopingEmbedUrl,
|
||||
canAutoplayTrailer,
|
||||
HeroTrailerBackdropComponent,
|
||||
TRAILER_BACKDROP_IDLE_MS,
|
||||
} from './hero-trailer-backdrop.component';
|
||||
import { TrailerDialogState } from './trailer-dialog-state';
|
||||
|
||||
const EMBED = 'https://www.youtube-nocookie.com/embed/abc123def';
|
||||
|
||||
describe('HeroTrailerBackdropComponent', () => {
|
||||
let fixture: ComponentFixture<HeroTrailerBackdropComponent>;
|
||||
let reducedMotion: boolean;
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
window.matchMedia = jest.fn().mockImplementation(() => ({
|
||||
matches: reducedMotion,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
}));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HeroTrailerBackdropComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(HeroTrailerBackdropComponent);
|
||||
fixture.componentRef.setInput('embedUrl', EMBED);
|
||||
fixture.componentRef.setInput('title', 'Black Harbor');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
function host(): HTMLElement {
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
/** jsdom reports an unfocused document until something is focused. */
|
||||
function renderFocused(): void {
|
||||
fixture.detectChanges();
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('starts the muted loop after the idle delay and toggles the sound', () => {
|
||||
renderFocused();
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
const frame = host().querySelector('iframe');
|
||||
expect(frame).not.toBeNull();
|
||||
expect(frame?.getAttribute('src')).toContain('autoplay=1');
|
||||
expect(frame?.getAttribute('src')).toContain('mute=1');
|
||||
expect(frame?.getAttribute('src')).toContain('playlist=abc123def');
|
||||
|
||||
const postMessage = jest.fn();
|
||||
Object.defineProperty(frame, 'contentWindow', {
|
||||
value: { postMessage },
|
||||
configurable: true,
|
||||
});
|
||||
fixture.componentInstance.toggleMute();
|
||||
fixture.detectChanges();
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"unMute"'),
|
||||
'*'
|
||||
);
|
||||
expect(fixture.componentInstance.muted()).toBe(false);
|
||||
});
|
||||
|
||||
it('never starts under reduced motion', () => {
|
||||
reducedMotion = true;
|
||||
renderFocused();
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS * 2);
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the frame and waits again, muted, when the trailer changes', () => {
|
||||
renderFocused();
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.toggleMute();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.muted()).toBe(false);
|
||||
|
||||
fixture.componentRef.setInput(
|
||||
'embedUrl',
|
||||
'https://www.youtube-nocookie.com/embed/next456'
|
||||
);
|
||||
fixture.detectChanges();
|
||||
// No mounted frame may autoplay the next URL at once.
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')?.getAttribute('src')).toContain(
|
||||
'playlist=next456'
|
||||
);
|
||||
expect(fixture.componentInstance.muted()).toBe(true);
|
||||
});
|
||||
|
||||
it('stops while the document is hidden', () => {
|
||||
renderFocused();
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).not.toBeNull();
|
||||
|
||||
const visibility = jest
|
||||
.spyOn(document, 'visibilityState', 'get')
|
||||
.mockReturnValue('hidden');
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
visibility.mockRestore();
|
||||
});
|
||||
|
||||
it('stops while the trailer modal is open and waits again afterwards', () => {
|
||||
renderFocused();
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).not.toBeNull();
|
||||
|
||||
// The modal plays its own copy; an unmuted backdrop would sound
|
||||
// underneath it.
|
||||
const dialogs = TestBed.inject(TrailerDialogState);
|
||||
dialogs.opened();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
|
||||
dialogs.closed();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).not.toBeNull();
|
||||
expect(fixture.componentInstance.muted()).toBe(true);
|
||||
});
|
||||
|
||||
it('stops when the window loses focus', () => {
|
||||
renderFocused();
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).not.toBeNull();
|
||||
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trailer backdrop helpers', () => {
|
||||
it('builds a looping, muted, controls-free embed with the JS API', () => {
|
||||
const url = new URL(buildLoopingEmbedUrl(EMBED));
|
||||
expect(url.searchParams.get('loop')).toBe('1');
|
||||
expect(url.searchParams.get('controls')).toBe('0');
|
||||
expect(url.searchParams.get('enablejsapi')).toBe('1');
|
||||
expect(url.searchParams.get('playlist')).toBe('abc123def');
|
||||
});
|
||||
|
||||
it('respects a metered connection', () => {
|
||||
const original = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
'connection'
|
||||
);
|
||||
Object.defineProperty(navigator, 'connection', {
|
||||
value: { saveData: true },
|
||||
configurable: true,
|
||||
});
|
||||
expect(canAutoplayTrailer()).toBe(false);
|
||||
if (original) {
|
||||
Object.defineProperty(navigator, 'connection', original);
|
||||
} else {
|
||||
delete (navigator as { connection?: unknown }).connection;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { TrailerDialogState } from './trailer-dialog-state';
|
||||
|
||||
/** Idle time on the page before the trailer takes over the backdrop. */
|
||||
export const TRAILER_BACKDROP_IDLE_MS = 3000;
|
||||
|
||||
/**
|
||||
* Plays a title's trailer, muted and looping, as the hero backdrop after a
|
||||
* few idle seconds (`Settings → Playback → Play trailers in details
|
||||
* background`). It stops when the hero scrolls out of view, the window
|
||||
* loses focus, the document is hidden or the trailer modal opens (which
|
||||
* plays its own copy), and never starts under `prefers-reduced-motion` or
|
||||
* on a metered connection. `toggleMute()`
|
||||
* drives the sound through the YouTube IFrame API (`enablejsapi`); the hero
|
||||
* renders that control itself, above its content layer.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-hero-trailer-backdrop',
|
||||
templateUrl: './hero-trailer-backdrop.component.html',
|
||||
styleUrl: './hero-trailer-backdrop.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { '[class.is-playing]': 'playing()' },
|
||||
})
|
||||
export class HeroTrailerBackdropComponent {
|
||||
/** A `youtube-nocookie.com/embed/...` URL, see `youtubeEmbedUrl`. */
|
||||
readonly embedUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
|
||||
readonly playing = signal(false);
|
||||
readonly muted = signal(true);
|
||||
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly trailerDialog = inject(TrailerDialogState);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly frame = viewChild<ElementRef<HTMLIFrameElement>>('frame');
|
||||
private readonly inView = signal(true);
|
||||
private readonly windowFocused = signal(true);
|
||||
private readonly documentVisible = signal(true);
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly frameUrl = computed<SafeResourceUrl>(() =>
|
||||
this.sanitizer.bypassSecurityTrustResourceUrl(
|
||||
buildLoopingEmbedUrl(this.embedUrl())
|
||||
)
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// A new trailer, a hero off screen or a hidden window: the frame
|
||||
// goes away and the idle wait starts over (a mounted frame would
|
||||
// autoplay the next URL at once).
|
||||
this.embedUrl();
|
||||
const visible =
|
||||
this.inView() &&
|
||||
this.windowFocused() &&
|
||||
this.documentVisible() &&
|
||||
!this.trailerDialog.dialogOpen();
|
||||
untracked(() => {
|
||||
this.clearIdleTimer();
|
||||
this.playing.set(false);
|
||||
if (visible && canAutoplayTrailer()) {
|
||||
this.idleTimer = setTimeout(() => {
|
||||
// Every frame mounts with `mute=1`.
|
||||
this.muted.set(true);
|
||||
this.playing.set(true);
|
||||
this.idleTimer = null;
|
||||
}, TRAILER_BACKDROP_IDLE_MS);
|
||||
}
|
||||
});
|
||||
});
|
||||
afterNextRender(() => this.observe());
|
||||
this.destroyRef.onDestroy(() => this.clearIdleTimer());
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
const nextMuted = !this.muted();
|
||||
this.muted.set(nextMuted);
|
||||
this.frame()?.nativeElement.contentWindow?.postMessage(
|
||||
JSON.stringify({
|
||||
event: 'command',
|
||||
func: nextMuted ? 'mute' : 'unMute',
|
||||
args: [],
|
||||
}),
|
||||
'*'
|
||||
);
|
||||
}
|
||||
|
||||
private observe(): void {
|
||||
const element = this.host.nativeElement;
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) =>
|
||||
this.inView.set(
|
||||
entries.some((entry) => entry.intersectionRatio > 0.4)
|
||||
),
|
||||
{ threshold: [0, 0.4, 1] }
|
||||
);
|
||||
observer.observe(element);
|
||||
this.destroyRef.onDestroy(() => observer.disconnect());
|
||||
}
|
||||
const onFocus = () => this.windowFocused.set(true);
|
||||
const onBlur = () => this.windowFocused.set(false);
|
||||
const onVisibility = () =>
|
||||
this.documentVisible.set(document.visibilityState !== 'hidden');
|
||||
this.windowFocused.set(document.hasFocus());
|
||||
onVisibility();
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('blur', onBlur);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
this.destroyRef.onDestroy(() => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
});
|
||||
}
|
||||
|
||||
private clearIdleTimer(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reduced motion and metered connections keep the backdrop still. */
|
||||
export function canAutoplayTrailer(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
return false;
|
||||
}
|
||||
const connection = (
|
||||
navigator as Navigator & { connection?: { saveData?: boolean } }
|
||||
).connection;
|
||||
return connection?.saveData !== true;
|
||||
}
|
||||
|
||||
/** Autoplay muted, loop the single video, no controls, JS API for the mute toggle. */
|
||||
export function buildLoopingEmbedUrl(embedUrl: string): string {
|
||||
try {
|
||||
const url = new URL(embedUrl);
|
||||
const videoId = url.pathname.split('/').pop() ?? '';
|
||||
url.searchParams.set('autoplay', '1');
|
||||
url.searchParams.set('mute', '1');
|
||||
url.searchParams.set('controls', '0');
|
||||
url.searchParams.set('loop', '1');
|
||||
url.searchParams.set('playlist', videoId);
|
||||
url.searchParams.set('rel', '0');
|
||||
url.searchParams.set('playsinline', '1');
|
||||
url.searchParams.set('enablejsapi', '1');
|
||||
return url.toString();
|
||||
} catch {
|
||||
return embedUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
padding: 3px 9px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--app-on-surface, #e6e1e5) 10%, transparent);
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:host(.meta-chip--rating) {
|
||||
color: var(--app-rating-color, #ffcf5c);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Production status is a state, not a fact about the title: the soft
|
||||
// selection tint sets it apart from the plain facts beside it.
|
||||
:host(.meta-chip--status) {
|
||||
background: var(--app-selection-surface, rgba(152, 184, 255, 0.16));
|
||||
color: var(--app-selection-color, #98b8ff);
|
||||
}
|
||||
|
||||
// Facets inside one chip ("Thriller · Drama"): each genre stays a button.
|
||||
:host ::ng-deep .meta-chip__facet {
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .meta-chip__sep {
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MetaChipComponent } from './meta-chip.component';
|
||||
|
||||
@Component({
|
||||
imports: [MetaChipComponent],
|
||||
template: `
|
||||
<app-meta-chip data-test-id="plain">2026</app-meta-chip>
|
||||
<app-meta-chip variant="rating" data-test-id="rating">★ 7.3</app-meta-chip>
|
||||
<app-meta-chip variant="status" data-test-id="status">
|
||||
<button type="button" class="meta-chip__facet">Returning</button>
|
||||
</app-meta-chip>
|
||||
`,
|
||||
})
|
||||
class HostComponent {}
|
||||
|
||||
describe('MetaChipComponent', () => {
|
||||
it('applies the variant classes on the host element', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const plain = host.querySelector('[data-test-id="plain"]')!;
|
||||
const rating = host.querySelector('[data-test-id="rating"]')!;
|
||||
const status = host.querySelector('[data-test-id="status"]')!;
|
||||
expect(plain.classList.contains('meta-chip')).toBe(true);
|
||||
expect(plain.classList.contains('meta-chip--rating')).toBe(false);
|
||||
expect(rating.classList.contains('meta-chip--rating')).toBe(true);
|
||||
expect(status.classList.contains('meta-chip--status')).toBe(true);
|
||||
expect(status.querySelector('button.meta-chip__facet')?.textContent?.trim()).toBe(
|
||||
'Returning'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
export type MetaChipVariant = 'default' | 'rating' | 'status';
|
||||
|
||||
/**
|
||||
* Pill meta chip shared by the VOD details hero and the dashboard hero
|
||||
* ("2026", "1 h 52 min", "★ 7.3", "Season 2", "Returning").
|
||||
*
|
||||
* A facet that opens the discover page keeps its keyboard behaviour as a
|
||||
* projected `<button class="meta-chip__facet">`; several facets in one chip
|
||||
* ("Thriller · Drama") are separated by `.meta-chip__sep`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-meta-chip',
|
||||
template: '<ng-content />',
|
||||
styleUrl: './meta-chip.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'meta-chip',
|
||||
'[class.meta-chip--rating]': 'variant() === "rating"',
|
||||
'[class.meta-chip--status]': 'variant() === "status"',
|
||||
},
|
||||
})
|
||||
export class MetaChipComponent {
|
||||
readonly variant = input<MetaChipVariant>('default');
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
buildSeriesMenuSections,
|
||||
SERIES_MENU_ACTION,
|
||||
} from './series-hero.state';
|
||||
|
||||
const BASE = {
|
||||
seasonWatchVisible: false,
|
||||
seasonFullyWatched: false,
|
||||
seasonEligibleCount: 0,
|
||||
seasonActionDisabled: false,
|
||||
seriesMenuVisible: true,
|
||||
seriesFullyWatched: false,
|
||||
seriesEligibleCount: 3,
|
||||
seriesCountKnown: true,
|
||||
seriesActionDisabled: false,
|
||||
hasProgress: true,
|
||||
playbackActive: false,
|
||||
startPending: false,
|
||||
watchBatchRunning: false,
|
||||
sourcesCount: 0,
|
||||
externalPlayerHint: 'MPV' as const,
|
||||
copyUrlEpisodeCode: null,
|
||||
downloadVisible: false,
|
||||
downloadCount: 0,
|
||||
downloadDisabled: false,
|
||||
downloadBusy: false,
|
||||
categoryName: null,
|
||||
inContinueWatching: false,
|
||||
};
|
||||
|
||||
function row(input: Partial<typeof BASE>, id: string) {
|
||||
return buildSeriesMenuSections({ ...BASE, ...input })
|
||||
.flatMap((section) => section.items)
|
||||
.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
function resetRow(input: Partial<typeof BASE>) {
|
||||
return row(input, SERIES_MENU_ACTION.ResetProgress);
|
||||
}
|
||||
|
||||
describe('buildSeriesMenuSections', () => {
|
||||
it('offers the progress reset while nothing plays', () => {
|
||||
expect(resetRow({})?.disabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it('disables the progress reset while an episode plays or launches', () => {
|
||||
// The running player would write the position right back.
|
||||
expect(resetRow({ playbackActive: true })?.disabled).toBe(true);
|
||||
// Independent of the bulk watched action's own state.
|
||||
expect(resetRow({ seriesActionDisabled: true })?.disabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it('disables the progress reset while a watched batch persists', () => {
|
||||
// The hosts refuse the reset request meanwhile; an enabled row
|
||||
// would close the menu and do nothing.
|
||||
expect(resetRow({ watchBatchRunning: true })?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the reset once every episode is watched', () => {
|
||||
expect(resetRow({ seriesFullyWatched: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('holds the external-player row while a start has not settled', () => {
|
||||
const external = SERIES_MENU_ACTION.ExternalPlayer;
|
||||
expect(row({}, external)?.disabled).toBeFalsy();
|
||||
// A second launch could not cancel the first: both players would open.
|
||||
expect(row({ startPending: true }, external)?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import { computed, type Signal } from '@angular/core';
|
||||
import type { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
formatRemainingLabel,
|
||||
shortCountryList,
|
||||
shortCountryName,
|
||||
splitSeasonSuffix,
|
||||
type SeriesQuickStartActionKind,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
seriesStatusLabelKey,
|
||||
type PlaybackPositionData,
|
||||
type TmdbCountryFacet,
|
||||
type TmdbEnrichedCastMember,
|
||||
type TmdbGenreFacet,
|
||||
type TmdbSeriesStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { castMembersFromNames, splitPeopleNames } from './cast-crew.util';
|
||||
import type { VodMoreMenuSection } from './vod-more-menu.model';
|
||||
|
||||
/** The next episode to play, as the quick-start helpers describe it. */
|
||||
export interface SeriesHeroQuickStart {
|
||||
readonly labelKey: string;
|
||||
readonly labelParams?: Record<string, number>;
|
||||
readonly episodeLabel: string | null;
|
||||
readonly icon: string;
|
||||
readonly disabled: boolean;
|
||||
readonly kind: SeriesQuickStartActionKind | null;
|
||||
readonly position: PlaybackPositionData | null;
|
||||
/** "S02E03" of the episode the button acts on. */
|
||||
readonly episodeCode: string | null;
|
||||
}
|
||||
|
||||
export interface SeriesHeroFacetChip<F> {
|
||||
readonly label: string;
|
||||
readonly facet: F | null;
|
||||
}
|
||||
|
||||
export interface SeriesHeroPrimaryAction {
|
||||
readonly label: string;
|
||||
readonly meta: string | null;
|
||||
readonly icon: string;
|
||||
readonly disabled: boolean;
|
||||
}
|
||||
|
||||
export interface SeriesHeroStateDeps {
|
||||
readonly title: Signal<string | undefined>;
|
||||
readonly sourceLabel: Signal<string | null | undefined>;
|
||||
readonly status: Signal<TmdbSeriesStatus | undefined | null>;
|
||||
readonly year: Signal<string | null | undefined>;
|
||||
readonly tmdbGenres: Signal<readonly TmdbGenreFacet[] | undefined>;
|
||||
readonly genre: Signal<string | undefined>;
|
||||
readonly tmdbCountries: Signal<readonly TmdbCountryFacet[] | undefined>;
|
||||
readonly country?: Signal<string | undefined>;
|
||||
readonly tmdbCast: Signal<readonly TmdbEnrichedCastMember[] | undefined>;
|
||||
readonly cast: Signal<string | undefined>;
|
||||
readonly tmdbDirectors: Signal<
|
||||
readonly TmdbEnrichedCastMember[] | undefined
|
||||
>;
|
||||
readonly director: Signal<string | undefined>;
|
||||
readonly quickStart: Signal<SeriesHeroQuickStart | null>;
|
||||
readonly translate: Pick<TranslateService, 'instant'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the series hero shows: the title without its season suffix,
|
||||
* the chips (season, status, year, genres, countries), the credits and the
|
||||
* primary button's two lines ("Continue" / "S02E03 · 18m left").
|
||||
*/
|
||||
export function createSeriesHeroState(deps: SeriesHeroStateDeps) {
|
||||
const translate = (key: string, params?: Record<string, unknown>) =>
|
||||
deps.translate.instant(key, params);
|
||||
const titleParts = computed(() => splitSeasonSuffix(deps.title()));
|
||||
const castMembers = computed<TmdbEnrichedCastMember[]>(() =>
|
||||
deps.tmdbCast()?.length
|
||||
? [...(deps.tmdbCast() ?? [])]
|
||||
: castMembersFromNames(splitPeopleNames(deps.cast()))
|
||||
);
|
||||
const directorMembers = computed<TmdbEnrichedCastMember[]>(() =>
|
||||
deps.tmdbDirectors()?.length
|
||||
? [...(deps.tmdbDirectors() ?? [])]
|
||||
: castMembersFromNames(splitPeopleNames(deps.director()))
|
||||
);
|
||||
|
||||
return {
|
||||
kindLabel: computed(() => {
|
||||
const kind = translate('WORKSPACE.DASHBOARD.TYPE_SERIES');
|
||||
const source = deps.sourceLabel()?.trim();
|
||||
return source ? `${kind} · ${source}` : kind;
|
||||
}),
|
||||
title: computed(() => titleParts().title),
|
||||
seasonChip: computed(() => titleParts().season),
|
||||
year: computed(() => deps.year() ?? null),
|
||||
statusLabel: computed(() => {
|
||||
const status = deps.status();
|
||||
return status ? translate(seriesStatusLabelKey(status)) : null;
|
||||
}),
|
||||
genreChips: computed<SeriesHeroFacetChip<TmdbGenreFacet>[]>(() => {
|
||||
const facets = deps.tmdbGenres();
|
||||
if (facets?.length) {
|
||||
return facets.map((facet) => ({ label: facet.name, facet }));
|
||||
}
|
||||
return (deps.genre() ?? '')
|
||||
.split(/[,/|]/)
|
||||
.map((genre) => genre.trim())
|
||||
.filter((genre) => genre.length > 0)
|
||||
.map((label) => ({ label, facet: null }));
|
||||
}),
|
||||
countryChips: computed<SeriesHeroFacetChip<TmdbCountryFacet>[]>(() => {
|
||||
const facets = deps.tmdbCountries();
|
||||
if (facets?.length) {
|
||||
return facets.map((facet) => ({
|
||||
label: shortCountryName(facet.name, facet.code),
|
||||
facet,
|
||||
}));
|
||||
}
|
||||
return shortCountryList(deps.country?.()).map((label) => ({
|
||||
label,
|
||||
facet: null,
|
||||
}));
|
||||
}),
|
||||
castMembers,
|
||||
directorMembers,
|
||||
castNames: computed(() => castMembers().map((member) => member.name)),
|
||||
directorNames: computed(() =>
|
||||
directorMembers().map((member) => member.name)
|
||||
),
|
||||
primaryAction: computed<SeriesHeroPrimaryAction | null>(() => {
|
||||
const quickStart = deps.quickStart();
|
||||
if (!quickStart) {
|
||||
return null;
|
||||
}
|
||||
if (quickStart.kind === 'completed') {
|
||||
return {
|
||||
label: translate(
|
||||
quickStart.labelKey,
|
||||
quickStart.labelParams
|
||||
),
|
||||
meta: quickStart.episodeLabel,
|
||||
icon: quickStart.icon,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
if (quickStart.kind === 'resume') {
|
||||
const remaining = formatRemainingLabel(quickStart.position);
|
||||
const parts = [
|
||||
quickStart.episodeCode,
|
||||
remaining
|
||||
? translate(remaining.key, remaining.params)
|
||||
: null,
|
||||
].filter((part): part is string => !!part);
|
||||
return {
|
||||
label: translate('WORKSPACE.DASHBOARD.HERO_CONTINUE'),
|
||||
meta: parts.length
|
||||
? parts.join(' · ')
|
||||
: quickStart.episodeLabel,
|
||||
icon: quickStart.icon,
|
||||
disabled: quickStart.disabled,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: translate('XTREAM.PLAY'),
|
||||
meta: quickStart.episodeLabel,
|
||||
icon: quickStart.icon,
|
||||
disabled: quickStart.disabled,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const SERIES_MENU_ACTION = {
|
||||
SeasonWatched: 'season-watched',
|
||||
SeriesWatched: 'series-watched',
|
||||
ResetProgress: 'reset-progress',
|
||||
Sources: 'sources',
|
||||
ExternalPlayer: 'external-player',
|
||||
CopyUrl: 'copy-url',
|
||||
DownloadSeason: 'download-season',
|
||||
ShowInCategory: 'show-in-category',
|
||||
HideFromContinueWatching: 'hide-continue-watching',
|
||||
} as const;
|
||||
|
||||
export interface SeriesMenuInput {
|
||||
readonly seasonWatchVisible: boolean;
|
||||
readonly seasonFullyWatched: boolean;
|
||||
readonly seasonEligibleCount: number;
|
||||
readonly seasonActionDisabled: boolean;
|
||||
readonly seriesMenuVisible: boolean;
|
||||
readonly seriesFullyWatched: boolean;
|
||||
readonly seriesEligibleCount: number;
|
||||
readonly seriesCountKnown: boolean;
|
||||
readonly seriesActionDisabled: boolean;
|
||||
readonly hasProgress: boolean;
|
||||
/** An episode plays or launches: its next position tick would undo a reset. */
|
||||
readonly playbackActive: boolean;
|
||||
/** A start has not settled: another launch would double it. */
|
||||
readonly startPending: boolean;
|
||||
/** A season/series watched batch still persists: a reset request is refused meanwhile. */
|
||||
readonly watchBatchRunning: boolean;
|
||||
readonly sourcesCount: number;
|
||||
readonly externalPlayerHint: 'MPV' | 'VLC' | null;
|
||||
readonly copyUrlEpisodeCode: string | null;
|
||||
readonly downloadVisible: boolean;
|
||||
readonly downloadCount: number;
|
||||
readonly downloadDisabled: boolean;
|
||||
readonly downloadBusy: boolean;
|
||||
readonly categoryName: string | null;
|
||||
readonly inContinueWatching: boolean;
|
||||
}
|
||||
|
||||
/** Rows of the series "…" menu. Rows a provider cannot serve are left out. */
|
||||
export function buildSeriesMenuSections(
|
||||
input: SeriesMenuInput
|
||||
): VodMoreMenuSection[] {
|
||||
const watching: VodMoreMenuSection['items'][number][] = [];
|
||||
if (input.seasonWatchVisible) {
|
||||
watching.push({
|
||||
id: SERIES_MENU_ACTION.SeasonWatched,
|
||||
labelKey: input.seasonFullyWatched
|
||||
? 'XTREAM.MARK_SEASON_UNWATCHED'
|
||||
: 'XTREAM.MARK_SEASON_WATCHED',
|
||||
labelParams: { count: input.seasonEligibleCount },
|
||||
icon: input.seasonFullyWatched ? 'remove_done' : 'done_all',
|
||||
disabled: input.seasonActionDisabled,
|
||||
testId: 'toggle-season-watched',
|
||||
});
|
||||
}
|
||||
if (input.seriesMenuVisible) {
|
||||
watching.push({
|
||||
id: SERIES_MENU_ACTION.SeriesWatched,
|
||||
labelKey: input.seriesFullyWatched
|
||||
? 'XTREAM.MARK_SERIES_UNWATCHED'
|
||||
: input.seriesCountKnown
|
||||
? 'XTREAM.MARK_SERIES_WATCHED'
|
||||
: 'XTREAM.MARK_SERIES_WATCHED_ALL',
|
||||
labelParams: { count: input.seriesEligibleCount },
|
||||
icon: input.seriesFullyWatched ? 'remove_done' : 'done_all',
|
||||
disabled: input.seriesActionDisabled,
|
||||
testId: 'toggle-series-watched',
|
||||
});
|
||||
}
|
||||
if (input.hasProgress && !input.seriesFullyWatched) {
|
||||
watching.push({
|
||||
id: SERIES_MENU_ACTION.ResetProgress,
|
||||
labelKey: 'PORTALS.DETAIL.RESET_PROGRESS',
|
||||
icon: 'history_toggle_off',
|
||||
disabled: input.playbackActive || input.watchBatchRunning,
|
||||
testId: 'vod-menu-reset-progress',
|
||||
});
|
||||
}
|
||||
const source: VodMoreMenuSection['items'][number][] = [];
|
||||
if (input.sourcesCount > 1) {
|
||||
source.push({
|
||||
id: SERIES_MENU_ACTION.Sources,
|
||||
labelKey: 'PORTALS.DETAIL.OTHER_SOURCES',
|
||||
icon: 'video_library',
|
||||
hint: input.sourcesCount,
|
||||
kind: 'sources',
|
||||
testId: 'vod-menu-sources',
|
||||
});
|
||||
}
|
||||
if (input.externalPlayerHint) {
|
||||
source.push({
|
||||
id: SERIES_MENU_ACTION.ExternalPlayer,
|
||||
labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER',
|
||||
icon: 'open_in_new',
|
||||
hint: input.externalPlayerHint,
|
||||
disabled: input.startPending,
|
||||
testId: 'vod-menu-external',
|
||||
});
|
||||
}
|
||||
if (input.copyUrlEpisodeCode) {
|
||||
source.push({
|
||||
id: SERIES_MENU_ACTION.CopyUrl,
|
||||
labelKey: 'PORTALS.COPY_STREAM_URL',
|
||||
icon: 'link',
|
||||
hint: input.copyUrlEpisodeCode,
|
||||
testId: 'vod-menu-copy-url',
|
||||
});
|
||||
}
|
||||
const download: VodMoreMenuSection['items'][number][] = [];
|
||||
if (input.downloadVisible) {
|
||||
download.push({
|
||||
id: SERIES_MENU_ACTION.DownloadSeason,
|
||||
labelKey: input.downloadBusy
|
||||
? 'DOWNLOADS.ADDING_TO_QUEUE'
|
||||
: 'DOWNLOADS.DOWNLOAD_SEASON',
|
||||
labelParams: { count: input.downloadCount },
|
||||
icon: 'download',
|
||||
disabled: input.downloadDisabled,
|
||||
testId: 'download-season',
|
||||
});
|
||||
}
|
||||
const place: VodMoreMenuSection['items'][number][] = [];
|
||||
if (input.categoryName) {
|
||||
place.push({
|
||||
id: SERIES_MENU_ACTION.ShowInCategory,
|
||||
labelKey: 'PORTALS.DETAIL.SHOW_IN_CATEGORY',
|
||||
icon: 'folder_open',
|
||||
hint: input.categoryName,
|
||||
testId: 'vod-menu-show-in-category',
|
||||
});
|
||||
}
|
||||
if (input.inContinueWatching) {
|
||||
place.push({
|
||||
id: SERIES_MENU_ACTION.HideFromContinueWatching,
|
||||
labelKey: 'PORTALS.DETAIL.HIDE_FROM_CONTINUE_WATCHING',
|
||||
icon: 'visibility_off',
|
||||
testId: 'vod-menu-hide-continue',
|
||||
});
|
||||
}
|
||||
return [
|
||||
{ labelKey: 'PORTALS.DETAIL.GROUP_WATCHING', items: watching },
|
||||
{ labelKey: 'PORTALS.DETAIL.GROUP_SOURCE', items: source },
|
||||
{ items: download },
|
||||
{ items: place },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@if (items().length) {
|
||||
<app-detail-rail [title]="title()" data-test-id="tmdb-similar">
|
||||
@for (item of items(); track item.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="similar-card"
|
||||
[title]="item.tooltip ?? item.title"
|
||||
(click)="selected.emit(item)"
|
||||
>
|
||||
@if (posterFor(item); as poster) {
|
||||
<img
|
||||
class="similar-card__poster"
|
||||
[src]="poster"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
(error)="markFailed(poster)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="similar-card__poster similar-card__poster--fallback"
|
||||
aria-hidden="true"
|
||||
>{{ item.title.charAt(0) }}</span
|
||||
>
|
||||
}
|
||||
<span class="similar-card__title">{{ item.title }}</span>
|
||||
@if (item.year) {
|
||||
<span class="similar-card__year">{{ item.year }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</app-detail-rail>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.similar-card {
|
||||
display: flex;
|
||||
flex: 0 0 140px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 140px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .similar-card__poster {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.similar-card__poster {
|
||||
display: block;
|
||||
width: 140px;
|
||||
aspect-ratio: 2 / 3;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
background: color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.similar-card__title {
|
||||
overflow: hidden;
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.similar-card__year {
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.similar-card__poster {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SimilarRailComponent } from './similar-rail.component';
|
||||
|
||||
describe('SimilarRailComponent', () => {
|
||||
let fixture: ComponentFixture<SimilarRailComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SimilarRailComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(SimilarRailComponent);
|
||||
fixture.componentRef.setInput('title', 'Similar');
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ key: 'a', title: 'Ted K', posterUrl: 'ted.jpg', year: 2021 },
|
||||
{ key: 'b', title: 'Zodiac', posterUrl: null, year: null, tooltip: 'Backup' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders the year, no source line, and a letter fallback', () => {
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const cards = host.querySelectorAll('.similar-card');
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0].textContent).toContain('2021');
|
||||
expect(cards[1].querySelector('.similar-card__poster--fallback')?.textContent?.trim()).toBe('Z');
|
||||
expect(cards[1].getAttribute('title')).toBe('Backup');
|
||||
expect(host.textContent).not.toContain('http');
|
||||
});
|
||||
|
||||
it('emits the item on click and swaps a broken poster for the fallback', () => {
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const spy = jest.fn();
|
||||
fixture.componentInstance.selected.subscribe(spy);
|
||||
host.querySelector<HTMLButtonElement>('.similar-card')!.click();
|
||||
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ key: 'a' }));
|
||||
|
||||
host.querySelector<HTMLImageElement>('img.similar-card__poster')!.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(host.querySelectorAll('.similar-card__poster--fallback')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { DetailRailComponent } from './detail-rail.component';
|
||||
|
||||
/** One poster of the "Similar" row. */
|
||||
export interface SimilarRailItem {
|
||||
readonly key: string;
|
||||
readonly title: string;
|
||||
readonly posterUrl: string | null;
|
||||
readonly year: number | null;
|
||||
/** Tooltip detail such as the playlist a cross-portal match lives in. */
|
||||
readonly tooltip?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Similar" row: 140px posters with the title and year. Hosts map their
|
||||
* TMDB and cross-portal matches to `SimilarRailItem` and react to clicks.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-similar-rail',
|
||||
imports: [DetailRailComponent],
|
||||
templateUrl: './similar-rail.component.html',
|
||||
styleUrl: './similar-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SimilarRailComponent {
|
||||
readonly title = input.required<string>();
|
||||
readonly items = input.required<readonly SimilarRailItem[]>();
|
||||
|
||||
readonly selected = output<SimilarRailItem>();
|
||||
|
||||
readonly failedPosters = signal<Record<string, true>>({});
|
||||
|
||||
posterFor(item: SimilarRailItem): string | null {
|
||||
const url = item.posterUrl;
|
||||
return url && !this.failedPosters()[url] ? url : null;
|
||||
}
|
||||
|
||||
markFailed(url: string): void {
|
||||
this.failedPosters.update((failed) => ({ ...failed, [url]: true }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { computed, Injectable, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Whether a trailer modal is open. The hero's trailer backdrop reads it and
|
||||
* unmounts meanwhile: the modal plays its own copy, and opening it neither
|
||||
* blurs the window nor hides the document, so nothing else would stop the
|
||||
* backdrop's sound.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TrailerDialogState {
|
||||
private readonly openDialogs = signal(0);
|
||||
readonly dialogOpen = computed(() => this.openDialogs() > 0);
|
||||
|
||||
opened(): void {
|
||||
this.openDialogs.update((count) => count + 1);
|
||||
}
|
||||
|
||||
closed(): void {
|
||||
this.openDialogs.update((count) => Math.max(0, count - 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export interface TrailerDialogData {
|
||||
/** A `youtube-nocookie.com/embed/...` URL, see `youtubeEmbedUrl`. */
|
||||
readonly embedUrl: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailer, as a 16:9 YouTube embed in a modal over the details column.
|
||||
* Escape and the backdrop close it; the title row keeps the single Close.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-trailer-dialog',
|
||||
imports: [MatDialogModule, MatButtonModule, TranslatePipe],
|
||||
template: `
|
||||
<div class="trailer">
|
||||
<div class="trailer__frame">
|
||||
<iframe
|
||||
class="trailer__iframe"
|
||||
[src]="embedUrl"
|
||||
[title]="data.title"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="trailer__bar">
|
||||
<h2 mat-dialog-title class="trailer__title">
|
||||
{{ 'PORTALS.DETAIL.TRAILER_OF' | translate: { title: data.title } }}
|
||||
</h2>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
class="trailer__close"
|
||||
mat-dialog-close
|
||||
data-test-id="trailer-dialog-close"
|
||||
>
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trailer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trailer__frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.trailer__iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.trailer__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
// Keeps the directive (the dialog's accessible name) but drops
|
||||
// Material's headline padding and the 40px strut before it.
|
||||
.trailer__title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.trailer__close {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TrailerDialogComponent {
|
||||
readonly data = inject<TrailerDialogData>(MAT_DIALOG_DATA);
|
||||
readonly embedUrl: SafeResourceUrl = inject(
|
||||
DomSanitizer
|
||||
).bypassSecurityTrustResourceUrl(withAutoplay(this.data.embedUrl));
|
||||
}
|
||||
|
||||
function withAutoplay(embedUrl: string): string {
|
||||
try {
|
||||
const url = new URL(embedUrl);
|
||||
url.searchParams.set('autoplay', '1');
|
||||
return url.toString();
|
||||
} catch {
|
||||
return embedUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Subject } from 'rxjs';
|
||||
import { TrailerDialogState } from './trailer-dialog-state';
|
||||
import { TrailerDialogService } from './trailer-dialog.service';
|
||||
|
||||
describe('TrailerDialogService', () => {
|
||||
it('reports the modal as open until it closed', () => {
|
||||
const closed = new Subject<void>();
|
||||
const open = jest.fn().mockReturnValue({ afterClosed: () => closed });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: MatDialog, useValue: { open } }],
|
||||
});
|
||||
const state = TestBed.inject(TrailerDialogState);
|
||||
|
||||
TestBed.inject(TrailerDialogService).open({
|
||||
embedUrl: 'https://www.youtube-nocookie.com/embed/abc',
|
||||
title: 'Black Harbor',
|
||||
});
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(state.dialogOpen()).toBe(true);
|
||||
|
||||
closed.next();
|
||||
expect(state.dialogOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatDialog, type MatDialogRef } from '@angular/material/dialog';
|
||||
import { TrailerDialogState } from './trailer-dialog-state';
|
||||
import {
|
||||
TrailerDialogComponent,
|
||||
type TrailerDialogData,
|
||||
} from './trailer-dialog.component';
|
||||
|
||||
export const TRAILER_DIALOG_PANEL_CLASS = 'app-trailer-dialog-panel';
|
||||
|
||||
/** Opens the trailer modal at one width for every details page. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TrailerDialogService {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly state = inject(TrailerDialogState);
|
||||
|
||||
open(data: TrailerDialogData): MatDialogRef<TrailerDialogComponent> {
|
||||
const ref = this.dialog.open(TrailerDialogComponent, {
|
||||
data,
|
||||
width: 'min(880px, calc(100vw - 48px))',
|
||||
maxWidth: '880px',
|
||||
autoFocus: 'dialog',
|
||||
panelClass: TRAILER_DIALOG_PANEL_CLASS,
|
||||
});
|
||||
this.state.opened();
|
||||
ref.afterClosed().subscribe(() => this.state.closed());
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<button
|
||||
#triggerButton
|
||||
type="button"
|
||||
class="more-trigger"
|
||||
cdkOverlayOrigin
|
||||
#trigger="cdkOverlayOrigin"
|
||||
aria-haspopup="menu"
|
||||
[class.more-trigger--open]="isOpen()"
|
||||
[attr.aria-expanded]="isOpen()"
|
||||
[attr.aria-label]="label()"
|
||||
[attr.data-testid]="testId()"
|
||||
[matTooltip]="label()"
|
||||
[disabled]="!hasRows()"
|
||||
(click)="toggle()"
|
||||
(keydown)="onTriggerKeydown($event)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">more_horiz</mat-icon>
|
||||
</button>
|
||||
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="trigger"
|
||||
[cdkConnectedOverlayOpen]="isOpen()"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||
[cdkConnectedOverlayPositions]="overlayPositions"
|
||||
[cdkConnectedOverlayViewportMargin]="12"
|
||||
[cdkConnectedOverlayFlexibleDimensions]="false"
|
||||
(backdropClick)="close()"
|
||||
(detach)="close()"
|
||||
(overlayKeydown)="onOverlayKeydown($event)"
|
||||
>
|
||||
<div
|
||||
#panel
|
||||
class="more-menu"
|
||||
[class.more-menu--sources]="view() === 'sources'"
|
||||
role="menu"
|
||||
[attr.aria-label]="label()"
|
||||
data-test-id="vod-more-menu"
|
||||
(keydown)="onMenuKeydown($event)"
|
||||
>
|
||||
@if (view() === 'menu') {
|
||||
@for (section of sections(); track $index) {
|
||||
@if (section.items.length) {
|
||||
@if ($index > 0) {
|
||||
<hr class="more-menu__divider" aria-hidden="true" />
|
||||
}
|
||||
@if (section.labelKey; as groupKey) {
|
||||
<div class="more-menu__group" role="presentation">
|
||||
{{ groupKey | translate }}
|
||||
</div>
|
||||
}
|
||||
@for (item of section.items; track item.id) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="more-menu__item"
|
||||
[disabled]="item.disabled"
|
||||
[attr.data-test-id]="item.testId ?? null"
|
||||
[attr.data-menu-item]="item.id"
|
||||
(click)="select(item)"
|
||||
>
|
||||
@if (item.icon; as icon) {
|
||||
<mat-icon
|
||||
class="more-menu__icon"
|
||||
aria-hidden="true"
|
||||
>{{ icon }}</mat-icon
|
||||
>
|
||||
}
|
||||
<span class="more-menu__label">{{
|
||||
item.labelKey | translate: item.labelParams
|
||||
}}</span>
|
||||
@if (item.hint !== null && item.hint !== undefined) {
|
||||
<span class="more-menu__hint">{{
|
||||
item.hint
|
||||
}}</span>
|
||||
}
|
||||
@if (item.kind === 'sources') {
|
||||
<mat-icon
|
||||
class="more-menu__chevron"
|
||||
aria-hidden="true"
|
||||
>chevron_right</mat-icon
|
||||
>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="more-menu__item more-menu__back"
|
||||
role="menuitem"
|
||||
(click)="showMenu()"
|
||||
>
|
||||
<mat-icon class="more-menu__icon" aria-hidden="true"
|
||||
>arrow_back</mat-icon
|
||||
>
|
||||
<span class="more-menu__label">{{ 'BACK' | translate }}</span>
|
||||
</button>
|
||||
<app-vod-sources-menu
|
||||
class="more-menu__sources"
|
||||
[sources]="sources()"
|
||||
[title]="sourcesTitle()"
|
||||
[matchKind]="matchKind()"
|
||||
[autoFailoverEnabled]="autoFailoverEnabled()"
|
||||
[autoFailoverSupported]="autoFailoverSupported()"
|
||||
[resumeLabel]="resumeLabel()"
|
||||
[playbackLive]="playbackLive()"
|
||||
(playRequested)="onSourcePlay($event)"
|
||||
(pinRequested)="onSourcePin($event)"
|
||||
(checkRequested)="sourceCheckRequested.emit($event)"
|
||||
(autoFailoverToggled)="autoFailoverToggled.emit($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,151 @@
|
||||
@use '../../../../styles/detail-view-actions' as actions;
|
||||
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.more-trigger {
|
||||
@include actions.ghost-action-surface;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&--open {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The dropdown. An opaque widget surface: a CDK overlay floats over
|
||||
// arbitrary page content, so a missing background is not cosmetic.
|
||||
.more-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 260px;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: min(380px, 60vh);
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 10px;
|
||||
background: var(--app-widget-bg, #151c28);
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
|
||||
&--sources {
|
||||
width: 660px;
|
||||
max-height: min(560px, 80vh);
|
||||
}
|
||||
|
||||
&__group {
|
||||
padding: 8px 10px 4px;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
margin: 4px 2px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible {
|
||||
background: var(--app-card-hover-bg, #1d2533);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
color: var(--app-eyebrow-color, #9aa3b2);
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
}
|
||||
|
||||
&__back {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { VodMoreMenuComponent } from './vod-more-menu.component';
|
||||
import type { VodMoreMenuSection } from './vod-more-menu.model';
|
||||
|
||||
const SECTIONS: VodMoreMenuSection[] = [
|
||||
{
|
||||
labelKey: 'GROUP_A',
|
||||
items: [
|
||||
{ id: 'copy', labelKey: 'COPY', testId: 'menu-copy' },
|
||||
{ id: 'sources', labelKey: 'SOURCES', kind: 'sources', hint: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [{ id: 'reset', labelKey: 'RESET', disabled: true }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('VodMoreMenuComponent', () => {
|
||||
let fixture: ComponentFixture<VodMoreMenuComponent>;
|
||||
let overlay: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VodMoreMenuComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
GROUP_A: 'Watching',
|
||||
COPY: 'Copy stream URL',
|
||||
SOURCES: 'Other sources',
|
||||
RESET: 'Reset progress',
|
||||
BACK: 'Back',
|
||||
});
|
||||
translate.use('en');
|
||||
overlay = TestBed.inject(OverlayContainer).getContainerElement();
|
||||
fixture = TestBed.createComponent(VodMoreMenuComponent);
|
||||
fixture.componentRef.setInput('sections', SECTIONS);
|
||||
fixture.componentRef.setInput('label', 'More');
|
||||
fixture.componentRef.setInput('testId', 'more');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function trigger(): HTMLButtonElement {
|
||||
return (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-testid="more"]'
|
||||
)!;
|
||||
}
|
||||
|
||||
function rows(): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
overlay.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')
|
||||
);
|
||||
}
|
||||
|
||||
it('opens on click with groups, hints and disabled rows, and emits the chosen id', () => {
|
||||
trigger().click();
|
||||
fixture.detectChanges();
|
||||
expect(trigger().getAttribute('aria-expanded')).toBe('true');
|
||||
expect(overlay.querySelector('[role="menu"]')).toBeTruthy();
|
||||
expect(overlay.textContent).toContain('Watching');
|
||||
expect(overlay.textContent).toContain('Other sources');
|
||||
expect(
|
||||
overlay.querySelector('.more-menu__hint')?.textContent?.trim()
|
||||
).toBe('2');
|
||||
const reset = rows().find((row) => row.textContent?.includes('Reset'))!;
|
||||
expect(reset.disabled).toBe(true);
|
||||
|
||||
const spy = jest.fn();
|
||||
fixture.componentInstance.selected.subscribe(spy);
|
||||
overlay
|
||||
.querySelector<HTMLButtonElement>('[data-test-id="menu-copy"]')!
|
||||
.click();
|
||||
fixture.detectChanges();
|
||||
expect(spy).toHaveBeenCalledWith('copy');
|
||||
expect(overlay.querySelector('[role="menu"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('moves focus with the arrow keys and closes on Escape', () => {
|
||||
trigger().click();
|
||||
fixture.detectChanges();
|
||||
const [first, second] = rows();
|
||||
first.focus();
|
||||
const menu = overlay.querySelector<HTMLElement>('[role="menu"]')!;
|
||||
menu.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||
);
|
||||
expect(document.activeElement).toBe(second);
|
||||
menu.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
|
||||
);
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
menu.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('switches to the sources panel, moves focus into it, and back', async () => {
|
||||
fixture.componentRef.setInput('sources', []);
|
||||
trigger().click();
|
||||
fixture.detectChanges();
|
||||
rows()
|
||||
.find((row) => row.textContent?.includes('Other sources'))!
|
||||
.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.view()).toBe('sources');
|
||||
expect(overlay.querySelector('app-vod-sources-menu')).toBeTruthy();
|
||||
// The focused row is gone; keyboard focus lands on the panel's first control.
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
expect(document.activeElement).toBe(
|
||||
overlay.querySelector('.more-menu__back')
|
||||
);
|
||||
overlay.querySelector<HTMLButtonElement>('.more-menu__back')!.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.view()).toBe('menu');
|
||||
});
|
||||
|
||||
it('disables the trigger when there are no rows', () => {
|
||||
fixture.componentRef.setInput('sections', [{ items: [] }]);
|
||||
fixture.detectChanges();
|
||||
expect(trigger().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
type ConnectedPosition,
|
||||
} from '@angular/cdk/overlay';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import type {
|
||||
VodSourceDescriptor,
|
||||
VodSourceMatchKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { VodSourcesMenuComponent } from '../vod-sources/vod-sources-menu.component';
|
||||
import type {
|
||||
VodMoreMenuItem,
|
||||
VodMoreMenuSection,
|
||||
} from './vod-more-menu.model';
|
||||
|
||||
type MenuView = 'menu' | 'sources';
|
||||
|
||||
/**
|
||||
* The "…" button of the details action row and its dropdown. Right edges
|
||||
* align with the button so the panel never leaves the details column; when
|
||||
* the space below is short the panel opens upward. Rows are plain buttons
|
||||
* with `role="menuitem"`, reachable with the arrow keys; Escape and an
|
||||
* outside click close the menu. Selecting the `sources` row swaps the panel
|
||||
* to the alternative-sources list.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-vod-more-menu',
|
||||
imports: [
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
VodSourcesMenuComponent,
|
||||
],
|
||||
templateUrl: './vod-more-menu.component.html',
|
||||
styleUrl: './vod-more-menu.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VodMoreMenuComponent {
|
||||
readonly sections = input.required<readonly VodMoreMenuSection[]>();
|
||||
readonly label = input.required<string>();
|
||||
readonly testId = input<string | null>(null);
|
||||
|
||||
// Alternative sources, shown by the `sources` row.
|
||||
readonly sources = input<VodSourceDescriptor[]>([]);
|
||||
readonly sourcesTitle = input('');
|
||||
readonly matchKind = input<VodSourceMatchKind>('title-year');
|
||||
readonly autoFailoverEnabled = input(false);
|
||||
readonly autoFailoverSupported = input(true);
|
||||
readonly playbackLive = input(false);
|
||||
readonly resumeLabel = input<string | null>(null);
|
||||
|
||||
readonly selected = output<string>();
|
||||
readonly sourcePlayRequested = output<string>();
|
||||
readonly sourcePinRequested = output<string>();
|
||||
readonly sourceCheckRequested = output<string>();
|
||||
readonly autoFailoverToggled = output<boolean>();
|
||||
|
||||
readonly isOpen = signal(false);
|
||||
readonly view = signal<MenuView>('menu');
|
||||
readonly hasRows = computed(() =>
|
||||
this.sections().some((section) => section.items.length > 0)
|
||||
);
|
||||
|
||||
private readonly panel = viewChild<ElementRef<HTMLElement>>('panel');
|
||||
private readonly trigger =
|
||||
viewChild.required<ElementRef<HTMLButtonElement>>('triggerButton');
|
||||
|
||||
/** Below the button, right-aligned; above it when the space below is short. */
|
||||
readonly overlayPositions: ConnectedPosition[] = [
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetY: 6,
|
||||
panelClass: 'vod-more-menu-pane--below',
|
||||
},
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -6,
|
||||
panelClass: 'vod-more-menu-pane--above',
|
||||
},
|
||||
];
|
||||
|
||||
toggle(): void {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.view.set('menu');
|
||||
this.isOpen.set(true);
|
||||
this.focusRow(0);
|
||||
}
|
||||
|
||||
close(restoreFocus = false): void {
|
||||
if (!this.isOpen()) {
|
||||
return;
|
||||
}
|
||||
this.isOpen.set(false);
|
||||
this.view.set('menu');
|
||||
if (restoreFocus) {
|
||||
this.trigger().nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
showMenu(): void {
|
||||
this.view.set('menu');
|
||||
this.focusRow(0);
|
||||
}
|
||||
|
||||
select(item: VodMoreMenuItem): void {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
if (item.kind === 'sources') {
|
||||
this.view.set('sources');
|
||||
this.focusFirstControl();
|
||||
return;
|
||||
}
|
||||
this.close(true);
|
||||
this.selected.emit(item.id);
|
||||
}
|
||||
|
||||
onOverlayKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close(true);
|
||||
} else if (event.key === 'Tab') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
onTriggerKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'ArrowDown' && !this.isOpen()) {
|
||||
event.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
onMenuKeydown(event: KeyboardEvent): void {
|
||||
if (this.view() !== 'menu') {
|
||||
return;
|
||||
}
|
||||
const rows = this.rows();
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const current = rows.indexOf(document.activeElement as HTMLElement);
|
||||
let next: number | null = null;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
next = (current + 1) % rows.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
next = (current - 1 + rows.length) % rows.length;
|
||||
break;
|
||||
case 'Home':
|
||||
next = 0;
|
||||
break;
|
||||
case 'End':
|
||||
next = rows.length - 1;
|
||||
break;
|
||||
}
|
||||
if (next !== null) {
|
||||
event.preventDefault();
|
||||
rows[next].focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Playing from a row hands the screen back to the player. */
|
||||
onSourcePlay(sourceId: string): void {
|
||||
this.close();
|
||||
this.sourcePlayRequested.emit(sourceId);
|
||||
}
|
||||
|
||||
onSourcePin(sourceId: string): void {
|
||||
this.close();
|
||||
this.sourcePinRequested.emit(sourceId);
|
||||
}
|
||||
|
||||
private rows(): HTMLElement[] {
|
||||
const panel = this.panel()?.nativeElement;
|
||||
return panel
|
||||
? Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(
|
||||
'[role="menuitem"]:not([disabled])'
|
||||
)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
private focusRow(index: number): void {
|
||||
// The overlay attaches after this change detection pass.
|
||||
setTimeout(() => this.rows()[index]?.focus());
|
||||
}
|
||||
|
||||
/** The sources panel replaces the focused row; focus follows into it. */
|
||||
private focusFirstControl(): void {
|
||||
setTimeout(() =>
|
||||
this.panel()
|
||||
?.nativeElement.querySelector<HTMLElement>(
|
||||
'button:not([disabled])'
|
||||
)
|
||||
?.focus()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** One row of the details "…" menu. Labels are i18n keys; hints are literal. */
|
||||
export interface VodMoreMenuItem {
|
||||
readonly id: string;
|
||||
readonly labelKey: string;
|
||||
readonly labelParams?: Record<string, string | number>;
|
||||
/** Right-aligned literal hint: a count, a category, a player name. */
|
||||
readonly hint?: string | number | null;
|
||||
readonly icon?: string;
|
||||
readonly disabled?: boolean;
|
||||
readonly testId?: string;
|
||||
/** `sources` swaps the panel to the alternative-sources list on select. */
|
||||
readonly kind?: 'action' | 'sources';
|
||||
}
|
||||
|
||||
/** A labelled group of rows; groups are separated by a hairline. */
|
||||
export interface VodMoreMenuSection {
|
||||
readonly labelKey?: string | null;
|
||||
readonly items: readonly VodMoreMenuItem[];
|
||||
}
|
||||
@@ -3,9 +3,13 @@
|
||||
<div class="shell__hero-inner">
|
||||
<app-content-hero
|
||||
[title]="title()"
|
||||
[kindLabel]="kindLabel()"
|
||||
[description]="description()"
|
||||
[posterUrl]="posterUrl()"
|
||||
[backdropUrl]="backdropUrl()"
|
||||
[progress]="progress()"
|
||||
[trailerBackdropUrl]="trailerBackdropUrl()"
|
||||
[contentKey]="contentKey()"
|
||||
[isLoading]="isLoading()"
|
||||
[errorMessage]="errorMessage()"
|
||||
>
|
||||
|
||||
@@ -5,13 +5,21 @@
|
||||
:host {
|
||||
// Shared surface palette (mirrors content-hero) so the area below the
|
||||
// natural-height hero and the About block match the page background.
|
||||
--surface-bg: #fffaf5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.5);
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
|
||||
--surface-bg: var(--app-content-bg, #f5f6f8);
|
||||
--text-primary: var(--app-heading-color, #1a1d23);
|
||||
--text-secondary: var(--app-body-color, #4a5060);
|
||||
--text-muted: var(--app-muted-color, #8891a4);
|
||||
--tag-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #1d1b1e) 10%,
|
||||
transparent
|
||||
);
|
||||
--tag-border: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #1d1b1e) 14%,
|
||||
transparent
|
||||
);
|
||||
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.3);
|
||||
--content-max-width: 1400px;
|
||||
|
||||
display: block;
|
||||
@@ -20,6 +28,9 @@
|
||||
overflow-x: hidden;
|
||||
background: var(--surface-bg);
|
||||
color: var(--text-primary);
|
||||
// The hero and the rows size themselves to this pane, not the viewport:
|
||||
// beside the categories panel it is narrower than the window.
|
||||
container: detail / inline-size;
|
||||
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: auto;
|
||||
@@ -31,13 +42,7 @@
|
||||
}
|
||||
|
||||
:host-context(.dark-theme) {
|
||||
--surface-bg: #141414;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -87,7 +92,11 @@
|
||||
.shell__episodes {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
padding: 0 32px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.shell__about {
|
||||
@@ -98,9 +107,16 @@
|
||||
}
|
||||
|
||||
.shell__extras {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 40px 40px;
|
||||
padding: 8px 32px 40px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shell-fade-in {
|
||||
|
||||
@@ -64,9 +64,17 @@ export class PortalDetailShellComponent {
|
||||
private readonly backNavigation = inject(WorkspaceBackNavigationService);
|
||||
|
||||
readonly title = input<string>();
|
||||
/** "Movie · playlist name" eyebrow above the hero title. */
|
||||
readonly kindLabel = input<string | null>(null);
|
||||
readonly description = input<string>();
|
||||
readonly posterUrl = input<string>();
|
||||
readonly backdropUrl = input<string>();
|
||||
/** 0–100 watched share shown as the hero's resume bar. */
|
||||
readonly progress = input<number | null>(null);
|
||||
/** Trailer embed to play muted behind the hero, when the setting is on. */
|
||||
readonly trailerBackdropUrl = input<string | null>(null);
|
||||
/** Stable identity of the shown title; see `ContentHeroComponent.contentKey`. */
|
||||
readonly contentKey = input<string | null>(null);
|
||||
readonly isLoading = input(false);
|
||||
readonly errorMessage = input<string>();
|
||||
readonly backLabel = input<string>();
|
||||
|
||||
@@ -1,36 +1,13 @@
|
||||
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
isPortalPlaybackWatched,
|
||||
parseDurationSeconds,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Pure helpers for episode duration parsing and progress-text formatting. */
|
||||
|
||||
export function parseDuration(duration: string | number | undefined): number {
|
||||
if (!duration) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof duration === 'number') {
|
||||
return duration;
|
||||
}
|
||||
|
||||
// Providers report "45 min" (Stalker VOD) and "1h 30min" (Xtream) —
|
||||
// the hour component must not be dropped when both are present.
|
||||
const minutesMatch = duration.match(/(?:(\d+)\s*h\w*)?\s*(\d+)\s*min/);
|
||||
if (minutesMatch) {
|
||||
return (
|
||||
parseInt(minutesMatch[1] ?? '0', 10) * 3600 +
|
||||
parseInt(minutesMatch[2], 10) * 60
|
||||
);
|
||||
}
|
||||
|
||||
const parts = duration.split(':').map(Number);
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
return Number(duration) || 0;
|
||||
return parseDurationSeconds(duration);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,3 +51,13 @@ export function formatEpisodePositionText(
|
||||
.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
|
||||
): number {
|
||||
const seconds = Number(info?.duration_secs);
|
||||
return Number.isFinite(seconds) && seconds > 0
|
||||
? Math.floor(seconds)
|
||||
: parseDuration(info?.duration);
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
import {
|
||||
episodeRuntimeSeconds,
|
||||
formatEpisodePositionText,
|
||||
parseDuration,
|
||||
} from './episode-progress.util';
|
||||
|
||||
describe('episodeRuntimeSeconds', () => {
|
||||
it('prefers the provider seconds over the formatted string', () => {
|
||||
expect(episodeRuntimeSeconds({ duration_secs: 2520 })).toBe(2520);
|
||||
expect(
|
||||
episodeRuntimeSeconds({ duration_secs: 2520, duration: '00:10:00' })
|
||||
).toBe(2520);
|
||||
expect(episodeRuntimeSeconds({ duration: '00:42:00' })).toBe(2520);
|
||||
expect(
|
||||
episodeRuntimeSeconds({ duration_secs: 0, duration: '00:42:00' })
|
||||
).toBe(2520);
|
||||
expect(episodeRuntimeSeconds(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('episode-progress.util', () => {
|
||||
it('parses duration strings', () => {
|
||||
expect(parseDuration('01:00:30')).toBe(3630);
|
||||
|
||||
@@ -1,37 +1,69 @@
|
||||
<section class="seasons-section">
|
||||
<app-season-header
|
||||
[downloadVisible]="downloadPresenter.presentationVisible()"
|
||||
[batchRunning]="downloadPresenter.batchRunning()"
|
||||
[seasonDisabled]="downloadPresenter.seasonDisabled()"
|
||||
[eligibleEpisodeCount]="downloadPresenter.eligibleEpisodeCount()"
|
||||
[watchToggleVisible]="watchPresenter.seasonWatchToggleVisible()"
|
||||
[watchBatchRunning]="seasonWatchBatchRunning()"
|
||||
[seasonFullyWatched]="watchPresenter.selectedSeasonFullyWatched()"
|
||||
[watchEligibleCount]="watchPresenter.seasonWatchEligibleCount()"
|
||||
[seriesMenuVisible]="watchPresenter.seriesMenuVisible()"
|
||||
[seriesActionDisabled]="watchPresenter.seriesActionDisabled()"
|
||||
[seriesFullyWatched]="watchPresenter.seriesFullyWatched()"
|
||||
[seriesEligibleCount]="watchPresenter.seriesWatchEligibleCount()"
|
||||
[seriesCountKnown]="watchPresenter.seriesCountKnown()"
|
||||
[viewToggleVisible]="hasSeasons() && !showSeasonEmptyState()"
|
||||
[viewMode]="viewMode()"
|
||||
(downloadSeason)="downloadPresenter.enqueueSeason()"
|
||||
(toggleSeasonWatched)="watchPresenter.toggleSeasonWatched()"
|
||||
(toggleSeriesWatched)="watchPresenter.toggleSeriesWatched()"
|
||||
(viewModeChange)="setViewMode($event)"
|
||||
/>
|
||||
<!-- "Episodes · Season 1 · Season 2 · 8 episodes": the season pills sit
|
||||
in the header; season and series actions live in the hero's menu. -->
|
||||
<header class="episodes-header">
|
||||
<h3 class="episodes-header__title" data-test-id="episodes-heading">
|
||||
{{ 'PORTALS.DETAIL.EPISODES' | translate }}
|
||||
</h3>
|
||||
@if (hasSeasons()) {
|
||||
<app-season-tabs
|
||||
class="episodes-header__tabs"
|
||||
[seasonKeys]="sortedSeasonKeys()"
|
||||
[selectedSeason]="selectedSeason()"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
/>
|
||||
@if (selectedSeasonEpisodes().length; as count) {
|
||||
<span class="episodes-header__count">
|
||||
{{
|
||||
(count === 1
|
||||
? 'PORTALS.EPISODE_COUNT_ONE'
|
||||
: 'PORTALS.EPISODE_COUNT_OTHER'
|
||||
) | translate: { count }
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
@if (hasSeasons() && !showSeasonEmptyState()) {
|
||||
<div class="view-toggle">
|
||||
<mat-button-toggle-group
|
||||
[value]="viewMode()"
|
||||
(change)="setViewMode($event.value)"
|
||||
hideSingleSelectionIndicator
|
||||
>
|
||||
<mat-button-toggle
|
||||
value="grid"
|
||||
[aria-label]="'PORTALS.GRID_VIEW' | translate"
|
||||
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>grid_view</mat-icon>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle
|
||||
value="list"
|
||||
[aria-label]="'PORTALS.LIST_VIEW' | translate"
|
||||
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>view_list</mat-icon>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (hasSeasons()) {
|
||||
<!-- Season card: the selected season's own cover beside the tabs and
|
||||
description. Without a cover the column is not rendered at all,
|
||||
so the tabs sit exactly where they did before season posters. -->
|
||||
<div
|
||||
class="season-card"
|
||||
[class.season-card--with-cover]="selectedSeasonPosterUrl()"
|
||||
>
|
||||
@if (
|
||||
hasSeasons() &&
|
||||
(selectedSeasonPosterUrl() || selectedSeasonDescription())
|
||||
) {
|
||||
<!-- The selected season's own cover and synopsis, compact, under
|
||||
the header. Without either the row is not rendered at all. -->
|
||||
<div class="season-strip">
|
||||
@if (selectedSeasonPosterUrl(); as posterUrl) {
|
||||
<img
|
||||
class="season-card__cover"
|
||||
class="season-strip__cover"
|
||||
data-testid="season-cover"
|
||||
[src]="posterUrl"
|
||||
[alt]="
|
||||
@@ -42,17 +74,14 @@
|
||||
(error)="onSeasonPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<app-season-tabs
|
||||
[seasonKeys]="sortedSeasonKeys()"
|
||||
[selectedSeason]="selectedSeason()"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonDescription]="selectedSeasonDescription()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
/>
|
||||
@if (selectedSeasonDescription(); as description) {
|
||||
<app-expandable-text
|
||||
class="season-strip__description"
|
||||
data-testid="season-description"
|
||||
[text]="description"
|
||||
[clampLines]="3"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -133,19 +162,16 @@
|
||||
</div>
|
||||
}
|
||||
@if (getEpisodeProgress(episode) > 0) {
|
||||
<app-progress-capsule
|
||||
[progress]="getEpisodeProgress(episode)"
|
||||
/>
|
||||
@if (
|
||||
getEpisodePositionText(episode);
|
||||
as time
|
||||
) {
|
||||
<div
|
||||
class="episode-card__position-text"
|
||||
>
|
||||
{{ time }}
|
||||
</div>
|
||||
}
|
||||
<span
|
||||
class="episode-card__progress"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i
|
||||
[style.width.%]="
|
||||
getEpisodeProgress(episode)
|
||||
"
|
||||
></i>
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
@@ -248,9 +274,6 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__number">
|
||||
{{ episode.episode_num }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__info">
|
||||
<div class="episode-card__title-row">
|
||||
@@ -280,10 +303,10 @@
|
||||
{{ episodeInfo?.plot }}
|
||||
</p>
|
||||
}
|
||||
@if (episodeInfo?.duration) {
|
||||
<span class="episode-card__duration">
|
||||
{{ episodeInfo?.duration }}
|
||||
</span>
|
||||
@if (getEpisodeSubline(episode); as subline) {
|
||||
<small class="episode-card__subline">
|
||||
{{ subline }}
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
.seasons-section {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.episode-card,
|
||||
.episode-card__thumbnail img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -21,8 +28,16 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)),
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
rgba(255, 255, 255, 0.03)
|
||||
),
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
transparent 60%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 18px 40px rgba(0, 0, 0, 0.22);
|
||||
@@ -64,90 +79,160 @@
|
||||
&--season {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04)),
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 65%);
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.07),
|
||||
rgba(255, 255, 255, 0.04)
|
||||
),
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
rgba(255, 255, 255, 0.08),
|
||||
transparent 65%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Grid Layout
|
||||
.episodes-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
}
|
||||
|
||||
// Season card: an optional cover column (--season-cover-width follows
|
||||
// Settings.coverSize: 96 / 120 / 144px, tokens in apps/web/src/_cover-size.scss)
|
||||
// next to the tabs + description. Without a cover the grid is one column and
|
||||
// the tabs render exactly as they did before season posters existed.
|
||||
.season-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
margin-bottom: 16px;
|
||||
// "Episodes" header: title, season pills, count and the view toggle on one
|
||||
// line; the pills wrap first when the pane is narrow.
|
||||
.episodes-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
&--with-cover {
|
||||
grid-template-columns: var(--season-cover-width, 120px) minmax(0, 1fr);
|
||||
&__title {
|
||||
margin: 0 6px 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__count {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
margin-left: auto;
|
||||
|
||||
mat-button-toggle-group {
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 3%,
|
||||
transparent
|
||||
);
|
||||
|
||||
::ng-deep {
|
||||
.mat-button-toggle {
|
||||
background: transparent;
|
||||
|
||||
.mat-button-toggle-button {
|
||||
padding: 0 8px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.mat-button-toggle-checked {
|
||||
background: var(--app-selection-surface);
|
||||
|
||||
mat-icon {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.mat-button-toggle-checked) {
|
||||
background: var(--app-hover-overlay);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle-appearance-standard {
|
||||
.mat-button-toggle-label-content {
|
||||
line-height: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle + .mat-button-toggle {
|
||||
border-left: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 14%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The selected season's cover (--season-cover-width follows
|
||||
// Settings.coverSize) and synopsis under the header.
|
||||
.season-strip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
&__cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
width: calc(var(--season-cover-width, 120px) * 0.5);
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--app-widget-bg);
|
||||
box-shadow: var(--poster-shadow, 0 8px 40px rgba(0, 0, 0, 0.5));
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
&__description {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 760px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
app-season-tabs {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.season-card--with-cover {
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// Episode Card
|
||||
// Episode Card — flat: the thumbnail and the text sit on the page, no box.
|
||||
.episode-card {
|
||||
background: var(--app-widget-bg);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
position: relative;
|
||||
|
||||
// In-progress state
|
||||
&--in-progress {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--mat-sys-primary) 10%, transparent) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&--launching,
|
||||
&--active-external {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
|
||||
.episode-card__thumbnail {
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&--launching {
|
||||
@@ -157,14 +242,15 @@ app-season-tabs {
|
||||
}
|
||||
}
|
||||
|
||||
&--active-external {
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
|
||||
// Playing in the inline player
|
||||
&--playing .episode-card__thumbnail {
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
|
||||
}
|
||||
|
||||
// Playing in the inline player
|
||||
&--playing {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 70%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
// Watched state — the sole indicator is the watched toggle on the
|
||||
@@ -235,10 +321,6 @@ app-season-tabs {
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--app-card-hover-bg);
|
||||
border-color: color-mix(in srgb, var(--app-on-surface) 22%, transparent);
|
||||
|
||||
.episode-card__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -246,32 +328,44 @@ app-season-tabs {
|
||||
.episode-card__play {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.episode-card__thumbnail img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
&__thumbnail {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
&__position-text {
|
||||
// 3px watched share at the bottom of the artwork
|
||||
&__progress {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
z-index: 2;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--app-selection-color, #98b8ff);
|
||||
}
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
@@ -345,7 +439,11 @@ app-season-tabs {
|
||||
color: var(--mat-sys-tertiary) !important;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary) 80%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary) 80%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,34 +486,22 @@ app-season-tabs {
|
||||
}
|
||||
}
|
||||
|
||||
&__number {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&__info {
|
||||
padding: 12px;
|
||||
padding: 8px 2px 0;
|
||||
}
|
||||
|
||||
&__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
@@ -469,9 +555,9 @@ app-season-tabs {
|
||||
}
|
||||
|
||||
&__plot {
|
||||
font-size: 0.75rem;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 8px 0;
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -479,13 +565,10 @@ app-season-tabs {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
font-size: 0.6875rem;
|
||||
&__subline {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,7 +593,11 @@ app-season-tabs {
|
||||
|
||||
&:hover {
|
||||
background: var(--app-card-hover-bg);
|
||||
border-color: color-mix(in srgb, var(--app-on-surface) 22%, transparent);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 22%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.episode-list-item__actions {
|
||||
opacity: 1;
|
||||
@@ -526,12 +613,24 @@ app-season-tabs {
|
||||
}
|
||||
|
||||
&--in-progress {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
|
||||
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);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 60%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 8%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,7 +811,11 @@ app-season-tabs {
|
||||
color: var(--mat-sys-tertiary);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary) 15%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,12 +829,24 @@ app-season-tabs {
|
||||
|
||||
&--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);
|
||||
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);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 60%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,9 @@ describe('SeasonContainerComponent', () => {
|
||||
let emittedSeasons: string[];
|
||||
const snackBarOpen = jest.fn();
|
||||
|
||||
/** The season download, as the hero "…" menu drives it through the presenter. */
|
||||
const seasonDownload = () => component.downloadPresenter;
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>,
|
||||
isLoading = false
|
||||
@@ -593,19 +596,11 @@ describe('SeasonContainerComponent', () => {
|
||||
setRequiredInputs({ '1': [paused, eligible] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(button.textContent).toContain('Download season (1)');
|
||||
expect(button.getAttribute('aria-label')).toBe(
|
||||
'Download season, 1 episodes available'
|
||||
);
|
||||
expect(
|
||||
button.querySelector('.season-download-button__label')?.textContent
|
||||
).toContain('Download season (1)');
|
||||
expect(button.nextElementSibling?.classList).toContain('view-toggle');
|
||||
expect(seasonDownload().presentationVisible()).toBe(true);
|
||||
expect(seasonDownload().eligibleEpisodeCount()).toBe(1);
|
||||
expect(seasonDownload().seasonDisabled()).toBe(false);
|
||||
|
||||
button.click();
|
||||
await seasonDownload().enqueueSeason();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
|
||||
@@ -628,14 +623,11 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const action = episodeAction(episode.id);
|
||||
const seasonButton = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(action.disabled).toBe(true);
|
||||
expect(action.querySelector('mat-icon')?.textContent).toContain(
|
||||
'block'
|
||||
);
|
||||
expect(seasonButton.disabled).toBe(true);
|
||||
expect(seasonDownload().seasonDisabled()).toBe(true);
|
||||
expect(downloadsServiceStub.startDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -654,15 +646,12 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const action = episodeAction(episode.id);
|
||||
const seasonButton = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(action.disabled).toBe(false);
|
||||
expect(action.querySelector('mat-icon')?.textContent).toContain(
|
||||
'download'
|
||||
);
|
||||
expect(seasonButton.disabled).toBe(false);
|
||||
expect(seasonButton.textContent).toContain('Download season (1)');
|
||||
expect(seasonDownload().seasonDisabled()).toBe(false);
|
||||
expect(seasonDownload().eligibleEpisodeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('gives the grid and list view radios localized accessible names', () => {
|
||||
@@ -694,38 +683,34 @@ describe('SeasonContainerComponent', () => {
|
||||
setRequiredInputs({ '1': [first] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const seasonButton = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(seasonButton().disabled).toBe(true);
|
||||
expect(seasonDownload().seasonDisabled()).toBe(true);
|
||||
expect(downloadsServiceStub.hasLoadedDownloads()).toBe(true);
|
||||
expect(episodeAction(first.id).disabled).toBe(true);
|
||||
|
||||
downloadsServiceStub.hasAuthoritativeDownloadList.set(true);
|
||||
fixture.componentRef.setInput('isLoading', true);
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().disabled).toBe(true);
|
||||
expect(seasonDownload().seasonDisabled()).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('isLoading', false);
|
||||
fixture.componentRef.setInput('seasons', { '1': [] });
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().disabled).toBe(true);
|
||||
expect(seasonDownload().seasonDisabled()).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('seasons', { '1': [first] });
|
||||
downloadsServiceStub.downloads.set([
|
||||
createDownload(first, { status: 'paused' }),
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().disabled).toBe(true);
|
||||
expect(seasonDownload().seasonDisabled()).toBe(true);
|
||||
|
||||
downloadsServiceStub.downloads.set([]);
|
||||
const start = deferred<{ success: boolean }>();
|
||||
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
|
||||
fixture.detectChanges();
|
||||
seasonButton().click();
|
||||
void seasonDownload().enqueueSeason();
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().disabled).toBe(true);
|
||||
expect(seasonDownload().seasonDisabled()).toBe(true);
|
||||
|
||||
start.resolve({ success: true });
|
||||
await fixture.whenStable();
|
||||
@@ -740,33 +725,22 @@ describe('SeasonContainerComponent', () => {
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const seasonButton = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
seasonButton().click();
|
||||
void seasonDownload().enqueueSeason();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(seasonButton().textContent).toContain('Adding to queue…');
|
||||
expect(seasonButton().getAttribute('aria-label')).toBe(
|
||||
'Adding to queue…'
|
||||
);
|
||||
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
|
||||
expect(seasonDownload().batchRunning()).toBe(true);
|
||||
|
||||
start.resolve({ success: true });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
|
||||
expect(seasonDownload().batchRunning()).toBe(true);
|
||||
|
||||
refresh.resolve(undefined);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().hasAttribute('aria-busy')).toBe(false);
|
||||
expect(seasonButton().textContent).toContain('Download season (1)');
|
||||
expect(seasonButton().getAttribute('aria-label')).toBe(
|
||||
'Download season, 1 episodes available'
|
||||
);
|
||||
expect(seasonDownload().batchRunning()).toBe(false);
|
||||
expect(seasonDownload().eligibleEpisodeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('hides all download presentation on web, in provider-only mode, or without an adapter', () => {
|
||||
@@ -774,11 +748,7 @@ describe('SeasonContainerComponent', () => {
|
||||
setRequiredInputs({ '1': [first] });
|
||||
fixture.componentRef.setInput('downloadAdapter', downloadAdapter);
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(seasonDownload().presentationVisible()).toBe(false);
|
||||
|
||||
downloadsServiceStub.isAvailable.set(true);
|
||||
downloadsServiceStub.hasLoadedDownloads.set(true);
|
||||
@@ -789,20 +759,12 @@ describe('SeasonContainerComponent', () => {
|
||||
'[data-test-id^="episode-download-"]'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(seasonDownload().presentationVisible()).toBe(false);
|
||||
|
||||
fixture.componentRef.setInput('downloadsEnabled', true);
|
||||
fixture.componentRef.setInput('downloadAdapter', null);
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(seasonDownload().presentationVisible()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id^="episode-download-"]'
|
||||
@@ -999,12 +961,8 @@ describe('SeasonContainerComponent', () => {
|
||||
enableDownloads();
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
const seasonButton = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
seasonButton.click();
|
||||
seasonButton.click();
|
||||
void seasonDownload().enqueueSeason();
|
||||
void seasonDownload().enqueueSeason();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -1082,10 +1040,7 @@ describe('SeasonContainerComponent', () => {
|
||||
component.selectSeason('1');
|
||||
fixture.detectChanges();
|
||||
|
||||
const seasonButton = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
seasonButton.click();
|
||||
void seasonDownload().enqueueSeason();
|
||||
component.selectSeason('2');
|
||||
fixture.componentRef.setInput('seasons', {
|
||||
'1': [createEpisode({ id: '103', title: 'Replacement' })],
|
||||
@@ -1119,11 +1074,7 @@ describe('SeasonContainerComponent', () => {
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await seasonDownload().enqueueSeason();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
@@ -1151,10 +1102,28 @@ describe('SeasonContainerComponent', () => {
|
||||
playlistId: 'playlist-1',
|
||||
});
|
||||
|
||||
const toggleButton = (): HTMLButtonElement | null =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="toggle-season-watched"]'
|
||||
);
|
||||
/**
|
||||
* The season toggle as the hero "…" menu presents it: the presenter
|
||||
* owns label, count, disabled state and the click.
|
||||
*/
|
||||
const toggleButton = () => {
|
||||
const presenter = component.watchPresenter;
|
||||
if (!presenter.seasonWatchToggleVisible()) {
|
||||
return null;
|
||||
}
|
||||
const fullyWatched = presenter.selectedSeasonFullyWatched();
|
||||
const count = presenter.seasonWatchEligibleCount();
|
||||
return {
|
||||
textContent: fullyWatched
|
||||
? 'Mark season as unwatched'
|
||||
: `Mark season as watched (${count})`,
|
||||
icon: fullyWatched ? 'remove_done' : 'done_all',
|
||||
disabled:
|
||||
component.seasonWatchBatchRunning() ||
|
||||
(!fullyWatched && count === 0),
|
||||
click: () => presenter.toggleSeasonWatched(),
|
||||
};
|
||||
};
|
||||
|
||||
const threeEpisodes = () => ({
|
||||
'1': [
|
||||
@@ -1239,9 +1208,7 @@ describe('SeasonContainerComponent', () => {
|
||||
|
||||
const button = toggleButton();
|
||||
expect(button?.textContent).toContain('Mark season as unwatched');
|
||||
expect(button?.querySelector('mat-icon')?.textContent).toContain(
|
||||
'remove_done'
|
||||
);
|
||||
expect(button?.icon).toBe('remove_done');
|
||||
button?.click();
|
||||
|
||||
expect(emitted).toHaveLength(1);
|
||||
@@ -1299,8 +1266,7 @@ describe('SeasonContainerComponent', () => {
|
||||
|
||||
const button = toggleButton();
|
||||
expect(button?.disabled).toBe(true);
|
||||
expect(button?.getAttribute('aria-busy')).toBe('true');
|
||||
expect(button?.querySelector('mat-spinner')).not.toBeNull();
|
||||
expect(component.seasonWatchBatchRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the toggle without a playlist id and on empty seasons', () => {
|
||||
|
||||
@@ -12,14 +12,17 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
createLogger,
|
||||
formatDurationLabel,
|
||||
formatRemainingLabel,
|
||||
getPortalPlaybackProgressPercent,
|
||||
isPortalPlaybackInProgress,
|
||||
isPortalPlaybackWatched,
|
||||
@@ -29,19 +32,18 @@ import {
|
||||
XtreamSerieEpisode,
|
||||
XtreamSerieEpisodeInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
|
||||
import { ExpandableTextComponent } from '../expandable-text/expandable-text.component';
|
||||
import {
|
||||
EPISODE_INFO_PLAY,
|
||||
EpisodeInfoDialogComponent,
|
||||
buildEpisodeInfoDialogData,
|
||||
} from './episode-info-dialog.component';
|
||||
import { formatEpisodePositionText } from './episode-progress.util';
|
||||
import {
|
||||
formatEpisodePositionText,
|
||||
episodeRuntimeSeconds,
|
||||
} from './episode-progress.util';
|
||||
import { resolveAutoSelectedSeason } from './season-auto-select.util';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
import {
|
||||
type EpisodeViewMode,
|
||||
SeasonHeaderComponent,
|
||||
} from './season-header.component';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
import { SeasonWatchPresenter } from './season-watch-presenter';
|
||||
import {
|
||||
@@ -54,6 +56,8 @@ import {
|
||||
|
||||
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
|
||||
export type EpisodeViewMode = 'grid' | 'list';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
@@ -61,18 +65,19 @@ const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [SeasonDownloadPresenter, SeasonWatchPresenter],
|
||||
imports: [
|
||||
ExpandableTextComponent,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
ProgressCapsuleComponent,
|
||||
SeasonHeaderComponent,
|
||||
SeasonTabsComponent,
|
||||
TranslateModule,
|
||||
],
|
||||
})
|
||||
export class SeasonContainerComponent implements OnInit {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly logger = createLogger('SeasonContainer');
|
||||
private lastEmittedSeason: string | undefined;
|
||||
@@ -427,6 +432,25 @@ export class SeasonContainerComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
/** "42 min · 18m left", "42 min · watched", "42 min" — or null. */
|
||||
getEpisodeSubline(episode: XtreamSerieEpisode): string | null {
|
||||
const info = this.getEpisodeInfo(episode);
|
||||
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
|
||||
const position = this.playbackPositions().get(Number(episode.id));
|
||||
const remaining = formatRemainingLabel(position);
|
||||
const parts = [
|
||||
duration
|
||||
? this.translate.instant(duration.key, duration.params)
|
||||
: null,
|
||||
this.isEpisodeWatched(episode)
|
||||
? this.translate.instant('PORTALS.DETAIL.WATCHED')
|
||||
: remaining
|
||||
? this.translate.instant(remaining.key, remaining.params)
|
||||
: this.getEpisodePositionText(episode),
|
||||
].filter((part): part is string => !!part);
|
||||
return parts.length ? parts.join(' · ') : null;
|
||||
}
|
||||
|
||||
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
|
||||
return formatEpisodePositionText(this.getEpisodePosition(episode));
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ describe('SeasonContainerComponent season cover', () => {
|
||||
) as HTMLImageElement | null;
|
||||
|
||||
const coverColumnRendered = () =>
|
||||
fixture.nativeElement.querySelector('.season-card--with-cover') !==
|
||||
null;
|
||||
fixture.nativeElement.querySelector('.season-strip__cover') !== null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
|
||||
@@ -76,21 +76,27 @@ describe('SeasonContainerComponent series watched toggle', () => {
|
||||
fixture.componentRef.setInput('playlistId', playlistId);
|
||||
};
|
||||
|
||||
const menuTrigger = (): HTMLButtonElement | null =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="series-watch-menu"]'
|
||||
);
|
||||
/** The series row of the hero "…" menu exists only when the presenter says so. */
|
||||
const menuTrigger = (): object | null =>
|
||||
component.watchPresenter.seriesMenuVisible() ? {} : null;
|
||||
|
||||
const openSeriesMenuItem = (): HTMLButtonElement => {
|
||||
const trigger = menuTrigger();
|
||||
expect(trigger).not.toBeNull();
|
||||
trigger?.click();
|
||||
fixture.detectChanges();
|
||||
const item = document.querySelector(
|
||||
'[data-test-id="toggle-series-watched"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(item).not.toBeNull();
|
||||
return item as HTMLButtonElement;
|
||||
/** The series row as the hero "…" menu presents it, from the presenter. */
|
||||
const openSeriesMenuItem = () => {
|
||||
expect(menuTrigger()).not.toBeNull();
|
||||
const presenter = component.watchPresenter;
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
const labelKey = presenter.seriesFullyWatched()
|
||||
? 'XTREAM.MARK_SERIES_UNWATCHED'
|
||||
: presenter.seriesCountKnown()
|
||||
? 'XTREAM.MARK_SERIES_WATCHED'
|
||||
: 'XTREAM.MARK_SERIES_WATCHED_ALL';
|
||||
return {
|
||||
textContent: translate.instant(labelKey, {
|
||||
count: presenter.seriesWatchEligibleCount(),
|
||||
}) as string,
|
||||
disabled: presenter.seriesActionDisabled(),
|
||||
click: () => presenter.toggleSeriesWatched(),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
<div class="section-header">
|
||||
<div class="section-header__left">
|
||||
<h3 class="section-title">
|
||||
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
|
||||
</h3>
|
||||
</div>
|
||||
@if (
|
||||
downloadVisible() ||
|
||||
watchToggleVisible() ||
|
||||
viewToggleVisible() ||
|
||||
seriesMenuVisible()
|
||||
) {
|
||||
<div class="section-header__actions">
|
||||
@if (watchToggleVisible()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="season-watch-toggle-button"
|
||||
data-test-id="toggle-season-watched"
|
||||
[disabled]="
|
||||
watchBatchRunning() ||
|
||||
(!seasonFullyWatched() && watchEligibleCount() === 0)
|
||||
"
|
||||
[attr.aria-label]="
|
||||
(seasonFullyWatched()
|
||||
? 'XTREAM.MARK_SEASON_UNWATCHED'
|
||||
: 'XTREAM.MARK_SEASON_WATCHED'
|
||||
) | translate: { count: watchEligibleCount() }
|
||||
"
|
||||
[attr.aria-busy]="watchBatchRunning() ? 'true' : null"
|
||||
(click)="toggleSeasonWatched.emit()"
|
||||
>
|
||||
@if (watchBatchRunning()) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-hidden="true"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
seasonFullyWatched() ? 'remove_done' : 'done_all'
|
||||
}}</mat-icon>
|
||||
}
|
||||
<span class="season-watch-toggle-button__label">
|
||||
{{
|
||||
(seasonFullyWatched()
|
||||
? 'XTREAM.MARK_SEASON_UNWATCHED'
|
||||
: 'XTREAM.MARK_SEASON_WATCHED'
|
||||
) | translate: { count: watchEligibleCount() }
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (downloadVisible()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="season-download-button"
|
||||
data-test-id="download-season"
|
||||
[disabled]="seasonDisabled()"
|
||||
[attr.aria-label]="
|
||||
batchRunning()
|
||||
? ('DOWNLOADS.ADDING_TO_QUEUE' | translate)
|
||||
: ('DOWNLOADS.DOWNLOAD_SEASON_ARIA'
|
||||
| translate: { count: eligibleEpisodeCount() })
|
||||
"
|
||||
[attr.aria-busy]="batchRunning() ? 'true' : null"
|
||||
(click)="downloadSeason.emit()"
|
||||
>
|
||||
@if (batchRunning()) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-hidden="true"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>download</mat-icon>
|
||||
}
|
||||
<span class="season-download-button__label">
|
||||
@if (batchRunning()) {
|
||||
{{ 'DOWNLOADS.ADDING_TO_QUEUE' | translate }}
|
||||
} @else {
|
||||
{{
|
||||
'DOWNLOADS.DOWNLOAD_SEASON'
|
||||
| translate
|
||||
: { count: eligibleEpisodeCount() }
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (viewToggleVisible()) {
|
||||
<div class="view-toggle">
|
||||
<mat-button-toggle-group
|
||||
[value]="viewMode()"
|
||||
(change)="viewModeChange.emit($event.value)"
|
||||
hideSingleSelectionIndicator
|
||||
>
|
||||
<mat-button-toggle
|
||||
value="grid"
|
||||
[aria-label]="'PORTALS.GRID_VIEW' | translate"
|
||||
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>grid_view</mat-icon>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle
|
||||
value="list"
|
||||
[aria-label]="'PORTALS.LIST_VIEW' | translate"
|
||||
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>view_list</mat-icon>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
}
|
||||
@if (seriesMenuVisible()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="series-watch-menu-trigger"
|
||||
data-test-id="series-watch-menu"
|
||||
[matMenuTriggerFor]="seriesWatchMenu"
|
||||
[attr.aria-label]="'XTREAM.SERIES_WATCH_MENU' | translate"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #seriesWatchMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
class="series-watch-menu-item"
|
||||
data-test-id="toggle-series-watched"
|
||||
[disabled]="seriesActionDisabled()"
|
||||
[attr.aria-busy]="watchBatchRunning() ? 'true' : null"
|
||||
(click)="toggleSeriesWatched.emit()"
|
||||
>
|
||||
@if (watchBatchRunning()) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-hidden="true"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
seriesFullyWatched()
|
||||
? 'remove_done'
|
||||
: 'done_all'
|
||||
}}</mat-icon>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
seriesActionLabelKey()
|
||||
| translate
|
||||
: { count: seriesEligibleCount() }
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,132 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
// The actions move onto their own row before the title has to wrap. The
|
||||
// pane is narrower than the window (context panel, Back lane), so a
|
||||
// viewport breakpoint cannot tell when the two stop fitting side by side.
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
min-height: 32px;
|
||||
gap: 16px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
// Beside the title the header's space-between keeps the actions at the
|
||||
// end; once they move to their own row they start under the title.
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
mat-button-toggle-group {
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 3%,
|
||||
transparent
|
||||
);
|
||||
|
||||
::ng-deep {
|
||||
.mat-button-toggle {
|
||||
background: transparent;
|
||||
|
||||
.mat-button-toggle-button {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.mat-button-toggle-checked {
|
||||
background: var(--app-selection-surface);
|
||||
|
||||
mat-icon {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.mat-button-toggle-checked) {
|
||||
background: var(--app-hover-overlay);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle-appearance-standard {
|
||||
.mat-button-toggle-label-content {
|
||||
line-height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle + .mat-button-toggle {
|
||||
border-left: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 14%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.season-download-button,
|
||||
.season-watch-toggle-button {
|
||||
min-height: 36px;
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.series-watch-menu-trigger {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.series-watch-menu-item mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.section-header {
|
||||
align-items: flex-start;
|
||||
|
||||
&__actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
export type EpisodeViewMode = 'grid' | 'list';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-header',
|
||||
templateUrl: './season-header.component.html',
|
||||
styleUrls: ['./season-header.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
],
|
||||
})
|
||||
export class SeasonHeaderComponent {
|
||||
readonly downloadVisible = input.required<boolean>();
|
||||
readonly batchRunning = input.required<boolean>();
|
||||
readonly seasonDisabled = input.required<boolean>();
|
||||
readonly eligibleEpisodeCount = input.required<number>();
|
||||
readonly watchToggleVisible = input.required<boolean>();
|
||||
readonly watchBatchRunning = input.required<boolean>();
|
||||
readonly seasonFullyWatched = input.required<boolean>();
|
||||
readonly watchEligibleCount = input.required<number>();
|
||||
readonly seriesMenuVisible = input.required<boolean>();
|
||||
readonly seriesActionDisabled = input.required<boolean>();
|
||||
readonly seriesFullyWatched = input.required<boolean>();
|
||||
readonly seriesEligibleCount = input.required<number>();
|
||||
/** False while unloaded seasons make the series count a guess. */
|
||||
readonly seriesCountKnown = input.required<boolean>();
|
||||
readonly viewToggleVisible = input.required<boolean>();
|
||||
readonly viewMode = input.required<EpisodeViewMode>();
|
||||
|
||||
readonly downloadSeason = output<void>();
|
||||
readonly toggleSeasonWatched = output<void>();
|
||||
readonly toggleSeriesWatched = output<void>();
|
||||
readonly viewModeChange = output<EpisodeViewMode>();
|
||||
|
||||
seriesActionLabelKey(): string {
|
||||
if (this.seriesFullyWatched()) {
|
||||
return 'XTREAM.MARK_SERIES_UNWATCHED';
|
||||
}
|
||||
return this.seriesCountKnown()
|
||||
? 'XTREAM.MARK_SERIES_WATCHED'
|
||||
: 'XTREAM.MARK_SERIES_WATCHED_ALL';
|
||||
}
|
||||
}
|
||||
@@ -96,12 +96,3 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (seasonDescription(); as description) {
|
||||
<app-expandable-text
|
||||
class="season-tabs__description"
|
||||
data-testid="season-description"
|
||||
[text]="description"
|
||||
[clampLines]="3"
|
||||
/>
|
||||
}
|
||||
@@ -117,12 +117,3 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.season-tabs__description {
|
||||
margin: 10px 4px 0;
|
||||
max-width: 760px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
// Clamping + the show more/less toggle live in app-expandable-text.
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ExpandableTextComponent } from '../expandable-text/expandable-text.component';
|
||||
|
||||
/** Above this count the pill row becomes a dropdown selector. */
|
||||
const MAX_SEASON_PILLS = 6;
|
||||
@@ -34,7 +33,7 @@ const MAX_SEASON_PILLS = 6;
|
||||
templateUrl: './season-tabs.component.html',
|
||||
styleUrls: ['./season-tabs.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ExpandableTextComponent, MatIcon, MatMenuModule, TranslateModule],
|
||||
imports: [MatIcon, MatMenuModule, TranslateModule],
|
||||
})
|
||||
export class SeasonTabsComponent {
|
||||
/** Season keys, already sorted in display order. */
|
||||
@@ -43,7 +42,6 @@ export class SeasonTabsComponent {
|
||||
readonly episodeCounts = input<Record<string, number>>({});
|
||||
readonly watchedCounts = input<Record<string, number>>({});
|
||||
/** Description of the selected season (TMDB/provider), if available. */
|
||||
readonly seasonDescription = input<string | null>(null);
|
||||
/** Season key of the episode currently playing inline, if any. */
|
||||
readonly playingSeasonKey = input<string | null>(null);
|
||||
/**
|
||||
|
||||
@@ -219,6 +219,25 @@ export class SeasonWatchPresenter {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* "Reset progress": clear every loaded episode's saved position. The
|
||||
* host persists it through the same series toggle request as the menu.
|
||||
*/
|
||||
buildResetRequest(): SeasonContainerSeriesPlaybackToggleRequest | null {
|
||||
const sources = this.sources();
|
||||
const playlistId = sources?.playlistId();
|
||||
if (!sources || !playlistId || sources.batchRunning()) {
|
||||
return null;
|
||||
}
|
||||
return buildSeriesWatchToggleRequest({
|
||||
seasons: sources.seasons(),
|
||||
seriesId: sources.seriesId(),
|
||||
playlistId,
|
||||
isEpisodeWatched: sources.isEpisodeWatched,
|
||||
markWatched: false,
|
||||
});
|
||||
}
|
||||
|
||||
toggleSeriesWatched(): void {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
|
||||
Reference in new issue
Block a user