feat(portal): add series quick start CTA (#925)

* feat(portal): add series quick start CTA
Entire-Checkpoint: f957cd9849e0

* fix(portal): address series quick start review
Entire-Checkpoint: f957cd9849e0

* docs: use repo-relative quick start path
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray authored and GitHub committed 2026-05-12 23:45:02 +02:00
1 parent e7db920543
commit 9ae984ad7f
13 files changed
+615 -582

No files matched your search

@@ -117,7 +117,7 @@ an owner of embedded UI playback state.
## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in
`/Users/4gray/Code/iptvnator/libs/portal/shared/util/src/lib/series-quick-start.ts`.
`libs/portal/shared/util/src/lib/series-quick-start.ts`.
The helper flattens the loaded season/episode map, sorts seasons and episodes in
natural order, and returns the hero CTA state.
+7 -3
View File
@@ -146,9 +146,13 @@ Stalker has multiple real-world data shapes. The current implementation supports
- Seasons are fetched lazily.
- Episodes are fetched on season select.
- The series quick-start CTA can load the first unloaded VOD-series season
before playback, then starts its first episode. If all currently loaded
episodes are watched and more season metadata exists, quick start loads the
next unloaded season instead of showing the completed state.
before playback. Unloaded seasons are considered unplayed in full season
order, so an earlier unloaded season is not skipped just because a later
season was loaded manually. If all currently loaded episodes are watched and
more season metadata exists, quick start loads the next unloaded season
instead of showing the completed state. After a lazy load, quick start is
recomputed from the mapped episodes before playback so provider episode
ordering cannot start the wrong episode.
- For unloaded VOD-series seasons, the CTA target label is derived from season
metadata and rendered as `SxxE01` until episode details are loaded.
- Uses unique generated tracking IDs for episode playback position compatibility.
@@ -1,9 +1,8 @@
import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces';
import {
SERIES_QUICK_START_ACTION_KIND,
formatSeriesEpisodeCode,
getSeriesQuickStartAction,
getPositiveInteger,
padEpisodePart,
} from './series-quick-start';
function episode(
@@ -57,7 +56,6 @@ describe('getSeriesQuickStartAction', () => {
icon: 'play_arrow',
episode: firstEpisode,
position: null,
startTime: undefined,
disabled: false,
});
});
@@ -92,7 +90,7 @@ describe('getSeriesQuickStartAction', () => {
expect(action?.labelKey).toBe('XTREAM.RESUME_EPISODE');
expect(action?.episodeLabel).toBe('S01E02 · Episode 2');
expect(action?.episode).toBe(latestEpisode);
expect(action?.startTime).toBe(30);
expect(action?.position?.positionSeconds).toBe(30);
});
it('plays the next unwatched episode after watched episodes', () => {
@@ -175,7 +173,6 @@ describe('getSeriesQuickStartAction', () => {
icon: 'check_circle',
episode: finalEpisode,
position: expect.any(Object),
startTime: undefined,
disabled: true,
});
});
@@ -246,16 +243,14 @@ describe('getSeriesQuickStartAction', () => {
});
});
describe('episode label helpers', () => {
it('normalizes only positive integers', () => {
expect(getPositiveInteger(1)).toBe(1);
expect(getPositiveInteger(0)).toBeNull();
expect(getPositiveInteger(1.5)).toBeNull();
expect(getPositiveInteger(Number.NaN)).toBeNull();
describe('formatSeriesEpisodeCode', () => {
it('formats season and episode numbers with two digit minimums', () => {
expect(formatSeriesEpisodeCode(1, 2)).toBe('S01E02');
expect(formatSeriesEpisodeCode(12, 14)).toBe('S12E14');
});
it('pads single digit episode label parts', () => {
expect(padEpisodePart(1)).toBe('01');
expect(padEpisodePart(10)).toBe('10');
it('falls back to S01E01 for invalid positive integer parts', () => {
expect(formatSeriesEpisodeCode(0, -1)).toBe('S01E01');
expect(formatSeriesEpisodeCode(Number.NaN, 2)).toBe('S01E02');
});
});
@@ -21,7 +21,6 @@ export interface SeriesQuickStartAction {
icon: string;
episode: XtreamSerieEpisode;
position: PlaybackPositionData | null;
startTime?: number;
disabled: boolean;
}
@@ -62,7 +61,6 @@ export function getSeriesQuickStartAction(
icon: 'play_arrow',
episode: resumeEpisode.episode,
position: resumeEpisode.position,
startTime: resumeEpisode.position.positionSeconds,
disabled: false,
});
}
@@ -86,7 +84,6 @@ export function getSeriesQuickStartAction(
icon: 'play_arrow',
episode: firstUnwatchedEpisode.episode,
position: firstUnwatchedEpisode.position,
startTime: undefined,
disabled: false,
});
}
@@ -102,7 +99,6 @@ export function getSeriesQuickStartAction(
icon: 'check_circle',
episode: finalEpisode.episode,
position: finalEpisode.position,
startTime: undefined,
disabled: true,
});
}
@@ -178,20 +174,31 @@ function getTimestamp(value: string | undefined): number {
}
function getEpisodeLabel(episode: XtreamSerieEpisode): string {
const seasonNumber = getPositiveInteger(episode.season) ?? 1;
const episodeNumber = getPositiveInteger(episode.episode_num) ?? 1;
const episodeCode = `S${padEpisodePart(seasonNumber)}E${padEpisodePart(
episodeNumber
)}`;
const episodeCode = formatSeriesEpisodeCode(
episode.season,
episode.episode_num
);
const title = episode.title.trim();
return title ? `${episodeCode} · ${title}` : episodeCode;
}
export function getPositiveInteger(value: number): number | null {
export function formatSeriesEpisodeCode(
seasonNumber: number,
episodeNumber: number
): string {
const safeSeasonNumber = getPositiveInteger(seasonNumber) ?? 1;
const safeEpisodeNumber = getPositiveInteger(episodeNumber) ?? 1;
return `S${padEpisodePart(safeSeasonNumber)}E${padEpisodePart(
safeEpisodeNumber
)}`;
}
function getPositiveInteger(value: number): number | null {
return Number.isInteger(value) && value > 0 ? value : null;
}
export function padEpisodePart(value: number): string {
function padEpisodePart(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
@@ -0,0 +1,173 @@
import {
SERIES_QUICK_START_ACTION_KIND,
formatSeriesEpisodeCode,
getSeriesQuickStartAction,
type SeriesQuickStartAction,
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
type VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
import type {
PlaybackPositionData,
XtreamSerieEpisode,
} from 'shared-interfaces';
export interface StalkerQuickStartButton {
labelKey: string;
episodeLabel: string | null;
icon: string;
disabled: boolean;
action: SeriesQuickStartAction | null;
lazySeason: VodSeriesSeasonVm | null;
}
interface StalkerQuickStartRequest {
isVodSeries: boolean;
mappedSeasons: Record<string, XtreamSerieEpisode[]>;
playbackPositions: Map<number, PlaybackPositionData>;
vodSeriesSeasons: ReadonlyArray<VodSeriesSeasonVm>;
}
const naturalCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export function getStalkerSeriesQuickStartButton(
request: StalkerQuickStartRequest
): StalkerQuickStartButton | null {
const action = getSeriesQuickStartAction({
seasons: request.mappedSeasons,
playbackPositions: request.playbackPositions,
});
const orderedVodSeriesSeasons = getOrderedVodSeriesSeasons(
request.vodSeriesSeasons
);
const lazySeason = request.isVodSeries
? getQuickStartLazyVodSeriesSeason(
action,
request.mappedSeasons,
orderedVodSeriesSeasons
)
: null;
if (lazySeason) {
return {
labelKey: getLazyVodSeriesLabelKey(action),
episodeLabel: getLazyVodSeriesEpisodeLabel(
lazySeason,
orderedVodSeriesSeasons
),
icon: 'play_arrow',
disabled: lazySeason.isLoading,
action: null,
lazySeason,
};
}
if (!action) {
return null;
}
return {
labelKey: action.labelKey,
episodeLabel: action.episodeLabel,
icon: action.icon,
disabled: action.disabled,
action,
lazySeason: null,
};
}
function getOrderedVodSeriesSeasons(
seasons: ReadonlyArray<VodSeriesSeasonVm>
): VodSeriesSeasonVm[] {
return [...seasons].sort((seasonA, seasonB) =>
naturalCollator.compare(
getVodSeriesSeasonKey(seasonA),
getVodSeriesSeasonKey(seasonB)
)
);
}
function getQuickStartLazyVodSeriesSeason(
action: SeriesQuickStartAction | null,
mappedSeasons: Record<string, XtreamSerieEpisode[]>,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): VodSeriesSeasonVm | null {
const firstUnloadedSeason = getFirstUnloadedVodSeriesSeason(seasons);
if (!action) {
return firstUnloadedSeason;
}
if (
!firstUnloadedSeason ||
action.kind === SERIES_QUICK_START_ACTION_KIND.Resume
) {
return null;
}
if (action.kind === SERIES_QUICK_START_ACTION_KIND.Completed) {
return firstUnloadedSeason;
}
const unloadedSeasonIndex = seasons.findIndex(
(season) => season.id === firstUnloadedSeason.id
);
const actionSeasonIndex = findVodSeriesSeasonIndexForEpisode(
action.episode,
seasons,
mappedSeasons
);
return unloadedSeasonIndex !== -1 &&
actionSeasonIndex !== -1 &&
unloadedSeasonIndex < actionSeasonIndex
? firstUnloadedSeason
: null;
}
function getFirstUnloadedVodSeriesSeason(
seasons: ReadonlyArray<VodSeriesSeasonVm>
): VodSeriesSeasonVm | null {
return seasons.find((season) => season.episodes.length === 0) ?? null;
}
function findVodSeriesSeasonIndexForEpisode(
episode: XtreamSerieEpisode,
seasons: ReadonlyArray<VodSeriesSeasonVm>,
mappedSeasons: Record<string, XtreamSerieEpisode[]>
): number {
return seasons.findIndex((season) =>
(mappedSeasons[getVodSeriesSeasonKey(season)] ?? []).some(
(mappedEpisode) => mappedEpisode.id === episode.id
)
);
}
function getLazyVodSeriesLabelKey(
action: SeriesQuickStartAction | null
): string {
return action && action.kind !== SERIES_QUICK_START_ACTION_KIND.PlayFirst
? 'XTREAM.PLAY_NEXT_EPISODE'
: 'XTREAM.PLAY_FIRST_EPISODE';
}
function getLazyVodSeriesEpisodeLabel(
season: VodSeriesSeasonVm,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): string {
const seasonIndex = seasons.findIndex((item) => item.id === season.id);
const parsedSeasonNumber = Number(season.season_number);
const seasonNumber =
Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0
? parsedSeasonNumber
: getFallbackSeasonNumber(seasonIndex);
return formatSeriesEpisodeCode(seasonNumber, 1);
}
function getFallbackSeasonNumber(seasonIndex: number): number {
return seasonIndex >= 0 ? seasonIndex + 1 : 1;
}
@@ -4,10 +4,7 @@ import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import {
ContentHeroComponent,
SeasonContainerComponent,
} from 'components';
import { ContentHeroComponent, SeasonContainerComponent } from 'components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
@@ -131,8 +128,7 @@ describe('StalkerSeriesViewComponent', () => {
selectedItem,
selectedContentType,
currentPlaylist: signal({ _id: 'stalker-1' }),
getSerialSeasonsResource: () =>
serialSeasonsResource(),
getSerialSeasonsResource: () => serialSeasonsResource(),
getVodSeriesSeasonsResource: () =>
vodSeriesSeasonsResource(),
isVodSeriesSeasonsLoading: signal(false),
@@ -241,9 +237,7 @@ describe('StalkerSeriesViewComponent', () => {
expect(quickStartButton?.textContent).toContain(
'XTREAM.PLAY_FIRST_EPISODE'
);
expect(quickStartButton?.textContent).toContain(
'S01E01 · Episode 1'
);
expect(quickStartButton?.textContent).toContain('S01E01 · Episode 1');
quickStartButton?.click();
await fixture.whenStable();
@@ -285,6 +279,11 @@ describe('StalkerSeriesViewComponent', () => {
},
]);
fetchVodSeriesEpisodes.mockResolvedValue([
{
id: 'episode-10',
series_number: 10,
name: 'Finale',
},
{
id: 'episode-1',
series_number: 1,
@@ -321,6 +320,112 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('loads an earlier unloaded VOD-series season before showing completed', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
]);
fetchVodSeriesEpisodes.mockResolvedValue([
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
]);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [
{
id: 'episode-2',
series_number: 1,
name: 'Second Season Pilot',
},
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
const watchedEpisode =
fixture.componentInstance.mappedSeasons()['2'][0];
const watchedPosition: PlaybackPositionData = {
contentXtreamId: Number(watchedEpisode.id),
contentType: 'episode',
seriesXtreamId: 50001,
positionSeconds: 95,
durationSeconds: 100,
};
fixture.componentInstance.episodePlaybackPositions.set(
new Map([[Number(watchedEpisode.id), watchedPosition]])
);
fixture.detectChanges();
const quickStartButton: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(quickStartButton).not.toBeNull();
expect(quickStartButton?.disabled).toBe(false);
expect(quickStartButton?.textContent).toContain(
'XTREAM.PLAY_NEXT_EPISODE'
);
expect(quickStartButton?.textContent).toContain('S01E01');
quickStartButton?.click();
await fixture.whenStable();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
'50001',
'season-1'
);
expect(resolveVodPlayback).toHaveBeenCalledWith(
'/media/file_episode-1.mpg',
'VOD Flagged Series - Pilot',
'vod-series.jpg',
1,
expect.any(Number),
undefined
);
});
it('loads the next unloaded VOD-series season after the loaded season is watched', async () => {
selectedContentType.set('vod');
selectedItem.set({
@@ -384,7 +489,8 @@ describe('StalkerSeriesViewComponent', () => {
isExpanded: false,
},
]);
const watchedEpisode = fixture.componentInstance.mappedSeasons()['1'][0];
const watchedEpisode =
fixture.componentInstance.mappedSeasons()['1'][0];
const watchedPosition: PlaybackPositionData = {
contentXtreamId: Number(watchedEpisode.id),
contentType: 'episode',
@@ -27,13 +27,8 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
SERIES_QUICK_START_ACTION_KIND,
SeriesQuickStartAction,
createLogger,
getPositiveInteger,
getSeriesQuickStartAction,
getStalkerReturnToState,
padEpisodePart,
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
@@ -55,15 +50,10 @@ import {
PortalInlinePlayerComponent,
} from '@iptvnator/ui/playback';
import { DownloadsService } from 'services';
interface StalkerQuickStartButton {
labelKey: string;
episodeLabel: string | null;
icon: string;
disabled: boolean;
action: SeriesQuickStartAction | null;
lazySeason: VodSeriesSeasonVm | null;
}
import {
getStalkerSeriesQuickStartButton,
type StalkerQuickStartButton,
} from './stalker-series-quick-start';
/**
* Component for displaying series/episodes for Stalker portal content.
@@ -272,38 +262,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
);
readonly quickStartAction = computed<StalkerQuickStartButton | null>(() => {
const action = getSeriesQuickStartAction({
seasons: this.mappedSeasons(),
return getStalkerSeriesQuickStartButton({
isVodSeries: this.isVodSeries(),
mappedSeasons: this.mappedSeasons(),
playbackPositions: this.episodePlaybackPositions(),
vodSeriesSeasons: this.vodSeriesSeasons(),
});
const lazySeason = this.getQuickStartLazyVodSeriesSeason(action);
if (lazySeason) {
return {
labelKey:
action?.kind === SERIES_QUICK_START_ACTION_KIND.Completed
? 'XTREAM.PLAY_NEXT_EPISODE'
: 'XTREAM.PLAY_FIRST_EPISODE',
episodeLabel: this.getLazyVodSeriesEpisodeLabel(lazySeason),
icon: 'play_arrow',
disabled: lazySeason.isLoading,
action: null,
lazySeason,
};
}
if (!action) {
return null;
}
return {
labelKey: action.labelKey,
episodeLabel: action.episodeLabel,
icon: action.icon,
disabled: action.disabled,
action,
lazySeason: null,
};
});
/**
@@ -698,80 +662,34 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
private async loadAndPlayVodSeriesSeason(
season: VodSeriesSeasonVm
season: VodSeriesSeasonVm,
visitedSeasonIds = new Set<string>()
): Promise<void> {
if (visitedSeasonIds.has(season.id)) {
return;
}
visitedSeasonIds.add(season.id);
if (season.episodes.length === 0) {
await this.loadEpisodesForSeason(season);
}
const loadedSeason =
this.vodSeriesSeasons().find((item) => item.id === season.id) ??
season;
const seasonKey = getVodSeriesSeasonKey(loadedSeason);
const episode = this.mappedSeasons()[seasonKey]?.[0];
if (episode) {
this.onEpisodeClicked(episode);
}
}
private getQuickStartLazyVodSeriesSeason(
action: SeriesQuickStartAction | null
): VodSeriesSeasonVm | null {
if (!this.isVodSeries()) {
return null;
const quickStart = this.quickStartAction();
if (!quickStart || quickStart.disabled) {
return;
}
const seasons = this.vodSeriesSeasons();
if (!action) {
return this.getFirstUnloadedVodSeriesSeason(seasons);
if (quickStart.action) {
this.onEpisodeClicked(quickStart.action.episode);
return;
}
if (action.kind !== SERIES_QUICK_START_ACTION_KIND.Completed) {
return null;
if (quickStart.lazySeason) {
await this.loadAndPlayVodSeriesSeason(
quickStart.lazySeason,
visitedSeasonIds
);
}
const completedSeasonIndex = this.findVodSeriesSeasonIndexForEpisode(
action.episode,
seasons
);
if (completedSeasonIndex === -1) {
return null;
}
return this.getFirstUnloadedVodSeriesSeason(
seasons.slice(completedSeasonIndex + 1)
);
}
private getFirstUnloadedVodSeriesSeason(
seasons: ReadonlyArray<VodSeriesSeasonVm>
): VodSeriesSeasonVm | null {
return seasons.find((season) => season.episodes.length === 0) ?? null;
}
private findVodSeriesSeasonIndexForEpisode(
episode: XtreamSerieEpisode,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): number {
const mappedSeasons = this.mappedSeasons();
return seasons.findIndex((season) =>
(mappedSeasons[getVodSeriesSeasonKey(season)] ?? []).some(
(mappedEpisode) => mappedEpisode.id === episode.id
)
);
}
private getLazyVodSeriesEpisodeLabel(season: VodSeriesSeasonVm): string {
const seasonIndex = this.vodSeriesSeasons().findIndex(
(item) => item.id === season.id
);
const seasonNumber =
getPositiveInteger(Number(season.season_number)) ??
(seasonIndex >= 0 ? seasonIndex + 1 : 1);
return `S${padEpisodePart(seasonNumber)}E01`;
}
private updateEpisodePlaybackPosition(
@@ -2,6 +2,8 @@
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
@use '../../../../../../ui/styles/detail-view-actions' as detail-actions;
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
@@ -18,15 +20,30 @@
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
--play-idle-bg: linear-gradient(
135deg,
#fffaf2 0%,
#ffffff 52%,
#f7ecdf 100%
);
--play-idle-fg: #161310;
--play-idle-border: rgba(88, 48, 18, 0.12);
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
--play-launching-bg: linear-gradient(
135deg,
#ffd36f 0%,
#ffb14c 46%,
#f57d35 100%
);
--play-launching-fg: #241103;
--play-launching-border: rgba(142, 66, 0, 0.2);
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
--play-stop-bg: linear-gradient(
135deg,
#ffd1c8 0%,
#ff9f8d 42%,
#ef5e64 100%
);
--play-stop-fg: #321012;
--play-stop-border: rgba(162, 38, 45, 0.18);
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
@@ -54,15 +71,30 @@
--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);
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
--play-idle-bg: linear-gradient(
135deg,
#fff5e6 0%,
#f6efe5 58%,
#e7dac9 100%
);
--play-idle-fg: #15110d;
--play-idle-border: rgba(255, 255, 255, 0.18);
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
--play-launching-bg: linear-gradient(
135deg,
#ffdd79 0%,
#ffb347 42%,
#ef6a3a 100%
);
--play-launching-fg: #1f1105;
--play-launching-border: rgba(255, 223, 136, 0.26);
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
--play-stop-bg: linear-gradient(
135deg,
#ffd7cf 0%,
#ff9b8b 40%,
#eb5961 100%
);
--play-stop-fg: #2d1012;
--play-stop-border: rgba(255, 205, 205, 0.24);
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
@@ -221,34 +253,7 @@
box-shadow 220ms ease;
}
&__copy {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
line-height: 1.1;
text-align: left;
}
&__label {
display: block;
min-width: 0;
line-height: 1.15;
}
&__meta {
display: block;
max-width: min(42vw, 360px);
overflow: hidden;
color: var(--play-btn-fg);
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
opacity: 0.72;
text-overflow: ellipsis;
white-space: nowrap;
}
@include detail-actions.play-button-copy(var(--play-btn-fg));
&--state-launching,
&--state-stop {
@@ -269,7 +274,8 @@
}
.play-btn__icon {
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1)
infinite;
}
}
@@ -338,79 +344,15 @@
.favorite-btn,
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.favorite-btn,
.download-btn {
min-height: 64px;
padding: 0 24px;
border-radius: 16px;
font-size: 1rem;
font-weight: 600;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
@include detail-actions.secondary-action-button;
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
@include detail-actions.favorite-button-state;
}
.download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
@include detail-actions.download-button-state;
}
// ============================================================================
@@ -573,18 +515,7 @@
// So we should target the buttons directly for mobile
}
.play-btn,
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
.play-btn__meta {
max-width: 100%;
}
@include detail-actions.mobile-action-buttons;
.youtube-trailer {
margin-top: 16px;
@@ -2,6 +2,8 @@
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
@use '../../../../styles/detail-view-actions' as detail-actions;
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
@@ -449,97 +451,20 @@
height: 24px;
}
.play-btn__copy {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
line-height: 1.1;
text-align: left;
}
.play-btn__label {
display: block;
min-width: 0;
line-height: 1.15;
}
.play-btn__meta {
display: block;
max-width: min(42vw, 360px);
overflow: hidden;
color: currentColor;
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
opacity: 0.72;
text-overflow: ellipsis;
white-space: nowrap;
}
@include detail-actions.play-button-copy;
}
::ng-deep .favorite-btn,
::ng-deep .download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 64px;
padding: 0 24px;
font-size: 1rem;
font-weight: 600;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 16px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
@include detail-actions.secondary-action-button;
}
::ng-deep .favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
@include detail-actions.favorite-button-state;
}
::ng-deep .download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
@include detail-actions.download-button-state;
}
}
@@ -610,18 +535,7 @@
.action-buttons {
width: 100%;
::ng-deep .play-btn,
::ng-deep .favorite-btn,
::ng-deep .download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
::ng-deep .play-btn__meta {
max-width: 100%;
}
@include detail-actions.projected-mobile-action-buttons;
}
}
@@ -2,6 +2,8 @@
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
@use '../../../../styles/detail-view-actions' as detail-actions;
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
@@ -18,15 +20,30 @@
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
--play-idle-bg: linear-gradient(
135deg,
#fffaf2 0%,
#ffffff 52%,
#f7ecdf 100%
);
--play-idle-fg: #161310;
--play-idle-border: rgba(88, 48, 18, 0.12);
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
--play-launching-bg: linear-gradient(
135deg,
#ffd36f 0%,
#ffb14c 46%,
#f57d35 100%
);
--play-launching-fg: #241103;
--play-launching-border: rgba(142, 66, 0, 0.2);
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
--play-stop-bg: linear-gradient(
135deg,
#ffd1c8 0%,
#ff9f8d 42%,
#ef5e64 100%
);
--play-stop-fg: #321012;
--play-stop-border: rgba(162, 38, 45, 0.18);
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
@@ -54,15 +71,30 @@
--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);
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
--play-idle-bg: linear-gradient(
135deg,
#fff5e6 0%,
#f6efe5 58%,
#e7dac9 100%
);
--play-idle-fg: #15110d;
--play-idle-border: rgba(255, 255, 255, 0.18);
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
--play-launching-bg: linear-gradient(
135deg,
#ffdd79 0%,
#ffb347 42%,
#ef6a3a 100%
);
--play-launching-fg: #1f1105;
--play-launching-border: rgba(255, 223, 136, 0.26);
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
--play-stop-bg: linear-gradient(
135deg,
#ffd7cf 0%,
#ff9b8b 40%,
#eb5961 100%
);
--play-stop-fg: #2d1012;
--play-stop-border: rgba(255, 205, 205, 0.24);
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
@@ -221,34 +253,7 @@
box-shadow 220ms ease;
}
&__copy {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
line-height: 1.1;
text-align: left;
}
&__label {
display: block;
min-width: 0;
line-height: 1.15;
}
&__meta {
display: block;
max-width: min(42vw, 360px);
overflow: hidden;
color: var(--play-btn-fg);
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
opacity: 0.72;
text-overflow: ellipsis;
white-space: nowrap;
}
@include detail-actions.play-button-copy(var(--play-btn-fg));
&--state-launching,
&--state-stop {
@@ -269,7 +274,8 @@
}
.play-btn__icon {
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1)
infinite;
}
}
@@ -338,79 +344,15 @@
.favorite-btn,
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.favorite-btn,
.download-btn {
min-height: 64px;
padding: 0 24px;
border-radius: 16px;
font-size: 1rem;
font-weight: 600;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
@include detail-actions.secondary-action-button;
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
@include detail-actions.favorite-button-state;
}
.download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
@include detail-actions.download-button-state;
}
// ============================================================================
@@ -571,18 +513,7 @@
// So we should target the buttons directly for mobile
}
.play-btn,
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
.play-btn__meta {
max-width: 100%;
}
@include detail-actions.mobile-action-buttons;
.youtube-trailer {
margin-top: 16px;
+45 -114
View File
@@ -2,6 +2,8 @@
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
@use '../../../../styles/detail-view-actions' as detail-actions;
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
@@ -18,15 +20,30 @@
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
--play-idle-bg: linear-gradient(
135deg,
#fffaf2 0%,
#ffffff 52%,
#f7ecdf 100%
);
--play-idle-fg: #161310;
--play-idle-border: rgba(88, 48, 18, 0.12);
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
--play-launching-bg: linear-gradient(
135deg,
#ffd36f 0%,
#ffb14c 46%,
#f57d35 100%
);
--play-launching-fg: #241103;
--play-launching-border: rgba(142, 66, 0, 0.2);
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
--play-stop-bg: linear-gradient(
135deg,
#ffd1c8 0%,
#ff9f8d 42%,
#ef5e64 100%
);
--play-stop-fg: #321012;
--play-stop-border: rgba(162, 38, 45, 0.18);
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
@@ -54,15 +71,30 @@
--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);
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
--play-idle-bg: linear-gradient(
135deg,
#fff5e6 0%,
#f6efe5 58%,
#e7dac9 100%
);
--play-idle-fg: #15110d;
--play-idle-border: rgba(255, 255, 255, 0.18);
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
--play-launching-bg: linear-gradient(
135deg,
#ffdd79 0%,
#ffb347 42%,
#ef6a3a 100%
);
--play-launching-fg: #1f1105;
--play-launching-border: rgba(255, 223, 136, 0.26);
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
--play-stop-bg: linear-gradient(
135deg,
#ffd7cf 0%,
#ff9b8b 40%,
#eb5961 100%
);
--play-stop-fg: #2d1012;
--play-stop-border: rgba(255, 205, 205, 0.24);
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
@@ -221,34 +253,7 @@
box-shadow 220ms ease;
}
&__copy {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
line-height: 1.1;
text-align: left;
}
&__label {
display: block;
min-width: 0;
line-height: 1.15;
}
&__meta {
display: block;
max-width: min(42vw, 360px);
overflow: hidden;
color: var(--play-btn-fg);
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
opacity: 0.72;
text-overflow: ellipsis;
white-space: nowrap;
}
@include detail-actions.play-button-copy(var(--play-btn-fg));
&--state-launching,
&--state-stop {
@@ -269,7 +274,8 @@
}
.play-btn__icon {
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1)
infinite;
}
}
@@ -338,79 +344,15 @@
.favorite-btn,
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.favorite-btn,
.download-btn {
min-height: 64px;
padding: 0 24px;
border-radius: 16px;
font-size: 1rem;
font-weight: 600;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
@include detail-actions.secondary-action-button;
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
@include detail-actions.favorite-button-state;
}
.download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
@include detail-actions.download-button-state;
}
// ============================================================================
@@ -571,18 +513,7 @@
// So we should target the buttons directly for mobile
}
.play-btn,
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
.play-btn__meta {
max-width: 100%;
}
@include detail-actions.mobile-action-buttons;
.youtube-trailer {
margin-top: 16px;
+122
View File
@@ -0,0 +1,122 @@
@mixin play-button-copy($meta-color: currentColor) {
&__copy {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
line-height: 1.1;
text-align: left;
}
&__label {
display: block;
min-width: 0;
line-height: 1.15;
}
&__meta {
display: block;
max-width: min(42vw, 360px);
overflow: hidden;
color: $meta-color;
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
opacity: 0.72;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@mixin secondary-action-button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 64px;
padding: 0 24px;
font-size: 1rem;
font-weight: 600;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 16px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
@mixin favorite-button-state {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
}
@mixin download-button-state {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
}
@mixin mobile-action-buttons {
.play-btn,
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
.play-btn__meta {
max-width: 100%;
}
}
@mixin projected-mobile-action-buttons {
::ng-deep .play-btn,
::ng-deep .favorite-btn,
::ng-deep .download-btn {
flex: 1;
justify-content: center;
min-height: 58px;
padding: 10px 16px;
}
::ng-deep .play-btn__meta {
max-width: 100%;
}
}
+1
View File
@@ -9,3 +9,4 @@
@forward 'portal-layout';
@forward 'content-grid';
@forward 'portal-sidebar';
@forward 'detail-view-actions';