mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+173
@@ -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;
|
||||
}
|
||||
+116
-10
@@ -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',
|
||||
|
||||
+26
-108
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,4 @@
|
||||
@forward 'portal-layout';
|
||||
@forward 'content-grid';
|
||||
@forward 'portal-sidebar';
|
||||
@forward 'detail-view-actions';
|
||||
Reference in new issue
Block a user