mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): fullscreen episode panel for series playback (#1620)
Series playing in fullscreen get the same slide-in side panel the live channel list has, with season tabs and the episode list: rest the mouse on the left edge, click it, or press C; pick an episode and it plays inline without leaving fullscreen. - Panel contract: `FullscreenChannelPanelHost` gains optional `panelSearchEnabled` and `panelKind`; the template context gains `open`. Pointer/keyboard rules and the four live providers are unchanged. - Series host: `PortalInlinePlayerComponent` provides the token through `createEpisodePanelHost()` and stamps `app-fullscreen-episode-panel` (SeasonTabsComponent over rows with TMDB still or numeral tile, label, runtime, clamped overview, progress, watched check, now-playing marker; playing row centred on open). Episode clicks reuse the Up Next rail's inline path; season tab clicks reach the hosts' `onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD load with a Retry row after a failed request). - Gates: `Settings.fullscreenChannelPanel` (label now covers both lists in all locales), episode content only, native-view Embedded MPV withheld by the view, external players excluded. - Inline-series e2e moved to `xtream-series-playback.e2e.ts` with shared Xtream helpers in a fixture; adds a fullscreen episode switch through the panel. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
c110519412
commit
fa8ce26991
55 files changed
+2961
-570
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.
|
||||
@@ -574,8 +574,10 @@ unchanged. Contract: `docs/architecture/m3u-playlist-module.md`
|
||||
A confirmed frame-copy capability survives the unknown support probe during
|
||||
an engine remount, preserving panel search/scroll on channel changes; the
|
||||
first unknown probe and confirmed native/unsupported results withhold it.
|
||||
A live host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional
|
||||
`panelTitle`) and the panel slides that list over the video: left-edge hover
|
||||
A host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional
|
||||
`panelTitle`, `panelSearchEnabled` and `panelKind: 'channels' | 'episodes'`;
|
||||
the template context carries `searchTerm`, `open` and `close`) and the
|
||||
panel slides that list over the video: left-edge hover
|
||||
dwell, a click or tap on that edge, or `C`. The hot zone stays mounted above
|
||||
the scrim and below the panel during opening, so a delayed paint cannot turn
|
||||
stationary hover into a synthetic leave. Nothing is drawn while it is closed
|
||||
@@ -625,9 +627,28 @@ unchanged. Contract: `docs/architecture/m3u-playlist-module.md`
|
||||
ignored. Windowed commands keep the
|
||||
complete catalog.
|
||||
Xtream's two `PortalChannelsListComponent` instances relay favorite toggles
|
||||
through `XtreamFavoriteMarksService`. CDK overlays follow the
|
||||
through `XtreamFavoriteMarksService`. Series playback gets the same panel
|
||||
as an episode list: `PortalInlinePlayerComponent` (the component both
|
||||
series hosts render around the view, and the Up Next rail's host) is the
|
||||
nearest provider — `panelKind: 'episodes'`, no search field (a title row
|
||||
instead, `C` focuses the panel) — and stamps `app-fullscreen-episode-panel`
|
||||
(`libs/ui/playback/src/lib/fullscreen-episode-panel/`: `SeasonTabsComponent`
|
||||
over the selected season's rows with TMDB still or numeral tile, `S01E03`
|
||||
label, runtime, clamped overview, progress bar, watched check and
|
||||
now-playing marker; the tab follows the playing season, the playing row is
|
||||
centred on open) built by `buildFullscreenEpisodePanelSeasons` from the
|
||||
hosts' `seriesEpisodes` / `episodePlaybackPositions` / `seasonLoadStates`
|
||||
inputs. An episode click travels `upNextEpisodeSelected` (the rail's path,
|
||||
so fullscreen survives the engine remount) and closes the panel; a season
|
||||
tab click travels `episodePanelSeasonSelected` into the hosts'
|
||||
`onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD season
|
||||
load — a spinner row while in flight, a Retry row after a failed request).
|
||||
Movies never get it, external
|
||||
MPV/VLC never mount the inline player, native-view Embedded MPV is
|
||||
withheld by the view. CDK overlays follow the
|
||||
fullscreen element via `FullscreenOverlayContainer`. Contract:
|
||||
`docs/architecture/player-controls-contract.md` ("Fullscreen channel panel").
|
||||
`docs/architecture/player-controls-contract.md` ("Fullscreen channel panel",
|
||||
"Fullscreen episode panel").
|
||||
- Embedded MPV seek steps (arrow keys, ±10 s buttons, `PlayerController.seekBy`)
|
||||
go through the relative `seekEmbeddedMpvBy` IPC: every backend forwards the
|
||||
delta as mpv `seek <delta> relative+exact` (addon export `seekBy`, helper
|
||||
|
||||
@@ -1303,7 +1303,7 @@ engine` (restart required) or
|
||||
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
|
||||
contracts: `docs/architecture/embedded-mpv-native.md` and
|
||||
`tools/embedded-mpv/README.md`.
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked `<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 +1556,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,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": "此分類中沒有頻道",
|
||||
|
||||
@@ -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
|
||||
@@ -527,7 +537,9 @@ 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).
|
||||
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
|
||||
@@ -575,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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
+52
-48
@@ -46,68 +46,72 @@
|
||||
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>
|
||||
|
||||
+23
-1
@@ -126,7 +126,7 @@
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
// ─── Header: one row, search + close ─────────────────────────────────────────
|
||||
// ─── Header: one row, search (or title) + close ──────────────────────────────
|
||||
|
||||
.fullscreen-channel-panel__header {
|
||||
display: flex;
|
||||
@@ -136,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;
|
||||
|
||||
+110
@@ -66,6 +66,35 @@ 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',
|
||||
@@ -126,6 +155,7 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
imports: [
|
||||
HostComponent,
|
||||
NoHostComponent,
|
||||
NoSearchHostComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
@@ -686,4 +716,84 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
openByHover();
|
||||
expect(query('host-list')?.textContent?.trim()).toBe('');
|
||||
});
|
||||
|
||||
describe('host without a search field (series episodes)', () => {
|
||||
let noSearch: ComponentFixture<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+41
-15
@@ -23,6 +23,7 @@ import { FullscreenChannelPanelState } from './fullscreen-channel-panel-state';
|
||||
import {
|
||||
FULLSCREEN_CHANNEL_PANEL,
|
||||
type FullscreenChannelPanelContext,
|
||||
type FullscreenPanelKind,
|
||||
} from './fullscreen-channel-panel.model';
|
||||
|
||||
const EDITABLE_SELECTOR =
|
||||
@@ -42,15 +43,17 @@ function targetsEditable(event: KeyboardEvent): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-in channel list for fullscreen playback.
|
||||
* Slide-in side panel for fullscreen playback: a channel list for live
|
||||
* hosts, an episode list for series playback.
|
||||
*
|
||||
* Rendered by `WebPlayerViewComponent` beside the engine, inside the element
|
||||
* that owns DOM fullscreen, so the list stays visible while the video is
|
||||
* fullscreen and survives the engine remount a channel switch causes. The
|
||||
* content comes from the host page through {@link FULLSCREEN_CHANNEL_PANEL};
|
||||
* without a provider (VOD detail pages, series) nothing renders. The view
|
||||
* can also switch it off through `enabled` for an engine that paints above
|
||||
* the DOM (native-view Embedded MPV), where no DOM panel could show.
|
||||
* fullscreen and survives the engine remount a channel or episode switch
|
||||
* causes. The content comes from the host through
|
||||
* {@link FULLSCREEN_CHANNEL_PANEL}; without a provider (a movie in a VOD
|
||||
* detail page) nothing renders. The view can also switch it off through
|
||||
* `enabled` for an engine that paints above the DOM (native-view Embedded
|
||||
* MPV), where no DOM panel could show.
|
||||
*
|
||||
* Nothing is drawn over the video while the panel is closed and the pointer
|
||||
* rests. Moving the mouse over the stage reveals a slim hint tab on the left
|
||||
@@ -114,18 +117,35 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
readonly isFullscreen = this.fullscreen.isFullscreen;
|
||||
readonly template = computed(() => this.panelHost?.panelTemplate() ?? null);
|
||||
/**
|
||||
* The host's context label (playlist or category name). It carries no row
|
||||
* of its own: the search field's placeholder reads "Search in <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(),
|
||||
};
|
||||
|
||||
@@ -352,17 +372,23 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.state.show('keyboard');
|
||||
this.focusSearch();
|
||||
this.focusPanel();
|
||||
}
|
||||
|
||||
/** A keyboard opening lands in the search field; hover does not steal focus. */
|
||||
private focusSearch(): void {
|
||||
/**
|
||||
* A keyboard opening lands in the search field — or, for a host without
|
||||
* one, on the panel itself, so the next Tab reaches its first control;
|
||||
* hover does not steal focus.
|
||||
*/
|
||||
private focusPanel(): void {
|
||||
window.setTimeout(() => {
|
||||
if (this.state.open()) {
|
||||
this.searchInput()?.nativeElement.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
if (!this.state.open()) {
|
||||
return;
|
||||
}
|
||||
const target =
|
||||
this.searchInput()?.nativeElement ??
|
||||
this.panelElement()?.nativeElement;
|
||||
target?.focus({ preventScroll: true });
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
+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