diff --git a/.changes/playback-fullscreen-episode-panel.md b/.changes/playback-fullscreen-episode-panel.md new file mode 100644 index 000000000..543272186 --- /dev/null +++ b/.changes/playback-fullscreen-episode-panel.md @@ -0,0 +1,7 @@ +--- +type: feature +area: playback +highlight: Episode list in fullscreen +--- + +Series playback now has the slide-in list in fullscreen too: rest the mouse on the left edge, click it, or press C to see the season tabs and episodes with stills, runtimes, descriptions and watch progress, and jump to another episode without leaving fullscreen. Works for Xtream and Stalker series with any built-in player. diff --git a/AGENTS.md b/AGENTS.md index b89dbd57c..b757031cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -574,8 +574,10 @@ unchanged. Contract: `docs/architecture/m3u-playlist-module.md` A confirmed frame-copy capability survives the unknown support probe during an engine remount, preserving panel search/scroll on channel changes; the first unknown probe and confirmed native/unsupported results withhold it. - A live host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional - `panelTitle`) and the panel slides that list over the video: left-edge hover + A host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional + `panelTitle`, `panelSearchEnabled` and `panelKind: 'channels' | 'episodes'`; + the template context carries `searchTerm`, `open` and `close`) and the + panel slides that list over the video: left-edge hover dwell, a click or tap on that edge, or `C`. The hot zone stays mounted above the scrim and below the panel during opening, so a delayed paint cannot turn stationary hover into a synthetic leave. Nothing is drawn while it is closed @@ -625,9 +627,28 @@ unchanged. Contract: `docs/architecture/m3u-playlist-module.md` ignored. Windowed commands keep the complete catalog. Xtream's two `PortalChannelsListComponent` instances relay favorite toggles - through `XtreamFavoriteMarksService`. CDK overlays follow the + through `XtreamFavoriteMarksService`. Series playback gets the same panel + as an episode list: `PortalInlinePlayerComponent` (the component both + series hosts render around the view, and the Up Next rail's host) is the + nearest provider — `panelKind: 'episodes'`, no search field (a title row + instead, `C` focuses the panel) — and stamps `app-fullscreen-episode-panel` + (`libs/ui/playback/src/lib/fullscreen-episode-panel/`: `SeasonTabsComponent` + over the selected season's rows with TMDB still or numeral tile, `S01E03` + label, runtime, clamped overview, progress bar, watched check and + now-playing marker; the tab follows the playing season, the playing row is + centred on open) built by `buildFullscreenEpisodePanelSeasons` from the + hosts' `seriesEpisodes` / `episodePlaybackPositions` / `seasonLoadStates` + inputs. An episode click travels `upNextEpisodeSelected` (the rail's path, + so fullscreen survives the engine remount) and closes the panel; a season + tab click travels `episodePanelSeasonSelected` into the hosts' + `onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD season + load — a spinner row while in flight, a Retry row after a failed request). + Movies never get it, external + MPV/VLC never mount the inline player, native-view Embedded MPV is + withheld by the view. CDK overlays follow the fullscreen element via `FullscreenOverlayContainer`. Contract: - `docs/architecture/player-controls-contract.md` ("Fullscreen channel panel"). + `docs/architecture/player-controls-contract.md` ("Fullscreen channel panel", + "Fullscreen episode panel"). - Embedded MPV seek steps (arrow keys, ±10 s buttons, `PlayerController.seekBy`) go through the relative `seekEmbeddedMpvBy` IPC: every backend forwards the delta as mpv `seek relative+exact` (addon export `seekBy`, helper diff --git a/CLAUDE.md b/CLAUDE.md index db568b50f..2f29857b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1303,7 +1303,7 @@ engine` (restart required) or helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime contracts: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. -- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked ` - } - + + @if (searchTerm()) { + + } + + } + + `, + providers: [ + { + provide: FULLSCREEN_CHANNEL_PANEL, + useExisting: forwardRef(() => NoSearchHostComponent), + }, + ], +}) +class NoSearchHostComponent implements FullscreenChannelPanelHost { + private readonly panelRef = + viewChild>('panel'); + readonly panelTemplate = computed(() => this.panelRef() ?? null); + readonly panelTitle = signal('Some Show'); + readonly panelSearchEnabled = signal(false); + readonly panelKind = 'episodes' as const; +} + function pointerEvent( type: string, pointerType = 'mouse', @@ -126,6 +155,7 @@ describe('FullscreenChannelPanelComponent', () => { imports: [ HostComponent, NoHostComponent, + NoSearchHostComponent, NoopAnimationsModule, TranslateModule.forRoot(), ], @@ -686,4 +716,84 @@ describe('FullscreenChannelPanelComponent', () => { openByHover(); expect(query('host-list')?.textContent?.trim()).toBe(''); }); + + describe('host without a search field (series episodes)', () => { + let noSearch: ComponentFixture; + const q = (testId: string): T | null => + noSearch.nativeElement.querySelector(`[data-test-id="${testId}"]`); + + beforeEach(() => { + fixture.destroy(); + const translate = TestBed.inject(TranslateService); + translate.setTranslation( + 'en', + { + EMBEDDED_MPV: { + PLAYER: { + EPISODE_LIST: 'Episode list', + HIDE_EPISODE_LIST: 'Hide episode list', + }, + }, + }, + true + ); + translate.use('en'); + noSearch = TestBed.createComponent(NoSearchHostComponent); + noSearch.detectChanges(); + fullscreenElement = noSearch.nativeElement.querySelector('.stage'); + document.dispatchEvent(new Event('fullscreenchange')); + noSearch.detectChanges(); + }); + + afterEach(() => noSearch.destroy()); + + it('shows the title as a header row instead of the search field and names the list by kind', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })); + noSearch.detectChanges(); + + const panel = q('fullscreen-channel-panel'); + expect(panel?.classList).toContain( + 'fullscreen-channel-panel--open' + ); + expect(panel?.getAttribute('data-panel-kind')).toBe('episodes'); + expect(panel?.getAttribute('aria-label')).toBe('Some Show'); + expect(q('fullscreen-channel-panel-search')).toBeNull(); + expect( + q('fullscreen-channel-panel-title')?.textContent?.trim() + ).toBe('Some Show'); + expect( + q('fullscreen-channel-panel-close')?.getAttribute('aria-label') + ).toBe('Hide episode list'); + + noSearch.componentInstance.panelTitle.set(''); + noSearch.detectChanges(); + expect( + q('fullscreen-channel-panel-title')?.textContent?.trim() + ).toBe('Episode list'); + expect(panel?.getAttribute('aria-label')).toBe('Episode list'); + }); + + it('lands keyboard focus on the panel itself when C opens it', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })); + noSearch.detectChanges(); + jest.advanceTimersByTime(0); + + expect(document.activeElement).toBe(q('fullscreen-channel-panel')); + }); + + it('tells the host template whether the panel is open', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })); + noSearch.detectChanges(); + expect(q('host-body')?.textContent?.trim()).toBe('open'); + + q('host-close')?.click(); + noSearch.detectChanges(); + expect(q('host-body')?.textContent?.trim()).toBe('closed'); + expect( + q('fullscreen-channel-panel')?.classList.contains( + 'fullscreen-channel-panel--open' + ) + ).toBe(false); + }); + }); }); diff --git a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.ts b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.ts index 62af29eb7..d69d2332a 100644 --- a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.ts +++ b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.ts @@ -23,6 +23,7 @@ import { FullscreenChannelPanelState } from './fullscreen-channel-panel-state'; import { FULLSCREEN_CHANNEL_PANEL, type FullscreenChannelPanelContext, + type FullscreenPanelKind, } from './fullscreen-channel-panel.model'; const EDITABLE_SELECTOR = @@ -42,15 +43,17 @@ function targetsEditable(event: KeyboardEvent): boolean { } /** - * Slide-in channel list for fullscreen playback. + * Slide-in side panel for fullscreen playback: a channel list for live + * hosts, an episode list for series playback. * * Rendered by `WebPlayerViewComponent` beside the engine, inside the element * that owns DOM fullscreen, so the list stays visible while the video is - * fullscreen and survives the engine remount a channel switch causes. The - * content comes from the host page through {@link FULLSCREEN_CHANNEL_PANEL}; - * without a provider (VOD detail pages, series) nothing renders. The view - * can also switch it off through `enabled` for an engine that paints above - * the DOM (native-view Embedded MPV), where no DOM panel could show. + * fullscreen and survives the engine remount a channel or episode switch + * causes. The content comes from the host through + * {@link FULLSCREEN_CHANNEL_PANEL}; without a provider (a movie in a VOD + * detail page) nothing renders. The view can also switch it off through + * `enabled` for an engine that paints above the DOM (native-view Embedded + * MPV), where no DOM panel could show. * * Nothing is drawn over the video while the panel is closed and the pointer * rests. Moving the mouse over the stage reveals a slim hint tab on the left @@ -114,18 +117,35 @@ export class FullscreenChannelPanelComponent implements OnDestroy { readonly isFullscreen = this.fullscreen.isFullscreen; readonly template = computed(() => this.panelHost?.panelTemplate() ?? null); /** - * The host's context label (playlist or category name). It carries no row - * of its own: the search field's placeholder reads "Search in ". + * The host's context label (playlist, category or series name). With the + * search field it carries no row of its own: the placeholder reads + * "Search in <title>". Without the field it is the header's text. */ readonly panelTitle = computed( () => this.panelHost?.panelTitle?.()?.trim() ?? '' ); + /** The header's search field; hosts with their own navigation drop it. */ + readonly searchEnabled = computed( + () => this.panelHost?.panelSearchEnabled?.() ?? true + ); + readonly kind: FullscreenPanelKind = + this.panelHost?.panelKind ?? 'channels'; + /** Accessible name of the list and of the close button, per kind. */ + readonly listLabelKey = + this.kind === 'episodes' + ? 'EMBEDDED_MPV.PLAYER.EPISODE_LIST' + : 'EMBEDDED_MPV.PLAYER.CHANNEL_LIST'; + readonly hideLabelKey = + this.kind === 'episodes' + ? 'EMBEDDED_MPV.PLAYER.HIDE_EPISODE_LIST' + : 'EMBEDDED_MPV.PLAYER.HIDE_CHANNEL_LIST'; /** Every affordance exists only in fullscreen and only with a host list. */ readonly active = computed( () => this.enabled() && this.isFullscreen() && this.template() !== null ); readonly context: FullscreenChannelPanelContext = { searchTerm: this.searchTerm.asReadonly(), + open: this.state.open.asReadonly(), close: () => this.state.hide(), }; @@ -352,17 +372,23 @@ export class FullscreenChannelPanelComponent implements OnDestroy { return; } this.state.show('keyboard'); - this.focusSearch(); + this.focusPanel(); } - /** A keyboard opening lands in the search field; hover does not steal focus. */ - private focusSearch(): void { + /** + * A keyboard opening lands in the search field — or, for a host without + * one, on the panel itself, so the next Tab reaches its first control; + * hover does not steal focus. + */ + private focusPanel(): void { window.setTimeout(() => { - if (this.state.open()) { - this.searchInput()?.nativeElement.focus({ - preventScroll: true, - }); + if (!this.state.open()) { + return; } + const target = + this.searchInput()?.nativeElement ?? + this.panelElement()?.nativeElement; + target?.focus({ preventScroll: true }); }, 0); } } diff --git a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.model.ts b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.model.ts index 2c03e0484..0d9ec4488 100644 --- a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.model.ts +++ b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.model.ts @@ -3,27 +3,46 @@ import { InjectionToken, type Signal, type TemplateRef } from '@angular/core'; /** * Context handed to the host's panel template. * - * `searchTerm` is a signal rather than a string so the context object can stay - * stable for the lifetime of the embedded view: `NgTemplateOutlet` keeps the - * view (and with it the list's scroll position) as long as the template and - * context identity do not change, and the host re-renders from the signal. + * `searchTerm` and `open` are signals rather than plain values so the context + * object can stay stable for the lifetime of the embedded view: + * `NgTemplateOutlet` keeps the view (and with it the list's scroll position) + * as long as the template and context identity do not change, and the host + * re-renders from the signals. */ export interface FullscreenChannelPanelContext { - /** Raw text typed into the panel's search field; hosts normalize it. */ + /** + * Raw text typed into the panel's search field; hosts normalize it. Stays + * empty for a host that hides the field (`panelSearchEnabled` false). + */ readonly searchTerm: Signal<string>; + /** + * True while the panel is slid in. The body stays mounted between two + * openings of one fullscreen session, so a host that wants to react to + * the panel coming up (scroll the playing row into view) reads this + * instead of its own init hook. + */ + readonly open: Signal<boolean>; /** Closes the panel, e.g. after the host handled a selection. */ readonly close: () => void; } /** - * Contract a live host provides under {@link FULLSCREEN_CHANNEL_PANEL} to - * get a slide-in channel list inside the player's fullscreen surface. + * What the panel lists. Only changes the accessible names and the close + * button's tooltip: "channel list" for the live hosts, "episode list" for + * series playback. Every pointer and keyboard rule is the same for both. + */ +export type FullscreenPanelKind = 'channels' | 'episodes'; + +/** + * Contract a host provides under {@link FULLSCREEN_CHANNEL_PANEL} to get a + * slide-in side panel inside the player's fullscreen surface: a channel + * list for live playback, an episode list for series playback. * * The provider is DI-gated on purpose: the panel component injects the token - * optionally, so hosts without a channel list (VOD detail pages, series - * playback) render no hot zone, handle or shortcut at all. The host decides - * what goes into the panel and resolves the user preference itself by - * returning `null` from `panelTemplate` when the feature is disabled. + * optionally, so hosts without a list (VOD detail pages playing a movie) + * render no hot zone, hint tab or shortcut at all. The host decides what + * goes into the panel and resolves the user preference itself by returning + * `null` from `panelTemplate` when the feature is disabled. */ export interface FullscreenChannelPanelHost { /** @@ -31,8 +50,20 @@ export interface FullscreenChannelPanelHost { * every affordance around it. */ readonly panelTemplate: Signal<TemplateRef<FullscreenChannelPanelContext> | null>; - /** Display-ready header title, e.g. the playlist or category name. */ + /** + * Display-ready header title, e.g. the playlist, category or series + * name. With the search field it becomes the field's placeholder + * ("Search in <title>"); without it the header shows it as text. + */ readonly panelTitle?: Signal<string>; + /** + * Whether the header carries the search field (default true). A host + * whose body has its own navigation (season tabs) turns it off; the + * `C` key then lands focus on the panel itself instead of the field. + */ + readonly panelSearchEnabled?: Signal<boolean>; + /** Defaults to `'channels'`. */ + readonly panelKind?: FullscreenPanelKind; } export const FULLSCREEN_CHANNEL_PANEL = diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html new file mode 100644 index 000000000..5f01af5ab --- /dev/null +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html @@ -0,0 +1,145 @@ +<div class="episode-panel"> + <div class="episode-panel__tabs"> + <app-season-tabs + [seasonKeys]="seasonKeys()" + [selectedSeason]="selectedSeasonKey() ?? undefined" + [episodeCounts]="episodeCounts()" + [watchedCounts]="watchedCounts()" + [playingSeasonKey]="playingSeasonKey()" + (seasonSelected)="onSeasonSelected($event)" + (backToPlayingRequested)="backToPlaying()" + /> + </div> + + <ul + #list + class="episode-panel__list" + data-test-id="fullscreen-episode-panel-list" + > + @if (selectedSeason(); as season) { + @if (season.loadState === 'loading') { + <li + class="episode-panel__state" + data-test-id="fullscreen-episode-panel-loading" + > + <mat-spinner diameter="22" /> + <span>{{ 'PORTALS.LOADING_EPISODES' | translate }}</span> + </li> + } @else if (season.loadState === 'unloaded') { + <li + class="episode-panel__state episode-panel__state--stacked" + data-test-id="fullscreen-episode-panel-unloaded" + > + <span>{{ + 'PORTALS.EPISODES_LOAD_FAILED' | translate + }}</span> + <button + type="button" + class="episode-panel__retry" + data-test-id="fullscreen-episode-panel-retry" + (click)="retrySeason(season.key)" + > + <mat-icon aria-hidden="true">refresh</mat-icon> + {{ 'RETRY' | translate }} + </button> + </li> + } @else if (season.episodes.length === 0) { + <li + class="episode-panel__state" + data-test-id="fullscreen-episode-panel-empty" + > + <mat-icon aria-hidden="true">tv_off</mat-icon> + <span>{{ 'PORTALS.SEASON_EMPTY' | translate }}</span> + </li> + } @else { + @for (item of season.episodes; track item.id) { + <li class="episode-panel__row"> + <button + type="button" + class="episode-panel__item" + data-test-id="fullscreen-episode-panel-episode" + [class.episode-panel__item--playing]=" + item.isPlaying + " + [class.episode-panel__item--watched]="item.watched" + [attr.aria-current]="item.isPlaying ? 'true' : null" + [attr.data-episode-id]="item.id" + (click)="onEpisodeClick(item)" + > + <span + class="episode-panel__thumb" + aria-hidden="true" + > + @if (hasStill(item)) { + <img + class="episode-panel__still" + [src]="item.thumbnailUrl" + alt="" + loading="lazy" + (error)="onStillError(item)" + /> + } @else { + <span class="episode-panel__number"> + {{ item.episodeNumber }} + </span> + } + @if (item.isPlaying) { + <span class="episode-panel__playing-badge"> + <mat-icon>equalizer</mat-icon> + </span> + } + @if (item.progressPercent !== null) { + <span class="episode-panel__progress"> + <span + class="episode-panel__progress-bar" + [style.width.%]=" + item.progressPercent + " + ></span> + </span> + } + </span> + <span class="episode-panel__meta"> + <span class="episode-panel__label"> + <span>{{ item.label }}</span> + @if (item.durationLabel) { + <span class="episode-panel__duration"> + {{ item.durationLabel }} + </span> + } + @if (item.watched && !item.isPlaying) { + <mat-icon + class="episode-panel__watched" + [attr.aria-label]=" + 'XTREAM.WATCHED' | translate + " + >check</mat-icon + > + } + @if (item.isPlaying) { + <span + class="episode-panel__now-playing" + > + {{ + 'PORTALS.NOW_PLAYING' + | translate + }} + </span> + } + </span> + <span class="episode-panel__title"> + {{ item.title || item.label }} + </span> + @if (item.overview) { + <span class="episode-panel__overview"> + {{ item.overview }} + </span> + } + </span> + </button> + </li> + } + } + } + </ul> +</div> diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss new file mode 100644 index 000000000..a2494f043 --- /dev/null +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss @@ -0,0 +1,265 @@ +// Stamped into the fullscreen panel's body, which is its single flex child; +// the list below is the body's only scroll owner. The panel wears the +// `dark-theme` context class, so the light-on-dark palette here is fixed. +:host { + display: flex; + flex-direction: column; + min-height: 0; +} + +.episode-panel { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.episode-panel__tabs { + flex-shrink: 0; + padding: 10px 12px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + // The season tabs read the app's text/tag tokens, which the light theme + // resolves to dark ink. This panel is always dark, so they are pinned to + // the light-on-dark palette here; custom properties reach the child. + --text-primary: #ffffff; + --text-secondary: rgba(255, 255, 255, 0.72); + --text-muted: rgba(255, 255, 255, 0.5); + --tag-bg: rgba(255, 255, 255, 0.1); + --tag-border: rgba(255, 255, 255, 0.22); + --accent-success: #4ade80; +} + +.episode-panel__list { + // Positioned so a row's offsetTop is measured against this scroll box. + position: relative; + display: grid; + gap: 4px; + align-content: start; + flex: 1; + min-height: 0; + margin: 0; + padding: 8px; + list-style: none; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.24) transparent; +} + +.episode-panel__row { + display: block; +} + +.episode-panel__state { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 28px 12px; + color: rgba(255, 255, 255, 0.6); + font-size: 0.85rem; + + mat-icon { + color: rgba(255, 255, 255, 0.4); + } +} + +.episode-panel__state--stacked { + flex-direction: column; +} + +.episode-panel__retry { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 999px; + background: transparent; + color: #ffffff; + font: inherit; + font-size: 0.8rem; + cursor: pointer; + + &:hover, + &:focus-visible { + background: rgba(255, 255, 255, 0.1); + } + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + color: inherit; + } +} + +// ─── Row ───────────────────────────────────────────────────────────────────── + +.episode-panel__item { + display: flex; + gap: 12px; + align-items: flex-start; + width: 100%; + padding: 8px; + border: 0; + border-radius: 10px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 120ms ease; + + &:hover, + &:focus-visible { + background: rgba(255, 255, 255, 0.1); + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color, #78adff); + outline-offset: -2px; + } +} + +.episode-panel__item--playing { + background: rgba(255, 255, 255, 0.14); + cursor: default; +} + +// Watched rows step back so the unwatched ones read as "what's left". +.episode-panel__item--watched:not(.episode-panel__item--playing) { + .episode-panel__still, + .episode-panel__number, + .episode-panel__title { + opacity: 0.7; + } +} + +.episode-panel__thumb { + position: relative; + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 128px; + aspect-ratio: 16 / 9; + border-radius: 6px; + overflow: hidden; + background: rgba(255, 255, 255, 0.08); +} + +.episode-panel__still { + width: 100%; + height: 100%; + object-fit: cover; +} + +// No still (no TMDB match, provider repeats the poster): a large numeral +// tile stands in, so the row reads as designed rather than as a broken image. +.episode-panel__number { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: 0.02em; + color: rgba(255, 255, 255, 0.55); + font-variant-numeric: tabular-nums; +} + +.episode-panel__playing-badge { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + + mat-icon { + color: #ffffff; + } +} + +.episode-panel__progress { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + background: rgba(255, 255, 255, 0.28); +} + +.episode-panel__progress-bar { + display: block; + height: 100%; + background: #e50914; +} + +// ─── Text ──────────────────────────────────────────────────────────────────── + +.episode-panel__meta { + display: grid; + gap: 3px; + min-width: 0; + flex: 1; + padding-top: 2px; +} + +.episode-panel__label { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.72); +} + +.episode-panel__duration { + font-weight: 500; + letter-spacing: 0.02em; + color: rgba(255, 255, 255, 0.5); +} + +.episode-panel__watched { + width: 15px; + height: 15px; + font-size: 15px; + color: var(--accent-success, #4ade80); +} + +.episode-panel__now-playing { + font-size: 0.62rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #7dd87d; +} + +.episode-panel__title { + font-size: 0.92rem; + font-weight: 600; + line-height: 1.25; + color: #ffffff; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.episode-panel__overview { + font-size: 0.78rem; + line-height: 1.4; + color: rgba(255, 255, 255, 0.62); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@container (max-width: 560px) { + .episode-panel__thumb { + width: 104px; + } + + .episode-panel__overview { + -webkit-line-clamp: 2; + } +} diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.spec.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.spec.ts new file mode 100644 index 000000000..875495728 --- /dev/null +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.spec.ts @@ -0,0 +1,305 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { FullscreenEpisodePanelComponent } from './fullscreen-episode-panel.component'; +import type { + FullscreenEpisodePanelItem, + FullscreenEpisodePanelSeason, +} from './fullscreen-episode-panel.util'; + +function item( + id: number, + seasonKey: string, + episodeNumber: number, + overrides: Partial<FullscreenEpisodePanelItem> = {} +): FullscreenEpisodePanelItem { + return { + id, + seasonKey, + episodeNumber, + label: `S0${seasonKey}E0${episodeNumber}`, + title: `Episode ${episodeNumber}`, + thumbnailUrl: null, + overview: '', + durationLabel: null, + progressPercent: null, + watched: false, + isPlaying: false, + episode: { id: String(id) }, + ...overrides, + }; +} + +function seasons(playingId: number | null): FullscreenEpisodePanelSeason[] { + return [ + { + key: '1', + loadState: 'loaded', + episodes: [ + item(11, '1', 1, { + isPlaying: playingId === 11, + watched: true, + progressPercent: 100, + }), + item(12, '1', 2, { + isPlaying: playingId === 12, + thumbnailUrl: 'https://img.test/12.jpg', + overview: 'A rescue signal.', + durationLabel: '45 min', + progressPercent: 30, + }), + ], + }, + { + key: '2', + loadState: 'loaded', + episodes: [item(21, '2', 1, { isPlaying: playingId === 21 })], + }, + { key: '3', loadState: 'loading', episodes: [] }, + ]; +} + +describe('FullscreenEpisodePanelComponent', () => { + let fixture: ComponentFixture<FullscreenEpisodePanelComponent>; + let component: FullscreenEpisodePanelComponent; + + const rows = (): HTMLButtonElement[] => + Array.from( + fixture.nativeElement.querySelectorAll( + '[data-test-id="fullscreen-episode-panel-episode"]' + ) + ); + const pills = (): HTMLButtonElement[] => + Array.from( + fixture.nativeElement.querySelectorAll('.season-tabs__pill') + ); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + FullscreenEpisodePanelComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FullscreenEpisodePanelComponent); + component = fixture.componentInstance; + }); + + afterEach(() => fixture.destroy()); + + it('opens on the playing episode’s season and renders its rows with still, fallback numeral, marker and progress', () => { + fixture.componentRef.setInput('seasons', seasons(12)); + fixture.detectChanges(); + + expect(component.selectedSeasonKey()).toBe('1'); + expect( + pills().map((pill) => pill.getAttribute('aria-selected')) + ).toEqual(['true', 'false', 'false']); + const [first, second] = rows(); + expect(rows()).toHaveLength(2); + // No still: the numeral tile stands in. + expect( + first.querySelector('.episode-panel__number')?.textContent?.trim() + ).toBe('1'); + expect(first.querySelector('.episode-panel__watched')).not.toBeNull(); + expect(first.getAttribute('aria-current')).toBeNull(); + // Still, overview, runtime, marker and progress on the playing row. + expect( + second.querySelector<HTMLImageElement>('.episode-panel__still')?.src + ).toBe('https://img.test/12.jpg'); + expect(second.getAttribute('aria-current')).toBe('true'); + expect( + second.querySelector('.episode-panel__playing-badge') + ).not.toBeNull(); + expect( + second + .querySelector('.episode-panel__overview') + ?.textContent?.trim() + ).toBe('A rescue signal.'); + expect( + second + .querySelector('.episode-panel__duration') + ?.textContent?.trim() + ).toBe('45 min'); + expect( + second.querySelector<HTMLElement>('.episode-panel__progress-bar') + ?.style.width + ).toBe('30%'); + // A watched playing row shows the marker, not the check. + expect(second.querySelector('.episode-panel__watched')).toBeNull(); + }); + + it('falls back to the numeral tile when a still fails to load', () => { + fixture.componentRef.setInput('seasons', seasons(12)); + fixture.detectChanges(); + + const playingRow = rows()[1]; + expect( + playingRow.querySelector('.episode-panel__still') + ).not.toBeNull(); + playingRow + .querySelector('.episode-panel__still') + ?.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + expect(playingRow.querySelector('.episode-panel__still')).toBeNull(); + expect( + playingRow + .querySelector('.episode-panel__number') + ?.textContent?.trim() + ).toBe('2'); + }); + + it('emits a picked episode but ignores a click on the playing one', () => { + const picked: FullscreenEpisodePanelItem[] = []; + fixture.componentRef.setInput('seasons', seasons(12)); + component.episodeSelected.subscribe((picked_) => picked.push(picked_)); + fixture.detectChanges(); + + const [first, second] = rows(); + second.click(); + expect(picked).toEqual([]); + first.click(); + expect(picked.map((row) => row.id)).toEqual([11]); + }); + + it('switches seasons from the tabs, relays the pick to the host and offers the way back to the playing episode', () => { + const selected: string[] = []; + fixture.componentRef.setInput('seasons', seasons(12)); + component.seasonSelected.subscribe((key) => selected.push(key)); + fixture.detectChanges(); + + pills()[1].click(); + fixture.detectChanges(); + expect(selected).toEqual(['2']); + expect(component.selectedSeasonKey()).toBe('2'); + expect(rows().map((row) => row.dataset.episodeId)).toEqual(['21']); + + const back = fixture.nativeElement.querySelector( + '[data-testid="back-to-playing"]' + ) as HTMLButtonElement; + expect(back).not.toBeNull(); + back.click(); + fixture.detectChanges(); + expect(component.selectedSeasonKey()).toBe('1'); + // Coming back is a local move, not a host selection. + expect(selected).toEqual(['2']); + }); + + it('shows a pending season as loading and a loaded empty season as empty', () => { + fixture.componentRef.setInput('seasons', seasons(12)); + fixture.detectChanges(); + + pills()[2].click(); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="fullscreen-episode-panel-loading"]' + ) + ).not.toBeNull(); + + fixture.componentRef.setInput('seasons', [ + ...seasons(12).slice(0, 2), + { key: '3', loadState: 'loaded', episodes: [] }, + ]); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="fullscreen-episode-panel-empty"]' + ) + ).not.toBeNull(); + expect(rows()).toHaveLength(0); + }); + + it('offers a retry for an unanswered season, since the tabs never re-emit the selected key', () => { + const selected: string[] = []; + fixture.componentRef.setInput('seasons', [ + ...seasons(12).slice(0, 2), + { key: '3', loadState: 'unloaded', episodes: [] }, + ]); + component.seasonSelected.subscribe((key) => selected.push(key)); + fixture.detectChanges(); + + pills()[2].click(); + fixture.detectChanges(); + expect(selected).toEqual(['3']); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="fullscreen-episode-panel-loading"]' + ) + ).toBeNull(); + + pills()[2].click(); + expect(selected).toEqual(['3']); + const retry = fixture.nativeElement.querySelector( + '[data-test-id="fullscreen-episode-panel-retry"]' + ) as HTMLButtonElement; + retry.click(); + expect(selected).toEqual(['3', '3']); + }); + + it('keeps the rows as buttons inside list items', () => { + fixture.componentRef.setInput('seasons', seasons(12)); + fixture.detectChanges(); + + const list = fixture.nativeElement.querySelector( + '[data-test-id="fullscreen-episode-panel-list"]' + ) as HTMLElement; + expect(list.tagName).toBe('UL'); + for (const row of rows()) { + expect(row.tagName).toBe('BUTTON'); + expect(row.getAttribute('role')).toBeNull(); + expect(row.parentElement?.tagName).toBe('LI'); + } + }); + + it('keeps the user’s tab through progress rebuilds but follows playback into another season', () => { + fixture.componentRef.setInput('seasons', seasons(12)); + fixture.detectChanges(); + pills()[1].click(); + fixture.detectChanges(); + expect(component.selectedSeasonKey()).toBe('2'); + + // A progress tick rebuilds the season objects; same playing season. + fixture.componentRef.setInput('seasons', seasons(12)); + fixture.detectChanges(); + expect(component.selectedSeasonKey()).toBe('2'); + + // Autoplay carried playback into season 2 while the user looked at + // season 1: the tab jumps to the episode on screen. + pills()[0].click(); + fixture.detectChanges(); + expect(component.selectedSeasonKey()).toBe('1'); + fixture.componentRef.setInput('seasons', seasons(21)); + fixture.detectChanges(); + expect(component.selectedSeasonKey()).toBe('2'); + }); + + it('centers the playing row in the list when the panel opens, without touching the page', () => { + fixture.componentRef.setInput('seasons', seasons(12)); + fixture.componentRef.setInput('open', false); + fixture.detectChanges(); + + const list = fixture.nativeElement.querySelector( + '[data-test-id="fullscreen-episode-panel-list"]' + ) as HTMLElement; + const playingRow = rows()[1]; + Object.defineProperty(list, 'clientHeight', { value: 400 }); + Object.defineProperty(playingRow, 'offsetTop', { value: 900 }); + Object.defineProperty(playingRow, 'offsetHeight', { value: 100 }); + expect(list.scrollTop).toBe(0); + + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + // 900 - 400 / 2 + 100 / 2 + expect(list.scrollTop).toBe(750); + + // The user scrolls away; a progress rebuild must not drag them back. + list.scrollTop = 0; + fixture.componentRef.setInput('seasons', seasons(12)); + fixture.detectChanges(); + expect(list.scrollTop).toBe(0); + }); +}); diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts new file mode 100644 index 000000000..ad73320f8 --- /dev/null +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts @@ -0,0 +1,196 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + afterRenderEffect, + computed, + input, + linkedSignal, + output, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslateModule } from '@ngx-translate/core'; +import { SeasonTabsComponent } from '@iptvnator/ui/components'; +import type { + FullscreenEpisodePanelItem, + FullscreenEpisodePanelSeason, +} from './fullscreen-episode-panel.util'; + +/** + * Body of the fullscreen side panel during series playback: the season tabs + * of the detail page on top, the selected season's episodes underneath. + * + * Purely presentational. The inline player stamps it into the panel through + * `FULLSCREEN_CHANNEL_PANEL`, builds the seasons and relays a click to the + * host's inline episode flow — the same path the Up Next rail uses, so an + * episode chosen here keeps fullscreen exactly like a "next episode" does. + * A season tab click is relayed too, so a host that fetches seasons lazily + * (Stalker VOD series) or enriches them on demand (TMDB stills) runs the + * same hook the detail page's tabs run. + * + * The selected tab follows the playing episode's season and resets to it + * whenever playback moves to another season; a tab the user picks holds + * until then. Opening the panel scrolls the playing row into view. + */ +@Component({ + selector: 'app-fullscreen-episode-panel', + templateUrl: './fullscreen-episode-panel.component.html', + styleUrl: './fullscreen-episode-panel.component.scss', + imports: [ + MatIconModule, + MatProgressSpinnerModule, + SeasonTabsComponent, + TranslateModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FullscreenEpisodePanelComponent { + readonly seasons = input.required<FullscreenEpisodePanelSeason[]>(); + /** The panel is slid in; the playing row is brought into view on the rise. */ + readonly open = input(false); + + readonly seasonSelected = output<string>(); + readonly episodeSelected = output<FullscreenEpisodePanelItem>(); + + /** + * Stills whose image request failed. A provider URL that 404s (or an + * offline machine) must not leave a broken-image frame: the row falls + * back to the numeral tile it would have had without a still. + */ + readonly failedStills = signal<ReadonlySet<number>>(new Set()); + + private readonly list = viewChild<ElementRef<HTMLElement>>('list'); + + readonly seasonKeys = computed(() => this.seasons().map(({ key }) => key)); + /** `[seasonKey, episodeId]` of the playing row; primitives, so the scroll + * effect below re-runs on a real change only, not on every rebuild of the + * season objects a progress tick causes. */ + private readonly playingRow = computed<[string, number] | null>(() => { + for (const season of this.seasons()) { + const playing = season.episodes.find((e) => e.isPlaying); + if (playing) { + return [season.key, playing.id]; + } + } + return null; + }); + readonly playingSeasonKey = computed(() => this.playingRow()?.[0] ?? null); + private readonly playingEpisodeId = computed( + () => this.playingRow()?.[1] ?? null + ); + /** + * The user's pick holds until the playing season changes, when the tab + * jumps back to the episode on screen (autoplay into a new season). + */ + readonly selectedSeasonKey = linkedSignal<string | null>( + () => this.playingSeasonKey() ?? this.seasonKeys()[0] ?? null + ); + readonly selectedSeason = computed<FullscreenEpisodePanelSeason | null>( + () => { + const seasons = this.seasons(); + const selected = this.selectedSeasonKey(); + return ( + seasons.find((season) => season.key === selected) ?? + seasons[0] ?? + null + ); + } + ); + private readonly shownSeasonKey = computed( + () => this.selectedSeason()?.key ?? null + ); + readonly episodeCounts = computed(() => + countBySeason(this.seasons(), (season) => season.episodes.length) + ); + readonly watchedCounts = computed(() => + countBySeason( + this.seasons(), + (season) => + season.episodes.filter((episode) => episode.watched).length + ) + ); + + constructor() { + // Re-runs when the panel opens, the shown season changes, or playback + // moves to another episode — never on a mere progress update, which + // would yank a list the user is scrolling back to the playing row. + afterRenderEffect(() => { + const open = this.open(); + const shown = this.shownSeasonKey(); + const playing = this.playingSeasonKey(); + this.playingEpisodeId(); + if (!open || shown === null || shown !== playing) { + return; + } + untracked(() => this.scrollPlayingRowIntoView()); + }); + } + + onSeasonSelected(seasonKey: string): void { + this.selectedSeasonKey.set(seasonKey); + this.seasonSelected.emit(seasonKey); + } + + /** + * A season the portal never answered for (its request failed): the tabs + * do not re-emit an already selected key, so the state row offers the + * retry, which runs the host's selection hook again. + */ + retrySeason(seasonKey: string): void { + this.seasonSelected.emit(seasonKey); + } + + backToPlaying(): void { + const playing = this.playingSeasonKey(); + if (playing !== null) { + this.selectedSeasonKey.set(playing); + } + } + + onStillError(item: FullscreenEpisodePanelItem): void { + this.failedStills.update((failed) => new Set(failed).add(item.id)); + } + + hasStill(item: FullscreenEpisodePanelItem): boolean { + return item.thumbnailUrl !== null && !this.failedStills().has(item.id); + } + + onEpisodeClick(item: FullscreenEpisodePanelItem): void { + if (item.isPlaying) { + return; + } + this.episodeSelected.emit(item); + } + + /** + * Centers the playing row in the list's own scroll box. Written as + * `scrollTop` math rather than `scrollIntoView` so nothing outside the + * list — the fullscreen stage, the page — is ever scrolled with it. + */ + private scrollPlayingRowIntoView(): void { + const list = this.list()?.nativeElement; + const row = list?.querySelector<HTMLElement>('[aria-current="true"]'); + if (!list || !row) { + return; + } + list.scrollTop = Math.max( + 0, + row.offsetTop - list.clientHeight / 2 + row.offsetHeight / 2 + ); + } +} + +function countBySeason( + seasons: readonly FullscreenEpisodePanelSeason[], + count: (season: FullscreenEpisodePanelSeason) => number +): Record<string, number> { + const counts: Record<string, number> = {}; + for (const season of seasons) { + counts[season.key] = count(season); + } + return counts; +} diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.spec.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.spec.ts new file mode 100644 index 000000000..c530e837b --- /dev/null +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.spec.ts @@ -0,0 +1,207 @@ +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { + buildFullscreenEpisodePanelSeasons, + formatEpisodeDuration, + sortSeasonKeys, +} from './fullscreen-episode-panel.util'; + +function episode( + id: number, + season: number, + episodeNum: number, + info: Record<string, unknown> | [] = {} +) { + return { + id: String(id), + season, + episode_num: episodeNum, + title: `Episode ${episodeNum}`, + info, + }; +} + +function position( + contentXtreamId: number, + positionSeconds: number, + durationSeconds = 100 +): PlaybackPositionData { + return { + contentXtreamId, + contentType: 'episode', + positionSeconds, + durationSeconds, + }; +} + +describe('buildFullscreenEpisodePanelSeasons', () => { + it('returns nothing without seasons', () => { + expect( + buildFullscreenEpisodePanelSeasons({ + episodesBySeason: null, + currentEpisodeId: 1, + }) + ).toEqual([]); + }); + + it('orders seasons numerically, keeps every season and marks the playing row', () => { + const seasons = buildFullscreenEpisodePanelSeasons({ + episodesBySeason: { + '10': [episode(31, 10, 1)], + '2': [episode(21, 2, 1), episode(22, 2, 2)], + '1': [episode(11, 1, 1)], + }, + currentEpisodeId: '22', + }); + + expect(seasons.map((season) => season.key)).toEqual(['1', '2', '10']); + expect(seasons.every((season) => season.loadState === 'loaded')).toBe( + true + ); + const playing = seasons[1].episodes[1]; + expect(playing.isPlaying).toBe(true); + expect(playing.label).toBe('S02E02'); + expect(playing.seasonKey).toBe('2'); + expect(playing.episodeNumber).toBe(2); + expect( + seasons + .flatMap((season) => season.episodes) + .filter((e) => e.isPlaying) + ).toHaveLength(1); + }); + + it('marks no row when nothing plays, so the tab falls back to the first season', () => { + const seasons = buildFullscreenEpisodePanelSeasons({ + episodesBySeason: { '1': [episode(11, 1, 1)] }, + currentEpisodeId: null, + }); + expect(seasons[0].episodes[0].isPlaying).toBe(false); + }); + + it('reads still, overview and runtime off the (TMDB-overlaid) info', () => { + const [season] = buildFullscreenEpisodePanelSeasons({ + episodesBySeason: { + '1': [ + episode(11, 1, 1, { + movie_image: 'https://img.test/still.jpg', + plot: ' A rescue signal. ', + duration_secs: 2700, + }), + // No metadata at all: the provider sends `[]`. + episode(12, 1, 2, []), + // A non-http image never becomes a still. + episode(13, 1, 3, { + movie_image: 'file:///poster.jpg', + duration: '1h 30min', + }), + ], + }, + currentEpisodeId: 11, + }); + + expect(season.episodes[0]).toEqual( + expect.objectContaining({ + thumbnailUrl: 'https://img.test/still.jpg', + overview: 'A rescue signal.', + durationLabel: '45 min', + }) + ); + expect(season.episodes[1]).toEqual( + expect.objectContaining({ + thumbnailUrl: null, + overview: '', + durationLabel: null, + }) + ); + expect(season.episodes[2]).toEqual( + expect.objectContaining({ + thumbnailUrl: null, + durationLabel: '90 min', + }) + ); + }); + + it('derives progress and the watched flag from playback positions', () => { + const positions = new Map<number, PlaybackPositionData>([ + [11, position(11, 40)], + [12, position(12, 95)], + ]); + const [season] = buildFullscreenEpisodePanelSeasons({ + episodesBySeason: { + '1': [episode(11, 1, 1), episode(12, 1, 2), episode(13, 1, 3)], + }, + currentEpisodeId: 13, + playbackPositions: positions, + }); + + expect(season.episodes[0]).toEqual( + expect.objectContaining({ progressPercent: 40, watched: false }) + ); + expect(season.episodes[1]).toEqual( + expect.objectContaining({ progressPercent: 95, watched: true }) + ); + expect(season.episodes[2]).toEqual( + expect.objectContaining({ progressPercent: null, watched: false }) + ); + }); + + it('carries the host\u2019s in-flight and unanswered season states, defaulting to loaded', () => { + const seasons = buildFullscreenEpisodePanelSeasons({ + episodesBySeason: { '1': [episode(11, 1, 1)], '2': [], '3': [] }, + currentEpisodeId: 11, + seasonLoadStates: { '2': 'loading', '3': 'unloaded' }, + }); + + expect(seasons.map((season) => season.loadState)).toEqual([ + 'loaded', + 'loading', + 'unloaded', + ]); + expect(seasons[1]).toEqual({ + key: '2', + loadState: 'loading', + episodes: [], + }); + }); + + it('falls back to the season key and the list index when an episode carries no numbers', () => { + const [season] = buildFullscreenEpisodePanelSeasons({ + episodesBySeason: { + '3': [{ id: 5, title: 'Pilot' }, { id: 6 }], + }, + currentEpisodeId: 6, + }); + + expect(season.episodes.map((e) => e.label)).toEqual([ + 'S03E01', + 'S03E02', + ]); + expect(season.episodes[1].title).toBe(''); + }); +}); + +describe('formatEpisodeDuration', () => { + it.each([ + [2700, undefined, '45 min'], + [0, '45 min', '45 min'], + [undefined, '1h 30min', '90 min'], + [undefined, '01:30:00', '90 min'], + [undefined, '45:30', '46 min'], + [undefined, '', null], + [undefined, 'soon', null], + [-5, undefined, null], + ])('formats %s / %s as %s', (seconds, text, expected) => { + expect(formatEpisodeDuration(seconds, text)).toBe(expected); + }); +}); + +describe('sortSeasonKeys', () => { + it('sorts numeric keys ascending and keeps named keys after them in order', () => { + expect(sortSeasonKeys(['Specials', '2', 'Extras', '10', '1'])).toEqual([ + '1', + '2', + '10', + 'Specials', + 'Extras', + ]); + }); +}); diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.ts new file mode 100644 index 000000000..0922329b3 --- /dev/null +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.ts @@ -0,0 +1,218 @@ +import { + getPortalPlaybackProgressPercent, + isPortalPlaybackWatched, +} from '@iptvnator/portal/shared/util'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { formatSeriesEpisodeLabel } from '../portal-inline-player/series-playback-navigation'; +import type { UpNextRailItem } from '../portal-inline-player/up-next-rail.util'; + +/** + * The slice of an episode object the panel reads. Structural on purpose: + * both portals hand the inline player the Xtream episode shape + * (`XtreamSerieEpisode`, Stalker through `mappedSeasons`), and the TMDB + * overlay writes its stills and overviews into the same `info` fields. + */ +export interface FullscreenPanelEpisodeLike { + id: string | number; + season?: string | number; + episode_num?: string | number; + title?: string; + info?: + | { + movie_image?: string; + plot?: string; + duration_secs?: number; + duration?: string; + } + | unknown[]; +} + +/** + * One row of the fullscreen episode panel. Extends the Up Next entry so a + * click can travel the exact path the rail's click does: the host receives + * the same object shape and plays `episode` through its inline flow. + */ +export interface FullscreenEpisodePanelItem< + TEpisode = unknown, +> extends UpNextRailItem<TEpisode> { + seasonKey: string; + /** Number inside its season, the fallback tile when there is no still. */ + episodeNumber: number; + /** TMDB or provider overview, empty when neither says anything. */ + overview: string; + /** "45 min" style runtime, null when the provider states none. */ + durationLabel: string | null; + /** Watched (≥90 %) per the shared portal progress rule. */ + watched: boolean; +} + +/** + * Where a season's episode list stands with the portal. Every Xtream season + * is `loaded` up front; a Stalker lazy VOD season is `loading` while its + * request is on the wire and `unloaded` while the portal has not answered + * yet — after a failed request too, which is why the panel offers a retry + * there instead of a spinner that nothing would ever end. + */ +export type FullscreenPanelSeasonLoadState = 'loaded' | 'loading' | 'unloaded'; + +export interface FullscreenEpisodePanelSeason<TEpisode = unknown> { + /** Provider season key, as the season container uses it. */ + key: string; + episodes: FullscreenEpisodePanelItem<TEpisode>[]; + loadState: FullscreenPanelSeasonLoadState; +} + +export interface BuildFullscreenEpisodePanelSeasonsOptions< + TEpisode extends FullscreenPanelEpisodeLike, +> { + episodesBySeason: Record<string, readonly TEpisode[]> | null | undefined; + /** Id of the episode playing inline; its row carries the marker. */ + currentEpisodeId: string | number | null | undefined; + playbackPositions?: ReadonlyMap<number, PlaybackPositionData> | null; + /** Per season key, in flight or not yet answered; absent means loaded. */ + seasonLoadStates?: Readonly< + Record<string, Exclude<FullscreenPanelSeasonLoadState, 'loaded'>> + > | null; +} + +/** + * Builds the panel's seasons in display order: numeric keys ascending, + * non-numeric keys afterwards in insertion order — the same order the Up + * Next rail flattens seasons in, so both surfaces agree on "next". + */ +export function buildFullscreenEpisodePanelSeasons< + TEpisode extends FullscreenPanelEpisodeLike, +>({ + episodesBySeason, + currentEpisodeId, + playbackPositions, + seasonLoadStates, +}: BuildFullscreenEpisodePanelSeasonsOptions<TEpisode>): FullscreenEpisodePanelSeason<TEpisode>[] { + if (!episodesBySeason) { + return []; + } + + const currentId = + currentEpisodeId === null || currentEpisodeId === undefined + ? null + : Number(currentEpisodeId); + + return sortSeasonKeys(Object.keys(episodesBySeason)).map((seasonKey) => { + const episodes = episodesBySeason[seasonKey] ?? []; + return { + key: seasonKey, + loadState: seasonLoadStates?.[seasonKey] ?? 'loaded', + episodes: episodes.map((episode, index) => + toPanelItem( + episode, + seasonKey, + index, + currentId, + playbackPositions + ) + ), + }; + }); +} + +function toPanelItem<TEpisode extends FullscreenPanelEpisodeLike>( + episode: TEpisode, + seasonKey: string, + index: number, + currentId: number | null, + playbackPositions: + ReadonlyMap<number, PlaybackPositionData> | null | undefined +): FullscreenEpisodePanelItem<TEpisode> { + const id = Number(episode.id); + const info = readInfo(episode); + const position = playbackPositions?.get(id); + const percent = position ? getPortalPlaybackProgressPercent(position) : 0; + const seasonNumber = Number(episode.season) || Number(seasonKey) || 0; + const episodeNumber = Number(episode.episode_num) || index + 1; + + return { + id, + seasonKey, + episodeNumber, + label: formatSeriesEpisodeLabel(seasonNumber, episodeNumber), + title: episode.title?.trim() ?? '', + thumbnailUrl: toHttpUrl(info?.movie_image), + overview: info?.plot?.trim() ?? '', + durationLabel: formatEpisodeDuration( + info?.duration_secs, + info?.duration + ), + progressPercent: percent > 0 ? percent : null, + watched: isPortalPlaybackWatched(position), + isPlaying: currentId !== null && id === currentId, + episode, + }; +} + +function readInfo(episode: FullscreenPanelEpisodeLike) { + const info = episode.info; + return !info || Array.isArray(info) ? null : info; +} + +function toHttpUrl(url: string | undefined): string | null { + return url && /^https?:/i.test(url) ? url : null; +} + +/** + * Whole minutes from the provider's numeric runtime, else from the text + * forms the portals use — "45 min", "1h 30min", "01:30:00", "45:00". + * Returns null for anything that does not parse to a positive runtime. + */ +export function formatEpisodeDuration( + durationSeconds: number | undefined, + duration: string | undefined +): string | null { + const seconds = + typeof durationSeconds === 'number' && durationSeconds > 0 + ? durationSeconds + : parseDurationText(duration); + if (!seconds || !Number.isFinite(seconds)) { + return null; + } + const minutes = Math.max(1, Math.round(seconds / 60)); + return `${minutes} min`; +} + +function parseDurationText(duration: string | undefined): number { + if (!duration) { + return 0; + } + const minutesMatch = duration.match(/(?:(\d+)\s*h\w*)?\s*(\d+)\s*min/); + if (minutesMatch) { + return ( + parseInt(minutesMatch[1] ?? '0', 10) * 3600 + + parseInt(minutesMatch[2], 10) * 60 + ); + } + const parts = duration.split(':').map(Number); + if (parts.length === 3 && parts.every(Number.isFinite)) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + if (parts.length === 2 && parts.every(Number.isFinite)) { + return parts[0] * 60 + parts[1]; + } + return 0; +} + +/** Numeric keys ascending, then non-numeric keys in insertion order. */ +export function sortSeasonKeys(keys: readonly string[]): string[] { + return keys + .map((key, index) => ({ key, index, numeric: Number(key) })) + .sort((a, b) => { + const aNumeric = Number.isFinite(a.numeric); + const bNumeric = Number.isFinite(b.numeric); + if (aNumeric && bNumeric) { + return a.numeric - b.numeric; + } + if (aNumeric !== bNumeric) { + return aNumeric ? -1 : 1; + } + return a.index - b.index; + }) + .map(({ key }) => key); +} diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/index.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/index.ts new file mode 100644 index 000000000..6544ed535 --- /dev/null +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/index.ts @@ -0,0 +1,2 @@ +export * from './fullscreen-episode-panel.component'; +export * from './fullscreen-episode-panel.util'; diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.host.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.host.ts new file mode 100644 index 000000000..d6bd06184 --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.host.ts @@ -0,0 +1,95 @@ +import { computed, type Signal, signal, type TemplateRef } from '@angular/core'; +import type { + PlaybackPositionData, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import type { + FullscreenChannelPanelContext, + FullscreenChannelPanelHost, +} from '../fullscreen-channel-panel/fullscreen-channel-panel.model'; +import { + buildFullscreenEpisodePanelSeasons, + type FullscreenEpisodePanelSeason, + type FullscreenPanelEpisodeLike, + type FullscreenPanelSeasonLoadState, +} from '../fullscreen-episode-panel/fullscreen-episode-panel.util'; + +export type SeasonLoadStates = Readonly< + Record<string, Exclude<FullscreenPanelSeasonLoadState, 'loaded'>> +>; + +export interface EpisodePanelHostInputs { + /** The `#fullscreenEpisodePanel` template of the inline player. */ + template: Signal<TemplateRef<FullscreenChannelPanelContext> | undefined>; + /** `Settings.fullscreenChannelPanel`, the one toggle for both lists. */ + panelEnabled: () => boolean; + playback: Signal<ResolvedPortalPlayback | null>; + seriesEpisodes: Signal<Record< + string, + readonly FullscreenPanelEpisodeLike[] + > | null>; + playbackPositions: Signal<ReadonlyMap<number, PlaybackPositionData> | null>; + seasonLoadStates: Signal<SeasonLoadStates | null>; + seriesTitle: Signal<string | null>; + /** Playback title, the header fallback when the host names no series. */ + fallbackTitle: Signal<string>; +} + +export interface PortalInlinePlayerEpisodePanelHost extends FullscreenChannelPanelHost { + /** Seasons handed to `app-fullscreen-episode-panel`. */ + readonly seasons: Signal<FullscreenEpisodePanelSeason[]>; + /** The playing episode's id, or null while no episode plays inline. */ + readonly playingEpisodeId: Signal<number | null>; +} + +/** + * The `FULLSCREEN_CHANNEL_PANEL` host the inline player provides for its + * nested `app-web-player-view` during series playback: the episode list + * with season tabs, no search field. Kept out of the component so the + * player's own responsibilities stay readable; the component only wires + * its inputs in and stamps the panel body. + * + * `panelTemplate` is null — every affordance disappears — while no episode + * plays inline (a movie never gets the panel), while the host supplied no + * seasons, or when the user opted out of the fullscreen panel. Native-view + * Embedded MPV and the external players are excluded upstream: the view + * withholds the panel for the former, and the latter never mount the inline + * player at all. + */ +export function createEpisodePanelHost( + inputs: EpisodePanelHostInputs +): PortalInlinePlayerEpisodePanelHost { + const playingEpisodeId = computed<number | null>(() => { + const playback = inputs.playback(); + const info = playback?.contentInfo; + return info?.contentType === 'episode' && !playback?.isLive + ? info.contentXtreamId + : null; + }); + const seasons = computed(() => + buildFullscreenEpisodePanelSeasons({ + episodesBySeason: inputs.seriesEpisodes(), + currentEpisodeId: playingEpisodeId(), + playbackPositions: inputs.playbackPositions(), + seasonLoadStates: inputs.seasonLoadStates(), + }) + ); + + return { + seasons, + playingEpisodeId, + panelTemplate: computed(() => + !inputs.panelEnabled() || + playingEpisodeId() === null || + seasons().length === 0 + ? null + : (inputs.template() ?? null) + ), + panelTitle: computed( + () => inputs.seriesTitle()?.trim() || inputs.fallbackTitle() + ), + // Season tabs are the panel's navigation; no search field. + panelSearchEnabled: signal(false).asReadonly(), + panelKind: 'episodes', + }; +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts new file mode 100644 index 000000000..1aa24df4b --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts @@ -0,0 +1,251 @@ +import { Component, input, output, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { SettingsStore } from '@iptvnator/services'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { FULLSCREEN_CHANNEL_PANEL } from '../fullscreen-channel-panel/fullscreen-channel-panel.model'; +import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component'; + +jest.unstable_mockModule('video.js', () => ({ + default: jest.fn(), +})); + +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); + +@Component({ + selector: 'app-web-player-view', + standalone: true, + template: '<div data-test-id="stub-web-player-view"></div>', +}) +class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required<string>(); + readonly streamUrl = input.required<string>(); + readonly title = input(''); + readonly mediaTitle = input<unknown>(null); + readonly playback = input<unknown>(null); + readonly volume = input(1); + readonly playerOverride = input<unknown>(null); + readonly startTime = input(0); + readonly seriesNavigation = input<unknown>(null); + readonly alternativeSources = input<unknown[]>([]); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly externalFallbackRequested = output<unknown>(); + readonly alternativeSourceRequested = output<string>(); + readonly playbackFailed = output<unknown>(); + readonly playbackEnded = output<void>(); + readonly previousEpisodeRequested = output<void>(); + readonly nextEpisodeRequested = output<void>(); +} + +/** + * The inline player is the series host of the fullscreen side panel: it + * provides `FULLSCREEN_CHANNEL_PANEL` for the nested view and stamps the + * episode panel into it, routing a pick through the same output the Up Next + * rail uses. + */ +describe('PortalInlinePlayerComponent fullscreen episode panel host', () => { + let PortalInlinePlayerComponent: typeof import('./portal-inline-player.component').PortalInlinePlayerComponent; + let WebPlayerViewComponent: typeof import('../web-player-view/web-player-view.component').WebPlayerViewComponent; + let fixture: ComponentFixture<PortalInlinePlayerComponentInstance>; + let component: PortalInlinePlayerComponentInstance; + let panelEnabled: ReturnType<typeof signal<boolean>>; + + beforeAll(async () => { + ({ PortalInlinePlayerComponent } = + await import('./portal-inline-player.component')); + ({ WebPlayerViewComponent } = + await import('../web-player-view/web-player-view.component')); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + async function setup() { + panelEnabled = signal(true); + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [PortalInlinePlayerComponent, TranslateModule.forRoot()], + providers: [ + { + provide: SettingsStore, + useValue: { + player: signal('videojs'), + playerAmbientMode: signal(false), + playerUpNextRail: signal(true), + stripCountryPrefix: signal(false), + fullscreenChannelPanel: panelEnabled, + }, + }, + ], + }) + .overrideComponent(PortalInlinePlayerComponent, { + remove: { imports: [WebPlayerViewComponent] }, + add: { imports: [StubWebPlayerViewComponent] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PortalInlinePlayerComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('playbackSessionKey', 'panel-key'); + } + + const episodePlayback = { + streamUrl: 'https://example.com/episode.mp4', + title: 'Episode 2', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 12, + contentType: 'episode', + }, + }; + const moviePlayback = { + streamUrl: 'https://example.com/movie.mp4', + title: 'Some Movie', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 7, + contentType: 'movie', + }, + }; + const seriesEpisodes = { + '1': [ + { id: '11', season: 1, episode_num: 1, title: 'Pilot', info: [] }, + { id: '12', season: 1, episode_num: 2, title: 'Two', info: [] }, + ], + '2': [ + { id: '21', season: 2, episode_num: 1, title: 'Three', info: [] }, + ], + }; + + let closeCalls = 0; + const stampedViews: { destroy: () => void }[] = []; + afterEach(() => { + stampedViews.splice(0).forEach((view) => view.destroy()); + closeCalls = 0; + }); + + /** Stamps the host's panel template the way the panel component does. */ + function stampPanel(open = true): HTMLElement { + const host = fixture.debugElement.injector.get( + FULLSCREEN_CHANNEL_PANEL + ); + const template = host.panelTemplate(); + if (!template) { + throw new Error('panel template is null'); + } + const view = template.createEmbeddedView({ + searchTerm: signal('').asReadonly(), + open: signal(open).asReadonly(), + close: () => closeCalls++, + }); + stampedViews.push(view); + view.detectChanges(); + const container = document.createElement('div'); + view.rootNodes.forEach((node: Node) => container.appendChild(node)); + return container; + } + + it('provides the panel token to the nested view with episode semantics and no search field', async () => { + await setup(); + fixture.componentRef.setInput('playback', episodePlayback); + fixture.componentRef.setInput('seriesTitle', 'Some Show'); + fixture.componentRef.setInput('seriesEpisodes', seriesEpisodes); + fixture.detectChanges(); + + const host = fixture.debugElement.injector.get( + FULLSCREEN_CHANNEL_PANEL + ); + expect(host).toBe(component.episodePanel); + expect(host.panelTemplate()).not.toBeNull(); + expect(host.panelTitle?.()).toBe('Some Show'); + expect(host.panelSearchEnabled?.()).toBe(false); + expect(host.panelKind).toBe('episodes'); + }); + + it('builds the seasons for the panel with the playing row, positions and load states', async () => { + await setup(); + const positions = new Map<number, PlaybackPositionData>([ + [ + 11, + { + contentXtreamId: 11, + contentType: 'episode', + positionSeconds: 95, + durationSeconds: 100, + }, + ], + ]); + fixture.componentRef.setInput('playback', episodePlayback); + fixture.componentRef.setInput('seriesEpisodes', seriesEpisodes); + fixture.componentRef.setInput('episodePlaybackPositions', positions); + fixture.componentRef.setInput('seasonLoadStates', { '2': 'loading' }); + fixture.detectChanges(); + + const seasons = component.episodePanel.seasons(); + expect(seasons.map((season) => season.key)).toEqual(['1', '2']); + expect(seasons[0].episodes.map((e) => e.isPlaying)).toEqual([ + false, + true, + ]); + expect(seasons[0].episodes[0].watched).toBe(true); + expect(seasons[1].loadState).toBe('loading'); + }); + + it('withholds the panel for a movie, without seasons, and when the setting is off', async () => { + await setup(); + fixture.componentRef.setInput('playback', moviePlayback); + fixture.componentRef.setInput('seriesEpisodes', seriesEpisodes); + fixture.detectChanges(); + expect(component.episodePanel.panelTemplate()).toBeNull(); + + fixture.componentRef.setInput('playback', episodePlayback); + fixture.componentRef.setInput('seriesEpisodes', null); + fixture.detectChanges(); + expect(component.episodePanel.panelTemplate()).toBeNull(); + + fixture.componentRef.setInput('seriesEpisodes', seriesEpisodes); + fixture.detectChanges(); + expect(component.episodePanel.panelTemplate()).not.toBeNull(); + + panelEnabled.set(false); + fixture.detectChanges(); + expect(component.episodePanel.panelTemplate()).toBeNull(); + }); + + it('stamps the episode panel and relays a pick through the Up Next output, then closes the panel', async () => { + await setup(); + const picked: unknown[] = []; + const seasonsPicked: string[] = []; + fixture.componentRef.setInput('playback', episodePlayback); + fixture.componentRef.setInput('seriesEpisodes', seriesEpisodes); + component.upNextEpisodeSelected.subscribe((item) => picked.push(item)); + component.episodePanelSeasonSelected.subscribe((key) => + seasonsPicked.push(key) + ); + fixture.detectChanges(); + + const body = stampPanel(); + const rows = body.querySelectorAll<HTMLButtonElement>( + '[data-test-id="fullscreen-episode-panel-episode"]' + ); + expect(rows).toHaveLength(2); + expect(rows[1].getAttribute('aria-current')).toBe('true'); + + rows[0].click(); + expect(picked).toEqual([ + expect.objectContaining({ + id: 11, + episode: seriesEpisodes['1'][0], + }), + ]); + expect(closeCalls).toBe(1); + + ( + body.querySelectorAll('.season-tabs__pill')[1] as HTMLButtonElement + ).click(); + expect(seasonsPicked).toEqual(['2']); + }); +}); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-stage.util.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-stage.util.ts new file mode 100644 index 000000000..ee4353e38 --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-stage.util.ts @@ -0,0 +1,70 @@ +/** Narrowest useful "Up Next" rail; below this the stage stays centered. */ +export const UP_NEXT_RAIL_MIN_WIDTH = 320; +/** Keep in sync with `.player-shell__viewport--with-rail` in the stylesheet. */ +const RAIL_STAGE_PADDING = 12; +const RAIL_STAGE_GAP = 18; + +export interface StageSize { + width: number; + height: number; +} + +/** + * Width the rail would actually get: the stage minus its docked-mode + * padding, the 16:9 player sized to the remaining height, and the flex gap. + * Computed for the docked layout even while centered, so the gate answers + * "would the rail fit?" rather than "is there slack right now?". + */ +export function upNextRailAvailableWidth(size: StageSize | null): number { + if (!size || size.height <= 0) { + return 0; + } + + const innerWidth = size.width - RAIL_STAGE_PADDING * 2; + const innerHeight = size.height - RAIL_STAGE_PADDING * 2; + return innerWidth - (innerHeight * 16) / 9 - RAIL_STAGE_GAP; +} + +/** Safe `url(...)` value, or null when the poster URL is not a plain http/data URL. */ +export function ambientImageStyle( + url: string | null | undefined +): string | null { + if (!url || !/^(https?:|data:)/i.test(url)) { + return null; + } + + const safe = url.replace(/"/g, '%22').replace(/\\/g, '%5C'); + return `url("${safe}")`; +} + +/** + * Reports the element's border-box size through a ResizeObserver and returns + * the disconnect callback. Measuring the border box (not the content box) + * keeps the value stable when the rail modifier toggles the stage's own + * padding, so the rail gate cannot oscillate around its threshold. + */ +export function observeStageSize( + element: HTMLElement, + onSize: (size: StageSize) => void +): () => void { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + + // `borderBoxSize` is the padding-independent measurement; the + // `contentRect` fallback covers engines that omit it. + const borderBox = entry.borderBoxSize?.[0]; + onSize( + borderBox + ? { width: borderBox.inlineSize, height: borderBox.blockSize } + : { + width: entry.contentRect.width, + height: entry.contentRect.height, + } + ); + }); + observer.observe(element); + return () => observer.disconnect(); +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 593f9b881..4040fec8b 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -117,3 +117,15 @@ </div> </section> } + +<!-- Stamped into the player's fullscreen side panel (FULLSCREEN_CHANNEL_PANEL): + the series' seasons and episodes, picked through the same inline path as + the Up Next rail so fullscreen survives the switch. --> +<ng-template #fullscreenEpisodePanel let-open="open" let-close="close"> + <app-fullscreen-episode-panel + [seasons]="episodePanel.seasons()" + [open]="open()" + (seasonSelected)="episodePanelSeasonSelected.emit($event)" + (episodeSelected)="onPanelEpisodeSelected($event, close)" + /> +</ng-template> diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss index 876cbd54b..09290dc56 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss @@ -11,7 +11,11 @@ border-radius: 18px; border: 1px solid rgba(20, 20, 20, 0.08); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 250, 245, 0.92)), + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.78), + rgba(255, 250, 245, 0.92) + ), rgba(255, 250, 245, 0.9); box-shadow: 0 20px 56px rgba(25, 20, 20, 0.12), diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index e05301355..04e488b3a 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -9,6 +9,7 @@ import { input, output, signal, + TemplateRef, viewChild, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; @@ -16,6 +17,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; import { + PlaybackPositionData, PlayerContentInfo, ResolvedPortalPlayback, VideoPlayer, @@ -27,27 +29,39 @@ import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util'; import { SettingsStore } from '@iptvnator/services'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; import type { PlayerMediaTitle } from '../player-controls'; +import { + FULLSCREEN_CHANNEL_PANEL, + type FullscreenChannelPanelContext, +} from '../fullscreen-channel-panel/fullscreen-channel-panel.model'; +import { FullscreenEpisodePanelComponent } from '../fullscreen-episode-panel/fullscreen-episode-panel.component'; +import type { FullscreenPanelEpisodeLike } from '../fullscreen-episode-panel/fullscreen-episode-panel.util'; +import { + createEpisodePanelHost, + type SeasonLoadStates, +} from './portal-inline-player-episode-panel.host'; import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component'; import type { SeriesEpisodeMetadata, SeriesPlaybackNavigation, } from './series-playback-navigation'; import { VodSourcesChipComponent } from '@iptvnator/ui/components'; +import { + ambientImageStyle as toAmbientImageStyle, + observeStageSize, + type StageSize, + UP_NEXT_RAIL_MIN_WIDTH, + upNextRailAvailableWidth, +} from './portal-inline-player-stage.util'; import { UpNextRailComponent } from './up-next-rail.component'; import type { UpNextRailItem } from './up-next-rail.util'; -/** Narrowest useful "Up Next" rail; below this the stage stays centered. */ -const UP_NEXT_RAIL_MIN_WIDTH = 320; -/** Keep in sync with `.player-shell__viewport--with-rail` in the stylesheet. */ -const RAIL_STAGE_PADDING = 12; -const RAIL_STAGE_GAP = 18; - @Component({ selector: 'app-portal-inline-player', templateUrl: './portal-inline-player.component.html', styleUrl: './portal-inline-player.component.scss', imports: [ ClipboardModule, + FullscreenEpisodePanelComponent, MatButtonModule, MatIconModule, MatTooltipModule, @@ -56,6 +70,17 @@ const RAIL_STAGE_GAP = 18; VodSourcesChipComponent, WebPlayerViewComponent, ], + providers: [ + // The fullscreen side panel inside the nested player lists this + // series' episodes (see the `fullscreenEpisodePanel` template). A + // movie host gets `null` from `panelTemplate`, so nothing renders — + // and, being the nearest provider, this also shields the nested view + // from a page-level channel-list provider (the M3U player's). + { + provide: FULLSCREEN_CHANNEL_PANEL, + useFactory: () => inject(PortalInlinePlayerComponent).episodePanel, + }, + ], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'portal-inline-player', @@ -72,6 +97,24 @@ export class PortalInlinePlayerComponent { readonly seriesTitle = input<string | null>(null); /** "Up Next" entries built by the series host; null for movies/live. */ readonly upNextEpisodes = input<UpNextRailItem[] | null>(null); + /** + * Every season of the playing series, keyed like the season container's + * input, for the fullscreen episode panel; null for movies/live. + */ + readonly seriesEpisodes = input<Record< + string, + readonly FullscreenPanelEpisodeLike[] + > | null>(null); + /** Per-episode positions behind the panel's progress bars and check marks. */ + readonly episodePlaybackPositions = input<ReadonlyMap< + number, + PlaybackPositionData + > | null>(null); + /** + * Seasons in flight or not yet answered by the portal (Stalker lazy VOD + * series), keyed by season; absent keys are loaded. + */ + readonly seasonLoadStates = input<SeasonLoadStates | null>(null); /** * Initial player volume. Only hosts that own a persisted volume pass it * (the M3U player shares one across its channels); the portals keep the @@ -101,23 +144,11 @@ export class PortalInlinePlayerComponent { * Poster used for the "Ambient mode" fill behind the player. Live channels * carry logos rather than posters, so they are excluded. */ - private readonly ambientImageUrl = computed<string | null>(() => { - const playback = this.playback(); - if (!playback || playback.isLive) { - return null; - } - - return playback.thumbnail ?? null; - }); - /** Safe `url(...)` value, or null when the poster URL is not a plain http/data URL. */ readonly ambientImageStyle = computed<string | null>(() => { - const url = this.ambientImageUrl(); - if (!url || !/^(https?:|data:)/i.test(url)) { - return null; - } - - const safe = url.replace(/"/g, '%22').replace(/\\/g, '%5C'); - return `url("${safe}")`; + const playback = this.playback(); + return playback && !playback.isLive + ? toAmbientImageStyle(playback.thumbnail) + : null; }); // Web players only — mirrors the settings UI, which offers the ambient // and Up Next toggles for HTML5, Video.js, and ArtPlayer. Embedded MPV @@ -165,29 +196,8 @@ export class PortalInlinePlayerComponent { private readonly stageViewport = viewChild<ElementRef<HTMLElement>>('stageViewport'); - /** - * Border-box size of the theater stage, written by a ResizeObserver. - * Measuring the border box (not the content box) keeps the value stable - * when the rail modifier toggles the stage's own padding, so the gate - * below cannot oscillate around its threshold. - */ - readonly stageSize = signal<{ width: number; height: number } | null>(null); - /** - * Width the rail would actually get: the stage minus its docked-mode - * padding, the 16:9 player sized to the remaining height, and the flex - * gap. Computed for the docked layout even while centered, so the gate - * answers "would the rail fit?" rather than "is there slack right now?". - */ - private readonly upNextRailAvailableWidth = computed<number>(() => { - const size = this.stageSize(); - if (!size || size.height <= 0) { - return 0; - } - - const innerWidth = size.width - RAIL_STAGE_PADDING * 2; - const innerHeight = size.height - RAIL_STAGE_PADDING * 2; - return innerWidth - (innerHeight * 16) / 9 - RAIL_STAGE_GAP; - }); + /** Border-box size of the theater stage, written by a ResizeObserver. */ + readonly stageSize = signal<StageSize | null>(null); /** * The rail docks in only for inline series playback on web engines, when * the setting (default on) is enabled and the stage is wide enough; on @@ -201,13 +211,29 @@ export class PortalInlinePlayerComponent { !!this.upNextEpisodes()?.length && !playback?.isLive && playback?.contentInfo?.contentType === 'episode' && - this.upNextRailAvailableWidth() >= UP_NEXT_RAIL_MIN_WIDTH + upNextRailAvailableWidth(this.stageSize()) >= UP_NEXT_RAIL_MIN_WIDTH ); }); readonly upNextRailItems = computed<UpNextRailItem[]>( () => this.upNextEpisodes() ?? [] ); + private readonly fullscreenEpisodePanelTemplate = viewChild< + TemplateRef<FullscreenChannelPanelContext> + >('fullscreenEpisodePanel'); + /** FULLSCREEN_CHANNEL_PANEL host for the nested view: this series' episodes. */ + readonly episodePanel = createEpisodePanelHost({ + template: this.fullscreenEpisodePanelTemplate, + panelEnabled: () => + this.settingsStore.fullscreenChannelPanel?.() !== false, + playback: this.playback, + seriesEpisodes: this.seriesEpisodes, + playbackPositions: this.episodePlaybackPositions, + seasonLoadStates: this.seasonLoadStates, + seriesTitle: this.seriesTitle, + fallbackTitle: this.title, + }); + readonly closed = output<void>(); /** Back arrow in the now-playing bar: route-level back, not just close. */ readonly backClicked = output<void>(); @@ -245,7 +271,10 @@ export class PortalInlinePlayerComponent { readonly playbackEnded = output<void>(); readonly previousEpisodeRequested = output<void>(); readonly nextEpisodeRequested = output<void>(); + /** An episode picked in the Up Next rail or the fullscreen episode panel. */ readonly upNextEpisodeSelected = output<UpNextRailItem>(); + /** A season tab picked in the fullscreen episode panel (lazy load hook). */ + readonly episodePanelSeasonSelected = output<string>(); constructor() { effect((onCleanup) => { @@ -255,29 +284,9 @@ export class PortalInlinePlayerComponent { return; } - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) { - return; - } - - // `borderBoxSize` is the padding-independent measurement; the - // `contentRect` fallback covers engines that omit it. - const borderBox = entry.borderBoxSize?.[0]; - this.stageSize.set( - borderBox - ? { - width: borderBox.inlineSize, - height: borderBox.blockSize, - } - : { - width: entry.contentRect.width, - height: entry.contentRect.height, - } - ); - }); - observer.observe(element); - onCleanup(() => observer.disconnect()); + onCleanup( + observeStageSize(element, (size) => this.stageSize.set(size)) + ); }); } @@ -316,4 +325,10 @@ export class PortalInlinePlayerComponent { onUpNextEpisodeSelected(item: UpNextRailItem): void { this.upNextEpisodeSelected.emit(item); } + + /** Panel pick: same host path as the rail, then the panel slides away. */ + onPanelEpisodeSelected(item: UpNextRailItem, close: () => void): void { + this.upNextEpisodeSelected.emit(item); + close(); + } }