fix(portal): address series quick start review

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-11 22:47:12 +02:00
1 parent c45274be0e
commit 065980f60f
12 files changed
+637 -594

No files matched your search

+5 -3
View File
@@ -142,9 +142,11 @@ 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. 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,6 +1,7 @@
import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces';
import {
SERIES_QUICK_START_ACTION_KIND,
formatSeriesEpisodeCode,
getSeriesQuickStartAction,
} from './series-quick-start';
@@ -55,7 +56,6 @@ describe('getSeriesQuickStartAction', () => {
icon: 'play_arrow',
episode: firstEpisode,
position: null,
startTime: undefined,
disabled: false,
});
});
@@ -90,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', () => {
@@ -173,7 +173,6 @@ describe('getSeriesQuickStartAction', () => {
icon: 'check_circle',
episode: finalEpisode,
position: expect.any(Object),
startTime: undefined,
disabled: true,
});
});
@@ -243,3 +242,15 @@ describe('getSeriesQuickStartAction', () => {
expect(action).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('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,
});
}
@@ -116,7 +112,9 @@ function createQuickStartAction(
};
}
function getOrderedEpisodes(request: SeriesQuickStartRequest): OrderedEpisode[] {
function getOrderedEpisodes(
request: SeriesQuickStartRequest
): OrderedEpisode[] {
const orderedEpisodes: OrderedEpisode[] = [];
Object.entries(request.seasons)
@@ -176,16 +174,27 @@ 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 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;
}
@@ -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,10 +27,7 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
SERIES_QUICK_START_ACTION_KIND,
SeriesQuickStartAction,
createLogger,
getSeriesQuickStartAction,
getStalkerReturnToState,
} from '@iptvnator/portal/shared/util';
import {
@@ -53,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.
@@ -156,14 +148,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const normalizedSeriesId = this.toSeriesId(item.id);
this.logger.debug(
'Loading positions for series',
{
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
}
);
this.logger.debug('Loading positions for series', {
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
});
if (!isNaN(normalizedSeriesId)) {
void this.loadSeriesPositions(
playlist._id,
@@ -273,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,
};
});
/**
@@ -578,8 +541,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
error instanceof Error &&
error.message === 'nothing_to_play'
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
@@ -654,7 +616,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const posterUrl = episodeInfo?.movie_image;
const seasonNum = Number(episode.season || 1);
const episodeNum = episode.episode_num || 1;
const seriesTitle = item.info?.name || this.displayItem()?.info?.name || 'Series';
const seriesTitle =
item.info?.name || this.displayItem()?.info?.name || 'Series';
const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart(
2,
'0'
@@ -686,10 +649,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
playlistId: string,
seriesXtreamId: number
): Promise<void> {
const positions = await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
positions.forEach((position) => {
positionsMap.set(position.contentXtreamId, position);
@@ -698,83 +662,39 @@ 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];
const quickStart = this.quickStartAction();
if (!quickStart || quickStart.disabled) {
return;
}
if (episode) {
this.onEpisodeClicked(episode);
if (quickStart.action) {
this.onEpisodeClicked(quickStart.action.episode);
return;
}
if (quickStart.lazySeason) {
await this.loadAndPlayVodSeriesSeason(
quickStart.lazySeason,
visitedSeasonIds
);
}
}
private getQuickStartLazyVodSeriesSeason(
action: SeriesQuickStartAction | null
): VodSeriesSeasonVm | null {
if (!this.isVodSeries()) {
return null;
}
const seasons = this.vodSeriesSeasons();
if (!action) {
return this.getFirstUnloadedVodSeriesSeason(seasons);
}
if (action.kind !== SERIES_QUICK_START_ACTION_KIND.Completed) {
return null;
}
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(position: PlaybackPositionData): void {
private updateEpisodePlaybackPosition(
position: PlaybackPositionData
): void {
const updated = new Map(this.episodePlaybackPositions());
updated.set(position.contentXtreamId, position);
this.episodePlaybackPositions.set(updated);
@@ -835,11 +755,3 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return Math.abs(hash);
}
}
function getPositiveInteger(value: number): number | null {
return Number.isInteger(value) && value > 0 ? value : null;
}
function padEpisodePart(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
@@ -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';