diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index cea12ced9..76e8cff63 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 508e000cf..023cbb01d 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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. diff --git a/libs/portal/shared/util/src/lib/series-quick-start.spec.ts b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts index 4fa794a61..53d2578c8 100644 --- a/libs/portal/shared/util/src/lib/series-quick-start.spec.ts +++ b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts @@ -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'); }); }); diff --git a/libs/portal/shared/util/src/lib/series-quick-start.ts b/libs/portal/shared/util/src/lib/series-quick-start.ts index 50998f5d6..fde8a64d0 100644 --- a/libs/portal/shared/util/src/lib/series-quick-start.ts +++ b/libs/portal/shared/util/src/lib/series-quick-start.ts @@ -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); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts new file mode 100644 index 000000000..33f32ec94 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts @@ -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; + playbackPositions: Map; + vodSeriesSeasons: ReadonlyArray; +} + +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[] { + return [...seasons].sort((seasonA, seasonB) => + naturalCollator.compare( + getVodSeriesSeasonKey(seasonA), + getVodSeriesSeasonKey(seasonB) + ) + ); +} + +function getQuickStartLazyVodSeriesSeason( + action: SeriesQuickStartAction | null, + mappedSeasons: Record, + seasons: ReadonlyArray +): 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 | null { + return seasons.find((season) => season.episodes.length === 0) ?? null; +} + +function findVodSeriesSeasonIndexForEpisode( + episode: XtreamSerieEpisode, + seasons: ReadonlyArray, + mappedSeasons: Record +): 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 +): 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; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 0bf3d7a08..758b31db0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -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', diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index ca7ed2d88..1c8ec47df 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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(() => { - 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() ): Promise { + 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 | null { - return seasons.find((season) => season.episodes.length === 0) ?? null; - } - - private findVodSeriesSeasonIndexForEpisode( - episode: XtreamSerieEpisode, - seasons: ReadonlyArray - ): 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( diff --git a/libs/portal/stalker/feature/src/lib/styles/detail-view.scss b/libs/portal/stalker/feature/src/lib/styles/detail-view.scss index 0345bd764..ef24f0ddc 100644 --- a/libs/portal/stalker/feature/src/lib/styles/detail-view.scss +++ b/libs/portal/stalker/feature/src/lib/styles/detail-view.scss @@ -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; diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index d3d56192e..4b01a2d76 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -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; } } diff --git a/libs/ui/components/src/lib/styles/detail-view.scss b/libs/ui/components/src/lib/styles/detail-view.scss index fd5df3949..e59fb9bb5 100644 --- a/libs/ui/components/src/lib/styles/detail-view.scss +++ b/libs/ui/components/src/lib/styles/detail-view.scss @@ -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; diff --git a/libs/ui/playback/src/lib/styles/detail-view.scss b/libs/ui/playback/src/lib/styles/detail-view.scss index fd5df3949..e59fb9bb5 100644 --- a/libs/ui/playback/src/lib/styles/detail-view.scss +++ b/libs/ui/playback/src/lib/styles/detail-view.scss @@ -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; diff --git a/libs/ui/styles/_detail-view-actions.scss b/libs/ui/styles/_detail-view-actions.scss new file mode 100644 index 000000000..867a4cac0 --- /dev/null +++ b/libs/ui/styles/_detail-view-actions.scss @@ -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%; + } +} diff --git a/libs/ui/styles/_index.scss b/libs/ui/styles/_index.scss index 21a81b4d2..7c1405365 100644 --- a/libs/ui/styles/_index.scss +++ b/libs/ui/styles/_index.scss @@ -9,3 +9,4 @@ @forward 'portal-layout'; @forward 'content-grid'; @forward 'portal-sidebar'; +@forward 'detail-view-actions';