mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/strange-joliot-fba9bc
# Conflicts: # CLAUDE.md
This commit is contained in:
commit
1bad91ce3d
71 files changed
+4646
-638
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: feature
|
||||
area: playback
|
||||
highlight: Episode list in fullscreen
|
||||
---
|
||||
|
||||
Series playback now has the slide-in list in fullscreen too: rest the mouse on the left edge, click it, or press C to see the season tabs and episodes with stills, runtimes, descriptions and watch progress, and jump to another episode without leaving fullscreen. Works for Xtream and Stalker series with any built-in player.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -574,14 +574,20 @@ unchanged. Contract: `docs/architecture/m3u-playlist-module.md`
|
||||
A confirmed frame-copy capability survives the unknown support probe during
|
||||
an engine remount, preserving panel search/scroll on channel changes; the
|
||||
first unknown probe and confirmed native/unsupported results withhold it.
|
||||
A live host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional
|
||||
`panelTitle`) and the panel slides that list over the video: left-edge hover
|
||||
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
|
||||
A host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional
|
||||
`panelTitle`, `panelSearchEnabled` and `panelKind: 'channels' | 'episodes'`;
|
||||
the template context carries `searchTerm`, `open` and `close`) and the
|
||||
panel slides that list over the video: left-edge hover
|
||||
dwell, a click or tap on that edge, or `C`. The hot zone stays mounted above
|
||||
the scrim and below the panel during opening, so a delayed paint cannot turn
|
||||
stationary hover into a synthetic leave. Nothing is drawn while it is closed
|
||||
(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
|
||||
@@ -621,9 +627,28 @@ unchanged. Contract: `docs/architecture/m3u-playlist-module.md`
|
||||
ignored. Windowed commands keep the
|
||||
complete catalog.
|
||||
Xtream's two `PortalChannelsListComponent` instances relay favorite toggles
|
||||
through `XtreamFavoriteMarksService`. CDK overlays follow the
|
||||
through `XtreamFavoriteMarksService`. Series playback gets the same panel
|
||||
as an episode list: `PortalInlinePlayerComponent` (the component both
|
||||
series hosts render around the view, and the Up Next rail's host) is the
|
||||
nearest provider — `panelKind: 'episodes'`, no search field (a title row
|
||||
instead, `C` focuses the panel) — and stamps `app-fullscreen-episode-panel`
|
||||
(`libs/ui/playback/src/lib/fullscreen-episode-panel/`: `SeasonTabsComponent`
|
||||
over the selected season's rows with TMDB still or numeral tile, `S01E03`
|
||||
label, runtime, clamped overview, progress bar, watched check and
|
||||
now-playing marker; the tab follows the playing season, the playing row is
|
||||
centred on open) built by `buildFullscreenEpisodePanelSeasons` from the
|
||||
hosts' `seriesEpisodes` / `episodePlaybackPositions` / `seasonLoadStates`
|
||||
inputs. An episode click travels `upNextEpisodeSelected` (the rail's path,
|
||||
so fullscreen survives the engine remount) and closes the panel; a season
|
||||
tab click travels `episodePanelSeasonSelected` into the hosts'
|
||||
`onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD season
|
||||
load — a spinner row while in flight, a Retry row after a failed request).
|
||||
Movies never get it, external
|
||||
MPV/VLC never mount the inline player, native-view Embedded MPV is
|
||||
withheld by the view. CDK overlays follow the
|
||||
fullscreen element via `FullscreenOverlayContainer`. Contract:
|
||||
`docs/architecture/player-controls-contract.md` ("Fullscreen channel panel").
|
||||
`docs/architecture/player-controls-contract.md` ("Fullscreen channel panel",
|
||||
"Fullscreen episode panel").
|
||||
- Embedded MPV seek steps (arrow keys, ±10 s buttons, `PlayerController.seekBy`)
|
||||
go through the relative `seekEmbeddedMpvBy` IPC: every backend forwards the
|
||||
delta as mpv `seek <delta> relative+exact` (addon export `seekBy`, helper
|
||||
|
||||
@@ -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). The zoom shortcuts (Cmd/Ctrl and +/−/0, numpad included) are a renderer key binding in `WorkspaceKeyboardShortcutsService` — the Windows/Linux window has no menu (`setMenu(null)`) — calling the synchronous preload-local bridge method `adjustZoomLevel`, which steps the same frame-bound level (`stepZoomLevel` in `libs/shared/interfaces`: 0.5 per press like Electron's `zoomIn`/`zoomOut` roles, clamped to levels −4…6, a stored level already outside them never moved against the request, so the returned level is not itself guaranteed in range); on macOS the renderer's `preventDefault()` keeps the menu role from stepping a second time. 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**:
|
||||
@@ -1303,7 +1304,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 `<button>`, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. A completed pointer click then releases the focus it left on the control (`onBarClick` → `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`: non-empty click `pointerType`, else a recent press inside the clicked element): a focused control captures the keyboard — Space and Enter re-activated the clicked button and `ControlsShortcuts` yields to any interactive element in the key's path, so after a click on fullscreen Space left fullscreen instead of pausing. Keyboard activation (empty `pointerType`) keeps focus, only buttons and range sliders are released, Chromium keeps its sequential-focus starting point at the blurred control so Tab continues from it, and the volume popover ignores the release's `focusout` (`wasPointerFocusRelease`). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the Line truncated
|
||||
- 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 `<button>`, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. A completed pointer click then releases the focus it left on the control (`onBarClick` → `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`: non-empty click `pointerType`, else a recent press inside the clicked element): a focused control captures the keyboard — Space and Enter re-activated the clicked button and `ControlsShortcuts` yields to any interactive element in the key's path, so after a click on fullscreen Space left fullscreen instead of pausing. Keyboard activation (empty `pointerType`) keeps focus, only buttons and range sliders are released, Chromium keeps its sequential-focus starting point at the blurred control so Tab continues from it, and the volume popover ignores the release's `focusout` (`wasPointerFocusRelease`). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the Line truncated
|
||||
- Shared web picture-in-picture stays inside that default-on rollout.
|
||||
`PlayerController` exposes capability `pictureInPicture`, state
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
@@ -1556,7 +1557,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
|
||||
|
||||
- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
|
||||
- The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style)
|
||||
- For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill
|
||||
- For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill. In fullscreen the same host offers the whole series as the slide-in episode panel (see the fullscreen channel panel paragraph in Video Players)
|
||||
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
|
||||
- Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider `xtream_id` carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields.
|
||||
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -47,15 +49,16 @@ async function pressZoomShortcut(
|
||||
/**
|
||||
* 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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
interceptXtreamRequests,
|
||||
MOCK_SERVER,
|
||||
playFirstSeriesEpisode,
|
||||
rewriteSeriesEpisodesToMp4,
|
||||
routeEpisodeClip,
|
||||
selectWebPlayer,
|
||||
} from './xtream-series-playback.fixture';
|
||||
|
||||
/**
|
||||
* Xtream inline series playback in fullscreen: episode switches, the
|
||||
* fullscreen episode panel and the vendor-chrome shortcuts. Split out of
|
||||
* `xtream.e2e.ts` for size; same mock server and default scenario
|
||||
* (3 seasons × 8 episodes per series).
|
||||
*
|
||||
* Tag: @xtream
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await request.post(`${MOCK_SERVER}/reset`);
|
||||
await page.goto('/');
|
||||
await interceptXtreamRequests(page);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen survives an episode switch
|
||||
//
|
||||
// app-web-player-view remounts the engine component for every playback
|
||||
// application (next episode, channel, or alternative source). DOM fullscreen
|
||||
// used to be owned by that engine's shell, so the Fullscreen API exited the
|
||||
// moment the old shell left the document — every "next episode" click and
|
||||
// every autoplay hand-off dropped the viewer back to the page. The owner is
|
||||
// now the app-web-player-view host, which spans all applications of one mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('@xtream inline series fullscreen', () => {
|
||||
// No autoplay-policy flag is needed: the episode click is a user
|
||||
// activation and the fixture clip carries no audio track.
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('stays in fullscreen across manual and automatic episode switches', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
|
||||
// Persist the HTML5 engine first: Video.js rejects the mock's
|
||||
// video/matroska source type before any bytes are sniffed.
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const waitForMetadata = () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).readyState)
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
await waitForMetadata();
|
||||
|
||||
// The bar auto-hides during playback; a hover reveals it. The
|
||||
// overlay title renders only while the controls consider themselves
|
||||
// fullscreen, so it doubles as the controls-state assertion.
|
||||
// Asserted as "some element is fullscreen" rather than by owner tag,
|
||||
// so the test guards the behavior, not the wiring.
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
const overlayTitle = playerView.locator(
|
||||
'[data-test-id="player-controls-media-title"]'
|
||||
);
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// Chromium leaves the clicked button focused; the shared controls
|
||||
// release that focus so Space reaches the playback shortcut instead
|
||||
// of activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Manual switch from the shared controls' own next-episode button.
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.locator('[data-test-id="player-controls-next-episode"]')
|
||||
.click();
|
||||
await expect(overlayTitle).toContainText('S01E02', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
|
||||
// Automatic switch: the clip reaching its end autoplays the next
|
||||
// episode with no user activation anywhere near it.
|
||||
await waitForMetadata();
|
||||
await video.evaluate(async (el) => {
|
||||
const media = el as HTMLVideoElement;
|
||||
media.currentTime = Math.max(0, media.duration - 0.2);
|
||||
// Headless autoplay is not guaranteed for a remounted element;
|
||||
// the document already carries user activation from the clicks.
|
||||
await media.play();
|
||||
});
|
||||
await expect(overlayTitle).toContainText('S01E03', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView } = await playFirstSeriesEpisode(page, request);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
const overlayTitle = playerView.locator(
|
||||
'[data-test-id="player-controls-media-title"]'
|
||||
);
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// C slides in the episode panel: season tabs instead of a search
|
||||
// field, the season's eight rows, the playing one marked.
|
||||
await page.keyboard.press('c');
|
||||
const panel = page.locator('[data-test-id="fullscreen-channel-panel"]');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(panel).toHaveAttribute('data-panel-kind', 'episodes');
|
||||
await expect(
|
||||
panel.locator('[data-test-id="fullscreen-channel-panel-search"]')
|
||||
).toHaveCount(0);
|
||||
await expect(panel.locator('.season-tabs__pill')).toHaveCount(3);
|
||||
const rows = panel.locator(
|
||||
'[data-test-id="fullscreen-episode-panel-episode"]'
|
||||
);
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expect(rows.first()).toHaveAttribute('aria-current', 'true');
|
||||
|
||||
// Picking an episode plays it inline through the same path as the Up
|
||||
// Next rail: fullscreen survives the engine remount, the panel closes.
|
||||
await rows.nth(2).click();
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'true');
|
||||
await playerView.hover();
|
||||
await expect(overlayTitle).toContainText('S01E03', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
|
||||
// Reopened, the panel marks the new episode; another season's tab
|
||||
// lists that season, offers the way back, and plays from there too.
|
||||
await page.keyboard.press('c');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(rows.nth(2)).toHaveAttribute('aria-current', 'true');
|
||||
await panel.locator('.season-tabs__pill').nth(1).click();
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expect(panel.locator('[aria-current="true"]')).toHaveCount(0);
|
||||
await expect(
|
||||
panel.locator('[data-testid="back-to-playing"]')
|
||||
).toBeVisible();
|
||||
await rows.first().click();
|
||||
await playerView.hover();
|
||||
await expect(overlayTitle).toContainText('S02E01', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('keep working after a mouse click on a control-bar button', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
// Video.js with the shared controls opted out: the vendor control bar.
|
||||
await selectWebPlayer(page, 'Video.js player', false);
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Chromium leaves the clicked Video.js button focused, and a focused
|
||||
// Video.js component swallows every key; the legacy chrome releases
|
||||
// that focus so Space and M reach the playback shortcuts instead of
|
||||
// Space activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
await playerView.hover();
|
||||
await playerView.locator('.vjs-fullscreen-control').click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('m');
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect } from './fixtures';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import {
|
||||
getRegisteredProviderUrl,
|
||||
interceptProviderTargetRegistration,
|
||||
} from './provider-target-route';
|
||||
|
||||
/**
|
||||
* Shared Xtream helpers for `xtream.e2e.ts` and `xtream-series-playback.e2e.ts`:
|
||||
* the mock-server address and credentials, the PWA proxy intercept, the
|
||||
* portal import, and the inline series playback setup.
|
||||
*/
|
||||
|
||||
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
|
||||
export const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
|
||||
/** Default scenario credentials */
|
||||
export const DEFAULT_USERNAME = 'user1';
|
||||
export const DEFAULT_PASSWORD = 'pass1';
|
||||
|
||||
/**
|
||||
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
|
||||
* to the mock server. This avoids any real backend requirement.
|
||||
*/
|
||||
export async function interceptXtreamRequests(page: Page): Promise<void> {
|
||||
const providerTargets = await interceptProviderTargetRegistration(page);
|
||||
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const originalUrl = new URL(route.request().url());
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
const providerUrl = getRegisteredProviderUrl(
|
||||
originalUrl,
|
||||
providerTargets
|
||||
);
|
||||
|
||||
if (providerUrl) {
|
||||
mockUrl.searchParams.set('url', providerUrl);
|
||||
}
|
||||
|
||||
originalUrl.searchParams.forEach((value, key) => {
|
||||
if (key === 'targetId') {
|
||||
return;
|
||||
}
|
||||
|
||||
mockUrl.searchParams.set(key, value);
|
||||
});
|
||||
await route.continue({ url: mockUrl.toString() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an Xtream portal via the UI.
|
||||
*/
|
||||
export async function addXtreamPortal(
|
||||
page: Page,
|
||||
options: { name?: string; username?: string; password?: string } = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
name = 'Mock Xtream Portal',
|
||||
username = DEFAULT_USERNAME,
|
||||
password = DEFAULT_PASSWORD,
|
||||
} = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('#title'), name);
|
||||
await setInputValue(dialog.locator('#serverUrl'), MOCK_SERVER);
|
||||
await setInputValue(dialog.locator('#username'), username);
|
||||
await setInputValue(dialog.locator('#password'), password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline series playback helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
|
||||
* proprietary codecs, so the clip stands in for the mock's external HLS
|
||||
* redirect; the browser sniffs the WebM container from the bytes. Registered
|
||||
* after the beforeEach proxy route, so it runs first and fetches from the
|
||||
* mock itself (the mock ignores the url parameter).
|
||||
*/
|
||||
export async function routeEpisodeClip(page: Page): Promise<void> {
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a plain
|
||||
// 200 to a Range request clamps every seek to the start, which
|
||||
// would defeat end-of-episode steps.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2]
|
||||
? Math.min(Number(range[2]), last)
|
||||
: last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
|
||||
* get_series_info is rewritten to .mp4 — the extension the player gives to
|
||||
* the native source path.
|
||||
*/
|
||||
export async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist the web player engine and the shared-controls preference. */
|
||||
export async function selectWebPlayer(
|
||||
page: Page,
|
||||
engine: string,
|
||||
sharedControls = true
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page.getByRole('option', { name: engine, exact: true }).click();
|
||||
const toggle = page.locator(
|
||||
'[data-test-id="web-player-shared-controls-toggle"]'
|
||||
);
|
||||
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
|
||||
await toggle.click();
|
||||
}
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
|
||||
/** Add the mock portal and start the first episode of the first series. */
|
||||
export async function playFirstSeriesEpisode(
|
||||
page: Page,
|
||||
request: APIRequestContext
|
||||
): Promise<{ playerView: Locator; video: Locator }> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
);
|
||||
return { playerView, video: playerView.locator('video') };
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
rasterizedBorderContrast,
|
||||
@@ -9,9 +7,12 @@ import {
|
||||
waitForScrollIdle,
|
||||
} from './e2e-helpers';
|
||||
import {
|
||||
getRegisteredProviderUrl,
|
||||
interceptProviderTargetRegistration,
|
||||
} from './provider-target-route';
|
||||
addXtreamPortal,
|
||||
DEFAULT_PASSWORD,
|
||||
DEFAULT_USERNAME,
|
||||
interceptXtreamRequests,
|
||||
MOCK_SERVER,
|
||||
} from './xtream-series-playback.fixture';
|
||||
|
||||
/**
|
||||
* Xtream Codes E2E Tests
|
||||
@@ -28,12 +29,6 @@ import {
|
||||
* Tag: @xtream — run only Xtream tests with: nx e2e web-e2e --grep "@xtream"
|
||||
*/
|
||||
|
||||
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
|
||||
const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
|
||||
/** Default scenario credentials */
|
||||
const DEFAULT_USERNAME = 'user1';
|
||||
const DEFAULT_PASSWORD = 'pass1';
|
||||
const EPG_USERNAME = 'epg';
|
||||
const EPG_PASSWORD = 'epg';
|
||||
|
||||
@@ -41,65 +36,6 @@ const EPG_PASSWORD = 'epg';
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
|
||||
* to the mock server. This avoids any real backend requirement.
|
||||
*/
|
||||
async function interceptXtreamRequests(page: Page): Promise<void> {
|
||||
const providerTargets = await interceptProviderTargetRegistration(page);
|
||||
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const originalUrl = new URL(route.request().url());
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
const providerUrl = getRegisteredProviderUrl(
|
||||
originalUrl,
|
||||
providerTargets
|
||||
);
|
||||
|
||||
if (providerUrl) {
|
||||
mockUrl.searchParams.set('url', providerUrl);
|
||||
}
|
||||
|
||||
originalUrl.searchParams.forEach((value, key) => {
|
||||
if (key === 'targetId') {
|
||||
return;
|
||||
}
|
||||
|
||||
mockUrl.searchParams.set(key, value);
|
||||
});
|
||||
await route.continue({ url: mockUrl.toString() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an Xtream portal via the UI.
|
||||
*/
|
||||
async function addXtreamPortal(
|
||||
page: Page,
|
||||
options: { name?: string; username?: string; password?: string } = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
name = 'Mock Xtream Portal',
|
||||
username = DEFAULT_USERNAME,
|
||||
password = DEFAULT_PASSWORD,
|
||||
} = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('#title'), name);
|
||||
await setInputValue(dialog.locator('#serverUrl'), MOCK_SERVER);
|
||||
await setInputValue(dialog.locator('#username'), username);
|
||||
await setInputValue(dialog.locator('#password'), password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
}
|
||||
|
||||
async function openPlaylistDetailsDialog(page: Page, title: string) {
|
||||
const playlistSettingsButton = page.getByRole('button', {
|
||||
name: 'Playlist Settings',
|
||||
@@ -965,308 +901,6 @@ function formatXtreamDateTime(timestampSeconds: number): string {
|
||||
.replace('.000Z', '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen survives an episode switch
|
||||
//
|
||||
// app-web-player-view remounts the engine component for every playback
|
||||
// application (next episode, channel, or alternative source). DOM fullscreen
|
||||
// used to be owned by that engine's shell, so the Fullscreen API exited the
|
||||
// moment the old shell left the document — every "next episode" click and
|
||||
// every autoplay hand-off dropped the viewer back to the page. The owner is
|
||||
// now the app-web-player-view host, which spans all applications of one mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline series playback helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
|
||||
* proprietary codecs, so the clip stands in for the mock's external HLS
|
||||
* redirect; the browser sniffs the WebM container from the bytes. Registered
|
||||
* after the beforeEach proxy route, so it runs first and fetches from the
|
||||
* mock itself (the mock ignores the url parameter).
|
||||
*/
|
||||
async function routeEpisodeClip(page: Page): Promise<void> {
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a plain
|
||||
// 200 to a Range request clamps every seek to the start, which
|
||||
// would defeat end-of-episode steps.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2]
|
||||
? Math.min(Number(range[2]), last)
|
||||
: last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
|
||||
* get_series_info is rewritten to .mp4 — the extension the player gives to
|
||||
* the native source path.
|
||||
*/
|
||||
async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist the web player engine and the shared-controls preference. */
|
||||
async function selectWebPlayer(
|
||||
page: Page,
|
||||
engine: string,
|
||||
sharedControls = true
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page.getByRole('option', { name: engine, exact: true }).click();
|
||||
const toggle = page.locator(
|
||||
'[data-test-id="web-player-shared-controls-toggle"]'
|
||||
);
|
||||
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
|
||||
await toggle.click();
|
||||
}
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
|
||||
/** Add the mock portal and start the first episode of the first series. */
|
||||
async function playFirstSeriesEpisode(
|
||||
page: Page,
|
||||
request: APIRequestContext
|
||||
): Promise<{ playerView: Locator; video: Locator }> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
);
|
||||
return { playerView, video: playerView.locator('video') };
|
||||
}
|
||||
|
||||
test.describe('@xtream inline series fullscreen', () => {
|
||||
// No autoplay-policy flag is needed: the episode click is a user
|
||||
// activation and the fixture clip carries no audio track.
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('stays in fullscreen across manual and automatic episode switches', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
|
||||
// Persist the HTML5 engine first: Video.js rejects the mock's
|
||||
// video/matroska source type before any bytes are sniffed.
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const waitForMetadata = () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).readyState)
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
await waitForMetadata();
|
||||
|
||||
// The bar auto-hides during playback; a hover reveals it. The
|
||||
// overlay title renders only while the controls consider themselves
|
||||
// fullscreen, so it doubles as the controls-state assertion.
|
||||
// Asserted as "some element is fullscreen" rather than by owner tag,
|
||||
// so the test guards the behavior, not the wiring.
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
const overlayTitle = playerView.locator(
|
||||
'[data-test-id="player-controls-media-title"]'
|
||||
);
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await expect(overlayTitle).toContainText('S01E01');
|
||||
|
||||
// Chromium leaves the clicked button focused; the shared controls
|
||||
// release that focus so Space reaches the playback shortcut instead
|
||||
// of activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Manual switch from the shared controls' own next-episode button.
|
||||
await playerView.hover();
|
||||
await playerView
|
||||
.locator('[data-test-id="player-controls-next-episode"]')
|
||||
.click();
|
||||
await expect(overlayTitle).toContainText('S01E02', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
|
||||
// Automatic switch: the clip reaching its end autoplays the next
|
||||
// episode with no user activation anywhere near it.
|
||||
await waitForMetadata();
|
||||
await video.evaluate(async (el) => {
|
||||
const media = el as HTMLVideoElement;
|
||||
media.currentTime = Math.max(0, media.duration - 0.2);
|
||||
// Headless autoplay is not guaranteed for a remounted element;
|
||||
// the document already carries user activation from the clicks.
|
||||
await media.play();
|
||||
});
|
||||
await expect(overlayTitle).toContainText('S01E03', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('keep working after a mouse click on a control-bar button', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
// Video.js with the shared controls opted out: the vendor control bar.
|
||||
await selectWebPlayer(page, 'Video.js player', false);
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Chromium leaves the clicked Video.js button focused, and a focused
|
||||
// Video.js component swallows every key; the legacy chrome releases
|
||||
// that focus so Space and M reach the playback shortcuts instead of
|
||||
// Space activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
await playerView.hover();
|
||||
await playerView.locator('.vjs-fullscreen-control').click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('m');
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
test(`@xtream detail surfaces stay visible (${theme})`, async ({
|
||||
page,
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "تعبئة المساحة حول المشغل بنسخة مشوشة ومعتمة من الملصق بدلاً من الخطوط السوداء.",
|
||||
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات التالية",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "في النوافذ العريضة، تثبيت مشغل المسلسل على اليسار وعرض الحلقات القادمة بجانبه.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "قائمة القنوات في وضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "في وضع ملء الشاشة، حرّك الماوس إلى الحافة اليسرى أو اضغط C لإظهار قائمة القنوات وتبديل القناة دون الخروج من ملء الشاشة.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "قائمة القنوات والحلقات في وضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "ضع الماوس على الحافة اليسرى أو انقر عليها أو اضغط C في وضع ملء الشاشة لإظهار قائمة قنوات البث المباشر أو قائمة حلقات المسلسل والتبديل دون مغادرة وضع ملء الشاشة.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "اختر تنسيق البث الافتراضي (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "اختر لغة التطبيق",
|
||||
"THEME_DESCRIPTION": "اختر السمة البصرية للتطبيق",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "الخروج من ملء الشاشة",
|
||||
"CHANNEL_LIST": "قائمة القنوات",
|
||||
"HIDE_CHANNEL_LIST": "إخفاء قائمة القنوات",
|
||||
"EPISODE_LIST": "قائمة الحلقات",
|
||||
"HIDE_EPISODE_LIST": "إخفاء قائمة الحلقات",
|
||||
"SEARCH_CHANNELS": "البحث عن القنوات",
|
||||
"SEARCH_IN": "البحث في {{title}}",
|
||||
"CLEAR_SEARCH": "مسح البحث",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم",
|
||||
"PLAYBACK_ERROR": "فشل الحصول على رابط التشغيل",
|
||||
"LOADING_SEASONS": "جارٍ تحميل المواسم...",
|
||||
"LOADING_EPISODES": "جارٍ تحميل الحلقات...",
|
||||
"EPISODES_LOAD_FAILED": "تعذر تحميل الحلقات.",
|
||||
"LOADING_ALL_CHANNELS": "جارٍ تحميل قائمة القنوات الكاملة…",
|
||||
"REFRESH_CHANNEL_LIST": "تحديث قائمة القنوات",
|
||||
"NO_CHANNELS_IN_CATEGORY": "لا توجد قنوات في هذه الفئة",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "ملي المساحة حول المشغل بغلاف معتم وطابت بلاصة الشرائط السوداء.",
|
||||
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات الجاية",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "فالنوافذ العريضة، حط مشغل السلسلة على اليسار وبيّن الحلقات الجاية بحداه.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "لائحة القنوات فوضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "فوضع ملء الشاشة، حرّك الماوس للحافة اليسرى ولا ورّك على C باش تبان لائحة القنوات وتبدّل القناة بلا ما تخرج من ملء الشاشة.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "لائحة القنوات والحلقات فوضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "حط الفارة فالحافة اليسرى ولا كليكي عليها ولا ضغط C فوضع ملء الشاشة باش تبان لائحة القنوات ديال البث المباشر ولا لائحة الحلقات ديال المسلسل وتبدل بلا ما تخرج من ملء الشاشة.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "اختار صيغة البث الافتراضية (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "اختار لغة التطبيق",
|
||||
"THEME_DESCRIPTION": "اختار المظهر المرئي للتطبيق",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "خرج من ملء الشاشة",
|
||||
"CHANNEL_LIST": "لائحة القنوات",
|
||||
"HIDE_CHANNEL_LIST": "خبّي لائحة القنوات",
|
||||
"EPISODE_LIST": "لائحة الحلقات",
|
||||
"HIDE_EPISODE_LIST": "خبّي لائحة الحلقات",
|
||||
"SEARCH_CHANNELS": "قلّب على القنوات",
|
||||
"SEARCH_IN": "قلّب ف {{title}}",
|
||||
"CLEAR_SEARCH": "مسح البحث",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر",
|
||||
"PLAYBACK_ERROR": "فشل جلب رابط التشغيل",
|
||||
"LOADING_SEASONS": "جاري تشريج المواسم...",
|
||||
"LOADING_EPISODES": "جاري تشريج الحلقات...",
|
||||
"EPISODES_LOAD_FAILED": "ما قدرناش نشرجيو الحلقات.",
|
||||
"LOADING_ALL_CHANNELS": "كيتم تحميل لائحة القنوات كاملة…",
|
||||
"REFRESH_CHANNEL_LIST": "تحديث لائحة القنوات",
|
||||
"NO_CHANNELS_IN_CATEGORY": "ماكاينش قنوات فهاد الصنف",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Запаўніць прастор вакол плэйра размытай, затемнёнай вокладкай замяст чорных палос.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Панэль наступных эпізодаў",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "На шырокіх вокнах замацоўваць прайгравальнік серыяла злева і паказваць наступныя эпізоды побач з ім.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Спіс каналаў у поўнаэкранным рэжыме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Падвядзіце мыш да левага краю або націсніце C у поўнаэкранным рэжыме, каб высунуць спіс каналаў і пераключыць канал, не выходзячы з поўнаэкраннага рэжыму.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Спіс каналаў і серый у поўнаэкранным рэжыме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Навядзіце мыш на левы край, націсніце на яго або націсніце C у поўнаэкранным рэжыме, каб паказаць спіс каналаў прамога эфіру або спіс серый серыяла і пераключыцца, не выходзячы з поўнаэкраннага рэжыму.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Выберыце фармат патоку па змаўчанні (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Выбраць мову праграмы",
|
||||
"THEME_DESCRIPTION": "Выбраць візуальную тэму афармлення",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму",
|
||||
"CHANNEL_LIST": "Спіс каналаў",
|
||||
"HIDE_CHANNEL_LIST": "Схаваць спіс каналаў",
|
||||
"EPISODE_LIST": "Спіс серый",
|
||||
"HIDE_EPISODE_LIST": "Схаваць спіс серый",
|
||||
"SEARCH_CHANNELS": "Пошук каналаў",
|
||||
"SEARCH_IN": "Пошук у {{title}}",
|
||||
"CLEAR_SEARCH": "Ачысціць пошук",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы",
|
||||
"PLAYBACK_ERROR": "Не ўдалося атрымаць URL для прайгравання",
|
||||
"LOADING_SEASONS": "Загрузка сезонаў...",
|
||||
"LOADING_EPISODES": "Загрузка серый...",
|
||||
"EPISODES_LOAD_FAILED": "Не ўдалося загрузіць серыі.",
|
||||
"LOADING_ALL_CHANNELS": "Загрузка поўнага спісу каналаў…",
|
||||
"REFRESH_CHANNEL_LIST": "Абнавіць спіс каналаў",
|
||||
"NO_CHANNELS_IN_CATEGORY": "У гэтай катэгорыі няма каналаў",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Füllt den Bereich um den Player mit einem unscharfen, gedimmten Poster auf, anstelle von einfachen schwarzen Balken.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Folgenleiste „Als Nächstes“",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Dockt den Serienplayer in breiten Fenstern links an und zeigt daneben die kommenden Folgen an.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Senderliste im Vollbild",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Bewege die Maus im Vollbild an den linken Rand oder drücke C, um die Senderliste einzublenden und den Sender zu wechseln, ohne das Vollbild zu verlassen.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Sender- und Episodenliste im Vollbild",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Bewege die Maus im Vollbild an den linken Rand, klicke darauf oder drücke C, um die Senderliste eines Live-Streams oder die Episodenliste einer Serie einzublenden und zu wechseln, ohne das Vollbild zu verlassen.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto bevorzugt HLS. Schlägt Live-TV in MPV, VLC oder Embedded MPV fehl, den Player stoppen, ts wählen, speichern und den Sender erneut öffnen. Webplayer können bei einem anfänglichen HLS-HTTP-Fehler einmal angebotenes TS versuchen.",
|
||||
"LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus",
|
||||
"THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Vollbild beenden",
|
||||
"CHANNEL_LIST": "Senderliste",
|
||||
"HIDE_CHANNEL_LIST": "Senderliste ausblenden",
|
||||
"EPISODE_LIST": "Episodenliste",
|
||||
"HIDE_EPISODE_LIST": "Episodenliste ausblenden",
|
||||
"SEARCH_CHANNELS": "Sender suchen",
|
||||
"SEARCH_IN": "In {{title}} suchen",
|
||||
"CLEAR_SEARCH": "Suche löschen",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Dieser Inhalt ist auf diesem Server nicht verfügbar",
|
||||
"PLAYBACK_ERROR": "Wiedergabe-URL konnte nicht abgerufen werden",
|
||||
"LOADING_SEASONS": "Staffeln werden geladen …",
|
||||
"LOADING_EPISODES": "Episoden werden geladen …",
|
||||
"EPISODES_LOAD_FAILED": "Die Episoden konnten nicht geladen werden.",
|
||||
"LOADING_ALL_CHANNELS": "Vollständige Senderliste wird geladen…",
|
||||
"REFRESH_CHANNEL_LIST": "Senderliste aktualisieren",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Keine Sender in dieser Kategorie",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Γεμίστε το χώρο γύρω από τον αναπαραγωγό με θολωμένη, σκοτεινή αφίσα αντί για απλές μαύρες ράβδους.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Λίστα επόμενων επεισοδίων",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Σε πλατιά παράθυρα, ο αναπαραγωγός σειρών αγκυρώνεται στα αριστερά και τα επόμενα επεισόδια εμφανίζονται δίπλα του.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Λίστα καναλιών σε πλήρη οθόνη",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Σε πλήρη οθόνη, μετακινήστε το ποντίκι στην αριστερή άκρη ή πατήστε C για να εμφανιστεί η λίστα καναλιών και να αλλάξετε κανάλι χωρίς να βγείτε από την πλήρη οθόνη.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Λίστα καναλιών και επεισοδίων σε πλήρη οθόνη",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Αφήστε το ποντίκι στην αριστερή άκρη, κάντε κλικ σε αυτήν ή πατήστε C σε πλήρη οθόνη για να εμφανίσετε τη λίστα καναλιών μιας ζωντανής ροής ή τη λίστα επεισοδίων μιας σειράς και να αλλάξετε χωρίς να βγείτε από την πλήρη οθόνη.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Επιλογή προεπιλεγμένης μορφής ροής (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Επιλέξτε τη γλώσσα της εφαρμογής",
|
||||
"THEME_DESCRIPTION": "Επιλέξτε οπτικό θέμα της εφαρμογής",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη",
|
||||
"CHANNEL_LIST": "Λίστα καναλιών",
|
||||
"HIDE_CHANNEL_LIST": "Απόκρυψη λίστας καναλιών",
|
||||
"EPISODE_LIST": "Λίστα επεισοδίων",
|
||||
"HIDE_EPISODE_LIST": "Απόκρυψη λίστας επεισοδίων",
|
||||
"SEARCH_CHANNELS": "Αναζήτηση καναλιών",
|
||||
"SEARCH_IN": "Αναζήτηση σε {{title}}",
|
||||
"CLEAR_SEARCH": "Καθαρισμός αναζήτησης",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή",
|
||||
"PLAYBACK_ERROR": "Αποτυχία λήψης διεύθυνσης URL αναπαραγωγής",
|
||||
"LOADING_SEASONS": "Φόρτωση σεζόν...",
|
||||
"LOADING_EPISODES": "Φόρτωση επεισοδίων...",
|
||||
"EPISODES_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των επεισοδίων.",
|
||||
"LOADING_ALL_CHANNELS": "Φόρτωση πλήρους λίστας καναλιών…",
|
||||
"REFRESH_CHANNEL_LIST": "Ανανέωση λίστας καναλιών",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Δεν υπάρχουν κανάλια σε αυτήν την κατηγορία",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Up Next episode rail",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "On wide windows, dock the series player to the left and show the upcoming episodes beside it.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Channel list in fullscreen",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Rest the mouse on the left edge or press C while in fullscreen to slide in the channel list and switch channels without leaving fullscreen.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Channel and episode list in fullscreen",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Rest the mouse on the left edge, click it, or press C while in fullscreen to slide in the channel list of a live stream or the episode list of a series and switch without leaving fullscreen.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto prefers HLS. If live playback fails in MPV, VLC or Embedded MPV, stop the player, select ts, save and reopen the channel. Web players can try advertised TS once after an initial HLS HTTP failure.",
|
||||
"LANGUAGE_DESCRIPTION": "Select language of the application",
|
||||
"THEME_DESCRIPTION": "Select visual theme of the application",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Exit fullscreen",
|
||||
"CHANNEL_LIST": "Channel list",
|
||||
"HIDE_CHANNEL_LIST": "Hide channel list",
|
||||
"EPISODE_LIST": "Episode list",
|
||||
"HIDE_EPISODE_LIST": "Hide episode list",
|
||||
"SEARCH_CHANNELS": "Search channels",
|
||||
"SEARCH_IN": "Search in {{title}}",
|
||||
"CLEAR_SEARCH": "Clear search",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
|
||||
"PLAYBACK_ERROR": "Failed to get playback URL",
|
||||
"LOADING_SEASONS": "Loading seasons...",
|
||||
"LOADING_EPISODES": "Loading episodes...",
|
||||
"EPISODES_LOAD_FAILED": "The episodes could not be loaded.",
|
||||
"LOADING_ALL_CHANNELS": "Loading full channel list…",
|
||||
"REFRESH_CHANNEL_LIST": "Refresh channel list",
|
||||
"NO_CHANNELS_IN_CATEGORY": "No channels in this category",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Rellena el espacio alrededor del reproductor con una imagen de póster desenfocada y oscurecida en lugar de barras negras simples.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Panel de próximos episodios",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "En ventanas anchas, ancla el reproductor de series a la izquierda y muestra los próximos episodios a su lado.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista de canales en pantalla completa",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "En pantalla completa, acerca el ratón al borde izquierdo o pulsa C para desplegar la lista de canales y cambiar de canal sin salir de la pantalla completa.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista de canales y episodios en pantalla completa",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Deja el ratón en el borde izquierdo, haz clic en él o pulsa C en pantalla completa para mostrar la lista de canales de una emisión en directo o la lista de episodios de una serie y cambiar sin salir de la pantalla completa.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Selecciona el formato de transmision predeterminado (Xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Seleccione el idioma de la aplicación",
|
||||
"THEME_DESCRIPTION": "Seleccione el tema visual de la aplicación.",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Salir de pantalla completa",
|
||||
"CHANNEL_LIST": "Lista de canales",
|
||||
"HIDE_CHANNEL_LIST": "Ocultar la lista de canales",
|
||||
"EPISODE_LIST": "Lista de episodios",
|
||||
"HIDE_EPISODE_LIST": "Ocultar la lista de episodios",
|
||||
"SEARCH_CHANNELS": "Buscar canales",
|
||||
"SEARCH_IN": "Buscar en {{title}}",
|
||||
"CLEAR_SEARCH": "Borrar búsqueda",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor",
|
||||
"PLAYBACK_ERROR": "No se pudo obtener la URL de reproducción",
|
||||
"LOADING_SEASONS": "Cargando temporadas…",
|
||||
"LOADING_EPISODES": "Cargando episodios…",
|
||||
"EPISODES_LOAD_FAILED": "No se pudieron cargar los episodios.",
|
||||
"LOADING_ALL_CHANNELS": "Cargando la lista completa de canales…",
|
||||
"REFRESH_CHANNEL_LIST": "Actualizar lista de canales",
|
||||
"NO_CHANNELS_IN_CATEGORY": "No hay canales en esta categoría",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Remplit l'espace autour du lecteur avec une affiche floue et assombrie au lieu de barres noires simples.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Rail d'épisodes « À suivre »",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Sur les fenêtres larges, ancre le lecteur de séries à gauche et affiche les épisodes à venir à côté.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Liste des chaînes en plein écran",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "En plein écran, placez la souris sur le bord gauche ou appuyez sur C pour faire glisser la liste des chaînes et changer de chaîne sans quitter le plein écran.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Liste des chaînes et des épisodes en plein écran",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Laissez la souris sur le bord gauche, cliquez dessus ou appuyez sur C en plein écran pour afficher la liste des chaînes d'un direct ou la liste des épisodes d'une série et changer sans quitter le plein écran.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Sélectionnez le format de flux par défaut (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Sélectionnez la langue de l'application",
|
||||
"THEME_DESCRIPTION": "Sélectionnez le thème visuel de l'application",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Quitter le plein écran",
|
||||
"CHANNEL_LIST": "Liste des chaînes",
|
||||
"HIDE_CHANNEL_LIST": "Masquer la liste des chaînes",
|
||||
"EPISODE_LIST": "Liste des épisodes",
|
||||
"HIDE_EPISODE_LIST": "Masquer la liste des épisodes",
|
||||
"SEARCH_CHANNELS": "Rechercher des chaînes",
|
||||
"SEARCH_IN": "Rechercher dans {{title}}",
|
||||
"CLEAR_SEARCH": "Effacer la recherche",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur",
|
||||
"PLAYBACK_ERROR": "Échec de l'obtention de l'URL de lecture",
|
||||
"LOADING_SEASONS": "Chargement des saisons…",
|
||||
"LOADING_EPISODES": "Chargement des épisodes…",
|
||||
"EPISODES_LOAD_FAILED": "Les épisodes n'ont pas pu être chargés.",
|
||||
"LOADING_ALL_CHANNELS": "Chargement de la liste complète des chaînes…",
|
||||
"REFRESH_CHANNEL_LIST": "Actualiser la liste des chaînes",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Aucune chaîne dans cette catégorie",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
|
||||
"PLAYER_UP_NEXT_RAIL": "„Következik” epizódsáv",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Széles ablakban a sorozatlejátszó balra kerül, mellette pedig megjelennek a következő epizódok.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Csatornalista teljes képernyőn",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Teljes képernyőn vidd az egeret a bal szélre, vagy nyomd meg a C billentyűt: becsúszik a csatornalista, és a teljes képernyő elhagyása nélkül válthatsz csatornát.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Csatorna- és epizódlista teljes képernyőn",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Teljes képernyőn vidd az egeret a bal szélre, kattints rá, vagy nyomd meg a C billentyűt az élő adás csatornalistájának vagy a sorozat epizódlistájának megjelenítéséhez, és válts a teljes képernyő elhagyása nélkül.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Az alapértelmezett adatfolyam-formátum kiválasztása (Xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Az alkalmazás nyelvének kiválasztása",
|
||||
"THEME_DESCRIPTION": "Az alkalmazás megjelenési témájának kiválasztása",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Kilépés a teljes képernyős módból",
|
||||
"CHANNEL_LIST": "Csatornalista",
|
||||
"HIDE_CHANNEL_LIST": "Csatornalista elrejtése",
|
||||
"EPISODE_LIST": "Epizódlista",
|
||||
"HIDE_EPISODE_LIST": "Epizódlista elrejtése",
|
||||
"SEARCH_CHANNELS": "Csatornák keresése",
|
||||
"SEARCH_IN": "Keresés itt: {{title}}",
|
||||
"CLEAR_SEARCH": "Keresés törlése",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Ez a tartalom nem érhető el ezen a kiszolgálón",
|
||||
"PLAYBACK_ERROR": "Nem sikerült lekérni a lejátszáshoz szükséges URL-címet",
|
||||
"LOADING_SEASONS": "Évadok betöltése…",
|
||||
"LOADING_EPISODES": "Epizódok betöltése…",
|
||||
"EPISODES_LOAD_FAILED": "Az epizódokat nem sikerült betölteni.",
|
||||
"LOADING_ALL_CHANNELS": "Loading full channel list…",
|
||||
"REFRESH_CHANNEL_LIST": "Refresh channel list",
|
||||
"NO_CHANNELS_IN_CATEGORY": "No channels in this category",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Riempie lo spazio intorno al lettore con un poster sfocato e scurito invece delle semplici barre nere.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Pannello \"A seguire\" con i prossimi episodi",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Nelle finestre ampie, ancora il lettore delle serie a sinistra e mostra i prossimi episodi accanto.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Elenco canali a schermo intero",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "A schermo intero, porta il mouse sul bordo sinistro o premi C per far scorrere l'elenco dei canali e cambiare canale senza uscire dallo schermo intero.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Elenco canali ed episodi a schermo intero",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "A schermo intero, lascia il mouse sul bordo sinistro, fai clic su di esso o premi C per mostrare l'elenco dei canali di una diretta o l'elenco degli episodi di una serie e cambiare senza uscire dallo schermo intero.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Seleziona il formato di streaming predefinito (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Seleziona la lingua dell'applicazione",
|
||||
"THEME_DESCRIPTION": "Seleziona il tema visivo dell'applicazione",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Esci dallo schermo intero",
|
||||
"CHANNEL_LIST": "Elenco canali",
|
||||
"HIDE_CHANNEL_LIST": "Nascondi l'elenco dei canali",
|
||||
"EPISODE_LIST": "Elenco episodi",
|
||||
"HIDE_EPISODE_LIST": "Nascondi l'elenco degli episodi",
|
||||
"SEARCH_CHANNELS": "Cerca canali",
|
||||
"SEARCH_IN": "Cerca in {{title}}",
|
||||
"CLEAR_SEARCH": "Cancella ricerca",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server",
|
||||
"PLAYBACK_ERROR": "Impossibile ottenere l'URL di riproduzione",
|
||||
"LOADING_SEASONS": "Caricamento stagioni...",
|
||||
"LOADING_EPISODES": "Caricamento episodi...",
|
||||
"EPISODES_LOAD_FAILED": "Impossibile caricare gli episodi.",
|
||||
"LOADING_ALL_CHANNELS": "Caricamento dell'elenco completo dei canali…",
|
||||
"REFRESH_CHANNEL_LIST": "Aggiorna elenco canali",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Nessun canale in questa categoria",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "プレーヤーの周囲をぼかしたポスター画像で埋め、通常の黒い枠の代わりに表示します。",
|
||||
"PLAYER_UP_NEXT_RAIL": "「次に再生」エピソードレール",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "ウィンドウ幅が広い場合、シリーズのプレーヤーを左側に配置し、その横に次のエピソードの一覧を表示します。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全画面表示でのチャンネルリスト",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全画面表示中にマウスを左端に置くか C キーを押すと、チャンネルリストがスライド表示され、全画面を解除せずにチャンネルを切り替えられます。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全画面表示でのチャンネル・エピソードリスト",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全画面表示中に左端にマウスを置く、クリックする、または C キーを押すと、ライブ配信のチャンネルリストやシリーズのエピソードリストが表示され、全画面表示のまま切り替えられます。",
|
||||
"STREAM_FORMAT_DESCRIPTION": "デフォルトのストリーム形式を選択(Xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "アプリケーションの言語を選択",
|
||||
"THEME_DESCRIPTION": "アプリケーションの視覚テーマを選択",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "全画面表示を終了",
|
||||
"CHANNEL_LIST": "チャンネルリスト",
|
||||
"HIDE_CHANNEL_LIST": "チャンネルリストを隠す",
|
||||
"EPISODE_LIST": "エピソードリスト",
|
||||
"HIDE_EPISODE_LIST": "エピソードリストを隠す",
|
||||
"SEARCH_CHANNELS": "チャンネルを検索",
|
||||
"SEARCH_IN": "{{title}} 内を検索",
|
||||
"CLEAR_SEARCH": "検索をクリア",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません",
|
||||
"PLAYBACK_ERROR": "再生URLの取得に失敗しました",
|
||||
"LOADING_SEASONS": "シーズンを読み込み中...",
|
||||
"LOADING_EPISODES": "エピソードを読み込み中...",
|
||||
"EPISODES_LOAD_FAILED": "エピソードを読み込めませんでした。",
|
||||
"LOADING_ALL_CHANNELS": "全チャンネルリストを読み込み中…",
|
||||
"REFRESH_CHANNEL_LIST": "チャンネルリストを更新",
|
||||
"NO_CHANNELS_IN_CATEGORY": "このカテゴリーにチャンネルがありません",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "플레이어 주변을 검은 막대 대신 흐려지고 어두워진 포스터로 채웁니다.",
|
||||
"PLAYER_UP_NEXT_RAIL": "다음 에피소드 레일",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "넓은 창에서 시리즈 플레이어를 왼쪽에 고정하고 그 옆에 다음 에피소드를 표시합니다.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "전체 화면에서 채널 목록",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "전체 화면에서 마우스를 왼쪽 가장자리에 두거나 C 키를 누르면 채널 목록이 나타나 전체 화면을 벗어나지 않고 채널을 바꿀 수 있습니다.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "전체 화면에서 채널 및 에피소드 목록",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "전체 화면에서 마우스를 왼쪽 가장자리에 두거나 클릭하거나 C 키를 누르면 라이브 방송의 채널 목록이나 시리즈의 에피소드 목록이 나타나 전체 화면을 벗어나지 않고 전환할 수 있습니다.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "기본 스트림 형식 선택 (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "애플리케이션 언어 선택",
|
||||
"THEME_DESCRIPTION": "애플리케이션의 시각적 테마 선택",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "전체 화면 종료",
|
||||
"CHANNEL_LIST": "채널 목록",
|
||||
"HIDE_CHANNEL_LIST": "채널 목록 숨기기",
|
||||
"EPISODE_LIST": "에피소드 목록",
|
||||
"HIDE_EPISODE_LIST": "에피소드 목록 숨기기",
|
||||
"SEARCH_CHANNELS": "채널 검색",
|
||||
"SEARCH_IN": "{{title}}에서 검색",
|
||||
"CLEAR_SEARCH": "검색 지우기",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다",
|
||||
"PLAYBACK_ERROR": "재생 URL을 가져오지 못했습니다",
|
||||
"LOADING_SEASONS": "시즌을 불러오는 중...",
|
||||
"LOADING_EPISODES": "에피소드를 불러오는 중...",
|
||||
"EPISODES_LOAD_FAILED": "에피소드를 불러올 수 없습니다.",
|
||||
"LOADING_ALL_CHANNELS": "전체 채널 목록 불러오는 중…",
|
||||
"REFRESH_CHANNEL_LIST": "채널 목록 새로고침",
|
||||
"NO_CHANNELS_IN_CATEGORY": "이 카테고리에 채널이 없습니다",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Vult de ruimte rond de speler met een vervaagde, gedimde posterafbeelding in plaats van zwarte balken.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Hierna-paneel met afleveringen",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Zet de seriespeler in brede vensters links vast en toont de komende afleveringen ernaast.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Zenderlijst in volledig scherm",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Beweeg in volledig scherm de muis naar de linkerrand of druk op C om de zenderlijst in te schuiven en van zender te wisselen zonder het volledige scherm te verlaten.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Zender- en afleveringenlijst in volledig scherm",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Laat de muis op de linkerrand rusten, klik erop of druk op C in volledig scherm om de zenderlijst van een live-uitzending of de afleveringenlijst van een serie te tonen en te wisselen zonder het volledige scherm te verlaten.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Selecteer het standaard streamformaat (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Selecteer de taal van de applicatie",
|
||||
"THEME_DESCRIPTION": "Kies het visuele thema van de applicatie",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Volledig scherm sluiten",
|
||||
"CHANNEL_LIST": "Zenderlijst",
|
||||
"HIDE_CHANNEL_LIST": "Zenderlijst verbergen",
|
||||
"EPISODE_LIST": "Afleveringenlijst",
|
||||
"HIDE_EPISODE_LIST": "Afleveringenlijst verbergen",
|
||||
"SEARCH_CHANNELS": "Zenders zoeken",
|
||||
"SEARCH_IN": "Zoeken in {{title}}",
|
||||
"CLEAR_SEARCH": "Zoekopdracht wissen",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Deze inhoud is niet beschikbaar op deze server",
|
||||
"PLAYBACK_ERROR": "Ophalen van afspeel-URL mislukt",
|
||||
"LOADING_SEASONS": "Seizoenen laden...",
|
||||
"LOADING_EPISODES": "Afleveringen laden...",
|
||||
"EPISODES_LOAD_FAILED": "De afleveringen konden niet worden geladen.",
|
||||
"LOADING_ALL_CHANNELS": "Volledige zenderlijst wordt geladen…",
|
||||
"REFRESH_CHANNEL_LIST": "Zenderlijst vernieuwen",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Geen zenders in deze categorie",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Wypełnij przestrzeń wokół odtwarzacza rozmytym, przyciemnianym plakatem zamiast zwykłych czarnych pasków.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Panel następnych odcinków",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "W szerokich oknach zadokuj odtwarzacz serialu po lewej stronie i pokaż obok kolejne odcinki.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista kanałów na pełnym ekranie",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Na pełnym ekranie przesuń mysz do lewej krawędzi lub naciśnij C, aby wysunąć listę kanałów i zmienić kanał bez wychodzenia z pełnego ekranu.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista kanałów i odcinków w trybie pełnoekranowym",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "W trybie pełnoekranowym przytrzymaj mysz przy lewej krawędzi, kliknij ją lub naciśnij C, aby wysunąć listę kanałów transmisji na żywo lub listę odcinków serialu i przełączać bez wychodzenia z pełnego ekranu.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Wybierz domyślny format strumienia (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Wybierz język aplikacji",
|
||||
"THEME_DESCRIPTION": "Wybierz motyw wizualny aplikacji",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Wyłącz pełny ekran",
|
||||
"CHANNEL_LIST": "Lista kanałów",
|
||||
"HIDE_CHANNEL_LIST": "Ukryj listę kanałów",
|
||||
"EPISODE_LIST": "Lista odcinków",
|
||||
"HIDE_EPISODE_LIST": "Ukryj listę odcinków",
|
||||
"SEARCH_CHANNELS": "Szukaj kanałów",
|
||||
"SEARCH_IN": "Szukaj w {{title}}",
|
||||
"CLEAR_SEARCH": "Wyczyść wyszukiwanie",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Ta treść jest niedostępna na tym serwerze",
|
||||
"PLAYBACK_ERROR": "Nie udało się uzyskać URL odtwarzania",
|
||||
"LOADING_SEASONS": "Ładowanie sezonów...",
|
||||
"LOADING_EPISODES": "Ładowanie odcinków...",
|
||||
"EPISODES_LOAD_FAILED": "Nie udało się załadować odcinków.",
|
||||
"LOADING_ALL_CHANNELS": "Ładowanie pełnej listy kanałów…",
|
||||
"REFRESH_CHANNEL_LIST": "Odśwież listę kanałów",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Brak kanałów w tej kategorii",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Preenche o espaço ao redor do reprodutor com um pôster desfocado e escurecido em vez de barras pretas simples.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Painel de próximos episódios",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Em janelas largas, ancora o reprodutor de séries à esquerda e mostra os próximos episódios ao lado.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista de canais em tela cheia",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Em tela cheia, leve o mouse até a borda esquerda ou pressione C para deslizar a lista de canais e trocar de canal sem sair da tela cheia.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Lista de canais e episódios em tela cheia",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Deixe o mouse na borda esquerda, clique nela ou pressione C em tela cheia para mostrar a lista de canais de uma transmissão ao vivo ou a lista de episódios de uma série e trocar sem sair da tela cheia.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Selecionar formato de stream padrão (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Selecionar idioma do aplicativo",
|
||||
"THEME_DESCRIPTION": "Selecionar tema visual do aplicativo",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Sair da tela cheia",
|
||||
"CHANNEL_LIST": "Lista de canais",
|
||||
"HIDE_CHANNEL_LIST": "Ocultar lista de canais",
|
||||
"EPISODE_LIST": "Lista de episódios",
|
||||
"HIDE_EPISODE_LIST": "Ocultar lista de episódios",
|
||||
"SEARCH_CHANNELS": "Pesquisar canais",
|
||||
"SEARCH_IN": "Pesquisar em {{title}}",
|
||||
"CLEAR_SEARCH": "Limpar pesquisa",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Este conteúdo não está disponível neste servidor",
|
||||
"PLAYBACK_ERROR": "Falha ao obter URL de reprodução",
|
||||
"LOADING_SEASONS": "Carregando temporadas...",
|
||||
"LOADING_EPISODES": "Carregando episódios...",
|
||||
"EPISODES_LOAD_FAILED": "Não foi possível carregar os episódios.",
|
||||
"LOADING_ALL_CHANNELS": "A carregar a lista completa de canais…",
|
||||
"REFRESH_CHANNEL_LIST": "Atualizar lista de canais",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Nenhum canal nesta categoria",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Заполнять пространство вокруг плеера размытым затемнённым постером вместо чёрных полос.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Панель следующих серий",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Список каналов в полноэкранном режиме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Подведите мышь к левому краю или нажмите C в полноэкранном режиме, чтобы выдвинуть список каналов и переключить канал, не выходя из полноэкранного режима.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Список каналов и серий в полноэкранном режиме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "В полноэкранном режиме наведите мышь на левый край, нажмите на него или нажмите C, чтобы показать список каналов прямого эфира или список серий сериала и переключиться, не выходя из полноэкранного режима.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto предпочитает HLS. При сбое в MPV, VLC или Embedded MPV остановите плеер, выберите ts, сохраните настройки и снова откройте канал. Веб-плееры могут один раз попробовать заявленный TS после первоначальной HTTP-ошибки HLS.",
|
||||
"LANGUAGE_DESCRIPTION": "Выбрать язык приложения",
|
||||
"THEME_DESCRIPTION": "Выбрать визуальную тему оформления",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Выйти из полноэкранного режима",
|
||||
"CHANNEL_LIST": "Список каналов",
|
||||
"HIDE_CHANNEL_LIST": "Скрыть список каналов",
|
||||
"EPISODE_LIST": "Список серий",
|
||||
"HIDE_EPISODE_LIST": "Скрыть список серий",
|
||||
"SEARCH_CHANNELS": "Поиск каналов",
|
||||
"SEARCH_IN": "Поиск в {{title}}",
|
||||
"CLEAR_SEARCH": "Очистить поиск",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере",
|
||||
"PLAYBACK_ERROR": "Не удалось получить URL для воспроизведения",
|
||||
"LOADING_SEASONS": "Загрузка сезонов…",
|
||||
"LOADING_EPISODES": "Загрузка серий…",
|
||||
"EPISODES_LOAD_FAILED": "Не удалось загрузить серии.",
|
||||
"LOADING_ALL_CHANNELS": "Загрузка полного списка каналов…",
|
||||
"REFRESH_CHANNEL_LIST": "Обновить список каналов",
|
||||
"NO_CHANNELS_IN_CATEGORY": "В этой категории нет каналов",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Oynatıcının çevresindeki boşluğu siyah çubuklar yerine bulanık ve karartılmış bir poster ile doldurur.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Sıradaki bölümler paneli",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Geniş pencerelerde dizi oynatıcısını sola yaslar ve sıradaki bölümleri yanında gösterir.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Tam ekranda kanal listesi",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Tam ekrandayken fareyi sol kenara getirin veya C tuşuna basın: kanal listesi kayarak açılır ve tam ekrandan çıkmadan kanal değiştirebilirsiniz.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Tam ekranda kanal ve bölüm listesi",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Tam ekrandayken fareyi sol kenara getirin, tıklayın veya C tuşuna basın; canlı yayının kanal listesi ya da dizinin bölüm listesi açılır ve tam ekrandan çıkmadan geçiş yapabilirsiniz.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Varsayılan yayın formatını seçin (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Uygulamanın dilini seçin",
|
||||
"THEME_DESCRIPTION": "Uygulamanın görünüm temasını seçin",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "Tam ekrandan çık",
|
||||
"CHANNEL_LIST": "Kanal listesi",
|
||||
"HIDE_CHANNEL_LIST": "Kanal listesini gizle",
|
||||
"EPISODE_LIST": "Bölüm listesi",
|
||||
"HIDE_EPISODE_LIST": "Bölüm listesini gizle",
|
||||
"SEARCH_CHANNELS": "Kanal ara",
|
||||
"SEARCH_IN": "{{title}} içinde ara",
|
||||
"CLEAR_SEARCH": "Aramayı temizle",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Bu içerik bu sunucuda mevcut değil",
|
||||
"PLAYBACK_ERROR": "Oynatma URL'si alınamadı",
|
||||
"LOADING_SEASONS": "Sezonlar yükleniyor...",
|
||||
"LOADING_EPISODES": "Bölümler yükleniyor...",
|
||||
"EPISODES_LOAD_FAILED": "Bölümler yüklenemedi.",
|
||||
"LOADING_ALL_CHANNELS": "Tüm kanal listesi yükleniyor…",
|
||||
"REFRESH_CHANNEL_LIST": "Kanal listesini yenile",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Bu kategoride kanal yok",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、暗淡的海报填充播放器周围的空白,而不是黑条纹。",
|
||||
"PLAYER_UP_NEXT_RAIL": "「接下来播放」剧集栏",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在宽窗口下,将剧集播放器停靠在左侧,并在旁边显示后续剧集。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全屏时显示频道列表",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全屏播放时,将鼠标移到左边缘或按 C 键即可滑出频道列表,无需退出全屏即可切换频道。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全屏时的频道和剧集列表",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全屏时将鼠标停在左边缘、点击它或按 C 键,即可滑出直播的频道列表或剧集的分集列表,无需退出全屏即可切换。",
|
||||
"STREAM_FORMAT_DESCRIPTION": "选择默认的流格式(xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "选择应用程序的语言",
|
||||
"THEME_DESCRIPTION": "选择应用程序的视觉主题",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "退出全屏",
|
||||
"CHANNEL_LIST": "频道列表",
|
||||
"HIDE_CHANNEL_LIST": "隐藏频道列表",
|
||||
"EPISODE_LIST": "剧集列表",
|
||||
"HIDE_EPISODE_LIST": "隐藏剧集列表",
|
||||
"SEARCH_CHANNELS": "搜索频道",
|
||||
"SEARCH_IN": "在 {{title}} 中搜索",
|
||||
"CLEAR_SEARCH": "清除搜索",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "此服务器上没有此内容",
|
||||
"PLAYBACK_ERROR": "获取播放 URL 失败",
|
||||
"LOADING_SEASONS": "正在加载季…",
|
||||
"LOADING_EPISODES": "正在加载剧集…",
|
||||
"EPISODES_LOAD_FAILED": "无法加载剧集。",
|
||||
"LOADING_ALL_CHANNELS": "正在加载完整频道列表…",
|
||||
"REFRESH_CHANNEL_LIST": "刷新频道列表",
|
||||
"NO_CHANNELS_IN_CATEGORY": "此分类中没有频道",
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、昏暗的海報填充播放器周圍的空間,取代純黑色邊框。",
|
||||
"PLAYER_UP_NEXT_RAIL": "即將播放集數側欄",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在較寬的視窗中,將影集播放器停靠在左側,並在旁邊顯示即將播放的集數。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全螢幕時顯示頻道清單",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全螢幕播放時,將滑鼠移到左邊緣或按 C 鍵即可滑出頻道清單,不必離開全螢幕就能切換頻道。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全螢幕時的頻道與集數清單",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全螢幕時將滑鼠停在左側邊緣、點擊它或按 C 鍵,即可滑出直播的頻道清單或影集的集數清單,不需離開全螢幕即可切換。",
|
||||
"STREAM_FORMAT_DESCRIPTION": "選擇預設串流格式(xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "選擇應用程式語言",
|
||||
"THEME_DESCRIPTION": "選擇應用程式視覺主題",
|
||||
@@ -609,6 +609,8 @@
|
||||
"EXIT_FULLSCREEN": "離開全螢幕",
|
||||
"CHANNEL_LIST": "頻道清單",
|
||||
"HIDE_CHANNEL_LIST": "隱藏頻道清單",
|
||||
"EPISODE_LIST": "集數清單",
|
||||
"HIDE_EPISODE_LIST": "隱藏集數清單",
|
||||
"SEARCH_CHANNELS": "搜尋頻道",
|
||||
"SEARCH_IN": "在 {{title}} 中搜尋",
|
||||
"CLEAR_SEARCH": "清除搜尋",
|
||||
@@ -1254,6 +1256,8 @@
|
||||
"CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容",
|
||||
"PLAYBACK_ERROR": "無法取得播放網址",
|
||||
"LOADING_SEASONS": "正在載入季數...",
|
||||
"LOADING_EPISODES": "正在載入集數...",
|
||||
"EPISODES_LOAD_FAILED": "無法載入集數。",
|
||||
"LOADING_ALL_CHANNELS": "正在載入完整頻道清單…",
|
||||
"REFRESH_CHANNEL_LIST": "重新整理頻道清單",
|
||||
"NO_CHANNELS_IN_CATEGORY": "此分類中沒有頻道",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -248,6 +248,16 @@ seasons, with per-episode watch-progress bars from playback positions.
|
||||
- Layering: the rail is an opaque panel rendered on top of the stage, so the
|
||||
ambient fill stays behind it and shows in the flexible gap between the
|
||||
docked player and the rail on very wide stages.
|
||||
- Fullscreen: the rail cannot show over a fullscreen video, so the same
|
||||
host offers the whole series — season tabs plus the selected season's
|
||||
episodes — as the slide-in side panel of the player's fullscreen surface.
|
||||
`PortalInlinePlayerComponent` provides `FULLSCREEN_CHANNEL_PANEL` for the
|
||||
nested view from the hosts' `seriesEpisodes` / `episodePlaybackPositions` /
|
||||
`seasonLoadStates` inputs, an episode picked there travels the same
|
||||
`upNextEpisodeSelected` output as a rail click, and a season tab picked
|
||||
there travels `episodePanelSeasonSelected` into the host's
|
||||
`onSeasonSelected`. Contract: "Fullscreen episode panel" in
|
||||
`docs/architecture/player-controls-contract.md`.
|
||||
|
||||
Season navigation inside `SeasonContainerComponent` uses season tabs
|
||||
(`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old
|
||||
|
||||
@@ -399,11 +399,21 @@ is rendered by `WebPlayerViewComponent` as a sibling of the engine and staged
|
||||
on the view's `fullscreenSurface` — the same host element every engine
|
||||
receives as `fullscreenTarget` — so it sits inside the fullscreen element and
|
||||
survives the engine remount a channel switch causes.
|
||||
It injects `FULLSCREEN_CHANNEL_PANEL` optionally: a live host provides
|
||||
`FullscreenChannelPanelHost` (`panelTemplate`, optional `panelTitle`) from its
|
||||
component `providers`, and the panel stamps that template into its body with a
|
||||
`FullscreenChannelPanelContext` of `{ searchTerm: Signal<string>, close }`.
|
||||
Without a provider — VOD detail pages, series playback — nothing renders. The
|
||||
It is a host-agnostic side panel — a channel list for live hosts, an episode
|
||||
list for series playback (see "Fullscreen episode panel" below) — and keeps
|
||||
its `FULLSCREEN_CHANNEL_PANEL` name for history. It injects the token
|
||||
optionally: a host provides `FullscreenChannelPanelHost` from its component
|
||||
`providers` — `panelTemplate`, optional `panelTitle`, optional
|
||||
`panelSearchEnabled` (default true; false drops the header's search field and
|
||||
shows the title as a text row in its place, keeping the header height) and
|
||||
optional `panelKind` (`'channels'` | `'episodes'`, only the accessible name
|
||||
of the list and the close button's label; every pointer and keyboard rule
|
||||
is identical for both) — and the panel stamps that template into its body
|
||||
with a `FullscreenChannelPanelContext` of
|
||||
`{ searchTerm: Signal<string>, open: Signal<boolean>, close }`. `open` lets a
|
||||
body that stays mounted between openings react to the panel coming up (the
|
||||
episode list centres its playing row on it). Without a provider — a movie in
|
||||
a VOD detail page — nothing renders. The
|
||||
host resolves the user preference itself: `Settings.fullscreenChannelPanel`
|
||||
(default on, Settings → Playback, offered for the web players while the
|
||||
shared controls are on and for Embedded MPV — the legacy vendor chrome
|
||||
@@ -505,22 +515,44 @@ engine on first entry; a confirmed frame-copy capability survives an unknown
|
||||
probe during an MPV application remount, preserving the open panel and its
|
||||
search/scroll state. A confirmed native/unsupported result revokes it, and
|
||||
switching to a web engine clears the remembered MPV capability). Nothing is
|
||||
drawn over the video while the panel is closed — there is
|
||||
no handle or hint. An invisible 28px hot zone (40px on coarse pointers) on the
|
||||
drawn over the video while the panel is closed and the pointer rests. Mouse
|
||||
movement over the stage (the fullscreen element) reveals a slim, pointer-
|
||||
transparent hint tab on the left edge — a CSS chevron, no icon glyph or text
|
||||
— that fades `CHANNEL_PANEL_HINT_IDLE_MS` (2.5 s) after the last move, so it
|
||||
comes and goes with the controls chrome; it lights up (`--armed`) while the
|
||||
pointer rests in the hot zone, and it is not rendered while the panel is
|
||||
open. Touch movement never reveals it. The hint answered the first field
|
||||
report: the zone was an invisible strip with nothing telling the user where
|
||||
the list lived. An invisible 40px hot zone (48px on coarse pointers) on the
|
||||
left edge opens the panel after a 160ms mouse dwell; a sweep across the edge
|
||||
is ignored. The zone remains mounted above the scrim and below the panel while
|
||||
open, preserving the pointer target until the opening animation covers it.
|
||||
A delayed fullscreen paint therefore cannot turn a stationary edge hover into
|
||||
a synthetic mouse-leave that closes the panel. The zone stops above the controls bar (`bottom: max(25%, 140px)`)
|
||||
so the leftmost transport button never loses a click or tap to it. The `C` key opens it too and focuses the search field (hover does
|
||||
not steal focus). Touch has neither hover nor a `C` key, so a tap on the hot
|
||||
zone opens the panel at once: the handler is bound to `pointerup`, not
|
||||
`pointerdown`, so the hot zone is still the tap's click target and the click
|
||||
that follows dies on it instead of reaching the video. It closes when the
|
||||
mouse leaves the panel for 420ms, on the close button (tooltip names Escape),
|
||||
on Escape, on the host's `close`, or through a transparent scrim
|
||||
over the video that swallows the click so the player's click-to-pause never
|
||||
sees it. The Escape that closes the panel is consumed (`preventDefault`):
|
||||
is ignored, and a click or tap on the zone opens at once without the dwell —
|
||||
only a primary press that began inside the zone and is released there
|
||||
(`pointerdown` records the pointer, `pointerup` must match it; a drag
|
||||
released over the edge, a right or middle button, or a pen barrel button
|
||||
never opens). The synthetic `pointerenter` that follows an explicit close
|
||||
neither opens nor arms the hint: the zone re-arms on the next real
|
||||
`pointermove`. The zone remains mounted above the
|
||||
scrim and below the panel while open, preserving the pointer target until
|
||||
the opening animation covers it. A delayed fullscreen paint therefore cannot
|
||||
turn a stationary edge hover into a synthetic mouse-leave that closes the
|
||||
panel. The zone stops above the controls bar (`bottom: max(25%, 140px)`) so
|
||||
the leftmost transport button never loses a click or tap to it. The `C` key
|
||||
opens it too and focuses the search field — or, for a host without one, the
|
||||
panel itself (`tabindex="-1"`), so the next Tab reaches its first control
|
||||
(hover does not steal focus).
|
||||
Touch has neither hover nor a `C` key, so the tap path above is its way in:
|
||||
the handler is bound to `pointerup`, not `pointerdown`, so the hot zone is
|
||||
still the tap's click target and the click that follows dies on it instead
|
||||
of reaching the video. It closes when the mouse leaves the panel for
|
||||
`CHANNEL_PANEL_CLOSE_GRACE_MS` (1 s) — but only once the pointer has engaged
|
||||
with the panel: a hover-opened panel counts as engaged from the start, while
|
||||
a `C`-opened one ignores the mouse roaming over the video until it has
|
||||
visited the list, so the shortcut never leaves the user typing into a closing
|
||||
search field (`FullscreenChannelPanelState.show(opener)`) — on the close
|
||||
button (tooltip names Escape), on Escape, on the host's `close`, or through
|
||||
a transparent scrim over the video that swallows the click so the player's
|
||||
click-to-pause never sees it. Clicks inside the panel never close it: the
|
||||
panel sits above the scrim, so no in-panel hit can reach it. The Escape that closes the panel is consumed (`preventDefault`):
|
||||
Electron leaves HTML fullscreen on an unhandled Escape, and the close
|
||||
shortcut must only slide the panel away; a closed panel leaves Escape alone,
|
||||
so the key still exits fullscreen then. A CDK overlay the list opens (sort menu, row context menu) renders in
|
||||
@@ -555,6 +587,76 @@ without it every tooltip, sort menu and context menu opened while fullscreen —
|
||||
the panel's view-switcher tooltips and row context menus included — would sit
|
||||
invisibly under the top layer.
|
||||
|
||||
#### Fullscreen episode panel (series)
|
||||
|
||||
Series playback gets the same panel as an **episode list**. The provider is
|
||||
`PortalInlinePlayerComponent` (`libs/ui/playback/src/lib/portal-inline-player/`),
|
||||
the component both series hosts (Xtream `SerialDetailsComponent`, Stalker
|
||||
`StalkerSeriesViewComponent`) render around `app-web-player-view` and that
|
||||
already feeds the Up Next rail — so it is the nearest provider for the nested
|
||||
view, which also shields that view from a page-level channel-list provider
|
||||
(the M3U player's) while its VOD detail hosts the player. The host object
|
||||
itself is built by `createEpisodePanelHost()`
|
||||
(`portal-inline-player-episode-panel.host.ts`) from the component's inputs
|
||||
and exposed as `episodePanel`, so the player's own responsibilities stay
|
||||
readable. It declares `panelKind: 'episodes'` and `panelSearchEnabled:
|
||||
false`: season tabs are the navigation, and the header shows the series
|
||||
title (`seriesTitle`, else the playback title) where the search field would
|
||||
be.
|
||||
|
||||
- Data: the hosts pass their season→episodes map (`seriesEpisodes`, the same
|
||||
`Record<seasonKey, XtreamSerieEpisode[]>` the season container gets, so the
|
||||
TMDB overlay's stills and overviews ride in `info`), the per-episode
|
||||
playback-position map (`episodePlaybackPositions`) and, for Stalker lazy
|
||||
VOD series, per-season load states (`seasonLoadStates`: `loading` while
|
||||
a request is on the wire, `unloaded` while the portal has not answered —
|
||||
after a failed request too; `vodSeasonLoadStates` on the host, computed
|
||||
by `getVodSeasonLoadStates()` in `@iptvnator/portal/stalker/data-access`).
|
||||
`buildFullscreenEpisodePanelSeasons()`
|
||||
(`libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.ts`)
|
||||
turns them into `FullscreenEpisodePanelSeason[]` — numeric keys ascending,
|
||||
named keys after, each row a `FullscreenEpisodePanelItem` that extends the
|
||||
Up Next entry with `seasonKey`, `episodeNumber`, `overview`, a "45 min"
|
||||
`durationLabel` (numeric `duration_secs`, else the portals' text forms) and
|
||||
the shared ≥90 % `watched` rule; the playing row is the one whose id equals
|
||||
`contentInfo.contentXtreamId`.
|
||||
- Body: `app-fullscreen-episode-panel` stamps `SeasonTabsComponent` (the
|
||||
detail page's tabs: pills up to six seasons, a dropdown beyond, watched
|
||||
check marks, the "Back to playing episode" chip) over the selected season's
|
||||
rows: a 16:9 still or, without one, a large numeral tile so the no-TMDB
|
||||
case still looks designed; the `S01E03` label, runtime, watched check or
|
||||
"Now playing" marker; the title (label as fallback); a 3-line clamped
|
||||
overview when there is one; a progress bar on the thumbnail. Rows are
|
||||
buttons inside `<li>`s of a `<ul>`, so their button role survives for
|
||||
assistive technology. A loading season shows a spinner row, a loaded empty
|
||||
one the season-empty copy, and an unanswered one a "could not be loaded"
|
||||
row with a Retry button that re-emits the season selection — the tabs
|
||||
never re-emit an already selected key, so a failed lazy load would
|
||||
otherwise be stuck.
|
||||
- Selection: the tab follows the playing episode's season (`linkedSignal`)
|
||||
and resets to it whenever playback moves into another season; a tab the
|
||||
user picks holds until then. Opening the panel (context `open`) centres
|
||||
the playing row inside the list's own scroll box — `scrollTop` math, never
|
||||
`scrollIntoView`, so the stage and page are not scrolled with it — and the
|
||||
effect depends on primitives only (open flag, shown season key, playing
|
||||
season key and episode id), so the season objects a progress tick rebuilds
|
||||
never yank a list the user is scrolling.
|
||||
- Actions: an episode click emits the item through the inline player's
|
||||
`upNextEpisodeSelected` — the Up Next rail's output, so the host plays it
|
||||
through its inline episode flow and the engine remount keeps fullscreen
|
||||
exactly as a "next episode" does — and then calls the context's `close`;
|
||||
a click on the playing row is inert. A season tab click emits
|
||||
`episodePanelSeasonSelected` (as does the retry row), wired by both hosts
|
||||
to the same `onSeasonSelected` their season container uses, so Xtream's
|
||||
TMDB season enrichment and Stalker's lazy VOD season load run for the
|
||||
panel's season too.
|
||||
- Gates: `Settings.fullscreenChannelPanel` (one setting for channels and
|
||||
episodes; its label reads "Channel and episode list in fullscreen"),
|
||||
`contentInfo.contentType === 'episode'` and non-live playback (a movie
|
||||
never gets the panel), and at least one season. Native-view Embedded MPV is
|
||||
withheld by the view's `enabled` input as for channels; external MPV/VLC
|
||||
never mount the inline player, so they are excluded by construction.
|
||||
|
||||
### Keyboard ownership
|
||||
|
||||
Unmodified Space/K, F, arrow keys, and M are playback shortcuts. Playback keys
|
||||
|
||||
@@ -495,6 +495,80 @@ Zoom level (Cmd/Ctrl and +/−/0, issue #1109):
|
||||
`keyboard-shortcuts.spec.ts`, `workspace-keyboard-shortcuts.service.spec.ts`
|
||||
and `preload-zoom-level.spec.ts`.
|
||||
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from './lib/stalker-portal-discovery.service';
|
||||
export * from './lib/stalker-portal-discovery.utils';
|
||||
export * from './lib/stalker-portal-repair.service';
|
||||
export * from './lib/stalker-series.adapters';
|
||||
export * from './lib/stalker-series-load-state';
|
||||
export * from './lib/stalker-session.service';
|
||||
export * from './lib/stalker-vod.utils';
|
||||
export * from './lib/stalker.store';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { VodSeriesSeasonVm } from './stalker-series.adapters';
|
||||
import {
|
||||
getVodSeasonLoadStates,
|
||||
isVodSeasonHydrationPending,
|
||||
} from './stalker-series-load-state';
|
||||
|
||||
function season(
|
||||
key: string,
|
||||
overrides: Partial<VodSeriesSeasonVm> = {}
|
||||
): VodSeriesSeasonVm {
|
||||
return {
|
||||
id: `id-${key}`,
|
||||
video_id: 'v1',
|
||||
name: `Season ${key}`,
|
||||
season_number: key,
|
||||
episodes: [],
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
episodesLoaded: false,
|
||||
...overrides,
|
||||
} as VodSeriesSeasonVm;
|
||||
}
|
||||
|
||||
describe('stalker series load state', () => {
|
||||
it('reports in-flight, unanswered and loaded seasons for the fullscreen panel', () => {
|
||||
const states = getVodSeasonLoadStates([
|
||||
season('1', {
|
||||
episodes: [{ id: 'e1' } as never],
|
||||
episodesLoaded: true,
|
||||
}),
|
||||
season('2', { isLoading: true }),
|
||||
season('3'),
|
||||
// Answered with nothing: loaded-and-empty, not pending.
|
||||
season('4', { episodesLoaded: true }),
|
||||
]);
|
||||
|
||||
expect(states).toEqual({ '2': 'loading', '3': 'unloaded' });
|
||||
});
|
||||
|
||||
it('treats an answered empty season as hydrated', () => {
|
||||
expect(isVodSeasonHydrationPending(season('1'))).toBe(true);
|
||||
expect(
|
||||
isVodSeasonHydrationPending(season('1', { episodesLoaded: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { VodSeriesSeasonVm } from './stalker-series.adapters';
|
||||
import { getVodSeriesSeasonKey } from './stalker-series.adapters';
|
||||
|
||||
/**
|
||||
* True for a lazy VOD season the portal has never answered for. A season
|
||||
* that answered with zero episodes is loaded-and-empty, not pending —
|
||||
* treating it as pending would keep series labels countless forever and
|
||||
* make every series toggle re-fetch it.
|
||||
*/
|
||||
export function isVodSeasonHydrationPending(
|
||||
season: VodSeriesSeasonVm
|
||||
): boolean {
|
||||
return season.episodes.length === 0 && !season.episodesLoaded;
|
||||
}
|
||||
|
||||
export type VodSeasonLoadState = 'loading' | 'unloaded';
|
||||
|
||||
/**
|
||||
* Per-season load state for the fullscreen episode panel, keyed like the
|
||||
* season container's map: `loading` while a request is on the wire,
|
||||
* `unloaded` while the portal has not answered — after a failed request too,
|
||||
* where the panel offers a retry. Loaded seasons are absent.
|
||||
*/
|
||||
export function getVodSeasonLoadStates(
|
||||
seasons: readonly VodSeriesSeasonVm[]
|
||||
): Record<string, VodSeasonLoadState> {
|
||||
const states: Record<string, VodSeasonLoadState> = {};
|
||||
for (const season of seasons) {
|
||||
if (season.isLoading) {
|
||||
states[getVodSeriesSeasonKey(season)] = 'loading';
|
||||
} else if (isVodSeasonHydrationPending(season)) {
|
||||
states[getVodSeriesSeasonKey(season)] = 'unloaded';
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
+4
@@ -211,6 +211,9 @@
|
||||
[seriesTitle]="serial.info.name"
|
||||
[seriesNavigation]="inlineSeriesNavigation()"
|
||||
[upNextEpisodes]="upNextEpisodes()"
|
||||
[seriesEpisodes]="mappedSeasons()"
|
||||
[episodePlaybackPositions]="episodePlaybackPositions()"
|
||||
[seasonLoadStates]="vodSeasonLoadStates()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
@@ -219,6 +222,7 @@
|
||||
(previousEpisodeRequested)="playPreviousEpisode()"
|
||||
(nextEpisodeRequested)="playNextEpisode()"
|
||||
(upNextEpisodeSelected)="playUpNextEpisode($event)"
|
||||
(episodePanelSeasonSelected)="onSeasonSelected($event)"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
|
||||
+8
-1
@@ -51,7 +51,9 @@ import {
|
||||
resolveStalkerBackNavigation,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
getVodSeasonLoadStates,
|
||||
getVodSeriesSeasonKey,
|
||||
isVodSeasonHydrationPending,
|
||||
isVodSeriesItem,
|
||||
mapRegularSeriesEpisodes,
|
||||
mapRegularSeriesSeasons,
|
||||
@@ -1237,7 +1239,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
* every series toggle re-fetch it.
|
||||
*/
|
||||
private isSeasonHydrationPending(season: VodSeriesSeasonVm): boolean {
|
||||
return season.episodes.length === 0 && !season.episodesLoaded;
|
||||
return isVodSeasonHydrationPending(season);
|
||||
}
|
||||
|
||||
/** Seasons whose episode lists still need a portal request (lazy VOD). */
|
||||
@@ -1249,6 +1251,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
)
|
||||
);
|
||||
|
||||
/** Per-season load state for the fullscreen episode panel (lazy VOD). */
|
||||
readonly vodSeasonLoadStates = computed(() =>
|
||||
this.isVodSeries() ? getVodSeasonLoadStates(this.vodSeriesSeasons()) : {}
|
||||
);
|
||||
|
||||
async handleSeriesPlaybackToggleRequested(
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest
|
||||
): Promise<void> {
|
||||
|
||||
+4
@@ -48,6 +48,9 @@ export class StubPortalInlinePlayerComponent {
|
||||
readonly seriesTitle = input<string | null>(null);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly upNextEpisodes = input<unknown>(null);
|
||||
readonly seriesEpisodes = input<unknown>(null);
|
||||
readonly episodePlaybackPositions = input<unknown>(null);
|
||||
readonly seasonLoadStates = input<unknown>(null);
|
||||
readonly timeUpdate = output<unknown>();
|
||||
readonly closed = output<void>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
@@ -56,6 +59,7 @@ export class StubPortalInlinePlayerComponent {
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
readonly upNextEpisodeSelected = output<unknown>();
|
||||
readonly episodePanelSeasonSelected = output<string>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -218,6 +218,8 @@
|
||||
[seriesTitle]="item.info.name"
|
||||
[seriesNavigation]="inlineSeriesNavigation()"
|
||||
[upNextEpisodes]="upNextEpisodes()"
|
||||
[seriesEpisodes]="item.episodes"
|
||||
[episodePlaybackPositions]="episodePlaybackPositions()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
@@ -226,6 +228,7 @@
|
||||
(previousEpisodeRequested)="playPreviousEpisode()"
|
||||
(nextEpisodeRequested)="playNextEpisode()"
|
||||
(upNextEpisodeSelected)="playUpNextEpisode($event)"
|
||||
(episodePanelSeasonSelected)="onSeasonSelected($event)"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
|
||||
@@ -71,6 +71,9 @@ class StubPortalInlinePlayerComponent {
|
||||
readonly seriesTitle = input<string | null>(null);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly upNextEpisodes = input<unknown>(null);
|
||||
readonly seriesEpisodes = input<unknown>(null);
|
||||
readonly episodePlaybackPositions = input<unknown>(null);
|
||||
readonly seasonLoadStates = input<unknown>(null);
|
||||
readonly timeUpdate = output<unknown>();
|
||||
readonly closed = output<void>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
@@ -79,6 +82,7 @@ class StubPortalInlinePlayerComponent {
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
readonly upNextEpisodeSelected = output<unknown>();
|
||||
readonly episodePanelSeasonSelected = output<string>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -48,6 +48,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';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
export * from './lib/season-container/season-tabs.component';
|
||||
export * from './lib/season-container/season-watch-presenter';
|
||||
export * from './lib/season-container/season-watch-toggle.util';
|
||||
export * from './lib/vod-sources/vod-source-copy-row.component';
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './lib/embedded-mpv-player/embedded-mpv-player.component';
|
||||
export * from './lib/embedded-mpv-player/embedded-mpv-session-controller';
|
||||
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
|
||||
export * from './lib/fullscreen-channel-panel';
|
||||
export * from './lib/fullscreen-episode-panel';
|
||||
export * from './lib/html-video-player/html-video-player.component';
|
||||
export * from '@iptvnator/playback/util';
|
||||
export * from './lib/player-controls';
|
||||
|
||||
+111
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CHANNEL_PANEL_CLOSE_GRACE_MS,
|
||||
CHANNEL_PANEL_HINT_IDLE_MS,
|
||||
CHANNEL_PANEL_OPEN_DWELL_MS,
|
||||
FullscreenChannelPanelState,
|
||||
} from './fullscreen-channel-panel-state';
|
||||
@@ -62,6 +63,113 @@ describe('FullscreenChannelPanelState', () => {
|
||||
expect(state.open()).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a keyboard-opened panel ignore the mouse until it has visited the panel', () => {
|
||||
state.show('keyboard');
|
||||
|
||||
// Roaming over the video: no close.
|
||||
state.panelLeave();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
expect(state.open()).toBe(true);
|
||||
|
||||
// Once the pointer has been inside, leaving closes as usual.
|
||||
state.panelEnter();
|
||||
state.panelLeave();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
expect(state.open()).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a hover-opened panel as engaged from the start', () => {
|
||||
state.hotZoneEnter();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
expect(state.open()).toBe(true);
|
||||
|
||||
state.panelLeave();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
expect(state.open()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the edge hint while the pointer moves over the stage and hides it once idle', () => {
|
||||
state.stageActivity();
|
||||
expect(state.hintVisible()).toBe(true);
|
||||
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_HINT_IDLE_MS - 1);
|
||||
state.stageActivity();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_HINT_IDLE_MS - 1);
|
||||
expect(state.hintVisible()).toBe(true);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(state.hintVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('arms the hint while the pointer rests in the hot zone and drops both on open', () => {
|
||||
state.hotZoneEnter();
|
||||
expect(state.hotZoneHover()).toBe(true);
|
||||
state.hotZoneLeave();
|
||||
expect(state.hotZoneHover()).toBe(false);
|
||||
|
||||
state.stageActivity();
|
||||
state.hotZoneEnter();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
expect(state.open()).toBe(true);
|
||||
expect(state.hintVisible()).toBe(false);
|
||||
expect(state.hotZoneHover()).toBe(false);
|
||||
|
||||
// Activity over the stage while open draws no hint.
|
||||
state.stageActivity();
|
||||
expect(state.hintVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not reopen from the synthetic hot-zone enter that follows an explicit close', () => {
|
||||
state.hotZoneEnter();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
expect(state.open()).toBe(true);
|
||||
|
||||
// Escape while the mouse still rests on the edge: the panel slides
|
||||
// away and the browser reports the zone under the pointer again.
|
||||
state.hide();
|
||||
state.hotZoneEnter();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
expect(state.open()).toBe(false);
|
||||
// The disarmed edge shows no armed hint either.
|
||||
expect(state.hotZoneHover()).toBe(false);
|
||||
|
||||
// A real move inside the zone arms the hint and starts the dwell.
|
||||
state.stageActivity();
|
||||
expect(state.hotZoneHover()).toBe(true);
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS - 1);
|
||||
expect(state.open()).toBe(false);
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(state.open()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not arm from a move once the pointer has left the disarmed zone', () => {
|
||||
state.show();
|
||||
state.hide();
|
||||
state.hotZoneEnter();
|
||||
state.hotZoneLeave();
|
||||
state.stageActivity();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
expect(state.open()).toBe(false);
|
||||
expect(state.hotZoneHover()).toBe(false);
|
||||
});
|
||||
|
||||
it('re-arms the hot zone on a move made before the pointer reaches it', () => {
|
||||
state.show();
|
||||
state.hide();
|
||||
state.stageActivity();
|
||||
state.hotZoneEnter();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
expect(state.open()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not restart a running dwell on every move inside the zone', () => {
|
||||
state.hotZoneEnter();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS - 10);
|
||||
state.stageActivity();
|
||||
jest.advanceTimersByTime(10);
|
||||
expect(state.open()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a panel leave while the panel is closed', () => {
|
||||
state.panelLeave();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
@@ -95,7 +203,10 @@ describe('FullscreenChannelPanelState', () => {
|
||||
expect(state.mounted()).toBe(false);
|
||||
|
||||
state.hotZoneEnter();
|
||||
state.stageActivity();
|
||||
state.reset();
|
||||
expect(state.hotZoneHover()).toBe(false);
|
||||
expect(state.hintVisible()).toBe(false);
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
expect(state.open()).toBe(false);
|
||||
});
|
||||
|
||||
+102
-12
@@ -9,15 +9,38 @@ export const CHANNEL_PANEL_OPEN_DWELL_MS = 160;
|
||||
|
||||
/**
|
||||
* Grace period after the mouse leaves the panel before it closes. Brushing
|
||||
* past the panel's edge while scrolling the list must not slam it shut.
|
||||
* past the panel's edge while scrolling the list, or reaching for the video
|
||||
* to glance at it, must not slam the panel shut.
|
||||
*/
|
||||
export const CHANNEL_PANEL_CLOSE_GRACE_MS = 420;
|
||||
export const CHANNEL_PANEL_CLOSE_GRACE_MS = 1000;
|
||||
|
||||
/**
|
||||
* How long the edge hint stays visible after the last pointer movement over
|
||||
* the stage. Roughly the controls bar's own auto-hide, so the hint comes and
|
||||
* goes with the rest of the player chrome.
|
||||
*/
|
||||
export const CHANNEL_PANEL_HINT_IDLE_MS = 2500;
|
||||
|
||||
/** What brought the panel up; decides whether hover-away may close it. */
|
||||
export type ChannelPanelOpener = 'pointer' | 'keyboard';
|
||||
|
||||
/**
|
||||
* Framework-light owner of the fullscreen channel panel's open/closed state
|
||||
* and the hover-intent timers around it. The component binds pointer events
|
||||
* to these methods; the timers here are the only place that decides when a
|
||||
* hover becomes an open or a close.
|
||||
*
|
||||
* Hover-away closes the panel only once the pointer has engaged with it: a
|
||||
* hover-opened panel counts as engaged from the start (the pointer is on the
|
||||
* edge), while a keyboard-opened one ignores the mouse roaming over the
|
||||
* video until it has visited the panel. Otherwise pressing `C` and nudging
|
||||
* the mouse would close the list under the user's typing.
|
||||
*
|
||||
* An explicit close (Escape, close button, `C`) re-arms the hot zone only on
|
||||
* the next real pointer movement: when the panel slides out from under a
|
||||
* mouse that is still resting on the edge, the browser synthesizes a
|
||||
* `pointerenter` on the zone the panel had covered, and honouring it would
|
||||
* reopen the list 160ms after the user dismissed it.
|
||||
*/
|
||||
export class FullscreenChannelPanelState {
|
||||
readonly open = signal(false);
|
||||
@@ -27,53 +50,76 @@ export class FullscreenChannelPanelState {
|
||||
* position and search text between two openings of the same session.
|
||||
*/
|
||||
readonly mounted = signal(false);
|
||||
/**
|
||||
* The edge hint is showing: the pointer moved over the stage recently
|
||||
* while the panel was closed, so the user is looking for something.
|
||||
*/
|
||||
readonly hintVisible = signal(false);
|
||||
/**
|
||||
* The pointer is resting in an armed hot zone, waiting out the dwell.
|
||||
* Stays false for the synthetic enter that follows an explicit close, so
|
||||
* the hint does not light up on an edge that cannot open yet.
|
||||
*/
|
||||
readonly hotZoneHover = signal(false);
|
||||
|
||||
private pointerEngaged = false;
|
||||
/** False between an explicit close and the next pointer move. */
|
||||
private pointerMovedSinceHide = true;
|
||||
/** Where the pointer physically is, armed or not. */
|
||||
private pointerInHotZone = false;
|
||||
private openTimer: number | null = null;
|
||||
private closeTimer: number | null = null;
|
||||
private hintTimer: number | null = null;
|
||||
|
||||
show(): void {
|
||||
show(opener: ChannelPanelOpener = 'pointer'): void {
|
||||
this.clearTimers();
|
||||
this.pointerEngaged = opener === 'pointer';
|
||||
this.hintVisible.set(false);
|
||||
this.hotZoneHover.set(false);
|
||||
this.mounted.set(true);
|
||||
this.open.set(true);
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.clearTimers();
|
||||
this.pointerEngaged = false;
|
||||
this.pointerMovedSinceHide = false;
|
||||
this.open.set(false);
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
toggle(opener: ChannelPanelOpener = 'pointer'): void {
|
||||
if (this.open()) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
this.show(opener);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mouse entered the edge hot zone: open once it has rested there. */
|
||||
hotZoneEnter(): void {
|
||||
if (this.open()) {
|
||||
this.pointerInHotZone = true;
|
||||
if (this.open() || !this.pointerMovedSinceHide) {
|
||||
return;
|
||||
}
|
||||
this.clearOpenTimer();
|
||||
this.openTimer = window.setTimeout(() => {
|
||||
this.openTimer = null;
|
||||
this.show();
|
||||
}, CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
this.hotZoneHover.set(true);
|
||||
this.startDwell();
|
||||
}
|
||||
|
||||
hotZoneLeave(): void {
|
||||
this.pointerInHotZone = false;
|
||||
this.hotZoneHover.set(false);
|
||||
this.clearOpenTimer();
|
||||
}
|
||||
|
||||
/** Mouse is over the panel: a pending close no longer applies. */
|
||||
panelEnter(): void {
|
||||
this.pointerEngaged = true;
|
||||
this.clearCloseTimer();
|
||||
}
|
||||
|
||||
/** Mouse left the panel: close after the grace period. */
|
||||
panelLeave(): void {
|
||||
if (!this.open()) {
|
||||
if (!this.open() || !this.pointerEngaged) {
|
||||
return;
|
||||
}
|
||||
this.clearCloseTimer();
|
||||
@@ -83,9 +129,37 @@ export class FullscreenChannelPanelState {
|
||||
}, CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer moved over the stage while the panel is closed: show the edge
|
||||
* hint until the pointer goes idle. Nothing is drawn while it rests.
|
||||
*/
|
||||
stageActivity(): void {
|
||||
this.pointerMovedSinceHide = true;
|
||||
if (this.open()) {
|
||||
return;
|
||||
}
|
||||
this.hintVisible.set(true);
|
||||
this.clearHintTimer();
|
||||
this.hintTimer = window.setTimeout(() => {
|
||||
this.hintTimer = null;
|
||||
this.hintVisible.set(false);
|
||||
}, CHANNEL_PANEL_HINT_IDLE_MS);
|
||||
// The pointer was already resting in the zone when it re-armed: this
|
||||
// move arms the hint and starts the dwell.
|
||||
if (this.pointerInHotZone && this.openTimer === null) {
|
||||
this.hotZoneHover.set(true);
|
||||
this.startDwell();
|
||||
}
|
||||
}
|
||||
|
||||
/** Fullscreen ended: forget everything, including the mounted list. */
|
||||
reset(): void {
|
||||
this.clearTimers();
|
||||
this.pointerEngaged = false;
|
||||
this.pointerMovedSinceHide = true;
|
||||
this.pointerInHotZone = false;
|
||||
this.hintVisible.set(false);
|
||||
this.hotZoneHover.set(false);
|
||||
this.open.set(false);
|
||||
this.mounted.set(false);
|
||||
}
|
||||
@@ -94,9 +168,18 @@ export class FullscreenChannelPanelState {
|
||||
this.clearTimers();
|
||||
}
|
||||
|
||||
private startDwell(): void {
|
||||
this.clearOpenTimer();
|
||||
this.openTimer = window.setTimeout(() => {
|
||||
this.openTimer = null;
|
||||
this.show('pointer');
|
||||
}, CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
this.clearOpenTimer();
|
||||
this.clearCloseTimer();
|
||||
this.clearHintTimer();
|
||||
}
|
||||
|
||||
private clearOpenTimer(): void {
|
||||
@@ -112,4 +195,11 @@ export class FullscreenChannelPanelState {
|
||||
this.closeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearHintTimer(): void {
|
||||
if (this.hintTimer !== null) {
|
||||
window.clearTimeout(this.hintTimer);
|
||||
this.hintTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
-50
@@ -19,77 +19,99 @@
|
||||
data-test-id="fullscreen-channel-panel-hot-zone"
|
||||
aria-hidden="true"
|
||||
(pointerenter)="onHotZoneEnter($event)"
|
||||
(pointerleave)="state.hotZoneLeave()"
|
||||
(pointerleave)="onHotZoneLeave()"
|
||||
(pointerdown)="onHotZonePointerDown($event)"
|
||||
(pointerup)="onHotZonePointerUp($event)"
|
||||
></div>
|
||||
(pointercancel)="onHotZoneLeave()"
|
||||
>
|
||||
<!-- Purely visual (the zone itself is the target): a tab that shows
|
||||
where the list lives while the pointer is moving, and lights up
|
||||
while it rests on the edge. Keyboard users have C. -->
|
||||
@if (!state.open()) {
|
||||
<div
|
||||
class="fullscreen-channel-panel__edge-hint"
|
||||
[class.fullscreen-channel-panel__edge-hint--visible]="
|
||||
state.hintVisible() || state.hotZoneHover()
|
||||
"
|
||||
[class.fullscreen-channel-panel__edge-hint--armed]="
|
||||
state.hotZoneHover()
|
||||
"
|
||||
data-test-id="fullscreen-channel-panel-edge-hint"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside
|
||||
#panel
|
||||
class="fullscreen-channel-panel dark-theme"
|
||||
[class.fullscreen-channel-panel--open]="state.open()"
|
||||
role="dialog"
|
||||
[attr.aria-label]="
|
||||
panelTitle() || ('EMBEDDED_MPV.PLAYER.CHANNEL_LIST' | translate)
|
||||
"
|
||||
tabindex="-1"
|
||||
[attr.aria-label]="panelTitle() || (listLabelKey | translate)"
|
||||
[attr.aria-hidden]="!state.open()"
|
||||
[attr.inert]="state.open() ? null : ''"
|
||||
[attr.data-panel-kind]="kind"
|
||||
data-test-id="fullscreen-channel-panel"
|
||||
(pointerenter)="onPanelPointerEnter($event)"
|
||||
(pointerleave)="onPanelPointerLeave($event)"
|
||||
>
|
||||
<header class="fullscreen-channel-panel__header">
|
||||
<label class="fullscreen-channel-panel__search">
|
||||
<mat-icon
|
||||
class="fullscreen-channel-panel__search-icon"
|
||||
aria-hidden="true"
|
||||
>search</mat-icon
|
||||
@if (!searchEnabled()) {
|
||||
<h2
|
||||
class="fullscreen-channel-panel__title"
|
||||
data-test-id="fullscreen-channel-panel-title"
|
||||
>
|
||||
<input
|
||||
#searchInput
|
||||
class="fullscreen-channel-panel__search-input"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-id="fullscreen-channel-panel-search"
|
||||
[value]="searchTerm()"
|
||||
[placeholder]="
|
||||
panelTitle()
|
||||
? ('EMBEDDED_MPV.PLAYER.SEARCH_IN'
|
||||
| translate: { title: panelTitle() })
|
||||
: ('EMBEDDED_MPV.PLAYER.SEARCH_CHANNELS'
|
||||
| translate)
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SEARCH_CHANNELS' | translate
|
||||
"
|
||||
(input)="onSearchInput($event)"
|
||||
/>
|
||||
@if (searchTerm()) {
|
||||
<button
|
||||
type="button"
|
||||
class="fullscreen-channel-panel__search-clear"
|
||||
data-test-id="fullscreen-channel-panel-search-clear"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
|
||||
"
|
||||
(click)="clearSearch()"
|
||||
{{ panelTitle() || (listLabelKey | translate) }}
|
||||
</h2>
|
||||
} @else {
|
||||
<label class="fullscreen-channel-panel__search">
|
||||
<mat-icon
|
||||
class="fullscreen-channel-panel__search-icon"
|
||||
aria-hidden="true"
|
||||
>search</mat-icon
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
#searchInput
|
||||
class="fullscreen-channel-panel__search-input"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-id="fullscreen-channel-panel-search"
|
||||
[value]="searchTerm()"
|
||||
[placeholder]="
|
||||
panelTitle()
|
||||
? ('EMBEDDED_MPV.PLAYER.SEARCH_IN'
|
||||
| translate: { title: panelTitle() })
|
||||
: ('EMBEDDED_MPV.PLAYER.SEARCH_CHANNELS'
|
||||
| translate)
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SEARCH_CHANNELS' | translate
|
||||
"
|
||||
(input)="onSearchInput($event)"
|
||||
/>
|
||||
@if (searchTerm()) {
|
||||
<button
|
||||
type="button"
|
||||
class="fullscreen-channel-panel__search-clear"
|
||||
data-test-id="fullscreen-channel-panel-search-clear"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
|
||||
"
|
||||
(click)="clearSearch()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="fullscreen-channel-panel__close"
|
||||
data-test-id="fullscreen-channel-panel-close"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.HIDE_CHANNEL_LIST' | translate
|
||||
"
|
||||
[matTooltip]="
|
||||
('EMBEDDED_MPV.PLAYER.HIDE_CHANNEL_LIST' | translate) +
|
||||
' (Esc)'
|
||||
"
|
||||
[attr.aria-label]="hideLabelKey | translate"
|
||||
[matTooltip]="(hideLabelKey | translate) + ' (Esc)'"
|
||||
(click)="state.hide()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
|
||||
+80
-5
@@ -11,7 +11,7 @@
|
||||
|
||||
// ─── Closed state ────────────────────────────────────────────────────────────
|
||||
|
||||
// Invisible by design: nothing sits over the video until the mouse rests here.
|
||||
// Invisible by design: nothing sits over the video until the mouse moves.
|
||||
// It stops well above the shared controls bar (up to ~130px tall in its
|
||||
// two-row layout), whose leftmost button starts 16px from the edge — the
|
||||
// strip must never intercept a click or tap meant for play/pause.
|
||||
@@ -21,7 +21,59 @@
|
||||
bottom: max(25%, 140px);
|
||||
left: 0;
|
||||
z-index: 6;
|
||||
width: 28px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
// Slim tab on the edge, drawn only while the pointer is moving over the
|
||||
// stage (`--visible`) or resting in the hot zone (`--armed`). A CSS chevron
|
||||
// rather than an icon glyph keeps the zone free of text and of the icon
|
||||
// font. Pointer-transparent: the hot zone stays the event target.
|
||||
.fullscreen-channel-panel__edge-hint {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 22px;
|
||||
height: 96px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-left: 0;
|
||||
border-radius: 0 12px 12px 0;
|
||||
background: rgba(13, 15, 18, 0.72);
|
||||
box-shadow: 4px 0 18px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
opacity: 0;
|
||||
transform: translate(-100%, -50%);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 180ms ease-out,
|
||||
transform 180ms ease-out,
|
||||
background-color 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__edge-hint::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 5px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-top: 2px solid rgba(255, 255, 255, 0.75);
|
||||
border-right: 2px solid rgba(255, 255, 255, 0.75);
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__edge-hint--visible {
|
||||
opacity: 1;
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__edge-hint--armed {
|
||||
border-color: var(--app-selection-color, #78adff);
|
||||
background: rgba(13, 15, 18, 0.9);
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__edge-hint--armed::after {
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
// ─── Open state ──────────────────────────────────────────────────────────────
|
||||
@@ -74,7 +126,7 @@
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
// ─── Header: one row, search + close ─────────────────────────────────────────
|
||||
// ─── Header: one row, search (or title) + close ──────────────────────────────
|
||||
|
||||
.fullscreen-channel-panel__header {
|
||||
display: flex;
|
||||
@@ -84,6 +136,28 @@
|
||||
padding: 10px 6px 10px 12px;
|
||||
}
|
||||
|
||||
// A host without a search field (`panelSearchEnabled` false) shows its title in
|
||||
// the field's place, so the header keeps the same height in both shapes.
|
||||
.fullscreen-channel-panel__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
line-height: 36px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Focus lands here after a `C` opening without a search field; the visible
|
||||
// ring belongs to the controls inside, not to the dialog surface.
|
||||
.fullscreen-channel-panel:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -198,7 +272,7 @@
|
||||
@media (pointer: coarse) {
|
||||
// A finger needs a wider target than a resting mouse.
|
||||
.fullscreen-channel-panel__hot-zone {
|
||||
width: 40px;
|
||||
width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +283,8 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fullscreen-channel-panel {
|
||||
.fullscreen-channel-panel,
|
||||
.fullscreen-channel-panel__edge-hint {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
+275
-2
@@ -12,6 +12,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
CHANNEL_PANEL_CLOSE_GRACE_MS,
|
||||
CHANNEL_PANEL_HINT_IDLE_MS,
|
||||
CHANNEL_PANEL_OPEN_DWELL_MS,
|
||||
} from './fullscreen-channel-panel-state';
|
||||
import { FullscreenChannelPanelComponent } from './fullscreen-channel-panel.component';
|
||||
@@ -65,16 +66,53 @@ class HostComponent implements FullscreenChannelPanelHost {
|
||||
})
|
||||
class NoHostComponent {}
|
||||
|
||||
@Component({
|
||||
imports: [FullscreenChannelPanelComponent],
|
||||
template: `
|
||||
<div #stage class="stage">
|
||||
<app-fullscreen-channel-panel [stage]="stage" />
|
||||
</div>
|
||||
<ng-template #panel let-open="open" let-close="close">
|
||||
<div data-test-id="host-body">{{ open() ? 'open' : 'closed' }}</div>
|
||||
<button type="button" data-test-id="host-close" (click)="close()">
|
||||
close
|
||||
</button>
|
||||
</ng-template>
|
||||
`,
|
||||
providers: [
|
||||
{
|
||||
provide: FULLSCREEN_CHANNEL_PANEL,
|
||||
useExisting: forwardRef(() => NoSearchHostComponent),
|
||||
},
|
||||
],
|
||||
})
|
||||
class NoSearchHostComponent implements FullscreenChannelPanelHost {
|
||||
private readonly panelRef =
|
||||
viewChild<TemplateRef<FullscreenChannelPanelContext>>('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',
|
||||
init: { relatedTarget?: EventTarget | null } = {}
|
||||
init: {
|
||||
relatedTarget?: EventTarget | null;
|
||||
button?: number;
|
||||
pointerId?: number;
|
||||
} = {}
|
||||
): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'pointerType', { value: pointerType });
|
||||
Object.defineProperty(event, 'relatedTarget', {
|
||||
value: init.relatedTarget ?? null,
|
||||
});
|
||||
Object.defineProperty(event, 'button', { value: init.button ?? 0 });
|
||||
Object.defineProperty(event, 'pointerId', {
|
||||
value: init.pointerId ?? 1,
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -90,6 +128,11 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
const hotZone = () => query('fullscreen-channel-panel-hot-zone');
|
||||
const isOpen = () =>
|
||||
panel()?.classList.contains('fullscreen-channel-panel--open') === true;
|
||||
const edgeHint = () => query('fullscreen-channel-panel-edge-hint');
|
||||
const isHintVisible = () =>
|
||||
edgeHint()?.classList.contains(
|
||||
'fullscreen-channel-panel__edge-hint--visible'
|
||||
) === true;
|
||||
|
||||
const setFullscreen = (element: Element | null) => {
|
||||
fullscreenElement = element;
|
||||
@@ -97,7 +140,10 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
// A real mouse moves over the stage before it reaches the edge; the move
|
||||
// is what re-arms the hot zone after an explicit close.
|
||||
const openByHover = () => {
|
||||
stage.dispatchEvent(pointerEvent('pointermove'));
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerenter'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
fixture.detectChanges();
|
||||
@@ -109,6 +155,7 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
imports: [
|
||||
HostComponent,
|
||||
NoHostComponent,
|
||||
NoSearchHostComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
@@ -248,7 +295,7 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('draws nothing over the video while closed: only the hot zone beside the inert, off-screen panel', () => {
|
||||
it('draws nothing over the video while closed and idle: only the hot zone with its hidden hint beside the inert, off-screen panel', () => {
|
||||
setFullscreen(stage);
|
||||
|
||||
const children = Array.from(
|
||||
@@ -264,12 +311,133 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
]);
|
||||
expect(children[0]).toBe(hotZone());
|
||||
expect(hotZone()?.textContent?.trim()).toBe('');
|
||||
expect(hotZone()?.children.length).toBe(1);
|
||||
expect(hotZone()?.firstElementChild).toBe(edgeHint());
|
||||
expect(isHintVisible()).toBe(false);
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(panel()?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(panel()?.hasAttribute('inert')).toBe(true);
|
||||
expect(query('host-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals the edge hint while the mouse moves over the stage and hides it once idle', () => {
|
||||
setFullscreen(stage);
|
||||
|
||||
stage.dispatchEvent(pointerEvent('pointermove'));
|
||||
fixture.detectChanges();
|
||||
expect(isHintVisible()).toBe(true);
|
||||
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_HINT_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isHintVisible()).toBe(false);
|
||||
|
||||
// Touch has no pointer to hint at.
|
||||
stage.dispatchEvent(pointerEvent('pointermove', 'touch'));
|
||||
fixture.detectChanges();
|
||||
expect(isHintVisible()).toBe(false);
|
||||
|
||||
// Resting on the edge lights the hint up before the dwell completes.
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerenter'));
|
||||
fixture.detectChanges();
|
||||
expect(isHintVisible()).toBe(true);
|
||||
expect(
|
||||
edgeHint()?.classList.contains(
|
||||
'fullscreen-channel-panel__edge-hint--armed'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
expect(edgeHint()).toBeNull();
|
||||
});
|
||||
|
||||
it('opens at once on a primary click on the edge, without the dwell', () => {
|
||||
setFullscreen(stage);
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerdown'));
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerup'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(isOpen()).toBe(true);
|
||||
expect(query('host-list')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a release that is not a primary click begun on the edge', () => {
|
||||
setFullscreen(stage);
|
||||
const expectClosed = () => {
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
};
|
||||
|
||||
// A drag started elsewhere and released over the edge.
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerup'));
|
||||
expectClosed();
|
||||
|
||||
// Right and middle buttons.
|
||||
for (const button of [1, 2]) {
|
||||
hotZone()?.dispatchEvent(
|
||||
pointerEvent('pointerdown', 'mouse', { button })
|
||||
);
|
||||
hotZone()?.dispatchEvent(
|
||||
pointerEvent('pointerup', 'mouse', { button })
|
||||
);
|
||||
expectClosed();
|
||||
}
|
||||
|
||||
// A press that left the zone before its release.
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerdown'));
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerleave'));
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerup'));
|
||||
expectClosed();
|
||||
|
||||
// A different pointer releasing than the one that pressed.
|
||||
hotZone()?.dispatchEvent(
|
||||
pointerEvent('pointerdown', 'mouse', { pointerId: 1 })
|
||||
);
|
||||
hotZone()?.dispatchEvent(
|
||||
pointerEvent('pointerup', 'mouse', { pointerId: 2 })
|
||||
);
|
||||
expectClosed();
|
||||
});
|
||||
|
||||
it('keeps a C-opened panel while the mouse roams the video until it has visited the list', () => {
|
||||
setFullscreen(stage);
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' }));
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
query('fullscreen-channel-panel-scrim')?.dispatchEvent(
|
||||
pointerEvent('pointerover')
|
||||
);
|
||||
panel()?.dispatchEvent(pointerEvent('pointerleave'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
panel()?.dispatchEvent(pointerEvent('pointerenter'));
|
||||
panel()?.dispatchEvent(pointerEvent('pointerleave'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('never closes on a click inside the panel, even where the click target is replaced', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
const list = query('host-list');
|
||||
if (!list) {
|
||||
throw new Error('host list missing');
|
||||
}
|
||||
// A click that re-renders what was under the pointer: the pointer's
|
||||
// next target is still inside the panel, so no close is scheduled.
|
||||
list.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
panel()?.dispatchEvent(pointerEvent('pointerover'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('slides in after the mouse rests on the left edge and stamps the host list', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
@@ -325,6 +493,7 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerdown', 'touch'));
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerup', 'touch'));
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
@@ -467,6 +636,30 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('stays closed after Escape until the resting mouse actually moves', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
|
||||
// The panel slid out from under the pointer: the browser reports the
|
||||
// hot zone under it again without any movement. The edge stays clear.
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerenter'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(isHintVisible()).toBe(false);
|
||||
|
||||
stage.dispatchEvent(pointerEvent('pointermove'));
|
||||
fixture.detectChanges();
|
||||
expect(isHintVisible()).toBe(true);
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
@@ -523,4 +716,84 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
openByHover();
|
||||
expect(query('host-list')?.textContent?.trim()).toBe('');
|
||||
});
|
||||
|
||||
describe('host without a search field (series episodes)', () => {
|
||||
let noSearch: ComponentFixture<NoSearchHostComponent>;
|
||||
const q = <T extends HTMLElement>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+109
-32
@@ -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,25 +43,31 @@ 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: opening is
|
||||
* resting the mouse on the left edge (a tap on that edge for touch, which has
|
||||
* no hover) or pressing `C`. Closing: moving the mouse away, clicking the
|
||||
* video (a scrim swallows that click so it never pauses playback), the close
|
||||
* button, Escape, or leaving fullscreen. A CDK overlay opened from the list
|
||||
* (sort menu, row context menu) counts as part of the panel: the pointer
|
||||
* moving into it does not start the close, and Escape closes that overlay
|
||||
* first. The list stays mounted between openings of one fullscreen session
|
||||
* so its scroll position and search survive.
|
||||
* 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
|
||||
* edge (it fades with the rest of the chrome once the pointer goes idle), and
|
||||
* opening is resting the mouse on that edge, clicking or tapping it, or
|
||||
* pressing `C`. Closing: moving the mouse away after it has been inside the
|
||||
* panel, clicking the video (a scrim swallows that click so it never pauses
|
||||
* playback), the close button, Escape, or leaving fullscreen. A panel opened
|
||||
* with `C` ignores the mouse roaming over the video until it has visited the
|
||||
* list, so the key never leaves the user typing into a closing search field.
|
||||
* A CDK overlay opened from the list (sort menu, row context menu) counts as
|
||||
* part of the panel: the pointer moving into it does not start the close, and
|
||||
* Escape closes that overlay first. The list stays mounted between openings
|
||||
* of one fullscreen session so its scroll position and search survive.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-fullscreen-channel-panel',
|
||||
@@ -110,21 +117,41 @@ 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 <title>".
|
||||
* 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(),
|
||||
};
|
||||
|
||||
/** Pointer whose primary press began inside the hot zone, if any. */
|
||||
private hotZonePressPointerId: number | null = null;
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent) =>
|
||||
this.handleKeydown(event);
|
||||
|
||||
@@ -142,6 +169,25 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
}
|
||||
untracked(() => this.resetSession());
|
||||
});
|
||||
// The edge hint follows pointer activity over the stage, the element
|
||||
// that owns fullscreen: any mouse movement over the video reveals it.
|
||||
effect((onCleanup) => {
|
||||
const stage = this.stage();
|
||||
if (!this.active() || !stage) {
|
||||
return;
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'touch') {
|
||||
this.state.stageActivity();
|
||||
}
|
||||
};
|
||||
stage.addEventListener('pointermove', onPointerMove, {
|
||||
passive: true,
|
||||
});
|
||||
onCleanup(() =>
|
||||
stage.removeEventListener('pointermove', onPointerMove)
|
||||
);
|
||||
});
|
||||
// While open, hover intent is tracked document-wide: a CDK overlay the
|
||||
// list opens (sort menu, context menu) renders outside the <aside>, so
|
||||
// the aside's own pointerleave alone would close the panel under the
|
||||
@@ -176,16 +222,32 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch has no hover and no `C` key, so a tap on the edge is its way in.
|
||||
* Bound to pointerup, not pointerdown: the hot zone must still be the
|
||||
* click target when the tap completes, so the click that follows dies on
|
||||
* it instead of reaching the video.
|
||||
* A click or tap on the edge opens at once, without the hover dwell:
|
||||
* touch has no hover and no `C` key, and a mouse user who has found the
|
||||
* hint tab should not have to hold still. Only a primary press that
|
||||
* began inside the zone counts: a drag released over the edge, a right
|
||||
* or middle click and a pen barrel button are not a click. Completed on
|
||||
* pointerup, not pointerdown: the hot zone must still be the click
|
||||
* target when the press completes, so the click that follows dies on it
|
||||
* instead of reaching the video's click-to-pause.
|
||||
*/
|
||||
onHotZonePointerDown(event: PointerEvent): void {
|
||||
this.hotZonePressPointerId =
|
||||
event.button === 0 ? event.pointerId : null;
|
||||
}
|
||||
|
||||
onHotZonePointerUp(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'touch') {
|
||||
const pressed = this.hotZonePressPointerId;
|
||||
this.hotZonePressPointerId = null;
|
||||
if (pressed === null || pressed !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
this.state.show();
|
||||
this.state.show('pointer');
|
||||
}
|
||||
|
||||
onHotZoneLeave(): void {
|
||||
this.hotZonePressPointerId = null;
|
||||
this.state.hotZoneLeave();
|
||||
}
|
||||
|
||||
onPanelPointerEnter(event: PointerEvent): void {
|
||||
@@ -220,7 +282,7 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
}
|
||||
if (
|
||||
this.isInsidePanel(event.target) ||
|
||||
this.hotZoneElement()?.nativeElement === event.target ||
|
||||
this.isInsideHotZone(event.target) ||
|
||||
this.isInsideOverlay(event.target)
|
||||
) {
|
||||
this.state.panelEnter();
|
||||
@@ -229,6 +291,15 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private isInsideHotZone(target: EventTarget | null): boolean {
|
||||
const hotZone = this.hotZoneElement()?.nativeElement;
|
||||
return (
|
||||
hotZone !== undefined &&
|
||||
target instanceof Node &&
|
||||
hotZone.contains(target)
|
||||
);
|
||||
}
|
||||
|
||||
private isInsidePanel(target: EventTarget | null): boolean {
|
||||
const panel = this.panelElement()?.nativeElement;
|
||||
return (
|
||||
@@ -300,18 +371,24 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
this.state.hide();
|
||||
return;
|
||||
}
|
||||
this.state.show();
|
||||
this.focusSearch();
|
||||
this.state.show('keyboard');
|
||||
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);
|
||||
}
|
||||
}
|
||||
+43
-12
@@ -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 =
|
||||
|
||||
+145
@@ -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>
|
||||
+265
@@ -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;
|
||||
}
|
||||
}
|
||||
+305
@@ -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);
|
||||
});
|
||||
});
|
||||
+196
@@ -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;
|
||||
}
|
||||
+207
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './fullscreen-episode-panel.component';
|
||||
export * from './fullscreen-episode-panel.util';
|
||||
+95
@@ -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',
|
||||
};
|
||||
}
|
||||
+251
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user