From c1105194129f3b9d66723147af8cc0ba00935cff Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:12:00 +0200 Subject: [PATCH 1/3] fix(playback): make the fullscreen channel panel discoverable and calmer to close (#1619) * fix(playback): make the fullscreen channel panel discoverable and calmer to close The left-edge hot zone was an invisible 28px strip with nothing telling the user where the list lived, so "hovering the left side" rarely reached it while `C` always worked. Mouse movement over the stage now reveals a slim edge hint tab (CSS chevron, fades after 2.5 s idle, lit while the pointer rests in the zone), the zone grows to 40px (48px coarse), and a click or tap on the edge opens at once without the dwell. Closing follows the pointer more honestly: the mouse-leave grace grows from 420 ms to 1 s, and it applies only once the pointer has engaged with the panel, so a `C`-opened list no longer closes while the mouse merely roams over the video under the user's typing. Clicks inside the panel never close it. Found on the way: closing with Escape while the mouse still rests on the edge reopened the panel 160 ms later, because Chromium synthesizes a `pointerenter` on the hot zone the aside slid away from. An explicit close now re-arms the zone only on the next real pointer move. Verified live over CDP on HTML5, Video.js, ArtPlayer and Embedded MPV frame-copy; the hover mechanism itself was sound on every engine. Co-Authored-By: Claude Fable 5.1 * fix(playback): open the panel only on a primary click begun on the edge Review follow-up. The hot zone opened on every pointerup, so a drag released over the edge, a right or middle click and a pen barrel button all opened the panel; now pointerdown records the primary pointer and pointerup must match it (a leave or cancel forgets the press). The synthetic pointerenter that follows an explicit close no longer arms the hint either: the edge stays clear until the next real pointer move, which arms it and starts the dwell. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- .../playback-fullscreen-panel-edge-hint.md | 6 + AGENTS.md | 14 +- CLAUDE.md | 2 +- docs/architecture/player-controls-contract.md | 50 ++++-- .../fullscreen-channel-panel-state.spec.ts | 111 ++++++++++++ .../fullscreen-channel-panel-state.ts | 114 ++++++++++-- .../fullscreen-channel-panel.component.html | 22 ++- .../fullscreen-channel-panel.component.scss | 61 ++++++- ...fullscreen-channel-panel.component.spec.ts | 167 +++++++++++++++++- .../fullscreen-channel-panel.component.ts | 85 +++++++-- 10 files changed, 574 insertions(+), 58 deletions(-) create mode 100644 .changes/playback-fullscreen-panel-edge-hint.md diff --git a/.changes/playback-fullscreen-panel-edge-hint.md b/.changes/playback-fullscreen-panel-edge-hint.md new file mode 100644 index 000000000..61f60c7bd --- /dev/null +++ b/.changes/playback-fullscreen-panel-edge-hint.md @@ -0,0 +1,6 @@ +--- +type: fix +area: playback +--- + +The fullscreen channel list is easier to find and calmer to use: moving the mouse shows a small tab on the left edge, a click on that edge opens the list at once, hover still opens it after a short rest, and the list now stays open for a full second after the mouse leaves. Opened with `C`, it no longer closes while the mouse only wanders over the video. diff --git a/AGENTS.md b/AGENTS.md index c8b5c0de4..b89dbd57c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -576,12 +576,16 @@ unchanged. Contract: `docs/architecture/m3u-playlist-module.md` 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 - dwell, a touch 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 + 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 - (no handle), the hot zone stops above the controls bar, and scrim/Escape/ - mouse-leave close it — while a CDK overlay opened from the list counts as - the panel, so hover keeps it open and Escape closes the overlay first. The + and the pointer rests — mouse movement over the stage reveals a slim edge + hint tab that fades after 2.5 s idle — the hot zone stops above the controls + bar, and scrim/Escape/mouse-leave close it, mouse-leave after 1 s and only + once the pointer has been inside the panel (a `C`-opened panel survives the + mouse roaming over the video) — while a CDK overlay opened from the list + counts as the panel, so hover keeps it open and Escape closes the overlay + first. The header is one row (search whose placeholder carries the host title, plus close) and the list stays mounted per fullscreen session. `Settings.fullscreenChannelPanel` (default on) gates it, offered only for the diff --git a/CLAUDE.md b/CLAUDE.md index 0168e6922..db568b50f 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(); + } } From 6f987d79d86a4e883004b74763ba1c2a003d83a8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:43:43 +0200 Subject: [PATCH 3/3] fix(shell): reload the packaged renderer back onto its in-app route (#1622) The packaged renderer is index.html over file:// with path routing, so after in-app navigation the document URL names a path with no file behind it. A main-process reload (macOS View > Reload, DevTools) failed with ERR_FILE_NOT_FOUND and stranded the window on Chromium's error page; a renderer-initiated reload (the settings unsaved-changes guard's confirmed location.reload()) was cancelled by the will-navigate trust check and silently did nothing. Both legs now re-load the packaged index with the route in a restoreRoute query parameter, which main.ts restores with history.replaceState before Angular bootstraps. The did-fail-load recovery is deferred to the error page's dom-ready: a load issued from inside the failure event yields a document that never receives animation frames and never paints. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> --- .changes/shell-reload-in-app-route.md | 9 + CLAUDE.md | 1 + .../src/renderer-reload.e2e.ts | 82 ++++ .../src/renderer-reload.support.ts | 69 ++++ .../src/window-zoom-level.e2e.ts | 21 +- apps/electron-backend/src/app/app.spec.ts | 104 +++++- apps/electron-backend/src/app/app.ts | 27 ++ .../services/renderer-reload-fallback.spec.ts | 351 ++++++++++++++++++ .../app/services/renderer-reload-fallback.ts | 216 +++++++++++ apps/web/src/main.ts | 13 + docs/architecture/workspace-shell.md | 74 ++++ libs/shared/interfaces/src/index.ts | 1 + .../lib/renderer-reload-route.util.spec.ts | 85 +++++ .../src/lib/renderer-reload-route.util.ts | 72 ++++ 14 files changed, 1113 insertions(+), 12 deletions(-) create mode 100644 .changes/shell-reload-in-app-route.md create mode 100644 apps/electron-backend-e2e/src/renderer-reload.e2e.ts create mode 100644 apps/electron-backend-e2e/src/renderer-reload.support.ts create mode 100644 apps/electron-backend/src/app/services/renderer-reload-fallback.spec.ts create mode 100644 apps/electron-backend/src/app/services/renderer-reload-fallback.ts create mode 100644 libs/shared/interfaces/src/lib/renderer-reload-route.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/renderer-reload-route.util.ts diff --git a/.changes/shell-reload-in-app-route.md b/.changes/shell-reload-in-app-route.md new file mode 100644 index 000000000..475da5b69 --- /dev/null +++ b/.changes/shell-reload-in-app-route.md @@ -0,0 +1,9 @@ +--- +type: fix +area: shell +--- + +Reloading the desktop app while on any page no longer breaks it: View › Reload +on macOS used to leave a blank, dead window until restart, and the reload +offered by the settings unsaved-changes dialog did nothing. Both now reload the +app back onto the page you were on. diff --git a/CLAUDE.md b/CLAUDE.md index 2f29857b9..dd9b76ee1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -770,6 +770,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Registers event handlers for IPC communication - Creates the main window per the startup window mode (`app/app.ts` `initMainWindow`, resolver in `app/services/startup-window-mode.ts`): the electron-conf `STARTUP_WINDOW_MODE` mirror or the one-shot `--fullscreen` switch (consumed by the first window, so a window the macOS Dock re-creates in the same process follows the stored setting); `fullscreen: true` is a constructor option that Windows/Linux honour before the first paint, while macOS ignores it on a hidden window, so `ready-to-show` repeats the request after `show()` only when `isFullScreen()` is still false, through the same tracker the F11 toggle uses (`app/services/native-fullscreen-transitions.ts`: per-window fullscreen state seeded once at creation via `trackNativeFullScreen` and fed only by the enter/leave events afterwards, plus a pending record holding the latest target, cleared when an event lands on it, kept when an event lands on the other state, and ignored after 2 s; the tracker only observes and never issues a request itself, since a "repeat on mismatch" cannot be told apart from reversing the user's own green-button action — a toggle is never decided against `isFullScreen()`, which is stale mid-transition and, on Windows, even during the event), so F11 during the startup animation exits instead of re-requesting; `maximize()` waits for `ready-to-show` too (it would show a hidden window early). `attachWindowStateEvents` tracks native and HTML-element fullscreen as two flags OR-ed into `WINDOW:STATE_CHANGED`, because Electron leaves only the HTML state when the window was already natively fullscreen - Persists the app zoom level (issue #1109): the preload restores it with `webFrame.setZoomLevel` (temporary, frame-bound zoom; level answered over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC, applied at `DOMContentLoaded` and acknowledged with `WINDOW:ZOOM_LEVEL_APPLIED` — any earlier `webFrame.setZoomLevel` leaves a hidden Linux/Windows window without `ready-to-show`), never `webContents.setZoomLevel` — under `file://` Chromium keys zoom by the full URL, so the app's path routing would reset it on the next resize after a section change, and dev mode (`http://localhost`) never shows that. `app/services/window-zoom-level.ts` writes the live level to electron-conf `ZOOM_LEVEL` on close, `before-quit` and before every cross-document navigation (a reload drops the temporary level). Contract: `docs/architecture/workspace-shell.md`, "Zoom level" +- Recovers a renderer reload on an in-app route: the packaged renderer is `index.html` over `file://` with path routing, so a reload of `file:///…/web/workspace/sources` asks for a path with no file behind it. A main-process reload (the macOS default menu's View › Reload, DevTools) failed with `ERR_FILE_NOT_FOUND` and stranded the window on Chromium's error page; a renderer-initiated one (the settings unsaved-changes guard's confirmed `location.reload()`) was cancelled by the `will-navigate` trust check and silently did nothing. `app/services/renderer-reload-fallback.ts` handles both — `attachRendererReloadFallback` answers the main-frame `did-fail-load` (deferred to the error page's `dom-ready`: a load issued from inside the failure event yields a document that never paints), and `handleRendererNavigation` recognizes a routed renderer URL (`resolveRoutedRendererUrl`) — by re-loading the packaged index with the route in the `restoreRoute` query parameter (`restoreRendererRoute`); `apps/web/src/main.ts` consumes it before Angular bootstraps (`resolveRestoredRendererRoute` in `libs/shared/interfaces`, resolved against `document.baseURI` and confined to the renderer directory). A failed `index.html` itself is never re-requested. Contract: `docs/architecture/workspace-shell.md`, "Reloading the renderer on an in-app route" - Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded. **Database**: diff --git a/apps/electron-backend-e2e/src/renderer-reload.e2e.ts b/apps/electron-backend-e2e/src/renderer-reload.e2e.ts new file mode 100644 index 000000000..8184bb2ef --- /dev/null +++ b/apps/electron-backend-e2e/src/renderer-reload.e2e.ts @@ -0,0 +1,82 @@ +import { + closeElectronApp, + expect, + expectPathname, + launchElectronApp, + openSettings, + openSettingsSection, + openSources, + test, +} from './electron-test-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromMainProcess, + reloadFromRenderer, +} from './renderer-reload.support'; + +/** + * The packaged renderer is index.html over file:// with path routing, so + * once the user is on a section the document URL names a path with no file + * behind it. A main-process reload of that URL used to fail with + * ERR_FILE_NOT_FOUND and leave the window on Chromium's error page until the + * app restarted, and a renderer-initiated reload was cancelled by the + * navigation guard and silently did nothing. Both now boot the app straight + * back into the route it was on. + */ +test.describe('Renderer reload on an in-app route', () => { + test('@electron @window a main-process reload keeps the app on the Sources page', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + await openSources(app.mainWindow); + + await reloadFromMainProcess(app); + + await expectRendererReloadedOnRoute( + app.mainWindow, + /\/workspace\/sources$/ + ); + // A fresh data dir has no sources: the page shows its empty state. + await expect( + app.mainWindow.getByRole('heading', { + name: 'Add your first playlist', + }) + ).toBeVisible(); + // The page is alive, not a leftover paint: navigation still works. + await app.mainWindow + .getByRole('link', { name: 'Dashboard', exact: true }) + .click(); + await expectPathname(app.mainWindow, /\/workspace\/dashboard$/); + } finally { + await closeElectronApp(app); + } + }); + + test('@electron @window a renderer-initiated reload keeps the app on its settings section', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'playback'); + + await reloadFromRenderer(app); + + await expectRendererReloadedOnRoute( + app.mainWindow, + /\/workspace\/settings\/playback$/ + ); + await expect( + app.mainWindow.getByTestId('settings-container') + ).toBeVisible(); + await expect( + app.mainWindow.getByTestId('settings-section-playback') + ).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/renderer-reload.support.ts b/apps/electron-backend-e2e/src/renderer-reload.support.ts new file mode 100644 index 000000000..569adf408 --- /dev/null +++ b/apps/electron-backend-e2e/src/renderer-reload.support.ts @@ -0,0 +1,69 @@ +import { expect, Page } from '@playwright/test'; +import { LaunchedElectronApp } from './electron-test-fixtures'; + +/** + * Helpers for E2E that reload the packaged renderer on an in-app route. + * + * The URL before and after a recovered reload is the same routed `file://` + * URL, so waiting on the URL alone passes before anything happened. The + * current document is marked instead, and the wait is for a document + * WITHOUT the mark that has reached the route. + */ + +const DOCUMENT_MARK = 'data-e2e-pre-reload'; + +async function markCurrentDocument(page: Page): Promise<void> { + await page.evaluate((attribute) => { + document.documentElement.setAttribute(attribute, ''); + }, DOCUMENT_MARK); +} + +/** What the macOS View › Reload menu role does: `webContents.reload()`. */ +export async function reloadFromMainProcess( + app: LaunchedElectronApp +): Promise<void> { + await markCurrentDocument(app.mainWindow); + await app.electronApp.evaluate(({ BrowserWindow }) => { + const [win] = BrowserWindow.getAllWindows(); + win.webContents.reload(); + }); +} + +/** + * What the settings unsaved-changes guard does after a confirmed reload: + * `window.location.reload()`. The evaluate may lose its execution context + * to the navigation it starts; that is not a failure. + */ +export async function reloadFromRenderer( + app: LaunchedElectronApp +): Promise<void> { + await markCurrentDocument(app.mainWindow); + await app.mainWindow + .evaluate(() => { + window.location.reload(); + }) + .catch(() => undefined); +} + +/** + * Waits until a NEW document is rendered on `pathname` — the app re-booted + * on the route rather than the old document still being on screen — and + * checks that the restore parameter was consumed on the way. + */ +export async function expectRendererReloadedOnRoute( + page: Page, + pathname: RegExp +): Promise<void> { + await expect(page.locator(`html[${DOCUMENT_MARK}]`)).toHaveCount(0); + await expect(page).toHaveURL(pathname); + await expect + .poll(() => + page.evaluate( + () => + document.querySelector('app-root')?.innerHTML.trim() + .length ?? 0 + ) + ) + .toBeGreaterThan(0); + expect(new URL(page.url()).searchParams.has('restoreRoute')).toBe(false); +} diff --git a/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts b/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts index e8a27c64e..0fe5714ea 100644 --- a/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts +++ b/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts @@ -1,4 +1,3 @@ -import { join } from 'path'; import { closeElectronApp, expect, @@ -7,8 +6,11 @@ import { openSources, restartElectronApp, test, - workspaceRoot, } from './electron-test-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromMainProcess, +} from './renderer-reload.support'; /** * Chromium's zoom factor as the renderer actually renders it: the window's @@ -36,15 +38,16 @@ async function zoomInFromMenu(app: LaunchedElectronApp): Promise<void> { /** * A cross-document navigation of the renderer (what a reload is for zoom: * Chromium drops the temporary level and the new document's preload must - * restore it). Loads the packaged index the way startup does — a plain - * `page.reload()` on a routed `file://` URL has no file behind it. + * restore it). A real reload of the routed `file://` URL: the main process + * recovers the missing file by re-loading the index on the same route + * (`renderer-reload.e2e.ts`). */ async function reloadRenderer(app: LaunchedElectronApp): Promise<void> { - await app.electronApp.evaluate(({ BrowserWindow }, indexPath) => { - const [win] = BrowserWindow.getAllWindows(); - return win.loadFile(indexPath); - }, join(workspaceRoot, 'dist/apps/web/index.html')); - await app.mainWindow.waitForSelector('app-root'); + await reloadFromMainProcess(app); + await expectRendererReloadedOnRoute( + app.mainWindow, + /\/workspace\/sources$/ + ); } async function resizeWindowBy( diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 37cb9033e..029227e2e 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -55,7 +55,7 @@ import { isTrustedRendererNavigationUrl, } from './app'; import App from './app'; -import { app as electronApp, BrowserWindow, screen } from 'electron'; +import { app as electronApp, BrowserWindow, screen, shell } from 'electron'; import * as path from 'path'; import { pathToFileURL } from 'url'; import { store } from './services/store.service'; @@ -504,6 +504,9 @@ describe('Electron app security helpers', () => { return mainWindow; } + // Every listener of the event fires, since the window registers + // more than one `did-start-navigation` listener (zoom persistence + // and the renderer reload recovery). function fireHandlers( calls: Array<[string, (...args: unknown[]) => void]>, eventName: string, @@ -513,8 +516,10 @@ describe('Electron app security helpers', () => { .filter(([name]) => name === eventName) .map(([, handler]) => handler); - expect(handlers).toHaveLength(1); - handlers[0](...args); + expect(handlers.length).toBeGreaterThanOrEqual(1); + for (const handler of handlers) { + handler(...args); + } } function fireWindowEvent(win: MockMainWindow, eventName: string): void { @@ -613,6 +618,99 @@ describe('Electron app security helpers', () => { }); }); + describe('renderer reload recovery', () => { + const rendererRoot = path.dirname( + path.resolve(__dirname, '..', 'web', 'index.html') + ); + const routedUrl = pathToFileURL( + path.join(rendererRoot, 'workspace', 'sources') + ).href; + + function fireWebContentsEvent( + win: MockMainWindow, + eventName: string, + ...args: unknown[] + ): void { + const handlers = win.webContents.on.mock.calls + .filter(([name]) => name === eventName) + .map(([, handler]) => handler); + + expect(handlers).toHaveLength(1); + handlers[0](...args); + } + + function createPackagedWindow(): MockMainWindow { + process.env.ELECTRON_IS_DEV = '0'; + const mainWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow); + getAppInternals().onReady(); + return mainWindow; + } + + it('re-loads the packaged index with the route a failed file:// reload named', () => { + const mainWindow = createPackagedWindow(); + + fireWebContentsEvent( + mainWindow, + 'did-fail-load', + {}, + -6, + 'ERR_FILE_NOT_FOUND', + routedUrl, + true + ); + // Deferred to the error page's dom-ready, or the new document + // never paints. + expect(mainWindow.loadFile).not.toHaveBeenCalled(); + fireWebContentsEvent(mainWindow, 'dom-ready'); + + expect(mainWindow.loadFile).toHaveBeenCalledWith( + path.join(rendererRoot, 'index.html'), + { query: { restoreRoute: 'workspace/sources' } } + ); + }); + + it('sends a renderer-initiated reload of a routed URL to the index instead of cancelling it', () => { + const mainWindow = createPackagedWindow(); + const event = { preventDefault: jest.fn() }; + + fireWebContentsEvent(mainWindow, 'will-navigate', event, routedUrl); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mainWindow.loadFile).toHaveBeenCalledWith( + path.join(rendererRoot, 'index.html'), + { query: { restoreRoute: 'workspace/sources' } } + ); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('still opens external URLs in the browser and blocks other navigations', () => { + const mainWindow = createPackagedWindow(); + const external = { preventDefault: jest.fn() }; + const foreignFile = { preventDefault: jest.fn() }; + + fireWebContentsEvent( + mainWindow, + 'will-navigate', + external, + 'https://example.com/' + ); + fireWebContentsEvent( + mainWindow, + 'will-navigate', + foreignFile, + pathToFileURL(path.join(rendererRoot, '..', 'other')).href + ); + + expect(external.preventDefault).toHaveBeenCalled(); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://example.com/' + ); + expect(foreignFile.preventDefault).toHaveBeenCalled(); + expect(mainWindow.loadFile).not.toHaveBeenCalled(); + }); + }); + it('creates the main window immediately when Electron is already ready', () => { const mainWindow = createMockMainWindow(); (BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 7d1ef0c1f..ee9eae415 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -22,6 +22,11 @@ import { attachZoomLevelPersistence, persistZoomLevel, } from './services/window-zoom-level'; +import { + attachRendererReloadFallback, + resolveRoutedRendererUrl, + restoreRendererRoute, +} from './services/renderer-reload-fallback'; import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util'; import { FULLSCREEN_LAUNCH_SWITCH, @@ -358,6 +363,20 @@ export default class App { event.preventDefault(); + // A renderer-initiated reload (`location.reload()`, e.g. the + // settings unsaved-changes guard) on an in-app route arrives here as + // a routed file:// URL with no file behind it. Send it straight to + // the packaged index with that route instead of cancelling it. + if (!App.isDevelopmentMode() && App.mainWindow) { + const rendererIndexPath = getPackagedRendererIndexPath(); + const route = resolveRoutedRendererUrl(url, rendererIndexPath); + + if (route !== null) { + restoreRendererRoute(App.mainWindow, rendererIndexPath, route); + return; + } + } + if (isExternalBrowserUrl(url)) { shell.openExternal(url); } @@ -559,6 +578,14 @@ export default class App { // main process only saves it back before a reload drops it. attachZoomLevelPersistence(App.mainWindow); + // A reload on an in-app route asks file:// for a path that does not + // exist; re-load the packaged index with that route instead of + // leaving Chromium's error page (see renderer-reload-fallback.ts). + attachRendererReloadFallback( + App.mainWindow, + getPackagedRendererIndexPath() + ); + // Emitted when the window is closed. App.mainWindow.on('closed', () => { // Dereference the window object, usually you would store windows diff --git a/apps/electron-backend/src/app/services/renderer-reload-fallback.spec.ts b/apps/electron-backend/src/app/services/renderer-reload-fallback.spec.ts new file mode 100644 index 000000000..877b54abd --- /dev/null +++ b/apps/electron-backend/src/app/services/renderer-reload-fallback.spec.ts @@ -0,0 +1,351 @@ +import { join, resolve } from 'path'; +import { pathToFileURL } from 'url'; +import { + attachRendererReloadFallback, + ERR_FILE_NOT_FOUND, + RendererReloadFallbackWindow, + resolveReloadedRendererRoute, + resolveRoutedRendererUrl, + restoreRendererRoute, +} from './renderer-reload-fallback'; + +const rendererRoot = resolve('/opt/iptvnator/resources/app/web'); +const rendererIndexPath = join(rendererRoot, 'index.html'); + +function routedUrl(route: string): string { + return pathToFileURL(join(rendererRoot, route)).href; +} + +function failure( + validatedUrl: string, + overrides: { errorCode?: number; isMainFrame?: boolean } = {} +) { + return { + errorCode: ERR_FILE_NOT_FOUND, + isMainFrame: true, + validatedUrl, + ...overrides, + }; +} + +describe('resolveRoutedRendererUrl', () => { + it('maps a routed file URL under the renderer root to its route', () => { + expect( + resolveRoutedRendererUrl( + routedUrl('workspace/sources'), + rendererIndexPath + ) + ).toBe('workspace/sources'); + expect( + resolveRoutedRendererUrl( + routedUrl('workspace/settings/playback'), + rendererIndexPath + ) + ).toBe('workspace/settings/playback'); + }); + + it('rejects the index itself, http URLs and paths outside the root', () => { + expect( + resolveRoutedRendererUrl( + pathToFileURL(rendererIndexPath).href, + rendererIndexPath + ) + ).toBe(null); + expect( + resolveRoutedRendererUrl( + 'http://localhost:4200/workspace/sources', + rendererIndexPath + ) + ).toBe(null); + expect( + resolveRoutedRendererUrl( + pathToFileURL(resolve('/opt/iptvnator/other')).href, + rendererIndexPath + ) + ).toBe(null); + }); +}); + +describe('resolveReloadedRendererRoute', () => { + it('maps a routed file URL under the renderer root to its route', () => { + expect( + resolveReloadedRendererRoute( + failure(routedUrl('workspace/sources')), + rendererIndexPath + ) + ).toBe('workspace/sources'); + }); + + it('keeps the query and fragment of the failed URL', () => { + expect( + resolveReloadedRendererRoute( + failure( + `${routedUrl('workspace/xtreams/3/search')}?q=dune#top` + ), + rendererIndexPath + ) + ).toBe('workspace/xtreams/3/search?q=dune#top'); + }); + + it('decodes percent-encoded path segments', () => { + expect( + resolveReloadedRendererRoute( + failure(routedUrl('workspace/playlists/a b')), + rendererIndexPath + ) + ).toBe('workspace/playlists/a b'); + }); + + it('never re-requests the index itself, which would loop', () => { + expect( + resolveReloadedRendererRoute( + failure(pathToFileURL(rendererIndexPath).href), + rendererIndexPath + ) + ).toBe(null); + expect( + resolveReloadedRendererRoute( + failure( + `${pathToFileURL(rendererIndexPath).href}?restoreRoute=x` + ), + rendererIndexPath + ) + ).toBe(null); + }); + + it('ignores paths outside the renderer root', () => { + expect( + resolveReloadedRendererRoute( + failure(pathToFileURL(resolve('/opt/iptvnator/other')).href), + rendererIndexPath + ) + ).toBe(null); + expect( + resolveReloadedRendererRoute( + failure(pathToFileURL(rendererRoot).href), + rendererIndexPath + ) + ).toBe(null); + expect( + resolveReloadedRendererRoute( + failure( + pathToFileURL(join(rendererRoot, '..', 'sibling')).href + ), + rendererIndexPath + ) + ).toBe(null); + }); + + it('ignores other error codes, subframes and non-file URLs', () => { + expect( + resolveReloadedRendererRoute( + failure(routedUrl('workspace/sources'), { errorCode: -3 }), + rendererIndexPath + ) + ).toBe(null); + expect( + resolveReloadedRendererRoute( + failure(routedUrl('workspace/sources'), { isMainFrame: false }), + rendererIndexPath + ) + ).toBe(null); + expect( + resolveReloadedRendererRoute( + failure('http://localhost:4200/workspace/sources'), + rendererIndexPath + ) + ).toBe(null); + expect( + resolveReloadedRendererRoute( + failure('chrome-error://chromewebdata/'), + rendererIndexPath + ) + ).toBe(null); + expect( + resolveReloadedRendererRoute( + failure('not a url'), + rendererIndexPath + ) + ).toBe(null); + }); +}); + +describe('restoreRendererRoute', () => { + it('loads the index with the route in the query string', () => { + const win = { + isDestroyed: jest.fn(() => false), + loadFile: jest.fn(() => Promise.resolve()), + }; + + restoreRendererRoute(win, rendererIndexPath, 'workspace/sources?q=1'); + + expect(win.loadFile).toHaveBeenCalledWith(rendererIndexPath, { + query: { restoreRoute: 'workspace/sources?q=1' }, + }); + }); +}); + +describe('attachRendererReloadFallback', () => { + type Listener = (...args: unknown[]) => void; + + function createWindow() { + const listeners = new Map<string, Listener[]>(); + const win = { + isDestroyed: jest.fn(() => false), + loadFile: jest.fn(() => Promise.resolve()), + webContents: { + on: jest.fn((event: string, listener: Listener) => { + listeners.set(event, [ + ...(listeners.get(event) ?? []), + listener, + ]); + }), + }, + }; + attachRendererReloadFallback( + win as unknown as RendererReloadFallbackWindow, + rendererIndexPath + ); + const emit = (event: string, ...args: unknown[]) => { + const handlers = listeners.get(event) ?? []; + expect(handlers).toHaveLength(1); + handlers[0](...args); + }; + const failRoutedLoad = (url = routedUrl('workspace/sources')) => { + emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + }); + emit( + 'did-fail-load', + {}, + ERR_FILE_NOT_FOUND, + 'ERR_FILE_NOT_FOUND', + url, + true + ); + }; + return { win, emit, failRoutedLoad }; + } + + it('re-loads the index with the failed route once the error page is ready', () => { + const { win, emit, failRoutedLoad } = createWindow(); + + failRoutedLoad(); + // Never from inside did-fail-load: that document would never paint. + expect(win.loadFile).not.toHaveBeenCalled(); + + emit('dom-ready'); + + expect(win.loadFile).toHaveBeenCalledTimes(1); + expect(win.loadFile).toHaveBeenCalledWith(rendererIndexPath, { + query: { restoreRoute: 'workspace/sources' }, + }); + }); + + it('recovers only once per failure', () => { + const { win, emit, failRoutedLoad } = createWindow(); + + failRoutedLoad(); + emit('dom-ready'); + // The recovery load's own document. + emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + }); + emit('dom-ready'); + + expect(win.loadFile).toHaveBeenCalledTimes(1); + }); + + it('withdraws the recovery when another navigation starts first', () => { + const { win, emit, failRoutedLoad } = createWindow(); + + failRoutedLoad(); + emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + }); + emit('dom-ready'); + + expect(win.loadFile).not.toHaveBeenCalled(); + }); + + it('keeps the recovery across in-page and subframe navigations', () => { + const { win, emit, failRoutedLoad } = createWindow(); + + failRoutedLoad(); + emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: true, + }); + emit('did-start-navigation', { + isMainFrame: false, + isSameDocument: false, + }); + emit('dom-ready'); + + expect(win.loadFile).toHaveBeenCalledTimes(1); + }); + + it('leaves failures it cannot recover alone', () => { + const { win, emit } = createWindow(); + + emit( + 'did-fail-load', + {}, + -3, + 'ERR_ABORTED', + routedUrl('workspace/sources'), + true + ); + emit( + 'did-fail-load', + {}, + ERR_FILE_NOT_FOUND, + 'ERR_FILE_NOT_FOUND', + routedUrl('workspace/sources'), + false + ); + emit( + 'did-fail-load', + {}, + ERR_FILE_NOT_FOUND, + 'ERR_FILE_NOT_FOUND', + pathToFileURL(rendererIndexPath).href, + true + ); + emit('dom-ready'); + + expect(win.loadFile).not.toHaveBeenCalled(); + }); + + it('does not touch a destroyed window', () => { + const { win, emit, failRoutedLoad } = createWindow(); + win.isDestroyed.mockReturnValue(true); + + failRoutedLoad(); + emit('dom-ready'); + + expect(win.loadFile).not.toHaveBeenCalled(); + }); + + it('reports a failed recovery load instead of rejecting unhandled', async () => { + const { win, emit, failRoutedLoad } = createWindow(); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + win.loadFile.mockReturnValue(Promise.reject(new Error('gone'))); + + failRoutedLoad(); + emit('dom-ready'); + await Promise.resolve(); + await Promise.resolve(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to restore the renderer after a reload:', + expect.any(Error) + ); + errorSpy.mockRestore(); + }); +}); diff --git a/apps/electron-backend/src/app/services/renderer-reload-fallback.ts b/apps/electron-backend/src/app/services/renderer-reload-fallback.ts new file mode 100644 index 000000000..fb9bbcf6e --- /dev/null +++ b/apps/electron-backend/src/app/services/renderer-reload-fallback.ts @@ -0,0 +1,216 @@ +/** + * Recovery for a reload of the packaged renderer on an in-app route. + * + * The packaged renderer is `dist/apps/web/index.html` over `file://` and + * Angular routes by PATH, so after the user opens a section the document URL + * is `file:///…/web/workspace/sources`. Nothing exists at that path, and a + * reload of it goes wrong in one of two ways depending on who starts it: + * + * - A main-process reload (`webContents.reload()`: the macOS View › Reload + * menu role, DevTools) fires no `will-navigate`. It fails with + * `ERR_FILE_NOT_FOUND`, Chromium commits `chrome-error://chromewebdata/` + * and the window stays dead until the app restarts. + * `attachRendererReloadFallback` answers that exact main-frame failure by + * loading `index.html` again with the failed URL's route in the query. + * - A renderer-initiated reload (`window.location.reload()`: the settings + * unsaved-changes guard after the user confirmed a reload intent) does + * fire `will-navigate`, where the routed URL is not the trusted index and + * the navigation guard cancels it — silently, so the confirmed reload + * never happens. `resolveRoutedRendererUrl` lets that guard recognize the + * URL and `restoreRendererRoute` sends it straight to the index with the + * route, without a failed load in between. + * + * The renderer's `main.ts` restores the route before Angular bootstraps + * (`resolveRestoredRendererRoute`). A failed `index.html` itself is never + * re-requested (it would loop), and dev mode serves `http://localhost`, + * whose dev server already falls back to the index. + */ + +import { RENDERER_RESTORE_ROUTE_QUERY_PARAM } from '@iptvnator/shared/interfaces'; +import { dirname, isAbsolute, relative, resolve, sep } from 'path'; +import { fileURLToPath } from 'url'; +import { isWindowTraceEnabled, trace } from './debug-trace'; + +/** Chromium net error for a `file://` URL with no file behind it. */ +export const ERR_FILE_NOT_FOUND = -6; + +export interface RendererLoadFailure { + errorCode: number; + isMainFrame: boolean; + validatedUrl: string; +} + +/** The slice of `Electron.WebContents` the fallback listens on. */ +export interface RendererReloadFallbackWebContents { + on( + event: 'did-fail-load', + listener: ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void + ): unknown; + on( + event: 'did-start-navigation', + listener: (details: { + isMainFrame: boolean; + isSameDocument: boolean; + }) => void + ): unknown; + on(event: 'dom-ready', listener: () => void): unknown; +} + +/** The slice of `Electron.BrowserWindow` the recovery needs. */ +export interface RendererReloadFallbackWindow { + isDestroyed(): boolean; + loadFile( + filePath: string, + options: { query: Record<string, string> } + ): Promise<void>; + webContents: RendererReloadFallbackWebContents; +} + +/** + * The in-app route a routed renderer URL stands for — a `file://` path + * under the renderer root, relative to it, plus its query and fragment — + * or `null` for anything else, including the index itself (which exists + * and must never be rewritten, or the recovery would loop). + */ +export function resolveRoutedRendererUrl( + url: string, + rendererIndexPath: string +): string | null { + let parsedUrl: URL; + + try { + parsedUrl = new URL(url); + } catch { + return null; + } + + if (parsedUrl.protocol !== 'file:') { + return null; + } + + let filePath: string; + + try { + filePath = resolve(fileURLToPath(parsedUrl)); + } catch { + return null; + } + + const indexPath = resolve(rendererIndexPath); + const rendererRoot = dirname(indexPath); + const relativePath = relative(rendererRoot, filePath); + + if ( + relativePath === '' || + isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + filePath === indexPath + ) { + return null; + } + + return `${relativePath.split(sep).join('/')}${parsedUrl.search}${parsedUrl.hash}`; +} + +/** + * The route a failed load stood for, or `null` when the failure is not a + * main-frame `ERR_FILE_NOT_FOUND` for a routed renderer URL. + */ +export function resolveReloadedRendererRoute( + failure: RendererLoadFailure, + rendererIndexPath: string +): string | null { + if (failure.errorCode !== ERR_FILE_NOT_FOUND || !failure.isMainFrame) { + return null; + } + + return resolveRoutedRendererUrl(failure.validatedUrl, rendererIndexPath); +} + +/** + * Loads the packaged index with `route` in the query string, for the + * renderer to restore before Angular bootstraps. A no-op on a destroyed + * window; a failed load is reported, never left as an unhandled rejection. + */ +export function restoreRendererRoute( + win: Pick<RendererReloadFallbackWindow, 'isDestroyed' | 'loadFile'>, + rendererIndexPath: string, + route: string +): void { + if (win.isDestroyed()) { + return; + } + + if (isWindowTraceEnabled()) { + trace('window', 'restore-route', { route }); + } + + void win + .loadFile(rendererIndexPath, { + query: { [RENDERER_RESTORE_ROUTE_QUERY_PARAM]: route }, + }) + .catch((error) => { + console.error( + 'Failed to restore the renderer after a reload:', + error + ); + }); +} + +/** + * Re-loads the packaged index with the failed route whenever a main-frame + * load of a routed `file://` URL under the renderer root fails with + * `ERR_FILE_NOT_FOUND`. + * + * The recovery load is deferred to the error page's `dom-ready`, never + * issued from inside `did-fail-load`: a `loadFile` started while Chromium + * is still committing the error page produces a document that never + * receives animation frames — the splash stays on screen and the window + * never paints, with `document.visibilityState` still `visible` — whereas + * the same load after `dom-ready` paints normally (verified on the packaged + * build; `did-fail-load` → `dom-ready` is the order Electron emits them). + * A cross-document navigation that starts in between, anything other than + * the failed load itself, withdraws the pending recovery, so the error + * page's `dom-ready` can never re-load the index over a newer navigation. + */ +export function attachRendererReloadFallback( + win: RendererReloadFallbackWindow, + rendererIndexPath: string +): void { + let pendingRoute: string | null = null; + + win.webContents.on('did-start-navigation', (details) => { + if (details.isMainFrame && !details.isSameDocument) { + pendingRoute = null; + } + }); + win.webContents.on( + 'did-fail-load', + (_event, errorCode, _errorDescription, validatedURL, isMainFrame) => { + const route = resolveReloadedRendererRoute( + { errorCode, isMainFrame, validatedUrl: validatedURL }, + rendererIndexPath + ); + + if (route !== null) { + pendingRoute = route; + } + } + ); + win.webContents.on('dom-ready', () => { + if (pendingRoute === null) { + return; + } + + const route = pendingRoute; + pendingRoute = null; + restoreRendererRoute(win, rendererIndexPath, route); + }); +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index cf089c664..b19bf15d1 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,10 +1,23 @@ import { bootstrapApplication } from '@angular/platform-browser'; +import { resolveRestoredRendererRoute } from '@iptvnator/shared/interfaces'; import { registerAppDateLocales } from './app/app-date-locales'; import { AppComponent } from './app/app.component'; import { appConfig } from './app/app.config'; registerAppDateLocales(); +// A reloaded packaged renderer arrives on index.html with the route it was +// on carried in the query string (the Electron main process recovers the +// file:// reload that way). Put that route back before the router reads the +// URL for its initial navigation. +const restoredHref = resolveRestoredRendererRoute( + window.location.href, + document.baseURI +); +if (restoredHref !== null) { + window.history.replaceState(window.history.state, '', restoredHref); +} + bootstrapApplication(AppComponent, appConfig) .then(() => { // Splash is rendered eagerly by index.html so the user sees something diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 731e5cb6f..617e74136 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -461,6 +461,80 @@ Zoom level (Cmd/Ctrl and +/−, issue #1109): the rendered factor (content width ÷ `window.innerWidth`) across a section change, a resize, a reload and a restart. +Reloading the renderer on an in-app route: + +1. The packaged renderer is `dist/apps/web/index.html` over `file://` and + Angular routes by path (no hash strategy), so once the user is on a + section the document URL is `file:///…/web/workspace/sources` — a path + with no file behind it. A reload of that URL fails with + `ERR_FILE_NOT_FOUND` (-6) or is cancelled outright, depending on who + starts it. Two user-reachable triggers: the macOS default application + menu (nothing calls `Menu.setApplicationMenu`, so View › Reload / Force + Reload are live; Windows/Linux drop the menu bar via `setMenu(null)`), + and the settings unsaved-changes guard, which calls + `window.location.reload()` after the user confirms a reload intent on + `/workspace/settings/<section>`. Dev mode (`http://localhost:4200`) never + shows either — the dev server serves the index for every path. +2. Both legs live in `services/renderer-reload-fallback.ts` and end in the + same `restoreRendererRoute`: load the packaged index with the routed + URL's route — its path relative to the renderer root plus query and + fragment (`resolveRoutedRendererUrl`) — in the `restoreRoute` query + parameter. + - A main-process reload (`webContents.reload()`, the menu role, + DevTools) fires no `will-navigate`, so it cannot be redirected up + front: it fails, Chromium commits `chrome-error://chromewebdata/` + and `app-root` stays empty until the app restarts. + `attachRendererReloadFallback` recovers it after the fact from the + main-frame `did-fail-load` with `ERR_FILE_NOT_FOUND` + (`resolveReloadedRendererRoute`); other error codes, subframes and + non-`file:` URLs are left alone. The recovery load is deferred to + the error page's `dom-ready` and never issued from inside + `did-fail-load`: a `loadFile` started while Chromium is still + committing the error page yields a document that never receives + animation frames — the splash stays, nothing paints, while + `document.visibilityState` still says `visible` — and the same load + after `dom-ready` paints normally (Electron emits `did-fail-load` + before that `dom-ready`). A cross-document navigation starting in + between withdraws the pending recovery, so a stale `dom-ready` can + never re-load the index over a newer navigation. + - A renderer-initiated reload (`location.reload()`, the settings + guard) does fire `will-navigate`, where the routed URL is not the + trusted index and `handleRendererNavigation` would cancel it — + silently, so the confirmed reload simply never happened. The handler + now recognizes a routed renderer URL and sends it straight to the + index with its route, with no failed load in between; every other + untrusted navigation is still blocked (external URLs still open in + the browser). + A failed `index.html` itself is never re-requested (it would loop): + `resolveRoutedRendererUrl` rejects the index, and the recovery load + carries `index.html` as its path, so a second failure cannot recurse. +3. The renderer consumes the parameter before Angular bootstraps: + `apps/web/src/main.ts` calls `resolveRestoredRendererRoute` + (`libs/shared/interfaces/src/lib/renderer-reload-route.util.ts`, which + also owns the parameter name) and installs the result with + `history.replaceState`, so the router's initial navigation lands on the + route the user was on. The route is resolved against `document.baseURI` + (the packaged `<base href="./">`, i.e. the renderer directory — the same + prefix Angular strips from `location.pathname`), and anything that would + leave that directory (an absolute URL, another scheme, a `..` escape) + is dropped with only the parameter removed, so the app boots at its + default route instead of following an arbitrary target. +4. Zoom persistence is unaffected: the failed reload's + `did-start-navigation` already saved the level and released ownership, + the recovery load's `did-start-navigation` is then a no-op, and the new + document's preload restores the level as after any other reload. The + main-process close guard also treats the recovery like any full + navigation (`did-navigate` disarms it). +5. Regression coverage: `renderer-reload.e2e.ts` reloads from the main + process (`webContents.reload()`, the menu role) on Sources and from the + renderer (`window.location.reload()`, the settings guard) on a settings + section and asserts a NEW document is rendered on the same route with + the parameter gone (`renderer-reload.support.ts` marks the old document, + since the URL alone is identical before and after); + `window-zoom-level.e2e.ts` reloads the same way. Unit coverage: + `renderer-reload-fallback.spec.ts`, `renderer-reload-route.util.spec.ts`, + `app.spec.ts` ("renderer reload recovery"). + Layout integration: 1. `document.body` gets a `frameless-platform` class (set in diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 470e6ed23..9c76d4de0 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -47,6 +47,7 @@ export * from './lib/provider-overview.util'; export * from './lib/random-id.util'; export * from './lib/recording-metadata.interface'; export * from './lib/recording-program-overlap.util'; +export * from './lib/renderer-reload-route.util'; export * from './lib/security-policy-error.utils'; export * from './lib/settings.interface'; export * from './lib/stalker-auth-failure.util'; diff --git a/libs/shared/interfaces/src/lib/renderer-reload-route.util.spec.ts b/libs/shared/interfaces/src/lib/renderer-reload-route.util.spec.ts new file mode 100644 index 000000000..84bc3e9b7 --- /dev/null +++ b/libs/shared/interfaces/src/lib/renderer-reload-route.util.spec.ts @@ -0,0 +1,85 @@ +import { + RENDERER_RESTORE_ROUTE_QUERY_PARAM, + resolveRestoredRendererRoute, +} from './renderer-reload-route.util'; + +const rendererRoot = 'file:///Applications/IPTVnator.app/Contents/web/'; +const packagedIndex = `${rendererRoot}index.html`; + +function reloadedIndex(route: string): string { + const url = new URL(packagedIndex); + url.searchParams.set(RENDERER_RESTORE_ROUTE_QUERY_PARAM, route); + return url.href; +} + +describe('resolveRestoredRendererRoute', () => { + it('leaves a document without a restore request alone', () => { + expect(resolveRestoredRendererRoute(packagedIndex, packagedIndex)).toBe( + null + ); + expect( + resolveRestoredRendererRoute( + `${packagedIndex}?other=1`, + packagedIndex + ) + ).toBe(null); + }); + + it('restores a route relative to the renderer directory', () => { + expect( + resolveRestoredRendererRoute( + reloadedIndex('workspace/sources'), + packagedIndex + ) + ).toBe(`${rendererRoot}workspace/sources`); + }); + + it('keeps the restored route query and fragment', () => { + expect( + resolveRestoredRendererRoute( + reloadedIndex('workspace/xtreams/3/search?q=dune#top'), + packagedIndex + ) + ).toBe(`${rendererRoot}workspace/xtreams/3/search?q=dune#top`); + }); + + it('resolves against the base URI, not the document URL', () => { + // The packaged <base href="./"> resolves to the renderer directory + // even when the document itself sits deeper. + expect( + resolveRestoredRendererRoute( + `${rendererRoot}workspace/sources?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fdashboard`, + rendererRoot + ) + ).toBe(`${rendererRoot}workspace/dashboard`); + }); + + it('works for an http origin with a root base href', () => { + expect( + resolveRestoredRendererRoute( + `http://localhost:4200/?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fsettings%2Fplayback`, + 'http://localhost:4200/' + ) + ).toBe('http://localhost:4200/workspace/settings/playback'); + }); + + it.each([ + ['an absolute URL', 'https://example.com/phish'], + ['another scheme', 'javascript:alert(1)'], + ['a directory escape', '../../etc/passwd'], + ['a root-absolute path outside the renderer', '/etc/passwd'], + ['a scheme-relative URL', '//example.com/'], + ['the renderer directory itself', './'], + ['an empty route', ''], + ])('drops %s and only removes the parameter', (_label, route) => { + expect( + resolveRestoredRendererRoute(reloadedIndex(route), packagedIndex) + ).toBe(packagedIndex); + }); + + it('returns null for an unparsable document URL', () => { + expect(resolveRestoredRendererRoute('not a url', packagedIndex)).toBe( + null + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/renderer-reload-route.util.ts b/libs/shared/interfaces/src/lib/renderer-reload-route.util.ts new file mode 100644 index 000000000..4190060b7 --- /dev/null +++ b/libs/shared/interfaces/src/lib/renderer-reload-route.util.ts @@ -0,0 +1,72 @@ +/** + * Route hand-off for a reloaded packaged renderer. + * + * The packaged renderer is loaded from `dist/apps/web/index.html` over + * `file://` and Angular uses PATH routing, so after in-app navigation the + * document URL is `file:///…/web/workspace/sources` — a path with no file + * behind it. A reload (the macOS View › Reload menu, DevTools, the settings + * unsaved-changes guard's confirmed reload) therefore fails with + * `ERR_FILE_NOT_FOUND` and strands the window on Chromium's error page. + * + * The Electron main process recovers such a failure by loading `index.html` + * again with the failed URL's route (its path relative to the renderer root, + * plus query and fragment) carried in this query parameter. The renderer + * consumes it before Angular bootstraps: `resolveRestoredRendererRoute` + * turns the current document URL into the in-app URL the router should + * start from, and `main.ts` installs it with `history.replaceState`, so the + * router's initial navigation lands on the route the user was on. + */ + +/** Query parameter carrying the route to restore on the reloaded index. */ +export const RENDERER_RESTORE_ROUTE_QUERY_PARAM = 'restoreRoute'; + +/** + * The URL to present instead of `currentHref` before the router's initial + * navigation, or `null` when the document carries no restore request. + * + * `baseUri` is `document.baseURI`: the packaged build's `<base href="./">` + * resolves to the renderer's directory, which is also what Angular strips + * from `location.pathname` to obtain the route. A route that would leave + * that directory — an absolute URL, another scheme, a `..` escape — is + * dropped and only the parameter is removed, so the app boots at its + * default route rather than following an arbitrary target. + */ +export function resolveRestoredRendererRoute( + currentHref: string, + baseUri: string +): string | null { + let current: URL; + let baseDirectory: URL; + + try { + current = new URL(currentHref); + baseDirectory = new URL('./', baseUri); + } catch { + return null; + } + + const route = current.searchParams.get(RENDERER_RESTORE_ROUTE_QUERY_PARAM); + + if (route === null) { + return null; + } + + current.searchParams.delete(RENDERER_RESTORE_ROUTE_QUERY_PARAM); + const stripped = current.href; + + let target: URL; + + try { + target = new URL(route, baseDirectory); + } catch { + return stripped; + } + + const staysInsideRenderer = + target.protocol === baseDirectory.protocol && + target.host === baseDirectory.host && + target.pathname.startsWith(baseDirectory.pathname) && + target.pathname !== baseDirectory.pathname; + + return staysInsideRenderer ? target.href : stripped; +}