mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
feat(playback): slide-in channel list for fullscreen playback (#1519)
This commit is contained in:
1 parent
8eda5370eb
commit
d9d6f49757
85 files changed
+5664
-325
No files matched your search
@@ -0,0 +1,12 @@
|
||||
---
|
||||
type: feature
|
||||
area: playback
|
||||
issues: [48]
|
||||
highlight: Channel list in fullscreen
|
||||
---
|
||||
|
||||
Rest the mouse on the left edge (tap it on touch) or press C while a live
|
||||
channel plays fullscreen: the channel list (M3U all/groups/favorites/recent,
|
||||
Xtream, Stalker, global favorites) slides over the video with search, so you
|
||||
can zap without leaving fullscreen. Nothing covers the video while it is
|
||||
closed. M3U playlists also zap with PageUp/PageDown.
|
||||
@@ -30,7 +30,7 @@ This file provides guidance to coding agents working in this repository.
|
||||
must receive the temporary keychain's own password, not the `.p12` import
|
||||
password. macOS runner images since `macos-26-arm64` 20260831 verify that
|
||||
password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user
|
||||
name or passphrase you entered is not correct`. Keep the patch until
|
||||
name or passphrase you entered is not correct`. Keep the patch until
|
||||
electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+),
|
||||
and run `pnpm run deps:electron-builder:test` after related dependency
|
||||
updates — the test fails when the patched version no longer matches the
|
||||
@@ -341,6 +341,63 @@ Key files:
|
||||
commands are cancelled. Same-session IPC replies also yield to a broadcast
|
||||
snapshot received while the command was pending, preventing a successful
|
||||
recording acknowledgement from being rolled back by a stale reply.
|
||||
- `WebPlayerViewComponent` renders `app-fullscreen-channel-panel`
|
||||
(`libs/ui/playback/src/lib/fullscreen-channel-panel/`) beside the engine,
|
||||
staged on the view's `fullscreenSurface` — the same host element every engine
|
||||
receives as `fullscreenTarget` — so it lives inside the fullscreen element
|
||||
and survives the engine remount a channel switch causes. It is withheld
|
||||
(`enabled=false`) for native-view Embedded MPV, which paints above the DOM.
|
||||
A confirmed frame-copy capability survives the unknown support probe during
|
||||
an engine remount, preserving panel search/scroll on channel changes; the
|
||||
first unknown probe and confirmed native/unsupported results withhold it.
|
||||
A live host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional
|
||||
`panelTitle`) and the panel slides that list over the video: left-edge hover
|
||||
dwell, a touch tap on that edge, or `C`. Nothing is drawn while it is closed
|
||||
(no handle), the hot zone stops above the controls bar, and scrim/Escape/
|
||||
mouse-leave close it — while a CDK overlay opened from the list counts as
|
||||
the panel, so hover keeps it open and Escape closes the overlay first. The
|
||||
header is one row (search whose placeholder carries the host title, plus
|
||||
close) and the list stays mounted per fullscreen session.
|
||||
`Settings.fullscreenChannelPanel` (default on) gates it, offered only for the
|
||||
web players with shared controls and for Embedded MPV — the legacy vendor
|
||||
chrome fullscreens the engine's own element, outside which the panel cannot
|
||||
render. Providers: M3U `VideoPlayerComponent` (returns null while its VOD
|
||||
detail hosts the player; radio and recognized movies are filtered out of
|
||||
the list it is handed, since `app-audio-player` and the VOD detail shell
|
||||
each replace the fullscreen-owning `app-web-player-view`; with MPV/VLC
|
||||
configured, only DASH rows stay offered because other streams leave the
|
||||
inline host for the external-player UI), Xtream
|
||||
`LiveStreamLayoutComponent`,
|
||||
`StalkerLiveStreamLayoutComponent` (one `ng-template` stamped twice; a blank
|
||||
panel field shows the category untouched by the sidebar's search term (or
|
||||
the windowed full cache when playback starts from All Items without a
|
||||
category), the
|
||||
panel's search results are windowed by `PanelSearchWindow`, and on a paged
|
||||
portal the panel copy keeps requesting pages while its matches do not fill
|
||||
it, even while the sidebar's own search is active; the retained closed
|
||||
panel pauses paging and resumes automatic filling when reopened; inline video
|
||||
commits the selected channel with the resolved playback, retaining the old
|
||||
selection, EPG and recording metadata during a pending or failed replacement), and
|
||||
`UnifiedLiveTabComponent` (radio filtered the same way; it keeps the previous
|
||||
detail mounted until the next selection resolves, with `activeItem` paired
|
||||
to that detail so the session key and recording metadata keep describing
|
||||
the stream on screen — only the `activeUid` row highlight moves ahead; a
|
||||
second activation of the row still resolving folds its start-playback or
|
||||
auto-open intent into that request instead of launching the retained
|
||||
stream, and a
|
||||
failed replacement restores that highlight and retains the previous video,
|
||||
catch-up and session). M3U PageUp/PageDown yield to already-handled events
|
||||
and menu/dialog overlay targets even when the menu has no scroll overflow.
|
||||
Numeric and adjacent-channel commands share the panel eligibility filter
|
||||
while the live web-player host owns fullscreen (itself, or through the
|
||||
nested surface a legacy player fullscreens under the vendor-chrome
|
||||
opt-out); numbers keep their original positions and ineligible numbers are
|
||||
ignored. Windowed commands keep the
|
||||
complete catalog.
|
||||
Xtream's two `PortalChannelsListComponent` instances relay favorite toggles
|
||||
through `XtreamFavoriteMarksService`. CDK overlays follow the
|
||||
fullscreen element via `FullscreenOverlayContainer`. Contract:
|
||||
`docs/architecture/player-controls-contract.md` ("Fullscreen channel 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
|
||||
|
||||
@@ -91,7 +91,7 @@ pnpm nx show projects
|
||||
must receive the temporary keychain's own password, not the `.p12` import
|
||||
password. macOS runner images since `macos-26-arm64` 20260831 verify that
|
||||
password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user
|
||||
name or passphrase you entered is not correct`. Keep the patch until
|
||||
name or passphrase you entered is not correct`. Keep the patch until
|
||||
electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+),
|
||||
and run `pnpm run deps:electron-builder:test` after related dependency
|
||||
updates — the test fails when the patched version no longer matches the
|
||||
@@ -782,7 +782,6 @@ categories by provider ID and type. See `docs/architecture/category-management.m
|
||||
precedence. Contract: `docs/architecture/m3u-playlist-module.md`
|
||||
("User-Agent for URL sources").
|
||||
|
||||
|
||||
**Playlist Support**:
|
||||
|
||||
- M3U/M3U8 files (local or URL)
|
||||
@@ -1168,7 +1167,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
|
||||
|
||||
@@ -10,12 +10,16 @@ import {
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
LaunchedElectronApp,
|
||||
openAddPlaylistDialog,
|
||||
openSourceEditor,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
openSources,
|
||||
saveSourceDialog,
|
||||
saveSettings,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
updateSourceDialog,
|
||||
@@ -375,6 +379,123 @@ async function releasePlaybackRecommendationCapture(
|
||||
});
|
||||
}
|
||||
|
||||
for (const player of ['mpv', 'vlc']) {
|
||||
test(`@electron @dash fullscreen panel keeps DASH inline with ${player} configured`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const fixtureServer = await startDashFixtureServer();
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
try {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'playback');
|
||||
await page.getByTestId('select-video-player').click();
|
||||
await page.getByTestId(player).click();
|
||||
await saveSettings(page);
|
||||
await goToDashboard(page);
|
||||
const playlist = [
|
||||
buildDashPlaylist(fixtureServer.origin),
|
||||
'#EXTINF:-1 group-title="DASH",Next ClearKey DASH',
|
||||
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
|
||||
`#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`,
|
||||
`${fixtureServer.origin}/clearkey.mpd?next=1`,
|
||||
].join('\n');
|
||||
await importDashPlaylistFromText(app, playlist);
|
||||
await channelItemByTitle(page, 'ClearKey DASH').first().click();
|
||||
const video = page.locator('app-web-player-view video').first();
|
||||
await expect(video).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate((el: HTMLVideoElement) => el.currentTime)
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
await page
|
||||
.getByRole('button', { name: 'Enter fullscreen' })
|
||||
.click();
|
||||
const isFullscreen = () =>
|
||||
page.evaluate(() => document.fullscreenElement !== null);
|
||||
await expect.poll(isFullscreen).toBe(true);
|
||||
await page.getByTestId('fullscreen-channel-panel-hot-zone').hover();
|
||||
const panel = page.getByTestId('fullscreen-channel-panel');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(panel).toHaveCSS(
|
||||
'transform',
|
||||
'matrix(1, 0, 0, 1, 0, 0)'
|
||||
);
|
||||
await expect(
|
||||
panel.getByText('Unsupported MKV', { exact: false })
|
||||
).toHaveCount(0);
|
||||
const nextChannel = panel
|
||||
.getByTestId('channel-item')
|
||||
.filter({ hasText: 'Next ClearKey DASH' });
|
||||
await nextChannel.click();
|
||||
await expect(nextChannel).toHaveClass(/\bactive\b/);
|
||||
await expect.poll(isFullscreen).toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate((el: HTMLVideoElement) => el.currentTime)
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
expect(await isFullscreen()).toBe(true);
|
||||
|
||||
// A short context menu has no overflow, but still owns paging
|
||||
// keys. Observe the event after the app's document listener.
|
||||
const viewportHeight = await page.evaluate(
|
||||
() => window.innerHeight
|
||||
);
|
||||
await page.mouse.move(1, viewportHeight / 2);
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(panel).toHaveCSS(
|
||||
'transform',
|
||||
'matrix(1, 0, 0, 1, 0, 0)'
|
||||
);
|
||||
await nextChannel.click({ button: 'right' });
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
const pagingConsumed = page.evaluate(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(event) => resolve(event.defaultPrevented),
|
||||
{ once: true }
|
||||
);
|
||||
})
|
||||
);
|
||||
await menu.getByRole('menuitem').first().press('PageDown');
|
||||
expect(await pagingConsumed).toBe(false);
|
||||
await expect(nextChannel).toHaveClass(/\bactive\b/);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(menu).toBeHidden();
|
||||
await page.mouse.move(600, viewportHeight / 2);
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'true');
|
||||
// Original channel 3 is the excluded MKV. Numeric selection
|
||||
// must preserve fullscreen without renumbering the catalog.
|
||||
await page.keyboard.press('3');
|
||||
await expect(page.locator('.channel-number-overlay')).toHaveCount(
|
||||
1
|
||||
);
|
||||
await expect(page.locator('.channel-number-overlay')).toHaveCount(
|
||||
0
|
||||
);
|
||||
expect(await isFullscreen()).toBe(true);
|
||||
await expect(nextChannel).toHaveClass(/\bactive\b/);
|
||||
|
||||
await page.keyboard.press('PageDown');
|
||||
const firstChannel = panel.getByTestId('channel-item').filter({
|
||||
hasText: 'ClearKey DASH',
|
||||
hasNotText: 'Next ClearKey DASH',
|
||||
});
|
||||
await expect(firstChannel).toHaveClass(/\bactive\b/);
|
||||
expect(await isFullscreen()).toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await fixtureServer.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('@electron @dash ClearKey DASH filters DRM fallback and reports external launch states', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
|
||||
@@ -675,6 +675,32 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover
|
||||
});
|
||||
await expect(categories.nth(1).locator('.item-count')).toHaveText('40');
|
||||
|
||||
// Starting from All Items leaves the category unset. Its fullscreen
|
||||
// list must still show and window the full cache without a search term.
|
||||
await allItemsGrid.locator('mat-card').first().click();
|
||||
const playerView = page.locator('app-web-player-view');
|
||||
await expect(playerView).toBeVisible();
|
||||
await playerView.hover();
|
||||
await playerView.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||
await expect(
|
||||
playerView.getByRole('button', { name: 'Exit fullscreen' })
|
||||
).toBeVisible();
|
||||
await page
|
||||
.locator('[data-test-id="fullscreen-channel-panel-hot-zone"]')
|
||||
.hover();
|
||||
const panel = page.locator('[data-test-id="fullscreen-channel-panel"]');
|
||||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
const panelRows = panel.locator('[data-test-id="channel-item"]');
|
||||
await expect(panelRows).toHaveCount(100);
|
||||
await panel.locator('.channels-list').evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
await expect(panelRows).toHaveCount(200);
|
||||
await page.evaluate(() => document.exitFullscreen());
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => !!document.fullscreenElement))
|
||||
.toBe(false);
|
||||
|
||||
await categories.nth(1).click();
|
||||
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
provideHttpClient,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http';
|
||||
import {
|
||||
FullscreenOverlayContainer,
|
||||
OverlayContainer,
|
||||
} from '@angular/cdk/overlay';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
inject,
|
||||
@@ -104,6 +108,12 @@ export const appConfig: ApplicationConfig = {
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideAnimations(),
|
||||
// CDK overlays (menus, tooltips, dialogs) live in a container under
|
||||
// <body>, which the browser hides behind an element in DOM fullscreen.
|
||||
// This container follows the fullscreen element instead, so the
|
||||
// fullscreen channel panel's sort/context menus and the controls'
|
||||
// tooltips render while the player is fullscreen.
|
||||
{ provide: OverlayContainer, useClass: FullscreenOverlayContainer },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideStore({
|
||||
router: routerReducer,
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createSettingsForm(
|
||||
webPlayerSharedControls: true,
|
||||
playerAmbientMode: false,
|
||||
playerUpNextRail: true,
|
||||
fullscreenChannelPanel: true,
|
||||
vodAutoFailover: false,
|
||||
m3uVodDetails: true,
|
||||
...(supportsEpg
|
||||
@@ -165,6 +166,7 @@ export function createSettingsFromFormValue(
|
||||
webPlayerSharedControls: value.webPlayerSharedControls ?? true,
|
||||
playerAmbientMode: value.playerAmbientMode ?? false,
|
||||
playerUpNextRail: value.playerUpNextRail ?? true,
|
||||
fullscreenChannelPanel: value.fullscreenChannelPanel ?? true,
|
||||
vodAutoFailover: value.vodAutoFailover ?? false,
|
||||
m3uVodDetails: value.m3uVodDetails ?? true,
|
||||
streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat,
|
||||
|
||||
@@ -74,6 +74,7 @@ function createForm(player: VideoPlayer): FormGroup {
|
||||
webPlayerSharedControls: new FormControl(false),
|
||||
playerAmbientMode: new FormControl(false),
|
||||
playerUpNextRail: new FormControl(true),
|
||||
fullscreenChannelPanel: new FormControl(true),
|
||||
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
|
||||
openStreamOnDoubleClick: new FormControl(false),
|
||||
showExternalPlaybackBar: new FormControl(true),
|
||||
|
||||
@@ -128,6 +128,34 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (supportsFullscreenChannelPanel()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-test-id="fullscreen-channel-panel-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.FULLSCREEN_CHANNEL_PANEL' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.FULLSCREEN_CHANNEL_PANEL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
[aria-label]="
|
||||
'SETTINGS.FULLSCREEN_CHANNEL_PANEL' | translate
|
||||
"
|
||||
formControlName="fullscreenChannelPanel"
|
||||
data-test-id="fullscreen-channel-panel-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Only the web players raise a playback diagnostic, so under MPV, VLC
|
||||
or Embedded MPV nothing would ever trigger a failover. -->
|
||||
@if (supportsVodMultiSource() && isWebPlayerSelected()) {
|
||||
|
||||
@@ -374,6 +374,61 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
)?.placeholder
|
||||
).toBe(VLC_ARGUMENTS_PLACEHOLDER);
|
||||
});
|
||||
|
||||
it.each([
|
||||
VideoPlayer.VideoJs,
|
||||
VideoPlayer.Html5Player,
|
||||
VideoPlayer.ArtPlayer,
|
||||
])(
|
||||
'offers the fullscreen channel panel toggle for web player %s with shared controls',
|
||||
(player) => {
|
||||
const form = createForm(player);
|
||||
form.get('webPlayerSharedControls')?.setValue(true);
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
queryByTestId('fullscreen-channel-panel-setting')
|
||||
).not.toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('offers the fullscreen channel panel toggle for Embedded MPV regardless of the web preference', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'form',
|
||||
createForm(VideoPlayer.EmbeddedMpv)
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
queryByTestId('fullscreen-channel-panel-setting')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the fullscreen channel panel toggle for a web player on the legacy vendor chrome', () => {
|
||||
// The vendor chrome fullscreens the engine's own element, outside
|
||||
// which the panel cannot render, so the toggle would do nothing.
|
||||
const form = createForm(VideoPlayer.VideoJs);
|
||||
form.get('webPlayerSharedControls')?.setValue(false);
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(queryByTestId('fullscreen-channel-panel-setting')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([VideoPlayer.MPV, VideoPlayer.VLC])(
|
||||
'hides the fullscreen channel panel toggle for external player %s',
|
||||
(player) => {
|
||||
const form = createForm(player);
|
||||
form.get('webPlayerSharedControls')?.setValue(true);
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
queryByTestId('fullscreen-channel-panel-setting')
|
||||
).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function createForm(player = VideoPlayer.VideoJs): FormGroup {
|
||||
@@ -382,6 +437,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
|
||||
webPlayerSharedControls: new FormControl(false),
|
||||
playerAmbientMode: new FormControl(false),
|
||||
playerUpNextRail: new FormControl(true),
|
||||
fullscreenChannelPanel: new FormControl(true),
|
||||
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
|
||||
openStreamOnDoubleClick: new FormControl(false),
|
||||
showExternalPlaybackBar: new FormControl(true),
|
||||
|
||||
@@ -65,6 +65,22 @@ export class SettingsPlaybackSectionComponent {
|
||||
return reportsPlaybackFailures(this.form().value.player);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fullscreen channel panel lives inside the fullscreen element the
|
||||
* shared controls own (the player view host). The legacy vendor chrome
|
||||
* fullscreens the engine's own element and external MPV/VLC own their
|
||||
* own window, so in both cases the toggle would control nothing.
|
||||
* Embedded MPV always renders the shared controls.
|
||||
*/
|
||||
supportsFullscreenChannelPanel(): boolean {
|
||||
const value = this.form().value;
|
||||
return (
|
||||
(this.isWebPlayerSelected() &&
|
||||
value.webPlayerSharedControls !== false) ||
|
||||
value.player === VideoPlayer.EmbeddedMpv
|
||||
);
|
||||
}
|
||||
|
||||
isExternalPlayerSelected(): boolean {
|
||||
const player = this.form().value.player;
|
||||
return player === VideoPlayer.MPV || player === VideoPlayer.VLC;
|
||||
|
||||
@@ -87,6 +87,7 @@ export const DEFAULT_SETTINGS = {
|
||||
webPlayerSharedControls: true,
|
||||
playerAmbientMode: false,
|
||||
playerUpNextRail: true,
|
||||
fullscreenChannelPanel: true,
|
||||
vodAutoFailover: false,
|
||||
m3uVodDetails: true,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
|
||||
@@ -356,6 +356,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "تعبئة المساحة حول المشغل بنسخة مشوشة ومعتمة من الملصق بدلاً من الخطوط السوداء.",
|
||||
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات التالية",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "في النوافذ العريضة، تثبيت مشغل المسلسل على اليسار وعرض الحلقات القادمة بجانبه.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "قائمة القنوات في وضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "في وضع ملء الشاشة، حرّك الماوس إلى الحافة اليسرى أو اضغط C لإظهار قائمة القنوات وتبديل القناة دون الخروج من ملء الشاشة.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "اختر تنسيق البث الافتراضي (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "اختر لغة التطبيق",
|
||||
"THEME_DESCRIPTION": "اختر السمة البصرية للتطبيق",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "إنهاء صورة داخل صورة",
|
||||
"ENTER_FULLSCREEN": "ملء الشاشة",
|
||||
"EXIT_FULLSCREEN": "الخروج من ملء الشاشة",
|
||||
"CHANNEL_LIST": "قائمة القنوات",
|
||||
"HIDE_CHANNEL_LIST": "إخفاء قائمة القنوات",
|
||||
"SEARCH_CHANNELS": "البحث عن القنوات",
|
||||
"SEARCH_IN": "البحث في {{title}}",
|
||||
"CLEAR_SEARCH": "مسح البحث",
|
||||
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
|
||||
"SUBTITLE_DELAY": "تأخير الترجمة",
|
||||
"SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا",
|
||||
|
||||
@@ -356,6 +356,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "ملي المساحة حول المشغل بغلاف معتم وطابت بلاصة الشرائط السوداء.",
|
||||
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات الجاية",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "فالنوافذ العريضة، حط مشغل السلسلة على اليسار وبيّن الحلقات الجاية بحداه.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "لائحة القنوات فوضع ملء الشاشة",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "فوضع ملء الشاشة، حرّك الماوس للحافة اليسرى ولا ورّك على C باش تبان لائحة القنوات وتبدّل القناة بلا ما تخرج من ملء الشاشة.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "اختار صيغة البث الافتراضية (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "اختار لغة التطبيق",
|
||||
"THEME_DESCRIPTION": "اختار المظهر المرئي للتطبيق",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "خرج من صورة داخل صورة",
|
||||
"ENTER_FULLSCREEN": "دخل لملء الشاشة",
|
||||
"EXIT_FULLSCREEN": "خرج من ملء الشاشة",
|
||||
"CHANNEL_LIST": "لائحة القنوات",
|
||||
"HIDE_CHANNEL_LIST": "خبّي لائحة القنوات",
|
||||
"SEARCH_CHANNELS": "قلّب على القنوات",
|
||||
"SEARCH_IN": "قلّب ف {{title}}",
|
||||
"CLEAR_SEARCH": "مسح البحث",
|
||||
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
|
||||
"SUBTITLE_DELAY": "تأخير الترجمة",
|
||||
"SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل",
|
||||
|
||||
@@ -356,6 +356,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Запаўніць прастор вакол плэйра размытай, затемнёнай вокладкай замяст чорных палос.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Панэль наступных эпізодаў",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "На шырокіх вокнах замацоўваць прайгравальнік серыяла злева і паказваць наступныя эпізоды побач з ім.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Спіс каналаў у поўнаэкранным рэжыме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Падвядзіце мыш да левага краю або націсніце C у поўнаэкранным рэжыме, каб высунуць спіс каналаў і пераключыць канал, не выходзячы з поўнаэкраннага рэжыму.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Выберыце фармат патоку па змаўчанні (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Выбраць мову праграмы",
|
||||
"THEME_DESCRIPTION": "Выбраць візуальную тэму афармлення",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Выйсці з рэжыму «выява ў выяве»",
|
||||
"ENTER_FULLSCREEN": "Увайсці ў поўнаэкранны рэжым",
|
||||
"EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму",
|
||||
"CHANNEL_LIST": "Спіс каналаў",
|
||||
"HIDE_CHANNEL_LIST": "Схаваць спіс каналаў",
|
||||
"SEARCH_CHANNELS": "Пошук каналаў",
|
||||
"SEARCH_IN": "Пошук у {{title}}",
|
||||
"CLEAR_SEARCH": "Ачысціць пошук",
|
||||
"LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…",
|
||||
"SUBTITLE_DELAY": "Затрымка субтытраў",
|
||||
"SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus",
|
||||
"THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Bild-in-Bild beenden",
|
||||
"ENTER_FULLSCREEN": "Vollbild aktivieren",
|
||||
"EXIT_FULLSCREEN": "Vollbild beenden",
|
||||
"CHANNEL_LIST": "Senderliste",
|
||||
"HIDE_CHANNEL_LIST": "Senderliste ausblenden",
|
||||
"SEARCH_CHANNELS": "Sender suchen",
|
||||
"SEARCH_IN": "In {{title}} suchen",
|
||||
"CLEAR_SEARCH": "Suche löschen",
|
||||
"LOAD_SUBTITLE_FILE": "Untertiteldatei laden…",
|
||||
"SUBTITLE_DELAY": "Untertitelverzögerung",
|
||||
"SUBTITLE_DELAY_DECREASE": "Untertitel früher anzeigen",
|
||||
|
||||
@@ -356,6 +356,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Γεμίστε το χώρο γύρω από τον αναπαραγωγό με θολωμένη, σκοτεινή αφίσα αντί για απλές μαύρες ράβδους.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Λίστα επόμενων επεισοδίων",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Σε πλατιά παράθυρα, ο αναπαραγωγός σειρών αγκυρώνεται στα αριστερά και τα επόμενα επεισόδια εμφανίζονται δίπλα του.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Λίστα καναλιών σε πλήρη οθόνη",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Σε πλήρη οθόνη, μετακινήστε το ποντίκι στην αριστερή άκρη ή πατήστε C για να εμφανιστεί η λίστα καναλιών και να αλλάξετε κανάλι χωρίς να βγείτε από την πλήρη οθόνη.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Επιλογή προεπιλεγμένης μορφής ροής (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Επιλέξτε τη γλώσσα της εφαρμογής",
|
||||
"THEME_DESCRIPTION": "Επιλέξτε οπτικό θέμα της εφαρμογής",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Έξοδος από εικόνα εντός εικόνας",
|
||||
"ENTER_FULLSCREEN": "Μετάβαση σε πλήρη οθόνη",
|
||||
"EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη",
|
||||
"CHANNEL_LIST": "Λίστα καναλιών",
|
||||
"HIDE_CHANNEL_LIST": "Απόκρυψη λίστας καναλιών",
|
||||
"SEARCH_CHANNELS": "Αναζήτηση καναλιών",
|
||||
"SEARCH_IN": "Αναζήτηση σε {{title}}",
|
||||
"CLEAR_SEARCH": "Καθαρισμός αναζήτησης",
|
||||
"LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…",
|
||||
"SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων",
|
||||
"SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Select language of the application",
|
||||
"THEME_DESCRIPTION": "Select visual theme of the application",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Exit picture-in-picture",
|
||||
"ENTER_FULLSCREEN": "Enter fullscreen",
|
||||
"EXIT_FULLSCREEN": "Exit fullscreen",
|
||||
"CHANNEL_LIST": "Channel list",
|
||||
"HIDE_CHANNEL_LIST": "Hide channel list",
|
||||
"SEARCH_CHANNELS": "Search channels",
|
||||
"SEARCH_IN": "Search in {{title}}",
|
||||
"CLEAR_SEARCH": "Clear search",
|
||||
"LOAD_SUBTITLE_FILE": "Load subtitle file…",
|
||||
"SUBTITLE_DELAY": "Subtitle delay",
|
||||
"SUBTITLE_DELAY_DECREASE": "Show subtitles earlier",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"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.",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Salir de imagen en imagen",
|
||||
"ENTER_FULLSCREEN": "Entrar en pantalla completa",
|
||||
"EXIT_FULLSCREEN": "Salir de pantalla completa",
|
||||
"CHANNEL_LIST": "Lista de canales",
|
||||
"HIDE_CHANNEL_LIST": "Ocultar la lista de canales",
|
||||
"SEARCH_CHANNELS": "Buscar canales",
|
||||
"SEARCH_IN": "Buscar en {{title}}",
|
||||
"CLEAR_SEARCH": "Borrar búsqueda",
|
||||
"LOAD_SUBTITLE_FILE": "Cargar archivo de subtítulos…",
|
||||
"SUBTITLE_DELAY": "Retardo de subtítulos",
|
||||
"SUBTITLE_DELAY_DECREASE": "Mostrar subtítulos antes",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"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",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Quitter le mode image dans l’image",
|
||||
"ENTER_FULLSCREEN": "Passer en plein écran",
|
||||
"EXIT_FULLSCREEN": "Quitter le plein écran",
|
||||
"CHANNEL_LIST": "Liste des chaînes",
|
||||
"HIDE_CHANNEL_LIST": "Masquer la liste des chaînes",
|
||||
"SEARCH_CHANNELS": "Rechercher des chaînes",
|
||||
"SEARCH_IN": "Rechercher dans {{title}}",
|
||||
"CLEAR_SEARCH": "Effacer la recherche",
|
||||
"LOAD_SUBTITLE_FILE": "Charger un fichier de sous-titres…",
|
||||
"SUBTITLE_DELAY": "Décalage des sous-titres",
|
||||
"SUBTITLE_DELAY_DECREASE": "Afficher les sous-titres plus tôt",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"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",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Kilépés a kép a képben módból",
|
||||
"ENTER_FULLSCREEN": "Váltás teljes képernyőre",
|
||||
"EXIT_FULLSCREEN": "Kilépés a teljes képernyős módból",
|
||||
"CHANNEL_LIST": "Csatornalista",
|
||||
"HIDE_CHANNEL_LIST": "Csatornalista elrejtése",
|
||||
"SEARCH_CHANNELS": "Csatornák keresése",
|
||||
"SEARCH_IN": "Keresés itt: {{title}}",
|
||||
"CLEAR_SEARCH": "Keresés törlése",
|
||||
"LOAD_SUBTITLE_FILE": "Feliratfájl betöltése…",
|
||||
"SUBTITLE_DELAY": "Felirat késleltetése",
|
||||
"SUBTITLE_DELAY_DECREASE": "Feliratok megjelenítése korábban",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"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",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Esci da Picture-in-Picture",
|
||||
"ENTER_FULLSCREEN": "Attiva schermo intero",
|
||||
"EXIT_FULLSCREEN": "Esci dallo schermo intero",
|
||||
"CHANNEL_LIST": "Elenco canali",
|
||||
"HIDE_CHANNEL_LIST": "Nascondi l'elenco dei canali",
|
||||
"SEARCH_CHANNELS": "Cerca canali",
|
||||
"SEARCH_IN": "Cerca in {{title}}",
|
||||
"CLEAR_SEARCH": "Cancella ricerca",
|
||||
"LOAD_SUBTITLE_FILE": "Carica file sottotitoli…",
|
||||
"SUBTITLE_DELAY": "Ritardo sottotitoli",
|
||||
"SUBTITLE_DELAY_DECREASE": "Mostra i sottotitoli prima",
|
||||
|
||||
@@ -356,6 +356,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "プレーヤーの周囲をぼかしたポスター画像で埋め、通常の黒い枠の代わりに表示します。",
|
||||
"PLAYER_UP_NEXT_RAIL": "「次に再生」エピソードレール",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "ウィンドウ幅が広い場合、シリーズのプレーヤーを左側に配置し、その横に次のエピソードの一覧を表示します。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全画面表示でのチャンネルリスト",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全画面表示中にマウスを左端に置くか C キーを押すと、チャンネルリストがスライド表示され、全画面を解除せずにチャンネルを切り替えられます。",
|
||||
"STREAM_FORMAT_DESCRIPTION": "デフォルトのストリーム形式を選択(Xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "アプリケーションの言語を選択",
|
||||
"THEME_DESCRIPTION": "アプリケーションの視覚テーマを選択",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを終了",
|
||||
"ENTER_FULLSCREEN": "全画面表示",
|
||||
"EXIT_FULLSCREEN": "全画面表示を終了",
|
||||
"CHANNEL_LIST": "チャンネルリスト",
|
||||
"HIDE_CHANNEL_LIST": "チャンネルリストを隠す",
|
||||
"SEARCH_CHANNELS": "チャンネルを検索",
|
||||
"SEARCH_IN": "{{title}} 内を検索",
|
||||
"CLEAR_SEARCH": "検索をクリア",
|
||||
"LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…",
|
||||
"SUBTITLE_DELAY": "字幕の遅延",
|
||||
"SUBTITLE_DELAY_DECREASE": "字幕を早く表示",
|
||||
|
||||
@@ -356,6 +356,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "플레이어 주변을 검은 막대 대신 흐려지고 어두워진 포스터로 채웁니다.",
|
||||
"PLAYER_UP_NEXT_RAIL": "다음 에피소드 레일",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "넓은 창에서 시리즈 플레이어를 왼쪽에 고정하고 그 옆에 다음 에피소드를 표시합니다.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "전체 화면에서 채널 목록",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "전체 화면에서 마우스를 왼쪽 가장자리에 두거나 C 키를 누르면 채널 목록이 나타나 전체 화면을 벗어나지 않고 채널을 바꿀 수 있습니다.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "기본 스트림 형식 선택 (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "애플리케이션 언어 선택",
|
||||
"THEME_DESCRIPTION": "애플리케이션의 시각적 테마 선택",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "PIP 모드 종료",
|
||||
"ENTER_FULLSCREEN": "전체 화면",
|
||||
"EXIT_FULLSCREEN": "전체 화면 종료",
|
||||
"CHANNEL_LIST": "채널 목록",
|
||||
"HIDE_CHANNEL_LIST": "채널 목록 숨기기",
|
||||
"SEARCH_CHANNELS": "채널 검색",
|
||||
"SEARCH_IN": "{{title}}에서 검색",
|
||||
"CLEAR_SEARCH": "검색 지우기",
|
||||
"LOAD_SUBTITLE_FILE": "자막 파일 불러오기…",
|
||||
"SUBTITLE_DELAY": "자막 지연",
|
||||
"SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"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",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Beeld-in-beeld sluiten",
|
||||
"ENTER_FULLSCREEN": "Volledig scherm openen",
|
||||
"EXIT_FULLSCREEN": "Volledig scherm sluiten",
|
||||
"CHANNEL_LIST": "Zenderlijst",
|
||||
"HIDE_CHANNEL_LIST": "Zenderlijst verbergen",
|
||||
"SEARCH_CHANNELS": "Zenders zoeken",
|
||||
"SEARCH_IN": "Zoeken in {{title}}",
|
||||
"CLEAR_SEARCH": "Zoekopdracht wissen",
|
||||
"LOAD_SUBTITLE_FILE": "Ondertitelbestand laden…",
|
||||
"SUBTITLE_DELAY": "Ondertitelvertraging",
|
||||
"SUBTITLE_DELAY_DECREASE": "Ondertitels eerder tonen",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Wybierz domyślny format strumienia (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Wybierz język aplikacji",
|
||||
"THEME_DESCRIPTION": "Wybierz motyw wizualny aplikacji",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Wyłącz obraz w obrazie",
|
||||
"ENTER_FULLSCREEN": "Włącz pełny ekran",
|
||||
"EXIT_FULLSCREEN": "Wyłącz pełny ekran",
|
||||
"CHANNEL_LIST": "Lista kanałów",
|
||||
"HIDE_CHANNEL_LIST": "Ukryj listę kanałów",
|
||||
"SEARCH_CHANNELS": "Szukaj kanałów",
|
||||
"SEARCH_IN": "Szukaj w {{title}}",
|
||||
"CLEAR_SEARCH": "Wyczyść wyszukiwanie",
|
||||
"LOAD_SUBTITLE_FILE": "Wczytaj plik napisów…",
|
||||
"SUBTITLE_DELAY": "Opóźnienie napisów",
|
||||
"SUBTITLE_DELAY_DECREASE": "Pokaż napisy wcześniej",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Selecionar formato de stream padrão (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Selecionar idioma do aplicativo",
|
||||
"THEME_DESCRIPTION": "Selecionar tema visual do aplicativo",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Sair de imagem em imagem",
|
||||
"ENTER_FULLSCREEN": "Entrar em tela cheia",
|
||||
"EXIT_FULLSCREEN": "Sair da tela cheia",
|
||||
"CHANNEL_LIST": "Lista de canais",
|
||||
"HIDE_CHANNEL_LIST": "Ocultar lista de canais",
|
||||
"SEARCH_CHANNELS": "Pesquisar canais",
|
||||
"SEARCH_IN": "Pesquisar em {{title}}",
|
||||
"CLEAR_SEARCH": "Limpar pesquisa",
|
||||
"LOAD_SUBTITLE_FILE": "Carregar ficheiro de legendas…",
|
||||
"SUBTITLE_DELAY": "Atraso das legendas",
|
||||
"SUBTITLE_DELAY_DECREASE": "Mostrar legendas mais cedo",
|
||||
|
||||
@@ -356,6 +356,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Заполнять пространство вокруг плеера размытым затемнённым постером вместо чёрных полос.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Панель следующих серий",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Список каналов в полноэкранном режиме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Подведите мышь к левому краю или нажмите C в полноэкранном режиме, чтобы выдвинуть список каналов и переключить канал, не выходя из полноэкранного режима.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Выбрать язык приложения",
|
||||
"THEME_DESCRIPTION": "Выбрать визуальную тему оформления",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Выйти из режима «картинка в картинке»",
|
||||
"ENTER_FULLSCREEN": "Во весь экран",
|
||||
"EXIT_FULLSCREEN": "Выйти из полноэкранного режима",
|
||||
"CHANNEL_LIST": "Список каналов",
|
||||
"HIDE_CHANNEL_LIST": "Скрыть список каналов",
|
||||
"SEARCH_CHANNELS": "Поиск каналов",
|
||||
"SEARCH_IN": "Поиск в {{title}}",
|
||||
"CLEAR_SEARCH": "Очистить поиск",
|
||||
"LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…",
|
||||
"SUBTITLE_DELAY": "Задержка субтитров",
|
||||
"SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше",
|
||||
|
||||
@@ -356,6 +356,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.",
|
||||
"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",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "Resim içinde resim modundan çık",
|
||||
"ENTER_FULLSCREEN": "Tam ekrana geç",
|
||||
"EXIT_FULLSCREEN": "Tam ekrandan çık",
|
||||
"CHANNEL_LIST": "Kanal listesi",
|
||||
"HIDE_CHANNEL_LIST": "Kanal listesini gizle",
|
||||
"SEARCH_CHANNELS": "Kanal ara",
|
||||
"SEARCH_IN": "{{title}} içinde ara",
|
||||
"CLEAR_SEARCH": "Aramayı temizle",
|
||||
"LOAD_SUBTITLE_FILE": "Altyazı dosyası yükle…",
|
||||
"SUBTITLE_DELAY": "Altyazı gecikmesi",
|
||||
"SUBTITLE_DELAY_DECREASE": "Altyazıları daha erken göster",
|
||||
|
||||
@@ -356,6 +356,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、暗淡的海报填充播放器周围的空白,而不是黑条纹。",
|
||||
"PLAYER_UP_NEXT_RAIL": "「接下来播放」剧集栏",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在宽窗口下,将剧集播放器停靠在左侧,并在旁边显示后续剧集。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全屏时显示频道列表",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全屏播放时,将鼠标移到左边缘或按 C 键即可滑出频道列表,无需退出全屏即可切换频道。",
|
||||
"STREAM_FORMAT_DESCRIPTION": "选择默认的流格式(xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "选择应用程序的语言",
|
||||
"THEME_DESCRIPTION": "选择应用程序的视觉主题",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "退出画中画",
|
||||
"ENTER_FULLSCREEN": "进入全屏",
|
||||
"EXIT_FULLSCREEN": "退出全屏",
|
||||
"CHANNEL_LIST": "频道列表",
|
||||
"HIDE_CHANNEL_LIST": "隐藏频道列表",
|
||||
"SEARCH_CHANNELS": "搜索频道",
|
||||
"SEARCH_IN": "在 {{title}} 中搜索",
|
||||
"CLEAR_SEARCH": "清除搜索",
|
||||
"LOAD_SUBTITLE_FILE": "加载字幕文件…",
|
||||
"SUBTITLE_DELAY": "字幕延迟",
|
||||
"SUBTITLE_DELAY_DECREASE": "提前显示字幕",
|
||||
|
||||
@@ -356,6 +356,8 @@
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、昏暗的海報填充播放器周圍的空間,取代純黑色邊框。",
|
||||
"PLAYER_UP_NEXT_RAIL": "即將播放集數側欄",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在較寬的視窗中,將影集播放器停靠在左側,並在旁邊顯示即將播放的集數。",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "全螢幕時顯示頻道清單",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全螢幕播放時,將滑鼠移到左邊緣或按 C 鍵即可滑出頻道清單,不必離開全螢幕就能切換頻道。",
|
||||
"STREAM_FORMAT_DESCRIPTION": "選擇預設串流格式(xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "選擇應用程式語言",
|
||||
"THEME_DESCRIPTION": "選擇應用程式視覺主題",
|
||||
@@ -578,6 +580,11 @@
|
||||
"EXIT_PICTURE_IN_PICTURE": "退出子母畫面",
|
||||
"ENTER_FULLSCREEN": "進入全螢幕",
|
||||
"EXIT_FULLSCREEN": "離開全螢幕",
|
||||
"CHANNEL_LIST": "頻道清單",
|
||||
"HIDE_CHANNEL_LIST": "隱藏頻道清單",
|
||||
"SEARCH_CHANNELS": "搜尋頻道",
|
||||
"SEARCH_IN": "在 {{title}} 中搜尋",
|
||||
"CLEAR_SEARCH": "清除搜尋",
|
||||
"LOAD_SUBTITLE_FILE": "載入字幕檔案…",
|
||||
"SUBTITLE_DELAY": "字幕延遲",
|
||||
"SUBTITLE_DELAY_DECREASE": "提前顯示字幕",
|
||||
|
||||
@@ -303,6 +303,166 @@ label. The Xtream and Stalker series detail views pass the series name via
|
||||
`seriesTitle`; movie and live hosts need no extra wiring because
|
||||
`playback.title` already names the content.
|
||||
|
||||
### Fullscreen channel panel
|
||||
|
||||
`FullscreenChannelPanelComponent` (`libs/ui/playback/src/lib/fullscreen-channel-panel/`)
|
||||
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
|
||||
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
|
||||
fullscreens the engine's own element, outside which the panel cannot render,
|
||||
so the toggle is hidden there) makes the host return `null`, which removes
|
||||
every affordance. The M3U host also returns `null` while its VOD detail hosts
|
||||
the player (`showMovieDetail`): a movie is not something to zap away from,
|
||||
and the nested `WebPlayerViewComponent` would otherwise inherit the
|
||||
component-level provider.
|
||||
|
||||
Providers today: `VideoPlayerComponent` (M3U; `app-m3u-fullscreen-channel-list`
|
||||
adds a local icon-only all/groups/favorites/recent switcher — one segmented
|
||||
row, labels in tooltip and `aria-label` — over a second
|
||||
`ChannelListContainerComponent` instance in `compact` mode, which drops the
|
||||
container's per-view title/sort/collapse headers (`showHeader` on the
|
||||
all-channels and groups views; the persisted sort still applies) so nothing
|
||||
stacks between the search row and the first channel, and which also pins the
|
||||
groups view's rail to a fixed 148px — no resize handle, and the sidebar's
|
||||
persisted `m3u-groups-nav-width` (up to 320px) is neither read nor written,
|
||||
since inside a 400px panel it would leave the channel pane unusable — and with
|
||||
`resetActiveChannelOnDestroy` false, because the container's destroy hook
|
||||
otherwise clears the active channel and stops playback; radio stations and
|
||||
recognized movies are filtered out of the list it is handed — radio renders
|
||||
through `app-audio-player` and a movie, with TMDB enrichment and
|
||||
`m3uVodDetails` on, through the VOD detail shell, so selecting either destroys
|
||||
the `app-web-player-view` that owns fullscreen and drops the user out of it;
|
||||
one private `opensMovieDetail` predicate backs both `showMovieDetail` and the
|
||||
filter, and every panel view resolves against that one list, favorites and
|
||||
recent included. With external MPV/VLC configured, the panel offers only
|
||||
DASH rows: DASH forces the inline Shaka player, but selecting a non-DASH
|
||||
channel would replace the fullscreen owner with the external-player UI.
|
||||
While the live `app-web-player-view` owns fullscreen — itself under shared
|
||||
controls, or through the nested surface a legacy player fullscreens under the
|
||||
vendor-chrome opt-out (`.video-js`, ArtPlayer's container, the `<video>`), so
|
||||
the check accepts any fullscreen element inside that host — numeric
|
||||
selection and PageUp/PageDown (including remote commands) apply that same
|
||||
eligibility predicate. Numbers retain their original playlist positions and an ineligible
|
||||
number is ignored; adjacent selection skips ineligible rows. Windowed playback
|
||||
keeps the complete catalog. Already-handled events and menu/dialog overlay targets keep their keys,
|
||||
even when a short menu has no scroll overflow), `LiveStreamLayoutComponent` (Xtream; the sidebar's
|
||||
rows via `channelsOverride` so the second list instance never re-applies the
|
||||
route category, and with `fullscreenPanelCopy` so only the sidebar's pane
|
||||
carries the `live-channels` id that the category list's keyboard hand-off
|
||||
targets — the Stalker template applies the same rule to its panel copy; each
|
||||
`PortalChannelsListComponent` instance owns the map
|
||||
behind its heart icons, so toggles are relayed between instances through
|
||||
`XtreamFavoriteMarksService`, or a heart flipped in the panel would stay stale
|
||||
in the sidebar), `StalkerLiveStreamLayoutComponent` (its list markup is one
|
||||
`ng-template` stamped into both the sidebar and the panel with its own search
|
||||
term — a blank panel field shows the category untouched by the sidebar's
|
||||
term, `panelIdleChannels`, so the panel never shows an unexplained subset or
|
||||
an empty state under an empty search box, and that copy grows against the
|
||||
category (`panelIdleHasMore` / `loadMoreForPanel`) or the windowed full cache
|
||||
when playback starts from All Items with no selected category, never against the
|
||||
sidebar's filtered `hasMoreItems`, which a narrowing sidebar search turns
|
||||
false while the panel still has rows to reveal; every copy carries the
|
||||
`#scrollContainer` that drives infinite scroll,
|
||||
and the panel's own search results go through `PanelSearchWindow`, memoized
|
||||
per term and cut to the same 100-row window the sidebar uses — grown by the
|
||||
panel copy's scroll — because in full-list mode a broad term matches most of
|
||||
a multi-thousand-channel portal and the list has no virtual scroll; on a paged
|
||||
portal the panel copy also keeps requesting pages while its matches do not
|
||||
fill it — an empty or short result cannot scroll, and the term may match
|
||||
channels on pages never fetched; the sidebar's own search term does not stop
|
||||
it, since that term only gates the sidebar copy's automatic fill, and a page
|
||||
landing resets the in-flight flag whether or not the sidebar shows any of it
|
||||
— while in full-list mode it never pages, since its search already sees the
|
||||
whole catalog; closing the panel pauses window growth and automatic page
|
||||
requests while preserving the mounted list, and an observer of the aside's
|
||||
`inert` attribute resumes filling on reopen and disconnects with the list;
|
||||
inline video keeps the selected channel paired with its retained playback
|
||||
until the current resolver succeeds, so panel highlight, EPG and recording
|
||||
metadata remain on the playing channel during a pending or failed replacement),
|
||||
and `UnifiedLiveTabComponent` (global favorites/recent; its `activateItem`
|
||||
keeps the previous detail — and with it the mounted player that owns
|
||||
fullscreen — until the next selection has resolved, because unmounting the
|
||||
fullscreen element for the resolution round-trip would end fullscreen on
|
||||
every zap from the panel; only `activeUid`, the row highlight, moves ahead,
|
||||
while `activeItem` stays paired with that detail so `playbackSessionKey` and
|
||||
the recording/archive metadata derived from it keep describing the stream the
|
||||
player is still showing, and both swap together once the new detail is in.
|
||||
Because of that split, "the row is on screen" means highlight AND resolved
|
||||
item: `activateItem`'s fast path (a double-click's second click launching
|
||||
the external player, an auto-open reporting itself handled) requires both,
|
||||
or it would launch the retained stream in place of the one still resolving;
|
||||
a second activation of the row still resolving instead folds its
|
||||
start-playback/auto-open intent into that request (`pendingActivation`), so
|
||||
the detail launches when it lands without a second round-trip.
|
||||
If replacement resolution fails while a video is retained, its player,
|
||||
catch-up override and session remain intact and the row highlight returns to
|
||||
that item).
|
||||
|
||||
Behavior: every affordance exists only while the stage is fullscreen and the
|
||||
view's `enabled` input holds — `WebPlayerViewComponent` withholds the panel
|
||||
while the rendered engine is native-view Embedded MPV, which paints a platform
|
||||
view above the page where no DOM layer can show (frame-copy and the web
|
||||
players qualify; the gate fails closed until the MPV component reports its
|
||||
engine on first entry; a confirmed frame-copy capability survives an unknown
|
||||
probe during an MPV application remount, preserving the open panel and its
|
||||
search/scroll state. A confirmed native/unsupported result revokes it, and
|
||||
switching to a web engine clears the remembered MPV capability). Nothing is
|
||||
drawn over the video while the panel is closed — there is
|
||||
no handle or hint. An invisible 28px hot zone (40px on coarse pointers) on the
|
||||
left edge opens the panel after a 160ms mouse dwell; a sweep across the edge
|
||||
is ignored. The zone stops above the controls bar (`bottom: max(25%, 140px)`)
|
||||
so the leftmost transport button never loses a click or tap to it. The `C` key opens it too and focuses the search field (hover does
|
||||
not steal focus). Touch has neither hover nor a `C` key, so a tap on the hot
|
||||
zone opens the panel at once: the handler is bound to `pointerup`, not
|
||||
`pointerdown`, so the hot zone is still the tap's click target and the click
|
||||
that follows dies on it instead of reaching the video. It closes when the
|
||||
mouse leaves the panel for 420ms, on the close button (tooltip names Escape),
|
||||
on Escape, on the host's `close`, or through a transparent scrim
|
||||
over the video that swallows the click so the player's click-to-pause never
|
||||
sees it. The Escape that closes the panel is consumed (`preventDefault`):
|
||||
Electron leaves HTML fullscreen on an unhandled Escape, and the close
|
||||
shortcut must only slide the panel away; a closed panel leaves Escape alone,
|
||||
so the key still exits fullscreen then. A CDK overlay the list opens (sort menu, row context menu) renders in
|
||||
the fullscreen overlay container outside the `<aside>`, so it counts as part
|
||||
of the panel: while open, hover intent is tracked through a document-level
|
||||
`pointerover` (inside the aside or the overlay container cancels a pending
|
||||
close, anywhere else schedules one), the aside's own `pointerleave` ignores a
|
||||
move into the overlay container, and Escape is left to an overlay with a
|
||||
backdrop. The header is a single row: the search field, whose placeholder
|
||||
carries the host's `panelTitle` ("Search in <playlist or category>") so the
|
||||
title costs no row of its own, and the close button; the host list starts
|
||||
directly below. The list stays mounted between openings of one fullscreen
|
||||
session (scroll position and search survive) and is unmounted when fullscreen
|
||||
ends. The panel carries the `dark-theme` context class, so app and Material
|
||||
tokens inside it resolve to the dark palette whatever the app theme is — and
|
||||
because the app's global `.dark-theme { background: … !important }` rule
|
||||
(`apps/web/src/styles.scss`) claims the background of every element wearing
|
||||
that class, the panel's translucent gradient is declared on the compound
|
||||
`.fullscreen-channel-panel.dark-theme` selector with `!important`; without
|
||||
that the panel painted no background at all.
|
||||
Keyboard: `C` is ignored while any editable element has focus and while the
|
||||
player sits inside an `inert` region; the search field is an ordinary input,
|
||||
so the controls' Space/K/F/M shortcuts stay out of it. That is also why `C` is
|
||||
an open shortcut, not a close one: opening with `C` focuses the search field,
|
||||
where the next `C` is a typed character, so the close shortcut the button
|
||||
advertises is Escape, which closes from the field too (unless a modal overlay
|
||||
owns it).
|
||||
|
||||
CDK overlays render inside the fullscreen element because the app registers
|
||||
`FullscreenOverlayContainer` as the `OverlayContainer` (`app.config.ts`);
|
||||
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.
|
||||
|
||||
### Keyboard ownership
|
||||
|
||||
Unmodified Space/K, F, arrow keys, and M are playback shortcuts. Playback keys
|
||||
@@ -1048,6 +1208,21 @@ libs/ui/playback/src/lib/player-controls/
|
||||
Focused specs live beside these files. The subtree is exported from
|
||||
`libs/ui/playback/src/index.ts`.
|
||||
|
||||
The fullscreen channel panel rendered by `WebPlayerViewComponent` lives in:
|
||||
|
||||
```text
|
||||
libs/ui/playback/src/lib/fullscreen-channel-panel/
|
||||
├── fullscreen-channel-panel.model.ts # FULLSCREEN_CHANNEL_PANEL + host/context contracts
|
||||
├── fullscreen-channel-panel-state.ts # open/mounted state, hover-intent timers
|
||||
├── fullscreen-channel-panel.component.ts
|
||||
├── fullscreen-channel-panel.component.html
|
||||
├── fullscreen-channel-panel.component.scss
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
The M3U body (`app-m3u-fullscreen-channel-list`) lives beside the M3U player
|
||||
in `libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/`.
|
||||
|
||||
The Embedded MPV integration lives in:
|
||||
|
||||
```text
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<div
|
||||
class="m3u-fullscreen-channel-list__views"
|
||||
role="tablist"
|
||||
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.CHANNEL_LIST' | translate"
|
||||
>
|
||||
@for (option of views; track option.id) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="m3u-fullscreen-channel-list__view"
|
||||
[class.m3u-fullscreen-channel-list__view--active]="
|
||||
view() === option.id
|
||||
"
|
||||
[attr.aria-selected]="view() === option.id"
|
||||
[attr.aria-label]="option.labelKey | translate"
|
||||
[matTooltip]="option.labelKey | translate"
|
||||
matTooltipPosition="below"
|
||||
[attr.data-test-id]="'m3u-fullscreen-view-' + option.id"
|
||||
(click)="selectView(option.id)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{ option.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-channel-list-container
|
||||
class="m3u-fullscreen-channel-list__list"
|
||||
[channelList]="channels()"
|
||||
[channelsLoading]="channelsLoading()"
|
||||
[activeView]="view()"
|
||||
[recentItems]="recentItems()"
|
||||
[searchTerm]="searchTerm()"
|
||||
[compact]="true"
|
||||
[resetActiveChannelOnDestroy]="false"
|
||||
(sidebarToggleRequested)="closeRequested.emit()"
|
||||
/>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
// Rendered only inside the fullscreen channel panel, which is always dark
|
||||
// (it carries the `dark-theme` context), so the colours are tuned for that
|
||||
// surface rather than for both themes.
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// One compact segmented control: four equal icon segments, labels in the
|
||||
// tooltip and aria-label. It replaces the per-view headers the container
|
||||
// draws in the sidebar (`compact` mode), so the row is the only chrome
|
||||
// between the search field and the first channel.
|
||||
.m3u-fullscreen-channel-list__views {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
margin: 8px 12px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.m3u-fullscreen-channel-list__view {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease-out,
|
||||
color 150ms ease-out;
|
||||
}
|
||||
|
||||
.m3u-fullscreen-channel-list__view mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.m3u-fullscreen-channel-list__view:hover,
|
||||
.m3u-fullscreen-channel-list__view:focus-visible {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.m3u-fullscreen-channel-list__view:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.m3u-fullscreen-channel-list__view--active,
|
||||
.m3u-fullscreen-channel-list__view--active:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.m3u-fullscreen-channel-list__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import { Component, Input, input, output } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
Channel,
|
||||
PlaylistRecentlyViewedItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { M3uFullscreenChannelListComponent } from './m3u-fullscreen-channel-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-container',
|
||||
template: '',
|
||||
})
|
||||
class StubChannelListContainerComponent {
|
||||
@Input() channelList: Channel[] = [];
|
||||
readonly channelsLoading = input(false);
|
||||
readonly activeView = input<string>('all');
|
||||
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
|
||||
readonly searchTerm = input<string | null>(null);
|
||||
readonly compact = input(false);
|
||||
readonly resetActiveChannelOnDestroy = input(true);
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
}
|
||||
|
||||
describe('M3uFullscreenChannelListComponent', () => {
|
||||
let fixture: ComponentFixture<M3uFullscreenChannelListComponent>;
|
||||
let component: M3uFullscreenChannelListComponent;
|
||||
|
||||
const container = (): StubChannelListContainerComponent =>
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubChannelListContainerComponent)
|
||||
).componentInstance;
|
||||
const chip = (view: string): HTMLButtonElement | null =>
|
||||
fixture.nativeElement.querySelector(
|
||||
`[data-test-id="m3u-fullscreen-view-${view}"]`
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
M3uFullscreenChannelListComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
})
|
||||
.overrideComponent(M3uFullscreenChannelListComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
StubChannelListContainerComponent,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(M3uFullscreenChannelListComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('starts on the routed view and never lets the panel list reset playback', () => {
|
||||
const channels = [{ id: 'one', url: 'http://one' } as Channel];
|
||||
fixture.componentRef.setInput('channels', channels);
|
||||
fixture.componentRef.setInput('initialView', 'favorites');
|
||||
fixture.componentRef.setInput('searchTerm', 'ne');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.view()).toBe('favorites');
|
||||
expect(chip('favorites')?.getAttribute('aria-selected')).toBe('true');
|
||||
expect(container().activeView()).toBe('favorites');
|
||||
expect(container().channelList).toBe(channels);
|
||||
expect(container().searchTerm()).toBe('ne');
|
||||
expect(container().compact()).toBe(true);
|
||||
expect(container().resetActiveChannelOnDestroy()).toBe(false);
|
||||
});
|
||||
|
||||
it('labels the icon-only view segments for assistive tech and tooltips', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
for (const view of ['all', 'groups', 'favorites', 'recent']) {
|
||||
const segment = chip(view);
|
||||
expect(segment?.getAttribute('role')).toBe('tab');
|
||||
expect(segment?.getAttribute('aria-label')).toBeTruthy();
|
||||
expect(segment?.textContent?.replace(/\s+/g, '')).toBe(
|
||||
segment?.querySelector('mat-icon')?.textContent?.trim()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to all channels for a view the panel does not offer', () => {
|
||||
fixture.componentRef.setInput('initialView', 'something-else');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.view()).toBe('all');
|
||||
expect(chip('all')?.getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('switches the list view locally without navigating', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
chip('groups')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.view()).toBe('groups');
|
||||
expect(container().activeView()).toBe('groups');
|
||||
expect(chip('all')?.getAttribute('aria-selected')).toBe('false');
|
||||
expect(chip('groups')?.getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it("turns the list's hide-sidebar request into a close request", () => {
|
||||
fixture.detectChanges();
|
||||
const closeRequested = jest.fn();
|
||||
component.closeRequested.subscribe(closeRequested);
|
||||
|
||||
container().sidebarToggleRequested.emit();
|
||||
|
||||
expect(closeRequested).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
Channel,
|
||||
PlaylistRecentlyViewedItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { ChannelListContainerComponent } from '@iptvnator/ui/components';
|
||||
|
||||
export type M3uFullscreenChannelView =
|
||||
'all' | 'groups' | 'favorites' | 'recent';
|
||||
|
||||
export interface M3uFullscreenChannelViewOption {
|
||||
readonly id: M3uFullscreenChannelView;
|
||||
readonly labelKey: string;
|
||||
readonly icon: string;
|
||||
}
|
||||
|
||||
export const M3U_FULLSCREEN_CHANNEL_VIEWS: readonly M3uFullscreenChannelViewOption[] =
|
||||
[
|
||||
{ id: 'all', labelKey: 'CHANNELS.ALL_CHANNELS', icon: 'list' },
|
||||
{ id: 'groups', labelKey: 'CHANNELS.GROUPS', icon: 'folder' },
|
||||
{ id: 'favorites', labelKey: 'CHANNELS.FAVORITES', icon: 'star' },
|
||||
{ id: 'recent', labelKey: 'PORTALS.SIDEBAR.RECENT', icon: 'history' },
|
||||
];
|
||||
|
||||
export function toM3uFullscreenChannelView(
|
||||
view: string | null | undefined
|
||||
): M3uFullscreenChannelView {
|
||||
return view === 'groups' || view === 'favorites' || view === 'recent'
|
||||
? view
|
||||
: 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the fullscreen channel panel for M3U playlists: an icon-only view
|
||||
* switcher (all / groups / favorites / recent) over the same channel list
|
||||
* container the sidebar renders, in its `compact` mode so the container's
|
||||
* per-view headers (title, sort, collapse) never stack under the panel's
|
||||
* search row. The view is local state on purpose — the sidebar's tabs are
|
||||
* routes, and switching a tab while fullscreen must not navigate the page
|
||||
* underneath the player.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-m3u-fullscreen-channel-list',
|
||||
templateUrl: './m3u-fullscreen-channel-list.component.html',
|
||||
styleUrl: './m3u-fullscreen-channel-list.component.scss',
|
||||
imports: [
|
||||
ChannelListContainerComponent,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class M3uFullscreenChannelListComponent {
|
||||
readonly channels = input<Channel[]>([]);
|
||||
readonly channelsLoading = input(false);
|
||||
/** The page's routed view; the panel opens on it and switches locally. */
|
||||
readonly initialView = input<string>('all');
|
||||
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
|
||||
readonly searchTerm = input('');
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
readonly views = M3U_FULLSCREEN_CHANNEL_VIEWS;
|
||||
readonly view = linkedSignal<M3uFullscreenChannelView>(() =>
|
||||
toM3uFullscreenChannelView(this.initialView())
|
||||
);
|
||||
|
||||
selectView(view: M3uFullscreenChannelView): void {
|
||||
this.view.set(view);
|
||||
}
|
||||
}
|
||||
+597
@@ -0,0 +1,597 @@
|
||||
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import {
|
||||
ChannelActions,
|
||||
selectActive,
|
||||
selectActivePlaylist,
|
||||
selectChannels,
|
||||
selectChannelsLoading,
|
||||
selectCurrentEpgProgram,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DataService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
ExternalPlayerSession,
|
||||
PlaylistMeta,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-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', () => ({}));
|
||||
|
||||
/**
|
||||
* Fullscreen channel panel host contract and keyboard zapping of the M3U
|
||||
* player. Kept apart from `video-player.component.spec.ts`, which sits at the
|
||||
* spec line budget; the template is reduced to the panel's `ng-template` so
|
||||
* no child stubs are needed.
|
||||
*/
|
||||
describe('VideoPlayerComponent fullscreen channel panel + zapping', () => {
|
||||
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
|
||||
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
|
||||
let component: VideoPlayerComponentInstance;
|
||||
|
||||
const sampleChannel = {
|
||||
id: 'channel-1',
|
||||
url: 'http://localhost/live.m3u8',
|
||||
name: 'Sample TV',
|
||||
radio: 'false',
|
||||
} as Channel;
|
||||
const nextChannel = {
|
||||
...sampleChannel,
|
||||
id: 'channel-2',
|
||||
url: 'http://localhost/next.m3u8',
|
||||
name: 'Next TV',
|
||||
} as Channel;
|
||||
|
||||
const activeChannel = signal<Channel | null>(sampleChannel);
|
||||
const channels = signal<Channel[]>([sampleChannel, nextChannel]);
|
||||
const activePlaylistMeta = signal<PlaylistMeta | null>(null);
|
||||
const fullscreenChannelPanelSetting = signal<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
const tmdbEnabled = signal(false);
|
||||
const player = signal(VideoPlayer.VideoJs);
|
||||
const movieChannel = {
|
||||
id: 'movie-1',
|
||||
url: 'http://localhost/movies/the-film.mkv',
|
||||
name: 'The Film (2019)',
|
||||
} as Channel;
|
||||
const channels$ = new BehaviorSubject<Channel[]>([
|
||||
sampleChannel,
|
||||
nextChannel,
|
||||
]);
|
||||
const activeChannel$ = new BehaviorSubject<Channel | null>(sampleChannel);
|
||||
|
||||
const storeMock = {
|
||||
dispatch: jest.fn(),
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectActive:
|
||||
return activeChannel;
|
||||
case selectChannels:
|
||||
return channels;
|
||||
case selectChannelsLoading:
|
||||
return signal(false);
|
||||
case selectActivePlaylist:
|
||||
return activePlaylistMeta;
|
||||
default:
|
||||
return signal(null);
|
||||
}
|
||||
}),
|
||||
select: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectChannels:
|
||||
return channels$.asObservable();
|
||||
case selectActive:
|
||||
return activeChannel$.asObservable();
|
||||
case selectCurrentEpgProgram:
|
||||
return of(null);
|
||||
default:
|
||||
return of(null);
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const setActive = (channel: Channel) => {
|
||||
activeChannel.set(channel);
|
||||
activeChannel$.next(channel);
|
||||
};
|
||||
|
||||
const setActiveChannelDispatch = (channel: Channel) =>
|
||||
ChannelActions.setActiveChannel({ channel, startPlayback: true });
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VideoPlayerComponent } = await import('./video-player.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
storeMock.dispatch.mockClear();
|
||||
setActive(sampleChannel);
|
||||
channels.set([sampleChannel, nextChannel]);
|
||||
channels$.next([sampleChannel, nextChannel]);
|
||||
activePlaylistMeta.set(null);
|
||||
fullscreenChannelPanelSetting.set(undefined);
|
||||
tmdbEnabled.set(false);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VideoPlayerComponent],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ id: 'playlist-1', view: 'all' }),
|
||||
queryParams: of({}),
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
queryParams: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: {
|
||||
url: '/workspace/playlists/playlist-1/all',
|
||||
navigate: jest.fn(),
|
||||
currentNavigation: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{
|
||||
provide: Overlay,
|
||||
useValue: { position: jest.fn(), create: jest.fn() },
|
||||
},
|
||||
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
supportsEpg: false,
|
||||
isElectron: false,
|
||||
supportsRemoteControl: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getPlaylist: jest.fn(() =>
|
||||
of({
|
||||
playlist: { items: channels() },
|
||||
favorites: [],
|
||||
})
|
||||
),
|
||||
addM3uRecentlyViewed: jest.fn(() =>
|
||||
of({ recentlyViewed: [] })
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EpgService,
|
||||
useValue: {
|
||||
currentEpgPrograms$: of([] as EpgProgram[]),
|
||||
getChannelMetadataForChannels: () => of(new Map()),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
resolvedPlaylistId: signal('playlist-1'),
|
||||
activePlaylist: signal(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled: () => tmdbEnabled() },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
player,
|
||||
showCaptions: signal(false),
|
||||
stripCountryPrefix: signal(false),
|
||||
resolvedEpgViewMode: signal('timeline'),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
epgUrl: signal<string[]>([]),
|
||||
fullscreenChannelPanel: fullscreenChannelPanelSetting,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StorageMap,
|
||||
useValue: {
|
||||
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: {
|
||||
activeSession: signal<ExternalPlayerSession | null>(
|
||||
null
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(VideoPlayerComponent, {
|
||||
set: {
|
||||
imports: [],
|
||||
template:
|
||||
'<ng-template #fullscreenChannelPanel></ng-template>',
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
storeMock.dispatch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
describe('FULLSCREEN_CHANNEL_PANEL host', () => {
|
||||
it.each([VideoPlayer.MPV, VideoPlayer.VLC])(
|
||||
'withholds rows that would leave forced-inline DASH for %s',
|
||||
(externalPlayer) => {
|
||||
const dashChannel = {
|
||||
...sampleChannel,
|
||||
url: 'http://localhost/live.mpd',
|
||||
};
|
||||
const nextDashChannel = {
|
||||
...nextChannel,
|
||||
url: 'http://localhost/next.mpd',
|
||||
};
|
||||
player.set(externalPlayer);
|
||||
component.playerSettings.player = externalPlayer;
|
||||
setActive(dashChannel);
|
||||
channels.set([dashChannel, sampleChannel, nextDashChannel]);
|
||||
|
||||
expect(component.shouldShowInlinePlayer(dashChannel)).toBe(
|
||||
true
|
||||
);
|
||||
expect(component.panelTemplate()).not.toBeNull();
|
||||
expect(component.fullscreenPanelChannels()).toEqual([
|
||||
dashChannel,
|
||||
nextDashChannel,
|
||||
]);
|
||||
expect(component.channels()).toHaveLength(3);
|
||||
|
||||
player.set(VideoPlayer.Html5Player);
|
||||
expect(component.fullscreenPanelChannels()).toHaveLength(3);
|
||||
}
|
||||
);
|
||||
|
||||
it('offers the panel template unless the preference is switched off', () => {
|
||||
expect(component.panelTemplate()).not.toBeNull();
|
||||
|
||||
fullscreenChannelPanelSetting.set(false);
|
||||
expect(component.panelTemplate()).toBeNull();
|
||||
|
||||
fullscreenChannelPanelSetting.set(true);
|
||||
expect(component.panelTemplate()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('withholds the panel while the VOD detail hosts the player', () => {
|
||||
tmdbEnabled.set(true);
|
||||
setActive(movieChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showMovieDetail()).toBe(true);
|
||||
expect(component.panelTemplate()).toBeNull();
|
||||
|
||||
setActive(sampleChannel);
|
||||
fixture.detectChanges();
|
||||
expect(component.showMovieDetail()).toBe(false);
|
||||
expect(component.panelTemplate()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('titles the panel with the playlist name', () => {
|
||||
expect(component.panelTitle()).toBe('');
|
||||
|
||||
activePlaylistMeta.set({ title: 'Living Room' } as PlaylistMeta);
|
||||
expect(component.panelTitle()).toBe('Living Room');
|
||||
});
|
||||
|
||||
it('withholds radio stations from the panel list', () => {
|
||||
// A radio row plays through app-audio-player, which replaces the
|
||||
// app-web-player-view that owns fullscreen — picking one would
|
||||
// drop the user out of the mode the panel exists to keep.
|
||||
const radioChannel = {
|
||||
...sampleChannel,
|
||||
id: 'radio-1',
|
||||
url: 'http://localhost/radio.mp3',
|
||||
name: 'Sample FM',
|
||||
radio: 'true',
|
||||
} as Channel;
|
||||
channels.set([sampleChannel, radioChannel, nextChannel]);
|
||||
|
||||
expect(component.fullscreenPanelChannels()).toEqual([
|
||||
sampleChannel,
|
||||
nextChannel,
|
||||
]);
|
||||
// The page's own sidebar still lists every channel.
|
||||
expect(component.channels()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fullscreen panel rows', () => {
|
||||
it('withholds recognized movies only while they would open the VOD detail', () => {
|
||||
// With enrichment on, a movie row swaps the live layout for the
|
||||
// VOD detail shell, which replaces the fullscreen-owning player —
|
||||
// the same trap as radio. With enrichment off it plays inline
|
||||
// like any live entry and stays offered.
|
||||
channels.set([sampleChannel, movieChannel, nextChannel]);
|
||||
|
||||
tmdbEnabled.set(true);
|
||||
expect(component.fullscreenPanelChannels()).toEqual([
|
||||
sampleChannel,
|
||||
nextChannel,
|
||||
]);
|
||||
|
||||
tmdbEnabled.set(false);
|
||||
expect(component.fullscreenPanelChannels()).toEqual([
|
||||
sampleChannel,
|
||||
movieChannel,
|
||||
nextChannel,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageUp/PageDown zapping', () => {
|
||||
it.each([VideoPlayer.MPV, VideoPlayer.VLC])(
|
||||
'keeps numeric and adjacent selections inline in fullscreen with %s',
|
||||
(externalPlayer) => {
|
||||
const first = {
|
||||
...sampleChannel,
|
||||
url: 'http://localhost/first.mpd',
|
||||
};
|
||||
const last = {
|
||||
...nextChannel,
|
||||
url: 'http://localhost/last.mpd',
|
||||
};
|
||||
const catalog = [first, sampleChannel, last];
|
||||
player.set(externalPlayer);
|
||||
channels.set(catalog);
|
||||
channels$.next(catalog);
|
||||
setActive(first);
|
||||
const stage = document.createElement('app-web-player-view');
|
||||
fixture.nativeElement.appendChild(stage);
|
||||
let fullscreen: Element | null = stage;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
document,
|
||||
'fullscreenElement'
|
||||
);
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => fullscreen,
|
||||
});
|
||||
try {
|
||||
component.switchToChannelByNumber(2);
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(sampleChannel)
|
||||
);
|
||||
component.switchToChannelByNumber(3);
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(last)
|
||||
);
|
||||
|
||||
storeMock.dispatch.mockClear();
|
||||
component.handleRemoteChannelChange('down');
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(last)
|
||||
);
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(sampleChannel)
|
||||
);
|
||||
|
||||
fullscreen = null;
|
||||
storeMock.dispatch.mockClear();
|
||||
component.switchToChannelByNumber(2);
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(sampleChannel)
|
||||
);
|
||||
} finally {
|
||||
if (descriptor)
|
||||
Object.defineProperty(
|
||||
document,
|
||||
'fullscreenElement',
|
||||
descriptor
|
||||
);
|
||||
else Reflect.deleteProperty(document, 'fullscreenElement');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('treats a legacy player fullscreening its nested surface as the live host owning fullscreen', () => {
|
||||
// Vendor-chrome opt-out: Video.js/ArtPlayer fullscreen their own
|
||||
// element inside app-web-player-view, not the host itself, and
|
||||
// leaving fullscreen through an ineligible channel is the same
|
||||
// loss there.
|
||||
const first = {
|
||||
...sampleChannel,
|
||||
url: 'http://localhost/first.mpd',
|
||||
};
|
||||
const catalog = [first, sampleChannel];
|
||||
player.set(VideoPlayer.MPV);
|
||||
channels.set(catalog);
|
||||
channels$.next(catalog);
|
||||
setActive(first);
|
||||
const stage = document.createElement('app-web-player-view');
|
||||
const legacySurface = document.createElement('div');
|
||||
legacySurface.className = 'video-js';
|
||||
stage.appendChild(legacySurface);
|
||||
fixture.nativeElement.appendChild(stage);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
document,
|
||||
'fullscreenElement'
|
||||
);
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => legacySurface,
|
||||
});
|
||||
try {
|
||||
component.switchToChannelByNumber(2);
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(sampleChannel)
|
||||
);
|
||||
} finally {
|
||||
if (descriptor)
|
||||
Object.defineProperty(
|
||||
document,
|
||||
'fullscreenElement',
|
||||
descriptor
|
||||
);
|
||||
else Reflect.deleteProperty(document, 'fullscreenElement');
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['menu', 'dialog', 'cdk-overlay-pane'])(
|
||||
'leaves paging keys to a short %s overlay',
|
||||
(kind) => {
|
||||
const overlay = document.createElement('div');
|
||||
if (kind === 'cdk-overlay-pane') {
|
||||
overlay.className = kind;
|
||||
} else {
|
||||
overlay.setAttribute('role', kind);
|
||||
}
|
||||
overlay.style.overflowY = 'auto';
|
||||
Object.defineProperty(overlay, 'scrollHeight', { value: 100 });
|
||||
Object.defineProperty(overlay, 'clientHeight', { value: 100 });
|
||||
const button = document.createElement('button');
|
||||
overlay.appendChild(button);
|
||||
document.body.appendChild(overlay);
|
||||
try {
|
||||
for (const key of ['PageUp', 'PageDown']) {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
button.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
}
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(nextChannel)
|
||||
);
|
||||
} finally {
|
||||
overlay.remove();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('honors a paging key already handled by another listener', () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'PageDown',
|
||||
cancelable: true,
|
||||
});
|
||||
event.preventDefault();
|
||||
document.dispatchEvent(event);
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(nextChannel)
|
||||
);
|
||||
});
|
||||
|
||||
it('PageDown plays the next channel and PageUp the previous one', () => {
|
||||
const pageDown = new KeyboardEvent('keydown', {
|
||||
key: 'PageDown',
|
||||
cancelable: true,
|
||||
});
|
||||
document.dispatchEvent(pageDown);
|
||||
|
||||
expect(pageDown.defaultPrevented).toBe(true);
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(nextChannel)
|
||||
);
|
||||
|
||||
storeMock.dispatch.mockClear();
|
||||
setActive(nextChannel);
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'PageUp' })
|
||||
);
|
||||
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(sampleChannel)
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the key to a focused row inside a scrollable list', () => {
|
||||
const list = document.createElement('div');
|
||||
list.style.overflowY = 'auto';
|
||||
Object.defineProperty(list, 'scrollHeight', { value: 500 });
|
||||
Object.defineProperty(list, 'clientHeight', { value: 100 });
|
||||
const row = document.createElement('button');
|
||||
list.appendChild(row);
|
||||
fixture.nativeElement.appendChild(list);
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'PageDown',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
row.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(nextChannel)
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores PageUp/PageDown with a modifier held', () => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'PageDown', ctrlKey: true })
|
||||
);
|
||||
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(nextChannel)
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores the keys until a channel is playing, without queueing them', () => {
|
||||
// handleRemoteChannelChange waits for an active channel, so a press
|
||||
// taken before the first selection would sit on a pending
|
||||
// subscription and zap straight off that channel once it arrives.
|
||||
activeChannel.set(null);
|
||||
activeChannel$.next(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'PageDown',
|
||||
cancelable: true,
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(nextChannel)
|
||||
);
|
||||
|
||||
// The queued press must not fire when the user picks a channel.
|
||||
setActive(sampleChannel);
|
||||
fixture.detectChanges();
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
setActiveChannelDispatch(nextChannel)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -213,3 +213,19 @@
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Stamped into the player's fullscreen channel panel (FULLSCREEN_CHANNEL_PANEL). -->
|
||||
<ng-template
|
||||
#fullscreenChannelPanel
|
||||
let-searchTerm="searchTerm"
|
||||
let-close="close"
|
||||
>
|
||||
<app-m3u-fullscreen-channel-list
|
||||
[channels]="fullscreenPanelChannels()"
|
||||
[channelsLoading]="channelsLoading()"
|
||||
[initialView]="activeView()"
|
||||
[recentItems]="activePlaylistRecentItems()"
|
||||
[searchTerm]="searchTerm()"
|
||||
(closeRequested)="close()"
|
||||
/>
|
||||
</ng-template>
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
Injector,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
TemplateRef,
|
||||
computed,
|
||||
effect,
|
||||
forwardRef,
|
||||
inject,
|
||||
linkedSignal,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -93,6 +96,9 @@ import {
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
FULLSCREEN_CHANNEL_PANEL,
|
||||
type FullscreenChannelPanelContext,
|
||||
type FullscreenChannelPanelHost,
|
||||
type PlaybackFallbackRequest,
|
||||
SidebarComponent,
|
||||
WebPlayerViewComponent,
|
||||
@@ -130,6 +136,7 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { M3uVodDetailComponent } from '../m3u-vod-detail/m3u-vod-detail.component';
|
||||
import { M3uFullscreenChannelListComponent } from './fullscreen-channel-list/m3u-fullscreen-channel-list.component';
|
||||
import { createM3uChannelPlaybackRequest } from './m3u-channel-playback-actions';
|
||||
|
||||
const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';
|
||||
@@ -156,6 +163,29 @@ function readStoredVolume(): number {
|
||||
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* PageUp/PageDown switch channels unless the key would scroll something the
|
||||
* user is focused in (the sidebar's virtual list, a menu): a focused row in a
|
||||
* scrollable list keeps the browser's native paging.
|
||||
*/
|
||||
function isInsideScrollableRegion(
|
||||
target: EventTarget | null,
|
||||
boundary: HTMLElement
|
||||
): boolean {
|
||||
let element = target instanceof HTMLElement ? target : null;
|
||||
while (element && element !== boundary && element !== document.body) {
|
||||
const { overflowY } = getComputedStyle(element);
|
||||
if (
|
||||
(overflowY === 'auto' || overflowY === 'scroll') &&
|
||||
element.scrollHeight > element.clientHeight
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-player',
|
||||
imports: [
|
||||
@@ -165,6 +195,7 @@ function readStoredVolume(): number {
|
||||
CommonModule,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
M3uFullscreenChannelListComponent,
|
||||
M3uVodDetailComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
@@ -175,10 +206,20 @@ function readStoredVolume(): number {
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
providers: [
|
||||
// The fullscreen channel panel inside the player renders this page's
|
||||
// channel list (see the `fullscreenChannelPanel` template).
|
||||
{
|
||||
provide: FULLSCREEN_CHANNEL_PANEL,
|
||||
useExisting: forwardRef(() => VideoPlayerComponent),
|
||||
},
|
||||
],
|
||||
templateUrl: './video-player.component.html',
|
||||
styleUrl: './video-player.component.scss',
|
||||
})
|
||||
export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
export class VideoPlayerComponent
|
||||
implements OnInit, OnDestroy, FullscreenChannelPanelHost
|
||||
{
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly dataService = inject(DataService);
|
||||
@@ -217,6 +258,45 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
readonly channels = this.store.selectSignal(selectChannels);
|
||||
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
|
||||
readonly activePlaylistRecentItems = computed(
|
||||
() => this.playlistContext.activePlaylist()?.recentlyViewed ?? []
|
||||
);
|
||||
|
||||
private readonly fullscreenChannelPanelTemplate = viewChild<
|
||||
TemplateRef<FullscreenChannelPanelContext>
|
||||
>('fullscreenChannelPanel');
|
||||
/**
|
||||
* FULLSCREEN_CHANNEL_PANEL: the playlist's channel list, unless opted
|
||||
* out. Withheld while the M3U VOD detail hosts the player: a movie is
|
||||
* not something to zap away from, and the nested `WebPlayerViewComponent`
|
||||
* would otherwise inherit this component-level provider.
|
||||
*/
|
||||
readonly panelTemplate = computed(() =>
|
||||
this.settingsStore.fullscreenChannelPanel?.() === false ||
|
||||
this.showMovieDetail()
|
||||
? null
|
||||
: (this.fullscreenChannelPanelTemplate() ?? null)
|
||||
);
|
||||
readonly panelTitle = computed(() =>
|
||||
playlistDisplayLabel(this.activePlaylistMeta()?.title)
|
||||
);
|
||||
/**
|
||||
* Rows the fullscreen panel may offer: everything that keeps
|
||||
* `app-web-player-view` — the element that owns fullscreen — mounted when
|
||||
* selected. Radio stations render through `app-audio-player` and
|
||||
* recognized movies (with TMDB enrichment and `m3uVodDetails` on) through
|
||||
* the VOD detail shell, so picking either destroys that element and drops
|
||||
* the user out of fullscreen — the opposite of what this panel exists
|
||||
* for. Every panel view resolves against this list (favorites and recent
|
||||
* look their rows up in it), so one filter covers all four. Numeric and
|
||||
* adjacent-channel commands apply the same restriction in fullscreen;
|
||||
* windowed playback keeps the complete catalog and its numbering.
|
||||
* With external MPV/VLC configured, only DASH rows remain inline; a
|
||||
* non-DASH selection would replace this host with the external-player UI.
|
||||
*/
|
||||
readonly fullscreenPanelChannels = computed(() =>
|
||||
this.channels().filter((channel) => this.keepsInlinePlayer(channel))
|
||||
);
|
||||
readonly archivePlaybackAvailable = computed(() =>
|
||||
isM3uCatchupPlaybackSupported(this.activeChannel())
|
||||
);
|
||||
@@ -251,13 +331,21 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
readonly showMovieDetail = computed(() => {
|
||||
const channel = this.activeChannel();
|
||||
return !!channel && this.opensMovieDetail(channel);
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether selecting `channel` swaps the live layout for the VOD detail
|
||||
* shell — the one predicate behind {@link showMovieDetail} and the
|
||||
* fullscreen panel's row filter, so the two can never disagree.
|
||||
*/
|
||||
private opensMovieDetail(channel: Channel): boolean {
|
||||
return (
|
||||
!!channel &&
|
||||
this.settingsStore.m3uVodDetails?.() !== false &&
|
||||
this.tmdbEnrichment.isEnabled() &&
|
||||
isLikelyM3uMovie(channel)
|
||||
);
|
||||
});
|
||||
}
|
||||
/** Full multi-day programme window for the active channel (timeline). */
|
||||
readonly epgPrograms = toSignal(this.epgService.currentEpgPrograms$, {
|
||||
initialValue: [] as EpgProgram[],
|
||||
@@ -860,7 +948,11 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
.subscribe({
|
||||
next: ({ channels, activeChannel }) => {
|
||||
const nextChannel = getAdjacentChannelItem(
|
||||
channels,
|
||||
this.isLivePlayerFullscreen()
|
||||
? channels.filter((channel) =>
|
||||
this.keepsInlinePlayer(channel)
|
||||
)
|
||||
: channels,
|
||||
activeChannel.url,
|
||||
direction,
|
||||
(channel) => channel.url
|
||||
@@ -1124,7 +1216,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent): void {
|
||||
if (isTypingInInput(event)) {
|
||||
if (event.defaultPrevented || isTypingInInput(event)) {
|
||||
return;
|
||||
}
|
||||
// Behind the workspace's phone context drawer the route content is
|
||||
@@ -1145,6 +1237,39 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
// PageUp/PageDown zap through the list like a remote's channel keys —
|
||||
// the one way to change channels from the keyboard in fullscreen.
|
||||
if (event.key === 'PageUp' || event.key === 'PageDown') {
|
||||
if (
|
||||
event
|
||||
.composedPath()
|
||||
.some(
|
||||
(target) =>
|
||||
target instanceof Element &&
|
||||
target.matches(
|
||||
'.cdk-overlay-pane, [role="menu"], [role="dialog"]'
|
||||
)
|
||||
) ||
|
||||
isInsideScrollableRegion(
|
||||
event.target,
|
||||
this.hostElement.nativeElement
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// There is nothing to zap from until a channel is playing, and
|
||||
// handleRemoteChannelChange would park the press on a pending
|
||||
// subscription that fires the moment the user picks their first
|
||||
// channel — jumping straight off it.
|
||||
if (!this.activeChannel()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.handleRemoteChannelChange(
|
||||
event.key === 'PageUp' ? 'up' : 'down'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Only handle digit keys (0-9)
|
||||
if (event.key >= '0' && event.key <= '9') {
|
||||
event.preventDefault();
|
||||
@@ -1185,7 +1310,11 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
)
|
||||
.subscribe((channel) => {
|
||||
if (channel) {
|
||||
if (
|
||||
channel &&
|
||||
(!this.isLivePlayerFullscreen() ||
|
||||
this.keepsInlinePlayer(channel))
|
||||
) {
|
||||
this.store.dispatch(
|
||||
createM3uChannelPlaybackRequest(channel)
|
||||
);
|
||||
@@ -1193,6 +1322,32 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private keepsInlinePlayer(channel: Channel): boolean {
|
||||
return (
|
||||
channel.radio !== 'true' &&
|
||||
!this.opensMovieDetail(channel) &&
|
||||
(!this.isExternalPlayer(this.settingsStore.player()) ||
|
||||
isDashChannel(channel))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this page's inline live player owns fullscreen. With shared
|
||||
* controls the fullscreen element is `app-web-player-view` itself; with
|
||||
* the vendor-chrome opt-out a legacy player fullscreens its own nested
|
||||
* surface (the `.video-js` element, ArtPlayer's container, the `<video>`),
|
||||
* so the check accepts any fullscreen element inside that host — leaving
|
||||
* fullscreen through an ineligible channel is the same loss either way.
|
||||
*/
|
||||
private isLivePlayerFullscreen(): boolean {
|
||||
const fullscreen = document.fullscreenElement;
|
||||
return (
|
||||
!this.showMovieDetail() &&
|
||||
!!fullscreen?.closest('app-web-player-view') &&
|
||||
this.hostElement.nativeElement.contains(fullscreen)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear channel number input and hide overlay
|
||||
*/
|
||||
|
||||
+36
@@ -120,4 +120,40 @@ describe('UnifiedLiveTabComponent channelsForList', () => {
|
||||
expect(row.tvArchive).toBeNull();
|
||||
expect(row.tvArchiveDuration).toBeNull();
|
||||
});
|
||||
|
||||
it('withholds radio rows from the fullscreen panel but keeps them on the page', () => {
|
||||
// A radio row plays through app-audio-player, which replaces the
|
||||
// app-web-player-view that owns fullscreen — picking one from the
|
||||
// panel would drop the user out of the mode it exists to keep.
|
||||
const base = {
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'pl-1',
|
||||
playlistName: 'Playlist One',
|
||||
logo: null,
|
||||
posterUrl: null,
|
||||
addedAt: '2026-04-30T12:00:00.000Z',
|
||||
position: 0,
|
||||
};
|
||||
const video = {
|
||||
...base,
|
||||
uid: 'm3u::pl-1::tv',
|
||||
name: 'Video Channel',
|
||||
radio: 'false',
|
||||
} as UnifiedCollectionItem;
|
||||
const radio = {
|
||||
...base,
|
||||
uid: 'm3u::pl-1::fm',
|
||||
name: 'Sample FM',
|
||||
radio: 'true',
|
||||
} as UnifiedCollectionItem;
|
||||
fixture.componentRef.setInput('items', [video, radio]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.channelsForList()).toHaveLength(2);
|
||||
expect(
|
||||
component.fullscreenPanelChannels().map((row) => row.uid)
|
||||
).toEqual([video.uid]);
|
||||
});
|
||||
});
|
||||
+21
@@ -155,3 +155,24 @@
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Stamped into the player's fullscreen channel panel (FULLSCREEN_CHANNEL_PANEL). -->
|
||||
<ng-template #fullscreenChannelPanel let-searchTerm="searchTerm">
|
||||
<app-global-favorites-list
|
||||
[channels]="fullscreenPanelChannels()"
|
||||
[mode]="mode()"
|
||||
[showEpg]="supportsEpg"
|
||||
[favoriteUids]="favoriteUids()"
|
||||
[epgMap]="epgMap()"
|
||||
[progressTick]="progressTick()"
|
||||
[activeUid]="activeUid()"
|
||||
[searchTermInput]="searchTerm()"
|
||||
[draggable]="false"
|
||||
[sortMode]="sortMode()"
|
||||
(channelSelected)="onChannelSelected($event)"
|
||||
(playbackRequested)="onChannelPlaybackRequested($event)"
|
||||
(epgMappingChanged)="onEpgMappingChanged()"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(removeRequested)="onRemoveRequested($event)"
|
||||
/>
|
||||
</ng-template>
|
||||
+125
-18
@@ -4,11 +4,14 @@ import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
forwardRef,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
TemplateRef,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -59,6 +62,9 @@ import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-st
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ElectronStreamHeadersService,
|
||||
FULLSCREEN_CHANNEL_PANEL,
|
||||
type FullscreenChannelPanelContext,
|
||||
type FullscreenChannelPanelHost,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -99,8 +105,16 @@ import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-ses
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
providers: [
|
||||
// The fullscreen channel panel inside the player renders this
|
||||
// collection's list (see the `fullscreenChannelPanel` template).
|
||||
{
|
||||
provide: FULLSCREEN_CHANNEL_PANEL,
|
||||
useExisting: forwardRef(() => UnifiedLiveTabComponent),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class UnifiedLiveTabComponent {
|
||||
export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
|
||||
readonly items = input.required<UnifiedCollectionItem[]>();
|
||||
readonly mode = input<'favorites' | 'recent'>('favorites');
|
||||
readonly searchTerm = input('');
|
||||
@@ -135,6 +149,23 @@ export class UnifiedLiveTabComponent {
|
||||
this.portalPlayer.isEmbeddedPlayer()
|
||||
);
|
||||
|
||||
private readonly fullscreenChannelPanelTemplate = viewChild<
|
||||
TemplateRef<FullscreenChannelPanelContext>
|
||||
>('fullscreenChannelPanel');
|
||||
/** FULLSCREEN_CHANNEL_PANEL: this collection's channels, unless opted out. */
|
||||
readonly panelTemplate = computed(() =>
|
||||
this.settingsStore.fullscreenChannelPanel?.() === false
|
||||
? null
|
||||
: (this.fullscreenChannelPanelTemplate() ?? null)
|
||||
);
|
||||
readonly panelTitle = computed(() =>
|
||||
this.translate.instant(
|
||||
this.mode() === 'favorites'
|
||||
? 'HOME.PLAYLISTS.GLOBAL_FAVORITES'
|
||||
: 'PORTALS.SIDEBAR.RECENT'
|
||||
)
|
||||
);
|
||||
|
||||
readonly activeDetail = signal<ResolvedLiveCollectionDetail | null>(null);
|
||||
readonly activeUid = signal<string | null>(null);
|
||||
/** Full collection item for the active selection — used to read
|
||||
@@ -426,7 +457,31 @@ export class UnifiedLiveTabComponent {
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* Rows for the fullscreen channel panel. Radio is withheld: it renders
|
||||
* through `app-audio-player` instead of `app-web-player-view`, so
|
||||
* selecting a station destroys the element that owns fullscreen and drops
|
||||
* the user out of it — the opposite of what the panel exists for. The
|
||||
* page's own list keeps every row.
|
||||
*/
|
||||
readonly fullscreenPanelChannels = computed(() =>
|
||||
this.channelsForList().filter((channel) => channel.radio !== 'true')
|
||||
);
|
||||
|
||||
private selectionRequestId = 0;
|
||||
/**
|
||||
* The selection still resolving. `activeUid` alone cannot tell a pending
|
||||
* highlight from the stream on screen — `activeItem`/`activeDetail` stay
|
||||
* paired with the retained player until the replacement is in — so a
|
||||
* second activation of the same pending row (a double-click's second
|
||||
* click, an auto-open) folds its intent in here rather than restarting
|
||||
* the request or, worse, launching the retained stream in its place.
|
||||
*/
|
||||
private pendingActivation: {
|
||||
readonly uid: string;
|
||||
startPlayback: boolean;
|
||||
isAutoOpen: boolean;
|
||||
} | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -474,15 +529,17 @@ export class UnifiedLiveTabComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeUid() === matchedItem.uid) {
|
||||
if (this.activeDetail()) {
|
||||
this.autoOpenHandled.emit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSelecting()) {
|
||||
return;
|
||||
}
|
||||
// Handled only once the row is on screen: while it is merely the
|
||||
// pending highlight, `activeDetail` still belongs to the retained
|
||||
// stream, and `activateItem` folds this intent into the request
|
||||
// in flight instead of restarting it.
|
||||
if (
|
||||
this.activeUid() === matchedItem.uid &&
|
||||
this.activeItem()?.uid === matchedItem.uid &&
|
||||
this.activeDetail()
|
||||
) {
|
||||
this.autoOpenHandled.emit();
|
||||
return;
|
||||
}
|
||||
|
||||
void this.activateItem(matchedItem, true);
|
||||
@@ -707,6 +764,7 @@ export class UnifiedLiveTabComponent {
|
||||
|
||||
onClose(): void {
|
||||
this.selectionRequestId += 1;
|
||||
this.pendingActivation = null;
|
||||
this.isSelecting.set(false);
|
||||
this.activeDetail.set(null);
|
||||
this.activeUid.set(null);
|
||||
@@ -741,7 +799,15 @@ export class UnifiedLiveTabComponent {
|
||||
startPlayback = false
|
||||
): Promise<void> {
|
||||
const activeDetail = this.activeDetail();
|
||||
if (this.activeUid() === item.uid && activeDetail) {
|
||||
// The row is on screen only when it is both the highlight and the
|
||||
// resolved item: while a replacement resolves, `activeUid` already
|
||||
// points at it but `activeDetail` still belongs to the retained
|
||||
// stream, and launching that here would open the wrong channel.
|
||||
if (
|
||||
this.activeUid() === item.uid &&
|
||||
this.activeItem()?.uid === item.uid &&
|
||||
activeDetail
|
||||
) {
|
||||
if (
|
||||
startPlayback &&
|
||||
this.shouldOpenExternalPlayback(activeDetail, true)
|
||||
@@ -756,11 +822,32 @@ export class UnifiedLiveTabComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pendingActivation;
|
||||
if (pending?.uid === item.uid && this.isSelecting()) {
|
||||
// The same row is still resolving: fold this activation's intent
|
||||
// into that request so its detail launches (or reports the
|
||||
// auto-open handled) when it lands, without a second round-trip.
|
||||
pending.startPlayback ||= startPlayback;
|
||||
pending.isAutoOpen ||= isAutoOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++this.selectionRequestId;
|
||||
const activation = { uid: item.uid, startPlayback, isAutoOpen };
|
||||
this.pendingActivation = activation;
|
||||
// `activeUid` is the pending selection (row highlight). `activeItem`
|
||||
// stays paired with `activeDetail`: the previous detail stays mounted
|
||||
// while the next one resolves, because the player it renders owns
|
||||
// DOM fullscreen and a selection from the fullscreen channel panel
|
||||
// must not unmount that element (which would end fullscreen) for the
|
||||
// resolution round-trip — and everything derived from the item
|
||||
// (`playbackSessionKey`, recording/archive metadata) must keep
|
||||
// describing the stream that player is still showing. The catch-up
|
||||
// override belongs to that detail too: clearing it early would drop
|
||||
// the mounted player back to the old channel's live URL for the
|
||||
// gap. All of it swaps together when the new detail is in; a failed
|
||||
// replacement keeps a previously mounted video and restores its row.
|
||||
this.activeUid.set(item.uid);
|
||||
this.activeItem.set(item);
|
||||
this.activeDetail.set(null);
|
||||
this.activeTimeshift.set(null);
|
||||
this.isSelecting.set(true);
|
||||
// A previously owned radio override must not survive into a
|
||||
// selection that never mounts a player surface of its own — external
|
||||
@@ -793,13 +880,20 @@ export class UnifiedLiveTabComponent {
|
||||
}
|
||||
}
|
||||
|
||||
this.activeTimeshift.set(null);
|
||||
this.activeItem.set(item);
|
||||
this.activeDetail.set(detail);
|
||||
|
||||
if (this.supportsEpg && detail.epgMode === 'm3u') {
|
||||
void this.hydrateSelectedM3uPrograms(item, detail, requestId);
|
||||
}
|
||||
|
||||
if (this.shouldOpenExternalPlayback(detail, startPlayback)) {
|
||||
if (
|
||||
this.shouldOpenExternalPlayback(
|
||||
detail,
|
||||
activation.startPlayback
|
||||
)
|
||||
) {
|
||||
void this.portalPlayer.openResolvedPlayback(detail.playback);
|
||||
}
|
||||
|
||||
@@ -813,16 +907,29 @@ export class UnifiedLiveTabComponent {
|
||||
// Keep playback/EPG visible even if history persistence fails.
|
||||
}
|
||||
|
||||
if (requestId === this.selectionRequestId && isAutoOpen) {
|
||||
if (
|
||||
requestId === this.selectionRequestId &&
|
||||
activation.isAutoOpen
|
||||
) {
|
||||
this.autoOpenHandled.emit();
|
||||
}
|
||||
} catch {
|
||||
if (requestId === this.selectionRequestId) {
|
||||
this.activeDetail.set(null);
|
||||
this.activeUid.set(null);
|
||||
// The fullscreen panel only offers video rows. Its previous
|
||||
// stream is still valid when resolution of a replacement
|
||||
// fails, so retain the player and its catch-up/session state.
|
||||
// Radio's scoped headers were released above; that path keeps
|
||||
// its existing reset behavior instead of reviving that scope.
|
||||
if (!activeDetail || this.isRadioDetail(activeDetail)) {
|
||||
this.activeTimeshift.set(null);
|
||||
this.activeDetail.set(null);
|
||||
this.activeItem.set(null);
|
||||
}
|
||||
this.activeUid.set(this.activeItem()?.uid ?? null);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === this.selectionRequestId) {
|
||||
this.pendingActivation = null;
|
||||
this.isSelecting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PORTAL_PLAYER,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StreamResolverService,
|
||||
UnifiedRecentDataService,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
|
||||
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
|
||||
import {
|
||||
StubAudioPlayerComponent,
|
||||
StubEpgTimelineComponent,
|
||||
StubGlobalFavoritesListComponent,
|
||||
StubResizableDirective,
|
||||
StubWebPlayerViewComponent,
|
||||
} from './unified-live-tab.spec-stubs';
|
||||
|
||||
/**
|
||||
* Focused spec for the fullscreen channel panel's zapping contract — kept
|
||||
* separate from the main layout spec, which sits at the max-lines test budget.
|
||||
*/
|
||||
describe('UnifiedLiveTabComponent fullscreen channel panel', () => {
|
||||
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
|
||||
let component: UnifiedLiveTabComponent;
|
||||
let streamResolver: {
|
||||
resolveLiveDetail: jest.Mock;
|
||||
resolveM3uPlaybackDetail: jest.Mock;
|
||||
resolveXtreamCatchupUrl: jest.Mock;
|
||||
loadM3uProgramsForItem: jest.Mock;
|
||||
loadEpgForItems: jest.Mock;
|
||||
};
|
||||
let recentData: { recordLivePlayback: jest.Mock };
|
||||
let portalPlayer: {
|
||||
isEmbeddedPlayer: jest.Mock;
|
||||
openResolvedPlayback: jest.Mock;
|
||||
openExternalPlayback: jest.Mock;
|
||||
};
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(async () => {
|
||||
window.electron = { platform: 'darwin' } as typeof window.electron;
|
||||
localStorage.removeItem('live-epg-panel-state');
|
||||
|
||||
streamResolver = {
|
||||
resolveLiveDetail: jest.fn(),
|
||||
resolveM3uPlaybackDetail: jest.fn(),
|
||||
resolveXtreamCatchupUrl: jest.fn().mockResolvedValue(null),
|
||||
loadM3uProgramsForItem: jest.fn().mockResolvedValue([]),
|
||||
loadEpgForItems: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
recentData = { recordLivePlayback: jest.fn() };
|
||||
portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
openExternalPlayback: jest.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent],
|
||||
providers: [
|
||||
{ provide: StreamResolverService, useValue: streamResolver },
|
||||
{ provide: UnifiedRecentDataService, useValue: recentData },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
get supportsEpg() {
|
||||
return Boolean(window.electron);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
player: signal(VideoPlayer.VideoJs),
|
||||
stripCountryPrefix: signal(false),
|
||||
resolvedEpgViewMode: signal('timeline'),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
},
|
||||
},
|
||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedLiveTabComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
AudioPlayerComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
ResizableDirective,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubAudioPlayerComponent,
|
||||
StubEpgTimelineComponent,
|
||||
StubGlobalFavoritesListComponent,
|
||||
StubResizableDirective,
|
||||
StubWebPlayerViewComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UnifiedLiveTabComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('keeps the current detail (and its fullscreen player) mounted while the next selection resolves', async () => {
|
||||
const first = buildM3uLiveItem();
|
||||
const second: UnifiedCollectionItem = {
|
||||
...first,
|
||||
uid: 'm3u::pl-1::m3u-channel-2',
|
||||
name: 'M3U Live 2',
|
||||
channelId: 'm3u-channel-2',
|
||||
tvgId: 'm3u-channel-2',
|
||||
streamUrl: 'https://example.com/m3u-2.m3u8',
|
||||
};
|
||||
const detailFor = (item: UnifiedCollectionItem) => ({
|
||||
epgMode: 'm3u' as const,
|
||||
playback: { streamUrl: item.streamUrl, title: item.name },
|
||||
channel: {
|
||||
id: item.channelId,
|
||||
name: item.name,
|
||||
url: item.streamUrl,
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: item.tvgId,
|
||||
name: item.name,
|
||||
url: '',
|
||||
logo: '',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
epgPrograms: [],
|
||||
});
|
||||
let resolveSecond: (
|
||||
detail: ReturnType<typeof detailFor>
|
||||
) => void = () => undefined;
|
||||
streamResolver.resolveM3uPlaybackDetail
|
||||
.mockResolvedValueOnce(detailFor(first))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
})
|
||||
);
|
||||
recentData.recordLivePlayback.mockImplementation(
|
||||
async (item: UnifiedCollectionItem) => item
|
||||
);
|
||||
// The inline web player (the fullscreen owner) only mounts for an
|
||||
// embedded player; external MPV/VLC never render it.
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
|
||||
fixture.componentRef.setInput('items', [first, second]);
|
||||
fixture.componentRef.setInput('mode', 'favorites');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
const firstDetail = component.activeDetail();
|
||||
expect(firstDetail?.playback.streamUrl).toBe(first.streamUrl);
|
||||
const firstSessionKey = component.playbackSessionKey();
|
||||
const playerView = () =>
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
);
|
||||
expect(playerView()).not.toBeNull();
|
||||
|
||||
// A zap from the fullscreen panel: the mounted player (the fullscreen
|
||||
// element) must survive the resolution round-trip. Only the row
|
||||
// highlight moves ahead; the item stays paired with the detail the
|
||||
// player is still showing, so the session key and the recording/
|
||||
// archive metadata derived from it keep describing that stream.
|
||||
const pending = component.onChannelSelected(
|
||||
component.channelsForList()[1]
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(component.activeUid()).toBe(second.uid);
|
||||
expect(component.activeItem()).toBe(first);
|
||||
expect(component.activeDetail()).toBe(firstDetail);
|
||||
expect(component.playbackSessionKey()).toBe(firstSessionKey);
|
||||
expect(playerView()).not.toBeNull();
|
||||
|
||||
resolveSecond(detailFor(second));
|
||||
await pending;
|
||||
fixture.detectChanges();
|
||||
expect(component.activeItem()).toBe(second);
|
||||
expect(component.activeDetail()?.playback.streamUrl).toBe(
|
||||
second.streamUrl
|
||||
);
|
||||
expect(component.playbackSessionKey()).not.toBe(firstSessionKey);
|
||||
});
|
||||
|
||||
it('launches the row a double-click was aimed at, not the retained stream', async () => {
|
||||
// External player, open-on-double-click: the first click on B starts
|
||||
// resolving while A's detail stays mounted, so `activeUid` is B but
|
||||
// `activeDetail` is A's. The double-click's second activation must
|
||||
// neither launch A nor let B resolve without launching.
|
||||
const first = buildM3uLiveItem();
|
||||
const second: UnifiedCollectionItem = {
|
||||
...first,
|
||||
uid: 'm3u::pl-1::m3u-channel-2',
|
||||
name: 'M3U Live 2',
|
||||
channelId: 'm3u-channel-2',
|
||||
tvgId: 'm3u-channel-2',
|
||||
streamUrl: 'https://example.com/m3u-2.m3u8',
|
||||
};
|
||||
const detailFor = (item: UnifiedCollectionItem) => ({
|
||||
epgMode: 'm3u' as const,
|
||||
playback: { streamUrl: item.streamUrl, title: item.name },
|
||||
channel: {
|
||||
id: item.channelId,
|
||||
name: item.name,
|
||||
url: item.streamUrl,
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: item.tvgId,
|
||||
name: item.name,
|
||||
url: '',
|
||||
logo: '',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
epgPrograms: [],
|
||||
});
|
||||
let resolveSecond: (
|
||||
detail: ReturnType<typeof detailFor>
|
||||
) => void = () => undefined;
|
||||
streamResolver.resolveM3uPlaybackDetail
|
||||
.mockResolvedValueOnce(detailFor(first))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
})
|
||||
);
|
||||
recentData.recordLivePlayback.mockImplementation(
|
||||
async (item: UnifiedCollectionItem) => item
|
||||
);
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(false);
|
||||
const settings = TestBed.inject(SettingsStore) as unknown as {
|
||||
openStreamOnDoubleClick: ReturnType<typeof signal<boolean>>;
|
||||
};
|
||||
settings.openStreamOnDoubleClick.set(true);
|
||||
|
||||
fixture.componentRef.setInput('items', [first, second]);
|
||||
fixture.componentRef.setInput('mode', 'favorites');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
||||
|
||||
const pending = component.onChannelSelected(
|
||||
component.channelsForList()[1]
|
||||
);
|
||||
expect(component.activeUid()).toBe(second.uid);
|
||||
expect(component.activeItem()).toBe(first);
|
||||
|
||||
await component.onChannelPlaybackRequested(
|
||||
component.channelsForList()[1]
|
||||
);
|
||||
// Neither the retained stream nor a second resolution of B.
|
||||
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
||||
expect(streamResolver.resolveM3uPlaybackDetail).toHaveBeenCalledTimes(
|
||||
2
|
||||
);
|
||||
|
||||
resolveSecond(detailFor(second));
|
||||
await pending;
|
||||
expect(component.activeItem()).toBe(second);
|
||||
expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledTimes(1);
|
||||
expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ streamUrl: second.streamUrl })
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a click on the retained row supersede the replacement still resolving', async () => {
|
||||
// A is on screen, B is pending: clicking A again is a new selection
|
||||
// (A is not the highlight), so B's late answer must be dropped.
|
||||
const first = buildM3uLiveItem();
|
||||
const second: UnifiedCollectionItem = {
|
||||
...first,
|
||||
uid: 'm3u::pl-1::m3u-channel-2',
|
||||
name: 'M3U Live 2',
|
||||
channelId: 'm3u-channel-2',
|
||||
tvgId: 'm3u-channel-2',
|
||||
streamUrl: 'https://example.com/m3u-2.m3u8',
|
||||
};
|
||||
const detailFor = (item: UnifiedCollectionItem) => ({
|
||||
epgMode: 'm3u' as const,
|
||||
playback: { streamUrl: item.streamUrl, title: item.name },
|
||||
channel: {
|
||||
id: item.channelId,
|
||||
name: item.name,
|
||||
url: item.streamUrl,
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: item.tvgId,
|
||||
name: item.name,
|
||||
url: '',
|
||||
logo: '',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
epgPrograms: [],
|
||||
});
|
||||
let resolveSecond: (
|
||||
detail: ReturnType<typeof detailFor>
|
||||
) => void = () => undefined;
|
||||
streamResolver.resolveM3uPlaybackDetail
|
||||
.mockResolvedValueOnce(detailFor(first))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(detailFor(first));
|
||||
recentData.recordLivePlayback.mockImplementation(
|
||||
async (item: UnifiedCollectionItem) => item
|
||||
);
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
|
||||
fixture.componentRef.setInput('items', [first, second]);
|
||||
fixture.componentRef.setInput('mode', 'favorites');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
const pending = component.onChannelSelected(
|
||||
component.channelsForList()[1]
|
||||
);
|
||||
expect(component.activeUid()).toBe(second.uid);
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
expect(streamResolver.resolveM3uPlaybackDetail).toHaveBeenCalledTimes(
|
||||
3
|
||||
);
|
||||
expect(component.activeUid()).toBe(first.uid);
|
||||
|
||||
resolveSecond(detailFor(second));
|
||||
await pending;
|
||||
expect(component.activeItem()).toBe(first);
|
||||
expect(component.activeUid()).toBe(first.uid);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps a catch-up override through replacement resolution (fails: %s)',
|
||||
async (fails) => {
|
||||
// The mounted player is showing an archive programme; clearing the
|
||||
// override before the replacement resolves would drop it back to the
|
||||
// old channel's live URL for the length of the round-trip.
|
||||
const first = buildM3uLiveItem();
|
||||
const second: UnifiedCollectionItem = {
|
||||
...first,
|
||||
uid: 'm3u::pl-1::m3u-channel-2',
|
||||
name: 'M3U Live 2',
|
||||
channelId: 'm3u-channel-2',
|
||||
tvgId: 'm3u-channel-2',
|
||||
streamUrl: 'https://example.com/m3u-2.m3u8',
|
||||
};
|
||||
const detailFor = (item: UnifiedCollectionItem) => ({
|
||||
epgMode: 'm3u' as const,
|
||||
playback: { streamUrl: item.streamUrl, title: item.name },
|
||||
channel: {
|
||||
id: item.channelId,
|
||||
name: item.name,
|
||||
url: item.streamUrl,
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: item.tvgId,
|
||||
name: item.name,
|
||||
url: '',
|
||||
logo: '',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
epgPrograms: [],
|
||||
});
|
||||
let resolveSecond: (
|
||||
detail: ReturnType<typeof detailFor>
|
||||
) => void = () => undefined;
|
||||
let rejectSecond: (error: Error) => void = () => undefined;
|
||||
streamResolver.resolveM3uPlaybackDetail
|
||||
.mockResolvedValueOnce(detailFor(first))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
resolveSecond = resolve;
|
||||
rejectSecond = reject;
|
||||
})
|
||||
);
|
||||
recentData.recordLivePlayback.mockImplementation(
|
||||
async (item: UnifiedCollectionItem) => item
|
||||
);
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
|
||||
fixture.componentRef.setInput('items', [first, second]);
|
||||
fixture.componentRef.setInput('mode', 'favorites');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
const retainedDetail = component.activeDetail();
|
||||
const retainedSession = component.playbackSessionKey();
|
||||
const retainedPlayer = fixture.debugElement.query(
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
).nativeElement;
|
||||
const archiveUrl = 'https://example.com/m3u-archive.m3u8';
|
||||
component.activeTimeshift.set({
|
||||
url: archiveUrl,
|
||||
program: { title: 'Archived show' } as EpgProgram,
|
||||
});
|
||||
expect(component.inlinePlayback()?.streamUrl).toBe(archiveUrl);
|
||||
|
||||
const pending = component.onChannelSelected(
|
||||
component.channelsForList()[1]
|
||||
);
|
||||
expect(component.inlinePlayback()?.streamUrl).toBe(archiveUrl);
|
||||
|
||||
if (fails) {
|
||||
rejectSecond(new Error('Replacement unavailable'));
|
||||
} else {
|
||||
resolveSecond(detailFor(second));
|
||||
}
|
||||
await pending;
|
||||
fixture.detectChanges();
|
||||
expect(component.isSelecting()).toBe(false);
|
||||
if (fails) {
|
||||
expect(component.activeItem()).toBe(first);
|
||||
expect(component.activeUid()).toBe(first.uid);
|
||||
expect(component.activeDetail()).toBe(retainedDetail);
|
||||
expect(component.playbackSessionKey()).toBe(retainedSession);
|
||||
expect(component.inlinePlayback()?.streamUrl).toBe(archiveUrl);
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
).nativeElement
|
||||
).toBe(retainedPlayer);
|
||||
} else {
|
||||
expect(component.activeTimeshift()).toBeNull();
|
||||
expect(component.inlinePlayback()?.streamUrl).toBe(
|
||||
second.streamUrl
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function buildM3uLiveItem(): UnifiedCollectionItem {
|
||||
return {
|
||||
uid: 'm3u::pl-1::m3u-channel',
|
||||
name: 'M3U Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'pl-1',
|
||||
playlistName: 'Playlist One',
|
||||
streamUrl: 'https://example.com/m3u.m3u8',
|
||||
channelId: 'm3u-channel',
|
||||
tvgId: 'm3u-channel',
|
||||
logo: 'm3u.png',
|
||||
radio: 'false',
|
||||
};
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
PanelSearchWindow,
|
||||
resolvePanelSearchScroll,
|
||||
shouldAutoFillStampedList,
|
||||
} from './panel-search-window';
|
||||
|
||||
interface Row {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
function rows(count: number, name: string): Row[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: index,
|
||||
name: `${name} ${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('PanelSearchWindow', () => {
|
||||
const matches = (row: Row, term: string) =>
|
||||
row.name.toLowerCase().includes(term);
|
||||
let window: PanelSearchWindow<Row>;
|
||||
let source: Row[];
|
||||
|
||||
beforeEach(() => {
|
||||
window = new PanelSearchWindow<Row>(100, matches);
|
||||
source = [...rows(250, 'Sports TV'), ...rows(20, 'Radio')];
|
||||
});
|
||||
|
||||
it('cuts a broad match to one chunk and grows chunk by chunk on demand', () => {
|
||||
expect(window.rows('tv', source)).toHaveLength(100);
|
||||
expect(window.hasMore()).toBe(true);
|
||||
expect(window.activeTerm()).toBe('tv');
|
||||
|
||||
window.loadMore();
|
||||
expect(window.rows('tv', source)).toHaveLength(200);
|
||||
window.loadMore();
|
||||
expect(window.rows('tv', source)).toHaveLength(250);
|
||||
expect(window.hasMore()).toBe(false);
|
||||
|
||||
window.loadMore();
|
||||
expect(window.rows('tv', source)).toHaveLength(250);
|
||||
});
|
||||
|
||||
it('starts a new term at one chunk again', () => {
|
||||
window.rows('tv', source);
|
||||
window.loadMore();
|
||||
expect(window.rows('tv', source)).toHaveLength(200);
|
||||
|
||||
expect(window.rows('sports', source)).toHaveLength(100);
|
||||
expect(window.rows('radio', source)).toHaveLength(20);
|
||||
expect(window.hasMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('memoizes the match list per term and source', () => {
|
||||
const spy = jest.fn(matches);
|
||||
window = new PanelSearchWindow<Row>(100, spy);
|
||||
|
||||
window.rows('tv', source);
|
||||
const calls = spy.mock.calls.length;
|
||||
window.rows('tv', source);
|
||||
expect(spy.mock.calls.length).toBe(calls);
|
||||
|
||||
window.rows('tv', [...source]);
|
||||
expect(spy.mock.calls.length).toBeGreaterThan(calls);
|
||||
});
|
||||
|
||||
it('forgets the term when the panel search is cleared', () => {
|
||||
window.rows('tv', source);
|
||||
window.clear();
|
||||
|
||||
expect(window.activeTerm()).toBe('');
|
||||
expect(window.hasMore()).toBe(false);
|
||||
window.loadMore();
|
||||
expect(window.rows('tv', source)).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('starts over when the user backspaces to a term grown earlier', () => {
|
||||
// The window belongs to one run of edits: refining "tv" to "tvx" and
|
||||
// deleting the x again must not remount the 200 rows "tv" had grown
|
||||
// to, which would defeat the guard the window exists for.
|
||||
window.rows('tv', source);
|
||||
window.loadMore();
|
||||
expect(window.rows('tv', source)).toHaveLength(200);
|
||||
|
||||
expect(window.rows('tvx', source)).toHaveLength(0);
|
||||
expect(window.rows('tv', source)).toHaveLength(100);
|
||||
expect(window.hasMore()).toBe(true);
|
||||
});
|
||||
|
||||
it('starts over when the search is cleared and the term typed again', () => {
|
||||
window.rows('tv', source);
|
||||
window.loadMore();
|
||||
window.clear();
|
||||
|
||||
expect(window.rows('tv', source)).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('keeps the grown window when the source refreshes under the same term', () => {
|
||||
// A paged portal appends a page: the matches the user scrolled
|
||||
// through must stay on screen, with the new ones behind them.
|
||||
window.rows('tv', source);
|
||||
window.loadMore();
|
||||
|
||||
const refreshed = [...source, ...rows(30, 'More TV')];
|
||||
expect(window.rows('tv', refreshed)).toHaveLength(200);
|
||||
expect(window.hasMore()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePanelSearchScroll', () => {
|
||||
it('does nothing away from the end of the list', () => {
|
||||
expect(
|
||||
resolvePanelSearchScroll({
|
||||
isPanelSearch: true,
|
||||
isNearEnd: false,
|
||||
panelHasMore: true,
|
||||
})
|
||||
).toBe('idle');
|
||||
});
|
||||
|
||||
it('grows the panel window while it still hides loaded matches', () => {
|
||||
expect(
|
||||
resolvePanelSearchScroll({
|
||||
isPanelSearch: true,
|
||||
isNearEnd: true,
|
||||
panelHasMore: true,
|
||||
})
|
||||
).toBe('grow-window');
|
||||
});
|
||||
|
||||
it('reaches provider pagination once the window covers every loaded match', () => {
|
||||
// A paged portal can hold matches on pages never fetched, so stopping
|
||||
// at the exhausted window would hide them from the panel for good.
|
||||
expect(
|
||||
resolvePanelSearchScroll({
|
||||
isPanelSearch: true,
|
||||
isNearEnd: true,
|
||||
panelHasMore: false,
|
||||
})
|
||||
).toBe('load-page');
|
||||
});
|
||||
|
||||
it('pages the sidebar list, which has no panel window', () => {
|
||||
expect(
|
||||
resolvePanelSearchScroll({
|
||||
isPanelSearch: false,
|
||||
isNearEnd: true,
|
||||
panelHasMore: false,
|
||||
})
|
||||
).toBe('load-page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldAutoFillStampedList', () => {
|
||||
it('lets the sidebar fill itself only while its search is idle', () => {
|
||||
expect(shouldAutoFillStampedList(false, false)).toBe(true);
|
||||
expect(shouldAutoFillStampedList(false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('always lets the fullscreen panel fill itself', () => {
|
||||
// The sidebar's search term is not the panel's concern: the panel's
|
||||
// own term is what may need the next page.
|
||||
expect(shouldAutoFillStampedList(true, false)).toBe(true);
|
||||
expect(shouldAutoFillStampedList(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
interface PanelSearchMemo<T> {
|
||||
readonly term: string;
|
||||
readonly source: readonly T[];
|
||||
readonly result: T[];
|
||||
}
|
||||
|
||||
/** What a scroll near the end of a stamped channel list should do next. */
|
||||
export type PanelSearchScrollAction = 'idle' | 'grow-window' | 'load-page';
|
||||
|
||||
/**
|
||||
* A panel search grows its own window over the matches already loaded, but
|
||||
* only while that window still hides some: on a paged portal (or a censored
|
||||
* category absent from the full-list cache) the term may also match channels
|
||||
* on pages that were never fetched, so once the window covers everything
|
||||
* loaded the scroll has to reach provider pagination instead of stopping.
|
||||
*/
|
||||
/**
|
||||
* Whether a stamped list that does not overflow should fill itself without a
|
||||
* scroll. The sidebar copy never does so while its own search is active
|
||||
* (scrolling it still pages) — that has always been its behaviour, since a
|
||||
* client-side search would otherwise walk the whole portal on its own. The
|
||||
* fullscreen panel's copy always does: its term is what may need the next
|
||||
* page, and the sidebar's term is not its concern.
|
||||
*/
|
||||
export function shouldAutoFillStampedList(
|
||||
isPanelContainer: boolean,
|
||||
sidebarSearchActive: boolean
|
||||
): boolean {
|
||||
return isPanelContainer || !sidebarSearchActive;
|
||||
}
|
||||
|
||||
export function resolvePanelSearchScroll(state: {
|
||||
readonly isPanelSearch: boolean;
|
||||
readonly isNearEnd: boolean;
|
||||
readonly panelHasMore: boolean;
|
||||
}): PanelSearchScrollAction {
|
||||
if (!state.isNearEnd) {
|
||||
return 'idle';
|
||||
}
|
||||
return state.isPanelSearch && state.panelHasMore
|
||||
? 'grow-window'
|
||||
: 'load-page';
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized, windowed matches for the fullscreen channel panel's own search
|
||||
* field.
|
||||
*
|
||||
* In full-list mode the search source is the portal's entire channel list,
|
||||
* and a broad term ("tv") matches most of it. The panel renders one
|
||||
* `app-channel-list-item` per row without virtual scrolling, so the matches
|
||||
* are rendered through the same bounded window the sidebar uses: `chunk`
|
||||
* rows first, another `chunk` each time the panel's list scrolls near its
|
||||
* end. The window belongs to the term it was grown for — a new term starts
|
||||
* at one chunk again — and the match list itself is memoized per
|
||||
* term + source so re-renders do not re-filter thousands of rows.
|
||||
*/
|
||||
export class PanelSearchWindow<T> {
|
||||
/**
|
||||
* The grown window. `run` ties it to one uninterrupted run of {@link rows}
|
||||
* calls for its term: the counter advances whenever the term changes (or
|
||||
* the search is cleared), so backspacing to a term grown earlier starts
|
||||
* at one chunk again instead of remounting the hundreds of rows that run
|
||||
* had reached. It is a plain field, not a signal, because {@link rows} is
|
||||
* read from computeds and templates, where writing a signal is an error.
|
||||
*/
|
||||
private readonly window = signal<{
|
||||
term: string;
|
||||
limit: number;
|
||||
run: number;
|
||||
}>({ term: '', limit: 0, run: 0 });
|
||||
private run = 0;
|
||||
private memo: PanelSearchMemo<T> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly chunk: number,
|
||||
private readonly matches: (item: T, term: string) => boolean
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Rows to render for a non-empty `term` over `source`: the matches, cut to
|
||||
* the current window. Reads the window signal, so a template calling this
|
||||
* re-renders when {@link loadMore} grows it.
|
||||
*/
|
||||
rows(term: string, source: readonly T[]): T[] {
|
||||
const memo = this.memo;
|
||||
if (memo === null || memo.term !== term) {
|
||||
this.run += 1;
|
||||
}
|
||||
const result =
|
||||
memo && memo.term === term && memo.source === source
|
||||
? memo.result
|
||||
: source.filter((item) => this.matches(item, term));
|
||||
this.memo = { term, source, result };
|
||||
return result.slice(0, this.limitFor(term));
|
||||
}
|
||||
|
||||
/** The panel's search field is empty again: nothing to window. */
|
||||
clear(): void {
|
||||
this.memo = null;
|
||||
}
|
||||
|
||||
/** The term the last {@link rows} call matched against, '' when cleared. */
|
||||
activeTerm(): string {
|
||||
return this.memo?.term ?? '';
|
||||
}
|
||||
|
||||
hasMore(): boolean {
|
||||
const memo = this.memo;
|
||||
return memo !== null && memo.result.length > this.limitFor(memo.term);
|
||||
}
|
||||
|
||||
/** Grows the window over the active term's matches by one chunk. */
|
||||
loadMore(): void {
|
||||
const memo = this.memo;
|
||||
if (memo === null || !this.hasMore()) {
|
||||
return;
|
||||
}
|
||||
this.window.set({
|
||||
term: memo.term,
|
||||
limit: this.limitFor(memo.term) + this.chunk,
|
||||
run: this.run,
|
||||
});
|
||||
}
|
||||
|
||||
private limitFor(term: string): number {
|
||||
const window = this.window();
|
||||
return window.term === term && window.run === this.run
|
||||
? window.limit
|
||||
: this.chunk;
|
||||
}
|
||||
}
|
||||
+115
-91
@@ -53,97 +53,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (isFullListLoading()) {
|
||||
<div class="full-list-progress" role="status">
|
||||
<mat-spinner diameter="14" />
|
||||
<span class="full-list-progress__label">
|
||||
{{ 'PORTALS.LOADING_ALL_CHANNELS' | translate }}
|
||||
</span>
|
||||
@if (fullListProgress(); as progress) {
|
||||
<span class="full-list-progress__count">
|
||||
{{ progress.loaded }}/{{ progress.total }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
#scrollContainer
|
||||
class="channels-list app-scrollbar"
|
||||
appChannelScrollFocus
|
||||
id="live-channels"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
>
|
||||
@if (isInitialChannelsLoading()) {
|
||||
<app-channel-list-skeleton
|
||||
[count]="9"
|
||||
[showEpg]="supportsEpg && !isRadioMode()"
|
||||
[actionCount]="1"
|
||||
/>
|
||||
} @else if (searchTerm() && visibleChannels().length === 0) {
|
||||
<div class="empty-search-state">
|
||||
<app-portal-empty-state
|
||||
icon="search_off"
|
||||
[message]="'CHANNELS.NO_SEARCH_RESULTS' | translate"
|
||||
/>
|
||||
</div>
|
||||
} @else if (isCategoryEmpty()) {
|
||||
<div class="empty-search-state">
|
||||
<app-portal-empty-state
|
||||
icon="tv_off"
|
||||
[message]="
|
||||
'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
@for (item of visibleChannels(); track item.id ?? $index) {
|
||||
<app-channel-list-item
|
||||
[name]="item.o_name || item.name || ''"
|
||||
[logo]="item.logo"
|
||||
[selected]="isSelectedChannel(item)"
|
||||
[epgProgram]="
|
||||
supportsEpg
|
||||
? (epgPreviewPrograms.get(
|
||||
normalizeStalkerEntityId(item.id)
|
||||
) ?? null)
|
||||
: null
|
||||
"
|
||||
[progressPercentage]="
|
||||
supportsEpg
|
||||
? (currentProgramsProgress.get(
|
||||
normalizeStalkerEntityId(item.id)
|
||||
) ?? 0)
|
||||
: 0
|
||||
"
|
||||
[showEpg]="supportsEpg && !isRadioMode()"
|
||||
[isRadio]="isRadioMode()"
|
||||
[showFavoriteButton]="true"
|
||||
[showProgramInfoButton]="true"
|
||||
[showDetailsContextMenu]="
|
||||
supportsEpgMapping && !isRadioMode()
|
||||
"
|
||||
[isFavorite]="
|
||||
favorites.get(normalizeStalkerEntityId(item.id)) ??
|
||||
false
|
||||
"
|
||||
(clicked)="playChannel(item)"
|
||||
(activated)="playChannel(item, true)"
|
||||
(keyboardActivated)="playChannel(item, true)"
|
||||
(favoriteToggled)="toggleFavorite(item)"
|
||||
(contextMenuRequested)="
|
||||
onChannelContextMenu(item, $event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isLoadingMore()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<ng-container *ngTemplateOutlet="stalkerChannelList" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -294,3 +204,117 @@
|
||||
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- The channel list, stamped into the sidebar and — with its own search
|
||||
term — into the player's fullscreen channel panel. Every instance carries
|
||||
the #scrollContainer that drives infinite scroll. -->
|
||||
<ng-template #stalkerChannelList let-panelSearchTerm="searchTerm">
|
||||
@if (isFullListLoading()) {
|
||||
<div class="full-list-progress" role="status">
|
||||
<mat-spinner diameter="14" />
|
||||
<span class="full-list-progress__label">
|
||||
{{ 'PORTALS.LOADING_ALL_CHANNELS' | translate }}
|
||||
</span>
|
||||
@if (fullListProgress(); as progress) {
|
||||
<span class="full-list-progress__count">
|
||||
{{ progress.loaded }}/{{ progress.total }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Only the sidebar copy is the `live-channels` pane the category
|
||||
list's ArrowRight/ArrowLeft hand-off targets; the fullscreen panel's
|
||||
copy must not duplicate that id. -->
|
||||
<div
|
||||
#scrollContainer
|
||||
class="channels-list app-scrollbar"
|
||||
appChannelScrollFocus
|
||||
[attr.id]="panelSearchTerm ? null : 'live-channels'"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
>
|
||||
@if (isInitialChannelsLoading()) {
|
||||
<app-channel-list-skeleton
|
||||
[count]="9"
|
||||
[showEpg]="supportsEpg && !isRadioMode()"
|
||||
[actionCount]="1"
|
||||
/>
|
||||
} @else if (
|
||||
hasSearchTerm(panelSearchTerm) &&
|
||||
channelsForList(panelSearchTerm).length === 0
|
||||
) {
|
||||
<div class="empty-search-state">
|
||||
<app-portal-empty-state
|
||||
icon="search_off"
|
||||
[message]="'CHANNELS.NO_SEARCH_RESULTS' | translate"
|
||||
/>
|
||||
</div>
|
||||
} @else if (isCategoryEmpty()) {
|
||||
<div class="empty-search-state">
|
||||
<app-portal-empty-state
|
||||
icon="tv_off"
|
||||
[message]="'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
@for (
|
||||
item of channelsForList(panelSearchTerm);
|
||||
track item.id ?? $index
|
||||
) {
|
||||
<app-channel-list-item
|
||||
[name]="item.o_name || item.name || ''"
|
||||
[logo]="item.logo"
|
||||
[selected]="isSelectedChannel(item)"
|
||||
[epgProgram]="
|
||||
supportsEpg
|
||||
? (epgPreviewPrograms.get(
|
||||
normalizeStalkerEntityId(item.id)
|
||||
) ?? null)
|
||||
: null
|
||||
"
|
||||
[progressPercentage]="
|
||||
supportsEpg
|
||||
? (currentProgramsProgress.get(
|
||||
normalizeStalkerEntityId(item.id)
|
||||
) ?? 0)
|
||||
: 0
|
||||
"
|
||||
[showEpg]="supportsEpg && !isRadioMode()"
|
||||
[isRadio]="isRadioMode()"
|
||||
[showFavoriteButton]="true"
|
||||
[showProgramInfoButton]="true"
|
||||
[showDetailsContextMenu]="
|
||||
supportsEpgMapping && !isRadioMode()
|
||||
"
|
||||
[isFavorite]="
|
||||
favorites.get(normalizeStalkerEntityId(item.id)) ??
|
||||
false
|
||||
"
|
||||
(clicked)="playChannel(item)"
|
||||
(activated)="playChannel(item, true)"
|
||||
(keyboardActivated)="playChannel(item, true)"
|
||||
(favoriteToggled)="toggleFavorite(item)"
|
||||
(contextMenuRequested)="onChannelContextMenu(item, $event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isLoadingMore()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Stamped into the player's fullscreen channel panel (FULLSCREEN_CHANNEL_PANEL). -->
|
||||
<ng-template #fullscreenChannelPanel let-searchTerm="searchTerm">
|
||||
<div class="fullscreen-channel-list">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
stalkerChannelList;
|
||||
context: { searchTerm: searchTerm }
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</ng-template>
|
||||
+9
@@ -82,6 +82,15 @@
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// Wrapper of the list stamped into the player's fullscreen channel panel:
|
||||
// gives `.channels-list` the bounded column it needs to own the scrolling.
|
||||
.fullscreen-channel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.load-more-epg {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
+307
-69
@@ -8,12 +8,16 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
forwardRef,
|
||||
inject,
|
||||
linkedSignal,
|
||||
OnDestroy,
|
||||
type Signal,
|
||||
signal,
|
||||
TemplateRef,
|
||||
untracked,
|
||||
viewChild,
|
||||
viewChildren,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -61,6 +65,9 @@ import {
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ElectronStreamHeadersService,
|
||||
FULLSCREEN_CHANNEL_PANEL,
|
||||
type FullscreenChannelPanelContext,
|
||||
type FullscreenChannelPanelHost,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -86,6 +93,11 @@ import {
|
||||
StalkerStore,
|
||||
normalizeStalkerEntityId,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
PanelSearchWindow,
|
||||
resolvePanelSearchScroll,
|
||||
shouldAutoFillStampedList,
|
||||
} from './panel-search-window';
|
||||
import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component';
|
||||
import {
|
||||
EPG_PREVIEW_FETCH_SIZE,
|
||||
@@ -113,6 +125,15 @@ interface StalkerPlaybackResolutionOwner {
|
||||
/** Channels rendered per "page" when the full list is served from the cache. */
|
||||
const FULL_LIST_RENDER_CHUNK = 100;
|
||||
|
||||
function matchesStalkerChannelTerm(
|
||||
item: StalkerItvChannel,
|
||||
term: string
|
||||
): boolean {
|
||||
return `${item.o_name ?? ''} ${item.name ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-live-stream-layout',
|
||||
templateUrl: './stalker-live-stream-layout.component.html',
|
||||
@@ -136,9 +157,19 @@ const FULL_LIST_RENDER_CHUNK = 100;
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
providers: [
|
||||
// The fullscreen channel panel inside the player renders this
|
||||
// category's channel list (see the `fullscreenChannelPanel` template).
|
||||
{
|
||||
provide: FULLSCREEN_CHANNEL_PANEL,
|
||||
useExisting: forwardRef(() => StalkerLiveStreamLayoutComponent),
|
||||
},
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
export class StalkerLiveStreamLayoutComponent
|
||||
implements OnDestroy, FullscreenChannelPanelHost
|
||||
{
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
@@ -184,13 +215,34 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
this.stalkerStore.itvSelectedCategoryFromCache()
|
||||
);
|
||||
/**
|
||||
* Channels matching the search phrase. Without a term, the current
|
||||
* category. With a term in full-list mode, the WHOLE portal's channel list
|
||||
* (every category) so search behaves like "search all channels" — merged
|
||||
* The rows a search term is matched against: the current category, or in
|
||||
* full-list mode the WHOLE portal's channel list (every category) merged
|
||||
* with the currently loaded channels, because a censored (adult) category
|
||||
* is paged from the portal and its channels are intentionally absent from
|
||||
* the full-list cache. Otherwise the loaded channels of the current
|
||||
* category.
|
||||
* the full-list cache.
|
||||
*/
|
||||
private readonly searchableChannels = computed(() => {
|
||||
const source = this.channels();
|
||||
if (!this.isFullListMode()) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const merged = new Map<string, StalkerItvChannel>();
|
||||
for (const channel of source) {
|
||||
merged.set(normalizeStalkerEntityId(channel.id), channel);
|
||||
}
|
||||
for (const channel of this.stalkerStore.itvFullChannelList()) {
|
||||
const id = normalizeStalkerEntityId(channel.id);
|
||||
if (!merged.has(id)) {
|
||||
merged.set(id, channel);
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
});
|
||||
/**
|
||||
* Channels matching the search phrase. Without a term, the current
|
||||
* category; with one, `searchableChannels` filtered, so search behaves
|
||||
* like "search all channels" whenever the full list is cached.
|
||||
*/
|
||||
readonly filteredChannels = computed(() => {
|
||||
const term = this.searchTerm();
|
||||
@@ -198,27 +250,20 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
return this.channels();
|
||||
}
|
||||
|
||||
let source = this.channels();
|
||||
if (this.isFullListMode()) {
|
||||
const merged = new Map<string, StalkerItvChannel>();
|
||||
for (const channel of source) {
|
||||
merged.set(normalizeStalkerEntityId(channel.id), channel);
|
||||
}
|
||||
for (const channel of this.stalkerStore.itvFullChannelList()) {
|
||||
const id = normalizeStalkerEntityId(channel.id);
|
||||
if (!merged.has(id)) {
|
||||
merged.set(id, channel);
|
||||
}
|
||||
}
|
||||
source = [...merged.values()];
|
||||
}
|
||||
|
||||
return source.filter((item) =>
|
||||
`${item.o_name ?? ''} ${item.name ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
return this.searchableChannels().filter((item) =>
|
||||
matchesStalkerChannelTerm(item, term)
|
||||
);
|
||||
});
|
||||
/**
|
||||
* The fullscreen panel's own search results, memoized and windowed like
|
||||
* the sidebar's rows: in full-list mode a broad term matches most of a
|
||||
* multi-thousand-channel portal, and the panel renders a component per
|
||||
* row.
|
||||
*/
|
||||
private readonly panelSearch = new PanelSearchWindow<StalkerItvChannel>(
|
||||
FULL_LIST_RENDER_CHUNK,
|
||||
matchesStalkerChannelTerm
|
||||
);
|
||||
readonly isFullListLoading = computed(
|
||||
() => !this.isRadioMode() && this.stalkerStore.itvFullListLoading()
|
||||
);
|
||||
@@ -250,6 +295,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
? this.filteredChannels().slice(0, this.renderLimit())
|
||||
: this.filteredChannels()
|
||||
);
|
||||
/**
|
||||
* The fullscreen panel's rows while its own search field is blank: the
|
||||
* current category, or the full cache when playback started in All Items
|
||||
* without a category. Never narrowed by the sidebar's search term.
|
||||
*/
|
||||
private readonly panelIdleSource = computed(() =>
|
||||
this.showItvAllItems() ? this.itvFullChannelList() : this.channels()
|
||||
);
|
||||
private readonly panelUsesCachedRows = computed(
|
||||
() => this.isCategoryFromCache() || this.showItvAllItems()
|
||||
);
|
||||
readonly panelIdleChannels = computed(() =>
|
||||
this.panelUsesCachedRows()
|
||||
? this.panelIdleSource().slice(0, this.renderLimit())
|
||||
: this.panelIdleSource()
|
||||
);
|
||||
/**
|
||||
* Whether {@link panelIdleChannels} can grow. Judged against the category,
|
||||
* never against the sidebar's filtered rows (`hasMoreItems`): a sidebar
|
||||
* search that fits its matches inside the render window says nothing
|
||||
* about the unfiltered rows the blank panel is showing.
|
||||
*/
|
||||
readonly panelIdleHasMore = computed(() =>
|
||||
this.panelUsesCachedRows()
|
||||
? this.panelIdleSource().length > this.renderLimit()
|
||||
: this.stalkerStore.hasMoreChannels()
|
||||
);
|
||||
readonly totalChannelCount = computed(() => this.filteredChannels().length);
|
||||
readonly hasMoreItems = computed(() =>
|
||||
this.isCategoryFromCache()
|
||||
@@ -500,9 +572,24 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
/** Favorites */
|
||||
readonly favorites = new Map<string | number, boolean>();
|
||||
|
||||
/** Scroll */
|
||||
readonly scrollContainer = viewChild<ElementRef>('scrollContainer');
|
||||
/**
|
||||
* Scroll. The channel list template is stamped twice — in the sidebar and
|
||||
* in the player's fullscreen channel panel — so every instance gets the
|
||||
* infinite-scroll listener.
|
||||
*/
|
||||
readonly scrollContainers = viewChildren<ElementRef>('scrollContainer');
|
||||
private scrollListener: (() => void) | null = null;
|
||||
|
||||
private readonly fullscreenChannelPanelTemplate = viewChild<
|
||||
TemplateRef<FullscreenChannelPanelContext>
|
||||
>('fullscreenChannelPanel');
|
||||
/** FULLSCREEN_CHANNEL_PANEL: the current category's list, unless opted out. */
|
||||
readonly panelTemplate = computed(() =>
|
||||
this.settingsStore.fullscreenChannelPanel?.() === false
|
||||
? null
|
||||
: (this.fullscreenChannelPanelTemplate() ?? null)
|
||||
);
|
||||
readonly panelTitle = computed(() => this.selectedCategoryTitle() ?? '');
|
||||
private epgPreviewRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private unsubscribeRemoteChannelChange?: () => void;
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
@@ -592,11 +679,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
// Reset loading state when channels load and keep preview data in sync with bulk EPG.
|
||||
effect(() => {
|
||||
const channels = this.visibleChannels();
|
||||
if (channels.length > 0) {
|
||||
// A page landing ends the in-flight state whether or not the
|
||||
// sidebar's search shows any of it — the fullscreen panel's own
|
||||
// search may be what asked for that page — and every stamped
|
||||
// list gets its fill check; the sidebar copy's gate on its search
|
||||
// term lives in checkIfNeedsMoreContent.
|
||||
if (this.channels().length > 0) {
|
||||
this.isLoadingMore.set(false);
|
||||
if (!this.searchTerm()) {
|
||||
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
|
||||
}
|
||||
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
|
||||
}
|
||||
|
||||
if (this.isRadioMode() || !this.supportsEpg) {
|
||||
@@ -658,10 +748,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
untracked(() => void this.stalkerStore.ensureBulkItvEpg(168));
|
||||
});
|
||||
|
||||
// Setup scroll listener when container becomes available
|
||||
// Setup scroll listeners when list containers become available
|
||||
effect(() => {
|
||||
const container = this.scrollContainer();
|
||||
if (container) {
|
||||
if (this.scrollContainers().length > 0) {
|
||||
this.setupScrollListener();
|
||||
}
|
||||
});
|
||||
@@ -784,7 +873,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
);
|
||||
const contentType = this.stalkerStore.selectedContentType();
|
||||
const isRadioMode = this.isRadioMode();
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
const deferSelection = !isRadioMode && this.usesEmbeddedPlayer();
|
||||
// Inline video retains its stream during resolution. Keep selection,
|
||||
// EPG and recording metadata paired with that stream until commit.
|
||||
if (!deferSelection) this.stalkerStore.setSelectedItem(item);
|
||||
const expectedSelectedId = this.selectedChannelId();
|
||||
this.ensureChannelWithinRenderWindow(channelId);
|
||||
// A previously owned radio override must not survive into a
|
||||
// selection that never mounts a player surface of its own — external
|
||||
@@ -800,7 +893,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
isRadioMode
|
||||
);
|
||||
const owner = { sourceId, contentType, channelId };
|
||||
if (!this.isPlaybackRequestCurrent(requestId, owner)) {
|
||||
if (
|
||||
!this.isPlaybackRequestCurrent(
|
||||
requestId,
|
||||
owner,
|
||||
expectedSelectedId
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -819,7 +918,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
const stillCurrent = headerSync ? await headerSync : true;
|
||||
if (
|
||||
!stillCurrent ||
|
||||
!this.isPlaybackRequestCurrent(requestId, owner)
|
||||
!this.isPlaybackRequestCurrent(
|
||||
requestId,
|
||||
owner,
|
||||
expectedSelectedId
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -827,6 +930,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deferSelection) {
|
||||
if (!sourceId || !channelId) return;
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
}
|
||||
|
||||
if (this.supportsEpg) {
|
||||
void this.loadEpgForChannel(item);
|
||||
}
|
||||
@@ -842,11 +950,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!this.isPlaybackRequestCurrent(requestId, {
|
||||
sourceId,
|
||||
contentType,
|
||||
channelId,
|
||||
})
|
||||
!this.isPlaybackRequestCurrent(
|
||||
requestId,
|
||||
{ sourceId, contentType, channelId },
|
||||
expectedSelectedId
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -889,11 +997,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
|
||||
private isPlaybackRequestCurrent(
|
||||
requestId: number,
|
||||
owner: StalkerPlaybackResolutionOwner
|
||||
owner: StalkerPlaybackResolutionOwner,
|
||||
expectedSelectedId: string | undefined
|
||||
): boolean {
|
||||
return (
|
||||
requestId === this.playbackRequestId &&
|
||||
this.selectedChannelId() === owner.channelId &&
|
||||
this.selectedChannelId() === expectedSelectedId &&
|
||||
normalizeStalkerEntityId(
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
) === owner.sourceId &&
|
||||
@@ -956,13 +1065,50 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
this.renderLimit.set(Math.max(this.renderLimit(), needed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows for one stamped list instance. The fullscreen panel's instance
|
||||
* searches with its own term over the same channels the sidebar search
|
||||
* covers, windowed by {@link PanelSearchWindow} (its scroll container
|
||||
* grows that window, see `setupScrollListener`); without one, every
|
||||
* instance shows the sidebar's windowed rows.
|
||||
*/
|
||||
channelsForList(panelSearchTerm?: Signal<string>): StalkerItvChannel[] {
|
||||
const term = panelSearchTerm?.().trim().toLowerCase() ?? '';
|
||||
if (!term) {
|
||||
if (!panelSearchTerm) {
|
||||
return this.visibleChannels();
|
||||
}
|
||||
// The panel copy with a blank field shows the category as is:
|
||||
// the sidebar's own search term stays out of it, or the panel
|
||||
// would show an unexplained subset (or an empty state) under an
|
||||
// empty search box. Only this copy owns the window, and it is
|
||||
// cleared here rather than on the sidebar's every render.
|
||||
this.panelSearch.clear();
|
||||
return this.panelIdleChannels();
|
||||
}
|
||||
|
||||
// A new term renders a fresh result the user cannot have scrolled yet;
|
||||
// once it is on screen, a result too short to overflow must still
|
||||
// reach provider pagination (see checkIfNeedsMoreContent). Scheduled,
|
||||
// not run here: this is called from the template.
|
||||
if (term !== this.panelSearch.activeTerm()) {
|
||||
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
|
||||
}
|
||||
return this.panelSearch.rows(term, this.searchableChannels());
|
||||
}
|
||||
|
||||
/** Whether the given stamped copy is showing search results. */
|
||||
hasSearchTerm(panelSearchTerm?: Signal<string>): boolean {
|
||||
return panelSearchTerm
|
||||
? Boolean(panelSearchTerm().trim())
|
||||
: Boolean(this.searchTerm());
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
if (this.isCategoryFromCache()) {
|
||||
// Extends the render window over the in-memory list — no request.
|
||||
if (this.hasMoreItems()) {
|
||||
this.renderLimit.update(
|
||||
(limit) => limit + FULL_LIST_RENDER_CHUNK
|
||||
);
|
||||
this.growRenderWindow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -975,6 +1121,24 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
this.stalkerStore.setPage(nextPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fullscreen panel's blank-field copy reached its end: grow it against
|
||||
* the category. `loadMore()` would consult `hasMoreItems`, which the
|
||||
* sidebar's search can turn false while the panel still has rows to show.
|
||||
*/
|
||||
loadMoreForPanel(): void {
|
||||
if (this.isLoadingMore() || !this.panelIdleHasMore()) return;
|
||||
if (this.panelUsesCachedRows()) {
|
||||
this.growRenderWindow();
|
||||
return;
|
||||
}
|
||||
this.loadMore();
|
||||
}
|
||||
|
||||
private growRenderWindow(): void {
|
||||
this.renderLimit.update((limit) => limit + FULL_LIST_RENDER_CHUNK);
|
||||
}
|
||||
|
||||
refreshChannels(): void {
|
||||
void this.stalkerStore.refreshItvChannels();
|
||||
}
|
||||
@@ -1329,37 +1493,111 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
private setupScrollListener() {
|
||||
this.removeScrollListener();
|
||||
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
const containers = this.scrollContainers()
|
||||
.map((ref) => ref.nativeElement as HTMLElement | undefined)
|
||||
.filter((element): element is HTMLElement => !!element);
|
||||
if (containers.length === 0) return;
|
||||
|
||||
const onScroll = () => {
|
||||
this.scheduleEpgPreviewRefresh();
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
const cleanups = containers.map((container) => {
|
||||
const isNearEnd = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const scrollThreshold = 150;
|
||||
return (
|
||||
scrollHeight - scrollTop - clientHeight <= scrollThreshold
|
||||
);
|
||||
};
|
||||
const onScroll = () => {
|
||||
this.scheduleEpgPreviewRefresh();
|
||||
this.driveStampedList(container, isNearEnd());
|
||||
};
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const scrollThreshold = 150;
|
||||
|
||||
if (scrollHeight - scrollTop - clientHeight <= scrollThreshold) {
|
||||
this.loadMore();
|
||||
container.addEventListener('scroll', onScroll, {
|
||||
passive: true,
|
||||
});
|
||||
// The panel retains its view while closed. Reopening may be the
|
||||
// only event capable of resuming an empty, non-scrollable query.
|
||||
const panel = container.closest('.fullscreen-channel-panel');
|
||||
const visibilityObserver = panel
|
||||
? new MutationObserver(() => this.checkIfNeedsMoreContent())
|
||||
: null;
|
||||
if (panel) {
|
||||
visibilityObserver?.observe(panel, {
|
||||
attributes: true,
|
||||
attributeFilter: ['inert'],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
this.scrollListener = () =>
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
visibilityObserver?.disconnect();
|
||||
};
|
||||
});
|
||||
this.scrollListener = () => cleanups.forEach((cleanup) => cleanup());
|
||||
}
|
||||
|
||||
/**
|
||||
* A stamped list that does not overflow can never scroll, so it is
|
||||
* driven as if scrolled to its end: the sidebar fills a tall viewport
|
||||
* this way, and the fullscreen panel's copy keeps paging a paged portal
|
||||
* while its search matches do not fill it — an empty or short result
|
||||
* says nothing about the pages never fetched.
|
||||
*/
|
||||
private checkIfNeedsMoreContent() {
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = container;
|
||||
if (scrollHeight <= clientHeight) {
|
||||
this.loadMore();
|
||||
const sidebarSearchActive = this.searchTerm() !== '';
|
||||
for (const ref of this.scrollContainers()) {
|
||||
const container = ref.nativeElement as HTMLElement | undefined;
|
||||
if (!container) continue;
|
||||
const isPanelContainer =
|
||||
container.closest('.fullscreen-channel-list') !== null;
|
||||
if (
|
||||
!shouldAutoFillStampedList(
|
||||
isPanelContainer,
|
||||
sidebarSearchActive
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const { scrollHeight, clientHeight } = container;
|
||||
this.driveStampedList(container, scrollHeight <= clientHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One stamped list reached (or cannot reach) its end. The fullscreen
|
||||
* panel's copy, while searching with its own term, scrolls through its
|
||||
* own windowed matches, not the sidebar's rows: it grows that window
|
||||
* first and only pages the portal once the window covers every loaded
|
||||
* match — and never in full-list mode, where its search already sees
|
||||
* the whole catalog and a page would only widen the sidebar's window.
|
||||
*/
|
||||
private driveStampedList(container: HTMLElement, nearEnd: boolean) {
|
||||
// A closed panel stays mounted to preserve search and scroll, but
|
||||
// must not grow its window or request pages until it is reopened.
|
||||
if (container.closest('[inert]')) return;
|
||||
const isPanelContainer =
|
||||
container.closest('.fullscreen-channel-list') !== null;
|
||||
const isPanelSearch =
|
||||
isPanelContainer && this.panelSearch.activeTerm() !== '';
|
||||
const action = resolvePanelSearchScroll({
|
||||
isPanelSearch,
|
||||
isNearEnd: nearEnd,
|
||||
panelHasMore: this.panelSearch.hasMore(),
|
||||
});
|
||||
if (action === 'idle') return;
|
||||
if (action === 'grow-window') {
|
||||
this.panelSearch.loadMore();
|
||||
return;
|
||||
}
|
||||
if (isPanelSearch && this.isCategoryFromCache()) return;
|
||||
if (isPanelContainer && !isPanelSearch) {
|
||||
// The blank panel shows the category, not the sidebar's filtered
|
||||
// rows, so its continuation is judged against the category too.
|
||||
this.loadMoreForPanel();
|
||||
return;
|
||||
}
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
this.loadMore();
|
||||
}
|
||||
|
||||
private removeScrollListener() {
|
||||
if (this.scrollListener) {
|
||||
this.scrollListener();
|
||||
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
LiveLayoutSidebarStateService,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
|
||||
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
|
||||
|
||||
const FILL_CHECK_DELAY_MS = 100;
|
||||
|
||||
/**
|
||||
* Focused spec for the fullscreen panel's search on a paged portal — kept
|
||||
* separate from the main layout spec, which sits at the max-lines budget.
|
||||
*/
|
||||
describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
|
||||
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
|
||||
let component: StalkerLiveStreamLayoutComponent;
|
||||
const channels = [
|
||||
{
|
||||
id: 'channel-one',
|
||||
cmd: 'ffrt4://itv/channel-one',
|
||||
name: 'One',
|
||||
o_name: 'One',
|
||||
logo: 'one.png',
|
||||
},
|
||||
{
|
||||
id: 'channel-two',
|
||||
cmd: 'ffrt4://itv/channel-two',
|
||||
name: 'Two',
|
||||
o_name: 'Two',
|
||||
logo: 'two.png',
|
||||
},
|
||||
];
|
||||
const hasMoreChannels = signal(false);
|
||||
const page = signal(0);
|
||||
const itvChannels = signal(channels);
|
||||
const searchPhrase = signal('');
|
||||
const itvFullListActive = signal(false);
|
||||
const itvSelectedCategoryFromCache = signal(false);
|
||||
const store = {
|
||||
getSelectedCategoryName: signal('All'),
|
||||
currentPlaylist: signal({ _id: 'playlist-one', title: 'Portal One' }),
|
||||
selectedContentType: signal<'itv' | 'radio'>('itv'),
|
||||
selectedCategoryId: signal<string | null>('all'),
|
||||
selectedItvId: signal<string | undefined>(undefined),
|
||||
selectedItem: signal(null),
|
||||
itvChannels,
|
||||
radioChannels: signal([]),
|
||||
searchPhrase,
|
||||
hasMoreChannels,
|
||||
page,
|
||||
// A legacy paged portal by default: no full-list cache in memory.
|
||||
itvFullListActive,
|
||||
itvSelectedCategoryFromCache,
|
||||
itvFullListLoading: signal(false),
|
||||
itvFullListProgress: signal(null),
|
||||
itvFullChannelList: signal<typeof channels>([]),
|
||||
isPaginatedContentLoading: signal(false),
|
||||
selectedItvEpgPrograms: signal([]),
|
||||
bulkItvEpgByChannel: signal({}),
|
||||
isLoadingBulkItvEpg: signal(false),
|
||||
setItvChannels: jest.fn(),
|
||||
setRadioChannels: jest.fn(),
|
||||
setPage: jest.fn(),
|
||||
preloadItvChannels: jest.fn(),
|
||||
applyMappedItvEpg: jest.fn(),
|
||||
clearBulkItvEpgCache: jest.fn(),
|
||||
ensureBulkItvEpg: jest.fn(),
|
||||
fetchChannelEpg: jest.fn(),
|
||||
resolveItvPlayback: jest.fn(),
|
||||
resolveRadioPlayback: jest.fn(),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
};
|
||||
|
||||
const settle = () =>
|
||||
new Promise((resolve) => setTimeout(resolve, FILL_CHECK_DELAY_MS + 20));
|
||||
|
||||
beforeEach(async () => {
|
||||
hasMoreChannels.set(false);
|
||||
page.set(0);
|
||||
itvChannels.set(channels);
|
||||
searchPhrase.set('');
|
||||
itvFullListActive.set(false);
|
||||
itvSelectedCategoryFromCache.set(false);
|
||||
store.selectedCategoryId.set('all');
|
||||
store.itvFullChannelList.set([]);
|
||||
store.setPage.mockClear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
StalkerLiveStreamLayoutComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: StalkerStore, useValue: store },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
supportsEpg: false,
|
||||
isElectron: false,
|
||||
supportsEpgMapping: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: { getPortalFavorites: () => of([]) },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: () => true,
|
||||
openResolvedPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ElectronStreamHeadersService,
|
||||
useValue: { apply: jest.fn(), clear: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: LiveLayoutSidebarStateService,
|
||||
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
|
||||
},
|
||||
{ provide: EpgRuntimeBridgeService, useValue: {} },
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
// Let the initial fill check (no more pages yet) come and go.
|
||||
await settle();
|
||||
store.setPage.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('keeps requesting pages while an empty panel search cannot scroll', async () => {
|
||||
// A term with no match on the loaded page renders nothing the user
|
||||
// could scroll, yet later pages may hold the channel — so the empty
|
||||
// result itself must reach provider pagination.
|
||||
hasMoreChannels.set(true);
|
||||
|
||||
expect(component.channelsForList(signal('zzz-nomatch'))).toEqual([]);
|
||||
await settle();
|
||||
|
||||
expect(store.setPage).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('stops at the last page', async () => {
|
||||
hasMoreChannels.set(false);
|
||||
|
||||
expect(component.channelsForList(signal('zzz-nomatch'))).toEqual([]);
|
||||
await settle();
|
||||
|
||||
expect(store.setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ends the in-flight state when a page lands that the sidebar search hides', async () => {
|
||||
// The panel's search asked for the page; the sidebar's own term
|
||||
// matches none of it, so the sidebar shows no row — that must not
|
||||
// leave the request flagged as still in flight, or the panel could
|
||||
// never ask for the page after it.
|
||||
hasMoreChannels.set(true);
|
||||
searchPhrase.set('sidebar-term-matching-nothing');
|
||||
fixture.detectChanges();
|
||||
expect(component.visibleChannels()).toEqual([]);
|
||||
|
||||
component.loadMore();
|
||||
expect(store.setPage).toHaveBeenCalledWith(1);
|
||||
expect(component.isLoadingMore()).toBe(true);
|
||||
|
||||
page.set(1);
|
||||
itvChannels.set([
|
||||
...channels,
|
||||
{
|
||||
id: 'channel-three',
|
||||
cmd: 'ffrt4://itv/channel-three',
|
||||
name: 'Three',
|
||||
o_name: 'Three',
|
||||
logo: 'three.png',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isLoadingMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the whole category under a blank panel search, whatever the sidebar searches', () => {
|
||||
// The sidebar's term narrows the sidebar; the panel's empty field
|
||||
// must not inherit it, or the panel shows an unexplained subset (or
|
||||
// an empty state) under an empty search box.
|
||||
searchPhrase.set('one');
|
||||
fixture.detectChanges();
|
||||
expect(component.visibleChannels().map((c) => c.id)).toEqual([
|
||||
'channel-one',
|
||||
]);
|
||||
|
||||
const blank = signal('');
|
||||
expect(component.hasSearchTerm(blank)).toBe(false);
|
||||
expect(component.channelsForList(blank).map((c) => c.id)).toEqual([
|
||||
'channel-one',
|
||||
'channel-two',
|
||||
]);
|
||||
// The sidebar copy (no panel signal) keeps its own search.
|
||||
expect(component.hasSearchTerm()).toBe(true);
|
||||
expect(component.channelsForList().map((c) => c.id)).toEqual([
|
||||
'channel-one',
|
||||
]);
|
||||
});
|
||||
|
||||
it('grows the blank panel against the category, not the sidebar search', () => {
|
||||
// Cached full-list category of 150 rows, sidebar narrowed to one:
|
||||
// `hasMoreItems` says the sidebar is complete, yet the blank panel is
|
||||
// showing the first 100 of 150 and must still be able to reach the rest.
|
||||
itvFullListActive.set(true);
|
||||
itvSelectedCategoryFromCache.set(true);
|
||||
itvChannels.set(
|
||||
Array.from({ length: 150 }, (_, index) => ({
|
||||
id: `channel-${index}`,
|
||||
cmd: `ffrt4://itv/channel-${index}`,
|
||||
name: index === 7 ? 'Needle' : `Channel ${index}`,
|
||||
o_name: `Channel ${index}`,
|
||||
logo: '',
|
||||
}))
|
||||
);
|
||||
searchPhrase.set('needle');
|
||||
fixture.detectChanges();
|
||||
|
||||
const blank = signal('');
|
||||
expect(component.visibleChannels()).toHaveLength(1);
|
||||
expect(component.hasMoreItems()).toBe(false);
|
||||
expect(component.channelsForList(blank)).toHaveLength(100);
|
||||
expect(component.panelIdleHasMore()).toBe(true);
|
||||
|
||||
component.loadMoreForPanel();
|
||||
expect(component.channelsForList(blank)).toHaveLength(150);
|
||||
expect(component.panelIdleHasMore()).toBe(false);
|
||||
// The sidebar copy is untouched by the panel's growth.
|
||||
expect(component.visibleChannels()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('windows the full cache under a blank panel search when All Items has no category', () => {
|
||||
const fullList = Array.from({ length: 250 }, (_, index) => ({
|
||||
...channels[0],
|
||||
id: `full-${index}`,
|
||||
name: `Full channel ${index}`,
|
||||
}));
|
||||
store.selectedCategoryId.set(null);
|
||||
itvFullListActive.set(true);
|
||||
store.itvFullChannelList.set(fullList);
|
||||
itvChannels.set([]);
|
||||
searchPhrase.set('sidebar-no-match');
|
||||
|
||||
const blank = signal('');
|
||||
expect(component.channelsForList(blank)).toEqual(
|
||||
fullList.slice(0, 100)
|
||||
);
|
||||
expect(component.panelIdleHasMore()).toBe(true);
|
||||
component.loadMoreForPanel();
|
||||
expect(component.channelsForList(blank)).toEqual(
|
||||
fullList.slice(0, 200)
|
||||
);
|
||||
component.loadMoreForPanel();
|
||||
expect(component.channelsForList(blank)).toEqual(fullList);
|
||||
expect(component.panelIdleHasMore()).toBe(false);
|
||||
component.loadMoreForPanel();
|
||||
expect(store.setPage).not.toHaveBeenCalled();
|
||||
|
||||
store.selectedCategoryId.set('chosen-category');
|
||||
itvSelectedCategoryFromCache.set(true);
|
||||
itvChannels.set(channels);
|
||||
expect(component.channelsForList(blank)).toEqual(channels);
|
||||
});
|
||||
|
||||
it('marks the sidebar copy as the live-channels keyboard pane', () => {
|
||||
// The category list's ArrowRight hand-off looks the pane up by id;
|
||||
// the sidebar copy owns it (the panel copy, stamped with a search
|
||||
// signal, renders none).
|
||||
const container = component.scrollContainers()[0].nativeElement;
|
||||
expect(container.id).toBe('live-channels');
|
||||
expect(container.getAttribute('tabindex')).toBe('0');
|
||||
expect(container.classList.contains('app-scrollbar')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the sidebar alone while its own search is active', async () => {
|
||||
// Only the panel copy fills itself during a sidebar search; the
|
||||
// sidebar has never paged automatically then, and this fixture
|
||||
// renders the sidebar copy only.
|
||||
hasMoreChannels.set(true);
|
||||
searchPhrase.set('one');
|
||||
fixture.detectChanges();
|
||||
await settle();
|
||||
|
||||
expect(store.setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pauses automatic paging while the panel is closed and resumes on reopen', async () => {
|
||||
hasMoreChannels.set(true);
|
||||
searchPhrase.set('sidebar-no-match');
|
||||
fixture.detectChanges();
|
||||
const container = component.scrollContainers()[0].nativeElement;
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'fullscreen-channel-panel';
|
||||
container.parentElement.appendChild(panel);
|
||||
panel.appendChild(container);
|
||||
container.classList.add('fullscreen-channel-list');
|
||||
// Reattach the real listener after placing the stamped list inside
|
||||
// the same persistent aside used by the fullscreen panel.
|
||||
component['setupScrollListener']();
|
||||
component.channelsForList(signal('panel-no-match'));
|
||||
await settle();
|
||||
expect(store.setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
panel.setAttribute('inert', '');
|
||||
page.set(1);
|
||||
itvChannels.set([...channels]);
|
||||
fixture.detectChanges();
|
||||
await settle();
|
||||
container.dispatchEvent(new Event('scroll'));
|
||||
expect(component.isLoadingMore()).toBe(false);
|
||||
expect(store.setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// No new query or page arrives to wake the closed list: opening
|
||||
// alone must resume filling its still-empty search result.
|
||||
panel.removeAttribute('inert');
|
||||
await settle();
|
||||
expect(store.setPage).toHaveBeenCalledTimes(2);
|
||||
expect(store.setPage).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
it('scrolls a blank panel through the cached category independently of sidebar matches', async () => {
|
||||
const category = Array.from({ length: 250 }, (_, index) => ({
|
||||
...channels[0],
|
||||
id: `channel-${index}`,
|
||||
name: `Channel ${index}`,
|
||||
o_name: `Channel ${index}`,
|
||||
}));
|
||||
store.itvFullListActive.set(true);
|
||||
store.itvSelectedCategoryFromCache.set(true);
|
||||
itvChannels.set(category);
|
||||
searchPhrase.set('Channel 249');
|
||||
fixture.detectChanges();
|
||||
await settle();
|
||||
|
||||
const blank = signal('');
|
||||
const container = component.scrollContainers()[0].nativeElement;
|
||||
// Exercise the installed scroll listener with the panel copy's DOM
|
||||
// marker. The sidebar's one matching row has no more items.
|
||||
container.classList.add('fullscreen-channel-list');
|
||||
Object.defineProperties(container, {
|
||||
scrollHeight: { configurable: true, value: 1000 },
|
||||
clientHeight: { configurable: true, value: 200 },
|
||||
});
|
||||
expect(component.hasMoreItems()).toBe(false);
|
||||
expect(component.channelsForList(blank)).toHaveLength(100);
|
||||
|
||||
container.scrollTop = 0;
|
||||
container.dispatchEvent(new Event('scroll'));
|
||||
expect(component.channelsForList(blank)).toHaveLength(100);
|
||||
|
||||
container.scrollTop = 800;
|
||||
container.dispatchEvent(new Event('scroll'));
|
||||
expect(component.channelsForList(blank)).toHaveLength(200);
|
||||
container.dispatchEvent(new Event('scroll'));
|
||||
expect(component.channelsForList(blank)).toEqual(category);
|
||||
container.dispatchEvent(new Event('scroll'));
|
||||
expect(component.channelsForList(blank)).toEqual(category);
|
||||
expect(component.visibleChannels()).toEqual([category[249]]);
|
||||
expect(store.setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+18
@@ -174,9 +174,13 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
const failed = deferred<ResolvedPortalPlayback>();
|
||||
resolveItvPlayback.mockReturnValueOnce(failed.promise);
|
||||
const failedSelection = component.playChannel(channels[1]);
|
||||
expect(selectedItem()).toBe(channels[0]);
|
||||
expect(component.isSelectedChannel(channels[0])).toBe(true);
|
||||
failed.reject(new Error('resolution failed'));
|
||||
await failedSelection;
|
||||
expect(session()).toEqual([firstPlayback, firstKey]);
|
||||
expect(selectedItem()).toBe(channels[0]);
|
||||
expect(component.isSelectedChannel(channels[0])).toBe(true);
|
||||
|
||||
const stale = deferred<ResolvedPortalPlayback>();
|
||||
const current = deferred<ResolvedPortalPlayback>();
|
||||
@@ -185,12 +189,15 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
.mockReturnValueOnce(current.promise);
|
||||
const staleSelection = component.playChannel(channels[0]);
|
||||
const currentSelection = component.playChannel(channels[1]);
|
||||
expect(selectedItem()).toBe(channels[0]);
|
||||
const currentPlayback = {
|
||||
streamUrl: 'https://two.example/live.m3u8',
|
||||
};
|
||||
current.resolve(currentPlayback);
|
||||
stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
|
||||
await Promise.all([staleSelection, currentSelection]);
|
||||
expect(selectedItem()).toBe(channels[1]);
|
||||
expect(component.isSelectedChannel(channels[1])).toBe(true);
|
||||
expect(session()).toEqual([
|
||||
currentPlayback,
|
||||
createPlaybackSessionKey({
|
||||
@@ -294,4 +301,15 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
{ duration: 3000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not overwrite a selection changed outside the pending playback request', async () => {
|
||||
const pending = deferred<ResolvedPortalPlayback>();
|
||||
resolveItvPlayback.mockReturnValueOnce(pending.promise);
|
||||
const request = component.playChannel(channels[0]);
|
||||
store.setSelectedItem(channels[1]);
|
||||
pending.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
|
||||
await request;
|
||||
expect(selectedItem()).toBe(channels[1]);
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
});
|
||||
});
|
||||
+12
@@ -211,3 +211,15 @@
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Stamped into the player's fullscreen channel panel (FULLSCREEN_CHANNEL_PANEL). -->
|
||||
<ng-template #fullscreenChannelPanel let-searchTerm="searchTerm">
|
||||
<app-portal-channels-list
|
||||
[sortMode]="liveChannelSortMode()"
|
||||
[channelsOverride]="fullscreenPanelChannels()"
|
||||
[searchTermInput]="searchTerm()"
|
||||
[fullscreenPanelCopy]="true"
|
||||
(playClicked)="playLive($event)"
|
||||
(playbackRequested)="playLive($event, true)"
|
||||
/>
|
||||
</ng-template>
|
||||
+44
-3
@@ -5,12 +5,15 @@ import {
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
TemplateRef,
|
||||
computed,
|
||||
effect,
|
||||
forwardRef,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { filter } from 'rxjs';
|
||||
@@ -59,6 +62,9 @@ import {
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import {
|
||||
FULLSCREEN_CHANNEL_PANEL,
|
||||
type FullscreenChannelPanelContext,
|
||||
type FullscreenChannelPanelHost,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -75,7 +81,10 @@ import {
|
||||
ResolvedPortalPlayback,
|
||||
toRecordingProgramSnapshot,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import {
|
||||
PortalChannelsListComponent,
|
||||
type XtreamChannelListItem,
|
||||
} from '../portal-channels-list/portal-channels-list.component';
|
||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||
import {
|
||||
RecordingsService,
|
||||
@@ -104,7 +113,15 @@ interface XtreamLiveChannelItem {
|
||||
selector: 'app-live-stream-layout',
|
||||
templateUrl: './live-stream-layout.component.html',
|
||||
styleUrls: ['./live-stream-layout.component.scss'],
|
||||
providers: [LiveStreamAutoOpenStateService],
|
||||
providers: [
|
||||
LiveStreamAutoOpenStateService,
|
||||
// The fullscreen channel panel inside the player renders this
|
||||
// category's channel list (see the `fullscreenChannelPanel` template).
|
||||
{
|
||||
provide: FULLSCREEN_CHANNEL_PANEL,
|
||||
useExisting: forwardRef(() => LiveStreamLayoutComponent),
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
@@ -124,7 +141,9 @@ interface XtreamLiveChannelItem {
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
export class LiveStreamLayoutComponent
|
||||
implements OnInit, OnDestroy, FullscreenChannelPanelHost
|
||||
{
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
@@ -149,6 +168,28 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
|
||||
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
|
||||
readonly liveChannelSortMode = signal<PortalChannelSortMode>('server');
|
||||
|
||||
private readonly fullscreenChannelPanelTemplate =
|
||||
viewChild<TemplateRef<FullscreenChannelPanelContext>>(
|
||||
'fullscreenChannelPanel'
|
||||
);
|
||||
/** FULLSCREEN_CHANNEL_PANEL: the current category's list, unless opted out. */
|
||||
readonly panelTemplate = computed(() =>
|
||||
this.settingsStore.fullscreenChannelPanel?.() === false
|
||||
? null
|
||||
: (this.fullscreenChannelPanelTemplate() ?? null)
|
||||
);
|
||||
readonly panelTitle = computed(
|
||||
() => this.selectedCategoryInfo()?.name ?? ''
|
||||
);
|
||||
/**
|
||||
* The sidebar's rows, handed to the panel's list instance as an override
|
||||
* so that instance never re-applies the route category on init.
|
||||
*/
|
||||
readonly fullscreenPanelChannels = computed(
|
||||
() =>
|
||||
this.xtreamStore.selectItemsFromSelectedCategory() as XtreamChannelListItem[]
|
||||
);
|
||||
readonly isElectron = this.runtime.isElectron;
|
||||
readonly supportsEpg = this.runtime.supportsEpg;
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
} @else if (filteredChannels().length > 0) {
|
||||
<cdk-virtual-scroll-viewport
|
||||
appChannelScrollFocus
|
||||
id="live-channels"
|
||||
[attr.id]="fullscreenPanelCopy() ? null : 'live-channels'"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
|
||||
[itemSize]="channelItemSize"
|
||||
class="scroll-viewport-portals app-scrollbar"
|
||||
|
||||
+73
@@ -15,6 +15,7 @@ import {
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||
import { PortalChannelsListComponent } from './portal-channels-list.component';
|
||||
import { XtreamFavoriteMarksService } from './xtream-favorite-marks.service';
|
||||
|
||||
function buildEpgItem(params: {
|
||||
id: string;
|
||||
@@ -472,6 +473,25 @@ describe('PortalChannelsListComponent', () => {
|
||||
expect(scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('carries the live-channels pane id only outside the fullscreen panel copy', () => {
|
||||
// The category list's ArrowRight hand-off looks the pane up by id,
|
||||
// so the panel's second instance must not duplicate it.
|
||||
selectedTypeContentLoading.set(false);
|
||||
selectedChannels.set([{ title: 'Channel 1', xtream_id: 1 }]);
|
||||
fixture.detectChanges();
|
||||
const viewport = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'cdk-virtual-scroll-viewport'
|
||||
) as HTMLElement;
|
||||
expect(viewport().id).toBe('live-channels');
|
||||
expect(viewport().hasAttribute('tabindex')).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('fullscreenPanelCopy', true);
|
||||
fixture.detectChanges();
|
||||
expect(viewport().hasAttribute('id')).toBe(false);
|
||||
expect(viewport().hasAttribute('tabindex')).toBe(true);
|
||||
});
|
||||
|
||||
it('passes the live content type when toggling a channel favorite', async () => {
|
||||
selectedTypeContentLoading.set(false);
|
||||
currentPlaylist.set({
|
||||
@@ -496,4 +516,57 @@ describe('PortalChannelsListComponent', () => {
|
||||
);
|
||||
expect(fixture.componentInstance.favorites.get('live:253')).toBe(true);
|
||||
});
|
||||
|
||||
it('shares favorite toggles with the other list instances of the playlist', async () => {
|
||||
// The sidebar and the fullscreen channel panel each mount their own
|
||||
// instance; a heart toggled in one must flip in the other too.
|
||||
selectedTypeContentLoading.set(false);
|
||||
currentPlaylist.set({
|
||||
id: 'playlist-1',
|
||||
password: 'secret',
|
||||
serverUrl: 'http://demo.example',
|
||||
username: 'demo',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const marks = TestBed.inject(XtreamFavoriteMarksService);
|
||||
const published: unknown[] = [];
|
||||
marks.changes$.subscribe((change) => published.push(change));
|
||||
|
||||
// A toggle here is announced to every instance.
|
||||
fixture.componentInstance.toggleFavorite(new MouseEvent('click'), {
|
||||
title: 'Cartoon Network',
|
||||
xtream_id: 253,
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(published).toEqual([
|
||||
{ playlistId: 'playlist-1', key: 'live:253', isFavorite: true },
|
||||
]);
|
||||
|
||||
// A toggle announced by another instance lands here …
|
||||
marks.notify({
|
||||
playlistId: 'playlist-1',
|
||||
key: 'live:7',
|
||||
isFavorite: true,
|
||||
});
|
||||
expect(fixture.componentInstance.favorites.get('live:7')).toBe(true);
|
||||
marks.notify({
|
||||
playlistId: 'playlist-1',
|
||||
key: 'live:253',
|
||||
isFavorite: false,
|
||||
});
|
||||
expect(
|
||||
fixture.componentInstance.favorites.get('live:253')
|
||||
).toBeUndefined();
|
||||
|
||||
// … unless it belongs to another playlist.
|
||||
marks.notify({
|
||||
playlistId: 'playlist-2',
|
||||
key: 'live:9',
|
||||
isFavorite: true,
|
||||
});
|
||||
expect(
|
||||
fixture.componentInstance.favorites.get('live:9')
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+31
@@ -50,6 +50,7 @@ import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access';
|
||||
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { XtreamFavoriteMarksService } from './xtream-favorite-marks.service';
|
||||
|
||||
export interface XtreamChannelListItem {
|
||||
readonly category_id?: string | number;
|
||||
@@ -92,9 +93,18 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
readonly sortMode = input<PortalChannelSortMode>('server');
|
||||
readonly channelsOverride = input<XtreamChannelListItem[] | null>(null);
|
||||
readonly searchTermInput = input('');
|
||||
/**
|
||||
* The fullscreen channel panel stamps a second instance of this list
|
||||
* beside the sidebar's. Only the sidebar's pane may carry the
|
||||
* `live-channels` id the category list's ArrowRight/ArrowLeft hand-off
|
||||
* targets (`ChannelScrollFocusDirective`); a duplicate id would be
|
||||
* invalid and could point that hand-off at the hidden copy.
|
||||
*/
|
||||
readonly fullscreenPanelCopy = input(false);
|
||||
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly favoritesService = inject(FavoritesService);
|
||||
private readonly favoriteMarks = inject(XtreamFavoriteMarksService);
|
||||
private readonly epgQueueService = inject(EpgQueueService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
@@ -253,6 +263,22 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
);
|
||||
});
|
||||
});
|
||||
// A toggle in another list instance (the sidebar and the
|
||||
// fullscreen channel panel render this component side by side)
|
||||
// must reach this instance's hearts too.
|
||||
this.subscriptions.add(
|
||||
this.favoriteMarks.changes$.subscribe((change) => {
|
||||
if (change.playlistId !== playlist.id) {
|
||||
return;
|
||||
}
|
||||
if (change.isFavorite) {
|
||||
this.favorites.set(change.key, true);
|
||||
} else {
|
||||
this.favorites.delete(change.key);
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (this.supportsEpg) {
|
||||
@@ -410,6 +436,11 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
this.favorites.delete(favoriteKey);
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
this.favoriteMarks.notify({
|
||||
playlistId,
|
||||
key: favoriteKey,
|
||||
isFavorite: result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
/** One row's favorite mark flipping, as seen by a channel list instance. */
|
||||
export interface XtreamFavoriteMarkChange {
|
||||
readonly playlistId: string;
|
||||
/** `PortalChannelsListComponent.favoriteKeyFor` of the toggled row. */
|
||||
readonly key: string;
|
||||
readonly isFavorite: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays favorite toggles between `PortalChannelsListComponent` instances.
|
||||
*
|
||||
* Each list instance owns the map behind its heart icons and loads it once
|
||||
* on init, and the store's `toggleFavorite` only reports the new state back
|
||||
* to the instance that asked. The fullscreen channel panel mounts a second
|
||||
* instance beside the sidebar, so without this relay a heart toggled in one
|
||||
* list stayed stale in the other until that list was recreated.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class XtreamFavoriteMarksService {
|
||||
private readonly changesSubject = new Subject<XtreamFavoriteMarkChange>();
|
||||
|
||||
/** Every toggle reported by any list instance, own toggles included. */
|
||||
readonly changes$: Observable<XtreamFavoriteMarkChange> =
|
||||
this.changesSubject.asObservable();
|
||||
|
||||
notify(change: XtreamFavoriteMarkChange): void {
|
||||
this.changesSubject.next(change);
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,24 @@ describe('SettingsStore dashboard rail settings', () => {
|
||||
expect(store.getSettings().playerUpNextRail).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults the fullscreen channel panel to true when the stored field is missing', async () => {
|
||||
storedSettings = {};
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.getSettings().fullscreenChannelPanel).toBe(true);
|
||||
});
|
||||
|
||||
it('restores a persisted false fullscreen channel panel preference', async () => {
|
||||
storedSettings = { fullscreenChannelPanel: false };
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.getSettings().fullscreenChannelPanel).toBe(false);
|
||||
});
|
||||
|
||||
it('restores a persisted true strip country prefix preference', async () => {
|
||||
storedSettings = {
|
||||
stripCountryPrefix: true,
|
||||
|
||||
@@ -31,6 +31,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
webPlayerSharedControls: true,
|
||||
playerAmbientMode: false,
|
||||
playerUpNextRail: true,
|
||||
fullscreenChannelPanel: true,
|
||||
vodAutoFailover: false,
|
||||
m3uVodDetails: true,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
@@ -267,6 +268,9 @@ export const SettingsStore = signalStore(
|
||||
playerUpNextRail:
|
||||
store.playerUpNextRail?.() ??
|
||||
DEFAULT_SETTINGS.playerUpNextRail,
|
||||
fullscreenChannelPanel:
|
||||
store.fullscreenChannelPanel?.() ??
|
||||
DEFAULT_SETTINGS.fullscreenChannelPanel,
|
||||
vodAutoFailover:
|
||||
store.vodAutoFailover?.() ??
|
||||
DEFAULT_SETTINGS.vodAutoFailover,
|
||||
|
||||
@@ -150,6 +150,14 @@ export interface Settings {
|
||||
* missing values mean enabled. Only affects the built-in web players.
|
||||
*/
|
||||
playerUpNextRail?: boolean;
|
||||
/**
|
||||
* Slide the live channel list over the video in fullscreen — resting the
|
||||
* mouse on the left edge, tapping that edge on touch, or pressing `C` —
|
||||
* so a channel can be switched without leaving fullscreen. On by
|
||||
* default; missing values mean enabled. Applies to the shared player
|
||||
* controls (HTML5, Video.js, ArtPlayer, Embedded MPV frame-copy).
|
||||
*/
|
||||
fullscreenChannelPanel?: boolean;
|
||||
/**
|
||||
* When a movie fails to play and the same film exists in another imported
|
||||
* playlist, switch to it automatically instead of showing the error.
|
||||
|
||||
+51
-49
@@ -1,53 +1,55 @@
|
||||
<header class="all-channels-header">
|
||||
<div class="all-channels-header__top">
|
||||
<h2>{{ 'CHANNELS.ALL_CHANNELS' | translate }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="all-channels-sort-button"
|
||||
[matMenuTriggerFor]="channelSortMenu"
|
||||
[matTooltip]="'Sort: ' + allChannelsSortLabel()"
|
||||
aria-label="Sort channels"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="all-channels-sidebar-toggle"
|
||||
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
|
||||
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
|
||||
(click)="sidebarToggleRequested.emit()"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (showHeader()) {
|
||||
<header class="all-channels-header">
|
||||
<div class="all-channels-header__top">
|
||||
<h2>{{ 'CHANNELS.ALL_CHANNELS' | translate }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="all-channels-sort-button"
|
||||
[matMenuTriggerFor]="channelSortMenu"
|
||||
[matTooltip]="'Sort: ' + allChannelsSortLabel()"
|
||||
aria-label="Sort channels"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="all-channels-sidebar-toggle"
|
||||
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
|
||||
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
|
||||
(click)="sidebarToggleRequested.emit()"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-menu #channelSortMenu="matMenu">
|
||||
<button mat-menu-item (click)="setAllChannelsSortMode('server')">
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
<span>Playlist Order</span>
|
||||
@if (allChannelsSortMode() === 'server') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setAllChannelsSortMode('name-asc')">
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>Name A-Z</span>
|
||||
@if (allChannelsSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setAllChannelsSortMode('name-desc')">
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>Name Z-A</span>
|
||||
@if (allChannelsSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</header>
|
||||
<div class="all-channels-divider"></div>
|
||||
<mat-menu #channelSortMenu="matMenu">
|
||||
<button mat-menu-item (click)="setAllChannelsSortMode('server')">
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
<span>Playlist Order</span>
|
||||
@if (allChannelsSortMode() === 'server') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setAllChannelsSortMode('name-asc')">
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>Name A-Z</span>
|
||||
@if (allChannelsSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setAllChannelsSortMode('name-desc')">
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>Name Z-A</span>
|
||||
@if (allChannelsSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</header>
|
||||
<div class="all-channels-divider"></div>
|
||||
}
|
||||
|
||||
<div id="all-channels" class="channel-list">
|
||||
@if (filteredChannels().length === 0 && searchTerm()) {
|
||||
|
||||
+36
@@ -191,6 +191,42 @@ describe('AllChannelsViewComponent', () => {
|
||||
).toEqual(['Zulu Vision', 'Middle News', 'Alpha Signal']);
|
||||
});
|
||||
|
||||
it('drops the title/sort/collapse header but keeps the persisted sort without it', () => {
|
||||
fixture.destroy();
|
||||
localStorage.setItem(ALL_CHANNELS_SORT_STORAGE_KEY, 'name-desc');
|
||||
|
||||
fixture = TestBed.createComponent(AllChannelsViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('channels', [
|
||||
createChannel('a', 'Alpha', 'https://example.com/a'),
|
||||
createChannel('z', 'Zulu', 'https://example.com/z'),
|
||||
]);
|
||||
fixture.componentRef.setInput('searchTerm', '');
|
||||
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
|
||||
fixture.componentRef.setInput(
|
||||
'channelIconMap',
|
||||
new Map<string, string>()
|
||||
);
|
||||
fixture.componentRef.setInput('progressTick', 0);
|
||||
fixture.componentRef.setInput('shouldShowEpg', false);
|
||||
fixture.componentRef.setInput('itemSize', 52);
|
||||
fixture.componentRef.setInput('showHeader', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.all-channels-header')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.all-channels-divider')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#all-channels')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
component.filteredChannels().map((channel) => channel.name)
|
||||
).toEqual(['Zulu', 'Alpha']);
|
||||
});
|
||||
|
||||
it('emits sidebar toggle requests from the inline header action', () => {
|
||||
const sidebarToggleRequested = jest.fn();
|
||||
component.sidebarToggleRequested.subscribe(sidebarToggleRequested);
|
||||
|
||||
+6
@@ -70,6 +70,12 @@ export class AllChannelsViewComponent {
|
||||
/** All channels (will be filtered by search) */
|
||||
readonly channels = input.required<Channel[]>();
|
||||
readonly searchTerm = input('');
|
||||
/**
|
||||
* The title / sort / collapse header. Off inside the fullscreen channel
|
||||
* panel, whose own chrome names the view; the persisted sort still
|
||||
* applies.
|
||||
*/
|
||||
readonly showHeader = input(true);
|
||||
|
||||
/** EPG map for channel enrichment */
|
||||
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
|
||||
|
||||
+9
-1
@@ -4,7 +4,12 @@
|
||||
[showEpg]="shouldShowEpg()"
|
||||
/>
|
||||
} @else if (displayedChannels().length) {
|
||||
@if (viewTitle() && activeView() !== 'groups' && activeView() !== 'all') {
|
||||
@if (
|
||||
!compact() &&
|
||||
viewTitle() &&
|
||||
activeView() !== 'groups' &&
|
||||
activeView() !== 'all'
|
||||
) {
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>{{ viewTitle() | translate }}</h2>
|
||||
@@ -28,6 +33,7 @@
|
||||
<app-all-channels-view
|
||||
[channels]="displayedChannels()"
|
||||
[searchTerm]="workspaceSearchTerm()"
|
||||
[showHeader]="!compact()"
|
||||
[channelEpgMap]="channelEpgMap()"
|
||||
[channelIconMap]="channelIconMap()"
|
||||
[progressTick]="progressTick()"
|
||||
@@ -46,6 +52,8 @@
|
||||
<app-groups-view
|
||||
[groupedChannels]="groupedChannels()"
|
||||
[searchTerm]="workspaceSearchTerm()"
|
||||
[showHeader]="!compact()"
|
||||
[compact]="compact()"
|
||||
[channelEpgMap]="channelEpgMap()"
|
||||
[channelIconMap]="channelIconMap()"
|
||||
[progressTick]="progressTick()"
|
||||
|
||||
+23
@@ -205,6 +205,29 @@ describe('ChannelListContainerComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the active channel on destroy when the host opts out (fullscreen panel copy)', () => {
|
||||
fixture.componentRef.setInput('resetActiveChannelOnDestroy', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith(
|
||||
ChannelActions.resetActiveChannel()
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers a host-supplied search term over the route query parameter', () => {
|
||||
fixture.componentRef.setInput('searchTerm', ' News ');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.workspaceSearchTerm()).toBe('news');
|
||||
|
||||
fixture.componentRef.setInput('searchTerm', null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.workspaceSearchTerm()).toBe('');
|
||||
});
|
||||
|
||||
it('does not enable EPG rows when runtime EPG support is unavailable', () => {
|
||||
runtimeCapabilities.supportsEpg = false;
|
||||
storageGet.mockReturnValue(
|
||||
|
||||
+31
-4
@@ -187,6 +187,27 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
readonly channelsLoading = input(false);
|
||||
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
|
||||
readonly sidebarWidth = input<number | null>(null);
|
||||
/**
|
||||
* Search term supplied by the host instead of the workspace header's
|
||||
* `?q=` query parameter — the fullscreen channel panel has its own field
|
||||
* because the header is not reachable in fullscreen. `null` keeps the
|
||||
* route-driven term.
|
||||
*/
|
||||
readonly searchTerm = input<string | null>(null);
|
||||
/**
|
||||
* Drops the per-view chrome (the favorites/recent context header and the
|
||||
* all-channels/groups title, sort and collapse headers). The fullscreen
|
||||
* channel panel picks the view with its own switcher and owns the search
|
||||
* row, so those headers would only stack under it. Sorting still follows
|
||||
* the sidebar's persisted choice.
|
||||
*/
|
||||
readonly compact = input(false);
|
||||
/**
|
||||
* A second list instance beside the sidebar (the fullscreen channel
|
||||
* panel) must not stop playback when it unmounts; only the page's own
|
||||
* list owns the active channel.
|
||||
*/
|
||||
readonly resetActiveChannelOnDestroy = input(true);
|
||||
readonly sidebarWidthRequested = output<number>();
|
||||
readonly sidebarWidthRequestEnded = output<number>();
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
@@ -196,9 +217,13 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
'q',
|
||||
(value) => (value ?? '').trim().toLowerCase()
|
||||
);
|
||||
readonly workspaceSearchTerm = computed(() =>
|
||||
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
|
||||
);
|
||||
readonly workspaceSearchTerm = computed(() => {
|
||||
const hostTerm = this.searchTerm();
|
||||
if (hostTerm !== null) {
|
||||
return hostTerm.trim().toLowerCase();
|
||||
}
|
||||
return this.isWorkspaceLayout ? this.routeSearchTerm() : '';
|
||||
});
|
||||
|
||||
readonly currentUrl = toSignal(
|
||||
this.router.events.pipe(
|
||||
@@ -406,7 +431,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.store.dispatch(ChannelActions.resetActiveChannel());
|
||||
if (this.resetActiveChannelOnDestroy()) {
|
||||
this.store.dispatch(ChannelActions.resetActiveChannel());
|
||||
}
|
||||
|
||||
if (this.epgRefreshInterval) {
|
||||
clearInterval(this.epgRefreshInterval);
|
||||
|
||||
+78
-73
@@ -24,11 +24,12 @@
|
||||
<div class="groups-view-layout">
|
||||
<aside
|
||||
class="groups-nav-panel"
|
||||
[class.groups-nav-panel--compact]="compact()"
|
||||
appResizable
|
||||
[minWidth]="164"
|
||||
[maxWidth]="320"
|
||||
[defaultWidth]="208"
|
||||
storageKey="m3u-groups-nav-width"
|
||||
[minWidth]="navMinWidth()"
|
||||
[maxWidth]="navMaxWidth()"
|
||||
[defaultWidth]="navDefaultWidth()"
|
||||
[storageKey]="navStorageKey()"
|
||||
(resizeStart)="onGroupsNavResizeStart()"
|
||||
(widthChange)="onGroupsNavWidthChange($event)"
|
||||
(resizeEnd)="onGroupsNavResizeEnd($event)"
|
||||
@@ -172,75 +173,77 @@
|
||||
|
||||
<section class="sidebar groups-content-panel">
|
||||
@if (selectedGroup(); as group) {
|
||||
<header class="sidebar-header groups-content-header">
|
||||
<div class="category-meta category-meta--stacked">
|
||||
<h2 class="category-title">
|
||||
@if (group.key) {
|
||||
{{ group.key | titlecase }}
|
||||
} @else {
|
||||
{{ 'CHANNELS.UNGROUPED' | translate }}
|
||||
}
|
||||
</h2>
|
||||
<span class="category-subtitle">
|
||||
{{ group.count }}
|
||||
{{ group.count === 1 ? 'item' : 'items' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="channelSortMenu"
|
||||
[matTooltip]="'Sort: ' + groupChannelSortLabel()"
|
||||
aria-label="Sort channels"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="groups-sidebar-toggle"
|
||||
[attr.aria-label]="
|
||||
'LAYOUT.HIDE_CHANNELS_LIST' | translate
|
||||
"
|
||||
[matTooltip]="
|
||||
'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate
|
||||
"
|
||||
(click)="sidebarToggleRequested.emit()"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<mat-menu #channelSortMenu="matMenu">
|
||||
@if (showHeader()) {
|
||||
<header class="sidebar-header groups-content-header">
|
||||
<div class="category-meta category-meta--stacked">
|
||||
<h2 class="category-title">
|
||||
@if (group.key) {
|
||||
{{ group.key | titlecase }}
|
||||
} @else {
|
||||
{{ 'CHANNELS.UNGROUPED' | translate }}
|
||||
}
|
||||
</h2>
|
||||
<span class="category-subtitle">
|
||||
{{ group.count }}
|
||||
{{ group.count === 1 ? 'item' : 'items' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setGroupChannelSortMode('server')"
|
||||
>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
<span>Playlist Order</span>
|
||||
@if (groupChannelSortMode() === 'server') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setGroupChannelSortMode('name-asc')"
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="channelSortMenu"
|
||||
[matTooltip]="'Sort: ' + groupChannelSortLabel()"
|
||||
aria-label="Sort channels"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>Name A-Z</span>
|
||||
@if (groupChannelSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setGroupChannelSortMode('name-desc')"
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="groups-sidebar-toggle"
|
||||
[attr.aria-label]="
|
||||
'LAYOUT.HIDE_CHANNELS_LIST' | translate
|
||||
"
|
||||
[matTooltip]="
|
||||
'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate
|
||||
"
|
||||
(click)="sidebarToggleRequested.emit()"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>Name Z-A</span>
|
||||
@if (groupChannelSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</header>
|
||||
<mat-menu #channelSortMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setGroupChannelSortMode('server')"
|
||||
>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
<span>Playlist Order</span>
|
||||
@if (groupChannelSortMode() === 'server') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setGroupChannelSortMode('name-asc')"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>Name A-Z</span>
|
||||
@if (groupChannelSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setGroupChannelSortMode('name-desc')"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>Name Z-A</span>
|
||||
@if (groupChannelSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</header>
|
||||
}
|
||||
|
||||
<cdk-virtual-scroll-viewport
|
||||
appChannelScrollFocus
|
||||
@@ -292,13 +295,15 @@
|
||||
/>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
} @else {
|
||||
<header class="sidebar-header groups-content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">
|
||||
{{ 'CHANNELS.GROUPS' | translate }}
|
||||
</h2>
|
||||
</div>
|
||||
</header>
|
||||
@if (showHeader()) {
|
||||
<header class="sidebar-header groups-content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">
|
||||
{{ 'CHANNELS.GROUPS' | translate }}
|
||||
</h2>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
<div class="groups-content-empty-state">
|
||||
<mat-icon class="empty-state-icon">
|
||||
|
||||
+14
@@ -33,6 +33,20 @@
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
// Fullscreen channel panel: the rail is pinned (min = max, see the
|
||||
// component's `compact` input), so the directive's handle would only show a
|
||||
// grip that cannot move. It is appended by the directive, outside this
|
||||
// component's encapsulation scope.
|
||||
.groups-nav-panel--compact {
|
||||
min-width: 148px;
|
||||
max-width: 148px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.groups-nav-panel--compact ::ng-deep .resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.groups-nav-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
+49
@@ -390,6 +390,39 @@ describe('GroupsViewComponent', () => {
|
||||
expect(component.selectedGroupKey()).toBe('Series');
|
||||
});
|
||||
|
||||
it('pins the groups rail and leaves the sidebar width alone in compact mode', () => {
|
||||
// Inside the 400px fullscreen panel a rail the user once dragged to
|
||||
// 320px would leave the channel pane unusable, so the panel instance
|
||||
// neither reads nor writes the sidebar's persisted width.
|
||||
localStorage.setItem('m3u-groups-nav-width', '320');
|
||||
// The rail is sized once, when the resize directive initialises, so
|
||||
// the panel instance is created compact from the start — exactly as
|
||||
// the container binds it.
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(GroupsViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('compact', true);
|
||||
setInputs();
|
||||
const sidebarWidthRequested = jest.fn();
|
||||
component.sidebarWidthRequested.subscribe(sidebarWidthRequested);
|
||||
|
||||
const rail = fixture.nativeElement.querySelector(
|
||||
'aside.groups-nav-panel'
|
||||
) as HTMLElement;
|
||||
expect(rail.classList.contains('groups-nav-panel--compact')).toBe(
|
||||
true
|
||||
);
|
||||
expect(rail.style.width).toBe('148px');
|
||||
expect(localStorage.getItem('m3u-groups-nav-width')).toBe('320');
|
||||
|
||||
component.onGroupsNavWidthChange(300);
|
||||
component.onGroupsNavResizeEnd(300);
|
||||
expect(sidebarWidthRequested).not.toHaveBeenCalled();
|
||||
|
||||
localStorage.removeItem('m3u-groups-nav-width');
|
||||
localStorage.removeItem('m3u-groups-nav-width-fullscreen');
|
||||
});
|
||||
|
||||
it('keeps group selection behavior unchanged when channel sort mode changes', () => {
|
||||
component.setGroupChannelSortMode('name-asc');
|
||||
component.selectGroup('Movies');
|
||||
@@ -481,6 +514,22 @@ describe('GroupsViewComponent', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the selected-group header in compact mode but keeps the groups rail header', () => {
|
||||
fixture.componentRef.setInput('showHeader', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedGroupKey()).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.groups-content-header')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.groups-nav-header')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.groups-channels-viewport')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('toggles the inline group search from the header action and filters the visible groups', () => {
|
||||
const searchButton = fixture.nativeElement.querySelector(
|
||||
'.groups-nav-action--search'
|
||||
|
||||
+35
@@ -47,6 +47,8 @@ import {
|
||||
} from './group-management-dialog/group-management-dialog.component';
|
||||
|
||||
const GROUP_CHANNEL_SORT_STORAGE_KEY = 'm3u-groups-channel-sort-mode';
|
||||
/** Rail width inside the fullscreen channel panel; see the `compact` input. */
|
||||
const COMPACT_GROUPS_NAV_WIDTH = 148;
|
||||
|
||||
interface GroupView {
|
||||
readonly channels: Channel[];
|
||||
@@ -94,6 +96,35 @@ export class GroupsViewComponent {
|
||||
/** Grouped channels object */
|
||||
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
|
||||
readonly searchTerm = input('');
|
||||
/**
|
||||
* The selected group's title / sort / collapse header over the channel
|
||||
* pane. Off inside the fullscreen channel panel; the groups rail keeps
|
||||
* its own header since it carries the group search.
|
||||
*/
|
||||
readonly showHeader = input(true);
|
||||
/**
|
||||
* Inside the fullscreen channel panel (≤400px wide): the groups rail is
|
||||
* pinned to {@link COMPACT_GROUPS_NAV_WIDTH} with no resize handle, and
|
||||
* the sidebar's persisted `m3u-groups-nav-width` — up to 320px — is
|
||||
* neither read nor written, since it would leave the channel pane a few
|
||||
* dozen pixels wide.
|
||||
*/
|
||||
readonly compact = input(false);
|
||||
readonly navMinWidth = computed(() =>
|
||||
this.compact() ? COMPACT_GROUPS_NAV_WIDTH : 164
|
||||
);
|
||||
readonly navMaxWidth = computed(() =>
|
||||
this.compact() ? COMPACT_GROUPS_NAV_WIDTH : 320
|
||||
);
|
||||
readonly navDefaultWidth = computed(() =>
|
||||
this.compact() ? COMPACT_GROUPS_NAV_WIDTH : 208
|
||||
);
|
||||
/** A key of its own, so the pinned width never overwrites the sidebar's. */
|
||||
readonly navStorageKey = computed(() =>
|
||||
this.compact()
|
||||
? 'm3u-groups-nav-width-fullscreen'
|
||||
: 'm3u-groups-nav-width'
|
||||
);
|
||||
|
||||
/** EPG map for channel enrichment */
|
||||
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
|
||||
@@ -459,14 +490,18 @@ export class GroupsViewComponent {
|
||||
}
|
||||
|
||||
onGroupsNavResizeStart(): void {
|
||||
if (this.compact()) return;
|
||||
this.preservedContentWidth = this.measureContentPanelWidth();
|
||||
}
|
||||
|
||||
onGroupsNavWidthChange(width: number): void {
|
||||
// The pinned panel rail must never ask the page's sidebar to resize.
|
||||
if (this.compact()) return;
|
||||
this.emitSidebarWidthRequest(width, this.sidebarWidthRequested);
|
||||
}
|
||||
|
||||
onGroupsNavResizeEnd(width: number): void {
|
||||
if (this.compact()) return;
|
||||
this.emitSidebarWidthRequest(width, this.sidebarWidthRequestEnded);
|
||||
this.preservedContentWidth = 0;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service
|
||||
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/html-video-player/html-video-player.component';
|
||||
export * from '@iptvnator/playback/util';
|
||||
export * from './lib/player-controls';
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
CHANNEL_PANEL_CLOSE_GRACE_MS,
|
||||
CHANNEL_PANEL_OPEN_DWELL_MS,
|
||||
FullscreenChannelPanelState,
|
||||
} from './fullscreen-channel-panel-state';
|
||||
|
||||
describe('FullscreenChannelPanelState', () => {
|
||||
let state: FullscreenChannelPanelState;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
state = new FullscreenChannelPanelState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.dispose();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('opens once the mouse has rested in the hot zone', () => {
|
||||
state.hotZoneEnter();
|
||||
expect(state.open()).toBe(false);
|
||||
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS - 1);
|
||||
expect(state.open()).toBe(false);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(state.open()).toBe(true);
|
||||
expect(state.mounted()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not open when the mouse sweeps through the hot zone', () => {
|
||||
state.hotZoneEnter();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS - 1);
|
||||
state.hotZoneLeave();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
|
||||
expect(state.open()).toBe(false);
|
||||
expect(state.mounted()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes after the grace period once the mouse leaves the panel', () => {
|
||||
state.show();
|
||||
state.panelLeave();
|
||||
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS - 1);
|
||||
expect(state.open()).toBe(true);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(state.open()).toBe(false);
|
||||
// The list stays mounted so scroll position and search survive.
|
||||
expect(state.mounted()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the panel open when the mouse comes back before the grace period ends', () => {
|
||||
state.show();
|
||||
state.panelLeave();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS - 1);
|
||||
state.panelEnter();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
|
||||
expect(state.open()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a panel leave while the panel is closed', () => {
|
||||
state.panelLeave();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
|
||||
expect(state.open()).toBe(false);
|
||||
expect(state.mounted()).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles between open and closed', () => {
|
||||
state.toggle();
|
||||
expect(state.open()).toBe(true);
|
||||
state.toggle();
|
||||
expect(state.open()).toBe(false);
|
||||
expect(state.mounted()).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels a pending hover open when hidden explicitly', () => {
|
||||
state.hotZoneEnter();
|
||||
state.hide();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
|
||||
expect(state.open()).toBe(false);
|
||||
});
|
||||
|
||||
it('reset forgets the mounted list and every pending timer', () => {
|
||||
state.show();
|
||||
state.panelLeave();
|
||||
state.reset();
|
||||
|
||||
expect(state.open()).toBe(false);
|
||||
expect(state.mounted()).toBe(false);
|
||||
|
||||
state.hotZoneEnter();
|
||||
state.reset();
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
expect(state.open()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* How long the mouse must rest in the edge hot zone before the panel slides
|
||||
* in. A cursor sweeping across the left edge on its way somewhere else must
|
||||
* not flip the panel open and shut.
|
||||
*/
|
||||
export const CHANNEL_PANEL_OPEN_DWELL_MS = 160;
|
||||
|
||||
/**
|
||||
* Grace period after the mouse leaves the panel before it closes. Brushing
|
||||
* past the panel's edge while scrolling the list must not slam it shut.
|
||||
*/
|
||||
export const CHANNEL_PANEL_CLOSE_GRACE_MS = 420;
|
||||
|
||||
/**
|
||||
* Framework-light owner of the fullscreen channel panel's open/closed state
|
||||
* and the hover-intent timers around it. The component binds pointer events
|
||||
* to these methods; the timers here are the only place that decides when a
|
||||
* hover becomes an open or a close.
|
||||
*/
|
||||
export class FullscreenChannelPanelState {
|
||||
readonly open = signal(false);
|
||||
/**
|
||||
* True from the first open until fullscreen ends. The host template stays
|
||||
* mounted while the panel is merely closed so the list keeps its scroll
|
||||
* position and search text between two openings of the same session.
|
||||
*/
|
||||
readonly mounted = signal(false);
|
||||
|
||||
private openTimer: number | null = null;
|
||||
private closeTimer: number | null = null;
|
||||
|
||||
show(): void {
|
||||
this.clearTimers();
|
||||
this.mounted.set(true);
|
||||
this.open.set(true);
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.clearTimers();
|
||||
this.open.set(false);
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (this.open()) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
/** Mouse entered the edge hot zone: open once it has rested there. */
|
||||
hotZoneEnter(): void {
|
||||
if (this.open()) {
|
||||
return;
|
||||
}
|
||||
this.clearOpenTimer();
|
||||
this.openTimer = window.setTimeout(() => {
|
||||
this.openTimer = null;
|
||||
this.show();
|
||||
}, CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
}
|
||||
|
||||
hotZoneLeave(): void {
|
||||
this.clearOpenTimer();
|
||||
}
|
||||
|
||||
/** Mouse is over the panel: a pending close no longer applies. */
|
||||
panelEnter(): void {
|
||||
this.clearCloseTimer();
|
||||
}
|
||||
|
||||
/** Mouse left the panel: close after the grace period. */
|
||||
panelLeave(): void {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
this.clearCloseTimer();
|
||||
this.closeTimer = window.setTimeout(() => {
|
||||
this.closeTimer = null;
|
||||
this.open.set(false);
|
||||
}, CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
}
|
||||
|
||||
/** Fullscreen ended: forget everything, including the mounted list. */
|
||||
reset(): void {
|
||||
this.clearTimers();
|
||||
this.open.set(false);
|
||||
this.mounted.set(false);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearTimers();
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
this.clearOpenTimer();
|
||||
this.clearCloseTimer();
|
||||
}
|
||||
|
||||
private clearOpenTimer(): void {
|
||||
if (this.openTimer !== null) {
|
||||
window.clearTimeout(this.openTimer);
|
||||
this.openTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearCloseTimer(): void {
|
||||
if (this.closeTimer !== null) {
|
||||
window.clearTimeout(this.closeTimer);
|
||||
this.closeTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
@if (active()) {
|
||||
@if (!state.open()) {
|
||||
<!-- Invisible: the only closed-state affordance. Mouse dwell or a
|
||||
touch tap on the left edge opens the panel. -->
|
||||
<div
|
||||
class="fullscreen-channel-panel__hot-zone"
|
||||
data-test-id="fullscreen-channel-panel-hot-zone"
|
||||
aria-hidden="true"
|
||||
(pointerenter)="onHotZoneEnter($event)"
|
||||
(pointerleave)="state.hotZoneLeave()"
|
||||
(pointerup)="onHotZonePointerUp($event)"
|
||||
></div>
|
||||
} @else {
|
||||
<!-- Swallows the click that dismisses the panel so the player's
|
||||
click-to-pause never sees it. -->
|
||||
<div
|
||||
class="fullscreen-channel-panel__scrim"
|
||||
data-test-id="fullscreen-channel-panel-scrim"
|
||||
aria-hidden="true"
|
||||
(click)="state.hide()"
|
||||
></div>
|
||||
}
|
||||
|
||||
<aside
|
||||
#panel
|
||||
class="fullscreen-channel-panel dark-theme"
|
||||
[class.fullscreen-channel-panel--open]="state.open()"
|
||||
role="dialog"
|
||||
[attr.aria-label]="
|
||||
panelTitle() || ('EMBEDDED_MPV.PLAYER.CHANNEL_LIST' | translate)
|
||||
"
|
||||
[attr.aria-hidden]="!state.open()"
|
||||
[attr.inert]="state.open() ? null : ''"
|
||||
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
|
||||
>
|
||||
<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)'
|
||||
"
|
||||
(click)="state.hide()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="fullscreen-channel-panel__body">
|
||||
@if (state.mounted() && template(); as template) {
|
||||
<ng-container *ngTemplateOutlet="template; context: context" />
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
// Global styles on purpose (ViewEncapsulation.None): the host page's list is
|
||||
// stamped into the panel body through an ng-template, so its nodes carry the
|
||||
// host's scoping attribute and an emulated `.body > *` rule could never reach
|
||||
// them. Every selector is prefixed with `fullscreen-channel-panel`.
|
||||
|
||||
.fullscreen-channel-panel-host {
|
||||
// Never take layout space beside the engine; every child is absolutely
|
||||
// positioned inside the `.web-player-view` box.
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// ─── Closed state ────────────────────────────────────────────────────────────
|
||||
|
||||
// Invisible by design: nothing sits over the video until the mouse rests here.
|
||||
// It stops well above the shared controls bar (up to ~130px tall in its
|
||||
// two-row layout), whose leftmost button starts 16px from the edge — the
|
||||
// strip must never intercept a click or tap meant for play/pause.
|
||||
.fullscreen-channel-panel__hot-zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: max(25%, 140px);
|
||||
left: 0;
|
||||
z-index: 6;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
// ─── Open state ──────────────────────────────────────────────────────────────
|
||||
|
||||
.fullscreen-channel-panel__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(400px, calc(100% - 56px));
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 18px 0 48px rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform 240ms cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
visibility 0s linear 240ms;
|
||||
}
|
||||
|
||||
// Translucent graphite over the video, matching the controls' popovers. The
|
||||
// panel carries `dark-theme` for its tokens, and the app's global
|
||||
// `.dark-theme { background: var(--mat-sys-surface) !important }`
|
||||
// (apps/web/src/styles.scss) would replace this gradient with the flat
|
||||
// surface — or with no background at all where that token is not defined —
|
||||
// so the compound selector and `!important` are required to out-rank it.
|
||||
.fullscreen-channel-panel.dark-theme {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(13, 15, 18, 0.94),
|
||||
rgba(13, 15, 18, 0.88)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel--open {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
transition:
|
||||
transform 240ms cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
// ─── Header: one row, search + close ─────────────────────────────────────────
|
||||
|
||||
.fullscreen-channel-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 6px 10px 12px;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 6px 0 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
background-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search:focus-within {
|
||||
border-color: var(--app-selection-color, #78adff);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search-input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search-clear:hover,
|
||||
.fullscreen-channel-panel__search-clear:focus-visible {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search-clear mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__close {
|
||||
flex-shrink: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
--mdc-icon-button-icon-color: currentColor;
|
||||
--mat-icon-button-icon-color: currentColor;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__close:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
// ─── Body ────────────────────────────────────────────────────────────────────
|
||||
|
||||
.fullscreen-channel-panel__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
// The stamped host list is the single scroll owner of the body.
|
||||
.fullscreen-channel-panel__body > * {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// Sidebar hosts draw their own right border; the panel already has one.
|
||||
.fullscreen-channel-panel__body > app-portal-channels-list {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
// ─── Adaptations ─────────────────────────────────────────────────────────────
|
||||
|
||||
@media (pointer: coarse) {
|
||||
// A finger needs a wider target than a resting mouse.
|
||||
.fullscreen-channel-panel__hot-zone {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 560px) {
|
||||
.fullscreen-channel-panel {
|
||||
width: calc(100% - 44px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fullscreen-channel-panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
import {
|
||||
Component,
|
||||
TemplateRef,
|
||||
computed,
|
||||
forwardRef,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
CHANNEL_PANEL_CLOSE_GRACE_MS,
|
||||
CHANNEL_PANEL_OPEN_DWELL_MS,
|
||||
} from './fullscreen-channel-panel-state';
|
||||
import { FullscreenChannelPanelComponent } from './fullscreen-channel-panel.component';
|
||||
import {
|
||||
FULLSCREEN_CHANNEL_PANEL,
|
||||
type FullscreenChannelPanelContext,
|
||||
type FullscreenChannelPanelHost,
|
||||
} from './fullscreen-channel-panel.model';
|
||||
|
||||
@Component({
|
||||
imports: [FullscreenChannelPanelComponent],
|
||||
template: `
|
||||
<div #stage class="stage">
|
||||
<app-fullscreen-channel-panel
|
||||
[stage]="stage"
|
||||
[enabled]="panelEnabled()"
|
||||
/>
|
||||
</div>
|
||||
<ng-template #panel let-searchTerm="searchTerm" let-close="close">
|
||||
<div data-test-id="host-list">{{ searchTerm() }}</div>
|
||||
<button type="button" data-test-id="host-close" (click)="close()">
|
||||
close
|
||||
</button>
|
||||
</ng-template>
|
||||
`,
|
||||
providers: [
|
||||
{
|
||||
provide: FULLSCREEN_CHANNEL_PANEL,
|
||||
useExisting: forwardRef(() => HostComponent),
|
||||
},
|
||||
],
|
||||
})
|
||||
class HostComponent implements FullscreenChannelPanelHost {
|
||||
private readonly panelRef =
|
||||
viewChild<TemplateRef<FullscreenChannelPanelContext>>('panel');
|
||||
readonly enabled = signal(true);
|
||||
readonly panelEnabled = signal(true);
|
||||
readonly panelTemplate = computed(() =>
|
||||
this.enabled() ? (this.panelRef() ?? null) : null
|
||||
);
|
||||
readonly panelTitle = signal('Playlist One');
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [FullscreenChannelPanelComponent],
|
||||
template: `
|
||||
<div #stage class="stage">
|
||||
<app-fullscreen-channel-panel [stage]="stage" />
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
class NoHostComponent {}
|
||||
|
||||
function pointerEvent(
|
||||
type: string,
|
||||
pointerType = 'mouse',
|
||||
init: { relatedTarget?: EventTarget | null } = {}
|
||||
): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'pointerType', { value: pointerType });
|
||||
Object.defineProperty(event, 'relatedTarget', {
|
||||
value: init.relatedTarget ?? null,
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('FullscreenChannelPanelComponent', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let host: HostComponent;
|
||||
let stage: HTMLElement;
|
||||
let fullscreenElement: Element | null;
|
||||
|
||||
const query = <T extends HTMLElement>(testId: string): T | null =>
|
||||
fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`);
|
||||
const panel = () => query('fullscreen-channel-panel');
|
||||
const hotZone = () => query('fullscreen-channel-panel-hot-zone');
|
||||
const isOpen = () =>
|
||||
panel()?.classList.contains('fullscreen-channel-panel--open') === true;
|
||||
|
||||
const setFullscreen = (element: Element | null) => {
|
||||
fullscreenElement = element;
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
const openByHover = () => {
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerenter'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
HostComponent,
|
||||
NoHostComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fullscreenElement = null;
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => fullscreenElement,
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
host = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
stage = fixture.nativeElement.querySelector('.stage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders nothing outside fullscreen', () => {
|
||||
expect(hotZone()).toBeNull();
|
||||
expect(panel()).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing without a host list, even in fullscreen', () => {
|
||||
fixture.destroy();
|
||||
const noHost = TestBed.createComponent(NoHostComponent);
|
||||
noHost.detectChanges();
|
||||
fullscreenElement = noHost.nativeElement.querySelector('.stage');
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
noHost.detectChanges();
|
||||
|
||||
expect(
|
||||
noHost.nativeElement.querySelector(
|
||||
'[data-test-id="fullscreen-channel-panel-hot-zone"]'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
noHost.nativeElement.querySelector(
|
||||
'[data-test-id="fullscreen-channel-panel"]'
|
||||
)
|
||||
).toBeNull();
|
||||
noHost.destroy();
|
||||
});
|
||||
|
||||
it('renders nothing when the host disables the panel', () => {
|
||||
host.enabled.set(false);
|
||||
setFullscreen(stage);
|
||||
|
||||
expect(hotZone()).toBeNull();
|
||||
expect(panel()).toBeNull();
|
||||
});
|
||||
|
||||
it('withdraws everything while the view disables it (native-view Embedded MPV)', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
host.panelEnabled.set(false);
|
||||
fixture.detectChanges();
|
||||
expect(panel()).toBeNull();
|
||||
expect(hotZone()).toBeNull();
|
||||
|
||||
host.panelEnabled.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(hotZone()).not.toBeNull();
|
||||
expect(query('host-list')).toBeNull();
|
||||
});
|
||||
|
||||
describe('CDK overlays opened from the list', () => {
|
||||
let overlay: HTMLElement;
|
||||
let pane: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
overlay = TestBed.inject(OverlayContainer).getContainerElement();
|
||||
pane = document.createElement('div');
|
||||
pane.className = 'cdk-overlay-pane';
|
||||
overlay.appendChild(pane);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pane.remove();
|
||||
});
|
||||
|
||||
it('keeps the panel open while the pointer is over the overlay and closes once it leaves it', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
|
||||
// Straight from the list into the menu: no close is scheduled.
|
||||
panel()?.dispatchEvent(
|
||||
pointerEvent('pointerleave', 'mouse', { relatedTarget: pane })
|
||||
);
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
// A pending close is cancelled by reaching the menu.
|
||||
panel()?.dispatchEvent(pointerEvent('pointerleave'));
|
||||
pane.dispatchEvent(pointerEvent('pointerover'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
// Leaving the menu for the video closes after the grace period.
|
||||
document.body.dispatchEvent(pointerEvent('pointerover'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS - 1);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
jest.advanceTimersByTime(1);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves Escape to an open menu and closes on the next press', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'cdk-overlay-backdrop';
|
||||
overlay.appendChild(backdrop);
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
backdrop.remove();
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('draws nothing over the video while closed: only the hot zone beside the inert, off-screen panel', () => {
|
||||
setFullscreen(stage);
|
||||
|
||||
const children = Array.from(
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'.fullscreen-channel-panel-host'
|
||||
) as HTMLElement
|
||||
).children
|
||||
);
|
||||
expect(children.map((child) => child.tagName.toLowerCase())).toEqual([
|
||||
'div',
|
||||
'aside',
|
||||
]);
|
||||
expect(children[0]).toBe(hotZone());
|
||||
expect(hotZone()?.textContent?.trim()).toBe('');
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(panel()?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(panel()?.hasAttribute('inert')).toBe(true);
|
||||
expect(query('host-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('slides in after the mouse rests on the left edge and stamps the host list', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
|
||||
expect(isOpen()).toBe(true);
|
||||
expect(query('host-list')).not.toBeNull();
|
||||
expect(query('fullscreen-channel-panel-scrim')).not.toBeNull();
|
||||
expect(hotZone()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not open for a mouse that merely sweeps across the edge', () => {
|
||||
setFullscreen(stage);
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerenter'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS - 1);
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerleave'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('opens on a touch tap on the edge without any dwell', () => {
|
||||
setFullscreen(stage);
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerenter', 'touch'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerup', 'touch'));
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('puts the host title into the search placeholder and the dialog label instead of a title row', () => {
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation(
|
||||
'en',
|
||||
{
|
||||
EMBEDDED_MPV: {
|
||||
PLAYER: {
|
||||
SEARCH_IN: 'Search in {{title}}',
|
||||
SEARCH_CHANNELS: 'Search channels',
|
||||
},
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
translate.use('en');
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
|
||||
const input = query<HTMLInputElement>(
|
||||
'fullscreen-channel-panel-search'
|
||||
);
|
||||
expect(input?.placeholder).toBe('Search in Playlist One');
|
||||
expect(panel()?.getAttribute('aria-label')).toBe('Playlist One');
|
||||
expect(panel()?.querySelector('h1, h2, h3')).toBeNull();
|
||||
|
||||
host.panelTitle.set('');
|
||||
fixture.detectChanges();
|
||||
expect(input?.placeholder).toBe('Search channels');
|
||||
});
|
||||
|
||||
it('feeds the search field into the host template context', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
|
||||
const input = query<HTMLInputElement>(
|
||||
'fullscreen-channel-panel-search'
|
||||
);
|
||||
input!.value = 'news';
|
||||
input!.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('host-list')?.textContent?.trim()).toBe('news');
|
||||
|
||||
query('fullscreen-channel-panel-search-clear')?.click();
|
||||
fixture.detectChanges();
|
||||
expect(query('host-list')?.textContent?.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('closes on the scrim but keeps the list mounted for the next opening', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
query('fullscreen-channel-panel-scrim')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(query('host-list')).not.toBeNull();
|
||||
expect(hotZone()).not.toBeNull();
|
||||
|
||||
openByHover();
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('closes from the close button', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
query('fullscreen-channel-panel-close')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes after the mouse leaves the panel unless it comes back', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
|
||||
panel()?.dispatchEvent(pointerEvent('pointerleave'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS - 1);
|
||||
panel()?.dispatchEvent(pointerEvent('pointerenter'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
panel()?.dispatchEvent(pointerEvent('pointerleave'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('lets the host close the panel through the template context', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
query('host-close')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles with the C key and focuses the search field', () => {
|
||||
setFullscreen(stage);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' }));
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
jest.advanceTimersByTime(0);
|
||||
expect(document.activeElement).toBe(
|
||||
query('fullscreen-channel-panel-search')
|
||||
);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' }));
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the C key alone while typing and outside fullscreen', () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' }));
|
||||
fixture.detectChanges();
|
||||
expect(panel()).toBeNull();
|
||||
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
const input = query<HTMLInputElement>(
|
||||
'fullscreen-channel-panel-search'
|
||||
);
|
||||
input?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'c', bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes only the Escape that closes the panel', () => {
|
||||
// Electron leaves HTML fullscreen on an unhandled Escape, so the close
|
||||
// shortcut must claim the key — while a closed panel leaves Escape
|
||||
// alone, so it still exits fullscreen then.
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
|
||||
const closing = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
cancelable: true,
|
||||
});
|
||||
document.dispatchEvent(closing);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(closing.defaultPrevented).toBe(true);
|
||||
|
||||
const idle = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
cancelable: true,
|
||||
});
|
||||
document.dispatchEvent(idle);
|
||||
fixture.detectChanges();
|
||||
expect(idle.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the panel, its list and the search when fullscreen ends', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
const input = query<HTMLInputElement>(
|
||||
'fullscreen-channel-panel-search'
|
||||
);
|
||||
input!.value = 'sport';
|
||||
input!.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
|
||||
setFullscreen(null);
|
||||
|
||||
expect(panel()).toBeNull();
|
||||
expect(hotZone()).toBeNull();
|
||||
|
||||
setFullscreen(stage);
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(query('host-list')).toBeNull();
|
||||
openByHover();
|
||||
expect(query('host-list')?.textContent?.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ControlsFullscreen } from '../player-controls/controls-fullscreen';
|
||||
import { FullscreenChannelPanelState } from './fullscreen-channel-panel-state';
|
||||
import {
|
||||
FULLSCREEN_CHANNEL_PANEL,
|
||||
type FullscreenChannelPanelContext,
|
||||
} from './fullscreen-channel-panel.model';
|
||||
|
||||
const EDITABLE_SELECTOR =
|
||||
'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
|
||||
|
||||
function targetsEditable(event: KeyboardEvent): boolean {
|
||||
const path =
|
||||
typeof event.composedPath === 'function'
|
||||
? event.composedPath()
|
||||
: [event.target];
|
||||
return path.some(
|
||||
(node) =>
|
||||
node instanceof Element &&
|
||||
(node.matches(EDITABLE_SELECTOR) ||
|
||||
(node as HTMLElement).isContentEditable === true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-in channel list for fullscreen 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.
|
||||
*
|
||||
* Nothing is drawn over the video while the panel is closed: opening is
|
||||
* resting the mouse on the left edge (a tap on that edge for touch, which has
|
||||
* no hover) or pressing `C`. Closing: moving the mouse away, clicking the
|
||||
* video (a scrim swallows that click so it never pauses playback), the close
|
||||
* button, Escape, or leaving fullscreen. A CDK overlay opened from the list
|
||||
* (sort menu, row context menu) counts as part of the panel: the pointer
|
||||
* moving into it does not start the close, and Escape closes that overlay
|
||||
* first. The list stays mounted between openings of one fullscreen session
|
||||
* so its scroll position and search survive.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-fullscreen-channel-panel',
|
||||
templateUrl: './fullscreen-channel-panel.component.html',
|
||||
styleUrl: './fullscreen-channel-panel.component.scss',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
NgTemplateOutlet,
|
||||
TranslatePipe,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
// The host template's nodes carry the host's style attribute, not this
|
||||
// component's, so the body sizing rules must reach them unscoped.
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: { class: 'fullscreen-channel-panel-host' },
|
||||
})
|
||||
export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
private readonly host = inject(ElementRef<HTMLElement>).nativeElement;
|
||||
private readonly panelHost = inject(FULLSCREEN_CHANNEL_PANEL, {
|
||||
optional: true,
|
||||
});
|
||||
private readonly overlayContainer = inject(OverlayContainer);
|
||||
|
||||
/** Element whose DOM fullscreen the panel lives in. */
|
||||
readonly stage = input<HTMLElement | null>(null);
|
||||
/**
|
||||
* False while the rendered engine cannot show DOM content over its video
|
||||
* (native-view Embedded MPV paints a platform view above the page): every
|
||||
* affordance disappears, exactly like outside fullscreen.
|
||||
*/
|
||||
readonly enabled = input(true);
|
||||
readonly searchInput =
|
||||
viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
private readonly panelElement = viewChild<ElementRef<HTMLElement>>('panel');
|
||||
|
||||
readonly state = new FullscreenChannelPanelState();
|
||||
readonly searchTerm = signal('');
|
||||
private readonly fullscreen = new ControlsFullscreen(
|
||||
() => this.stage(),
|
||||
() => this.onFullscreenChanged()
|
||||
);
|
||||
|
||||
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>".
|
||||
*/
|
||||
readonly panelTitle = computed(
|
||||
() => this.panelHost?.panelTitle?.()?.trim() ?? ''
|
||||
);
|
||||
/** 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(),
|
||||
close: () => this.state.hide(),
|
||||
};
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent) =>
|
||||
this.handleKeydown(event);
|
||||
|
||||
constructor() {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
}
|
||||
effect(() => {
|
||||
this.stage();
|
||||
this.fullscreen.sync();
|
||||
});
|
||||
effect(() => {
|
||||
if (this.active()) {
|
||||
return;
|
||||
}
|
||||
untracked(() => this.resetSession());
|
||||
});
|
||||
// While open, hover intent is tracked document-wide: a CDK overlay the
|
||||
// list opens (sort menu, context menu) renders outside the <aside>, so
|
||||
// the aside's own pointerleave alone would close the panel under the
|
||||
// menu, and nothing would close it once the pointer left that menu.
|
||||
effect((onCleanup) => {
|
||||
if (!this.state.open() || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const onPointerOver = (event: PointerEvent) =>
|
||||
this.onDocumentPointerOver(event);
|
||||
document.addEventListener('pointerover', onPointerOver);
|
||||
onCleanup(() =>
|
||||
document.removeEventListener('pointerover', onPointerOver)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
}
|
||||
this.fullscreen.dispose();
|
||||
this.state.dispose();
|
||||
}
|
||||
|
||||
onHotZoneEnter(event: PointerEvent): void {
|
||||
// Hover intent is a mouse concept; touch opens on the tap instead.
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
this.state.hotZoneEnter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch has no hover and no `C` key, so a tap on the edge is its way in.
|
||||
* Bound to pointerup, not pointerdown: the hot zone must still be the
|
||||
* click target when the tap completes, so the click that follows dies on
|
||||
* it instead of reaching the video.
|
||||
*/
|
||||
onHotZonePointerUp(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'touch') {
|
||||
return;
|
||||
}
|
||||
this.state.show();
|
||||
}
|
||||
|
||||
onPanelPointerEnter(event: PointerEvent): void {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
this.state.panelEnter();
|
||||
}
|
||||
|
||||
onPanelPointerLeave(event: PointerEvent): void {
|
||||
if (
|
||||
event.pointerType === 'touch' ||
|
||||
this.isInsideOverlay(event.relatedTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.state.panelLeave();
|
||||
}
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
this.searchTerm.set((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchTerm.set('');
|
||||
this.searchInput()?.nativeElement.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
private onDocumentPointerOver(event: PointerEvent): void {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.isInsidePanel(event.target) ||
|
||||
this.isInsideOverlay(event.target)
|
||||
) {
|
||||
this.state.panelEnter();
|
||||
} else {
|
||||
this.state.panelLeave();
|
||||
}
|
||||
}
|
||||
|
||||
private isInsidePanel(target: EventTarget | null): boolean {
|
||||
const panel = this.panelElement()?.nativeElement;
|
||||
return (
|
||||
panel !== undefined &&
|
||||
target instanceof Node &&
|
||||
panel.contains(target)
|
||||
);
|
||||
}
|
||||
|
||||
private isInsideOverlay(target: EventTarget | null): boolean {
|
||||
return (
|
||||
target instanceof Node &&
|
||||
this.overlayContainer.getContainerElement().contains(target)
|
||||
);
|
||||
}
|
||||
|
||||
/** A menu or dialog is up: Escape belongs to it, not to the panel. */
|
||||
private hasModalOverlay(): boolean {
|
||||
return (
|
||||
this.overlayContainer
|
||||
.getContainerElement()
|
||||
.querySelector('.cdk-overlay-backdrop') !== null
|
||||
);
|
||||
}
|
||||
|
||||
private onFullscreenChanged(): void {
|
||||
if (!this.isFullscreen()) {
|
||||
this.resetSession();
|
||||
}
|
||||
}
|
||||
|
||||
private resetSession(): void {
|
||||
this.state.reset();
|
||||
this.searchTerm.set('');
|
||||
}
|
||||
|
||||
private handleKeydown(event: KeyboardEvent): void {
|
||||
if (!this.active()) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (this.state.open() && !this.hasModalOverlay()) {
|
||||
// Consume the key: Electron exits HTML fullscreen on an
|
||||
// unhandled Escape, and the advertised close shortcut must
|
||||
// only slide the panel away, not end the session with it.
|
||||
// A closed panel leaves Escape alone, so it still exits
|
||||
// fullscreen then; an overlay-owned Escape is untouched too.
|
||||
event.preventDefault();
|
||||
this.state.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
(event.key !== 'c' && event.key !== 'C')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Typing into any field (the panel's own search included) and a
|
||||
// player behind a modal surface keep the key.
|
||||
if (targetsEditable(event) || this.host.closest('[inert]')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (this.state.open()) {
|
||||
this.state.hide();
|
||||
return;
|
||||
}
|
||||
this.state.show();
|
||||
this.focusSearch();
|
||||
}
|
||||
|
||||
/** A keyboard opening lands in the search field; hover does not steal focus. */
|
||||
private focusSearch(): void {
|
||||
window.setTimeout(() => {
|
||||
if (this.state.open()) {
|
||||
this.searchInput()?.nativeElement.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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.
|
||||
*/
|
||||
export interface FullscreenChannelPanelContext {
|
||||
/** Raw text typed into the panel's search field; hosts normalize it. */
|
||||
readonly searchTerm: Signal<string>;
|
||||
/** 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export interface FullscreenChannelPanelHost {
|
||||
/**
|
||||
* Template rendered inside the panel body. `null` disables the panel and
|
||||
* every affordance around it.
|
||||
*/
|
||||
readonly panelTemplate: Signal<TemplateRef<FullscreenChannelPanelContext> | null>;
|
||||
/** Display-ready header title, e.g. the playlist or category name. */
|
||||
readonly panelTitle?: Signal<string>;
|
||||
}
|
||||
|
||||
export const FULLSCREEN_CHANNEL_PANEL =
|
||||
new InjectionToken<FullscreenChannelPanelHost>('FULLSCREEN_CHANNEL_PANEL');
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './fullscreen-channel-panel.model';
|
||||
export * from './fullscreen-channel-panel-state';
|
||||
export * from './fullscreen-channel-panel.component';
|
||||
+2
@@ -32,6 +32,7 @@ import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from '.
|
||||
import {
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubFullscreenChannelPanelComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
} from './web-player-view.spec-stubs';
|
||||
@@ -112,6 +113,7 @@ describe('WebPlayerViewComponent external recovery integration', () => {
|
||||
PlaybackDiagnosticPanelComponent,
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubFullscreenChannelPanelComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
TranslateModule,
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
}
|
||||
} @else if (application.embeddedMpv) {
|
||||
<app-embedded-mpv-player
|
||||
#embeddedMpvPlayer
|
||||
[playback]="resolvedPlayback()"
|
||||
[fullscreenTarget]="fullscreenSurface"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
@@ -101,6 +102,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Sibling of the engine, inside the fullscreen host: survives the engine
|
||||
remount a channel switch causes and stays visible in fullscreen. Off for
|
||||
native-view Embedded MPV, which paints above the DOM. -->
|
||||
<app-fullscreen-channel-panel
|
||||
[stage]="fullscreenSurface"
|
||||
[enabled]="channelPanelAvailable()"
|
||||
/>
|
||||
|
||||
@if (visiblePlaybackDiagnostic(); as issue) {
|
||||
<app-playback-diagnostic-panel
|
||||
[diagnostic]="issue"
|
||||
|
||||
@@ -32,6 +32,7 @@ import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/p
|
||||
import {
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubFullscreenChannelPanelComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
} from './web-player-view.spec-stubs';
|
||||
@@ -133,6 +134,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
|
||||
PlaybackDiagnosticPanelComponent,
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubFullscreenChannelPanelComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
TranslateModule,
|
||||
|
||||
+80
@@ -27,6 +27,7 @@ import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls';
|
||||
import {
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubFullscreenChannelPanelComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
} from './web-player-view.spec-stubs';
|
||||
@@ -89,6 +90,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
imports: [
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubFullscreenChannelPanelComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
PlaybackDiagnosticPanelComponent,
|
||||
@@ -116,6 +118,84 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
describe('fullscreen channel panel staging', () => {
|
||||
const panel = () =>
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubFullscreenChannelPanelComponent)
|
||||
).componentInstance as StubFullscreenChannelPanelComponent;
|
||||
|
||||
it('stages the panel on the host element the engines fullscreen', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.fullscreenSurface).toBe(fixture.nativeElement);
|
||||
expect(panel().stage()).toBe(component.fullscreenSurface);
|
||||
expect(panel().enabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('offers the panel to Embedded MPV only once it reports the frame-copy engine', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'playerOverride',
|
||||
VideoPlayer.EmbeddedMpv
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const player = fixture.debugElement.query(
|
||||
By.directive(StubEmbeddedMpvPlayerComponent)
|
||||
).componentInstance as StubEmbeddedMpvPlayerComponent;
|
||||
// Native view (or engine not reported yet): nothing DOM-based
|
||||
// can show over the video, so the panel is withheld.
|
||||
expect(panel().enabled()).toBe(false);
|
||||
|
||||
player.support.set({
|
||||
supported: true,
|
||||
platform: 'darwin',
|
||||
engine: 'frame-copy',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(panel().enabled()).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['frame-copy', 'native', 'unsupported'] as const)(
|
||||
'retains frame-copy availability during remount then applies confirmed %s support',
|
||||
async (outcome) => {
|
||||
fixture.componentRef.setInput(
|
||||
'playerOverride',
|
||||
VideoPlayer.EmbeddedMpv
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const embeddedPlayer = () =>
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubEmbeddedMpvPlayerComponent)
|
||||
).componentInstance as StubEmbeddedMpvPlayerComponent;
|
||||
const first = embeddedPlayer();
|
||||
first.support.set({
|
||||
supported: true,
|
||||
platform: 'darwin',
|
||||
engine: 'frame-copy',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(panel().enabled()).toBe(true);
|
||||
|
||||
setPlayback({}, 'https://example.com/next-channel.m3u8');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const remounted = embeddedPlayer();
|
||||
expect(remounted).not.toBe(first);
|
||||
expect(remounted.support()).toBeNull();
|
||||
expect(panel().enabled()).toBe(true);
|
||||
|
||||
remounted.support.set({
|
||||
supported: outcome !== 'unsupported',
|
||||
platform: 'darwin',
|
||||
engine: outcome === 'frame-copy' ? 'frame-copy' : 'native',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(panel().enabled()).toBe(outcome === 'frame-copy');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('snapshots the shared controls setting for each player host', () => {
|
||||
webPlayerSharedControls.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
DeferBlockBehavior,
|
||||
@@ -15,6 +15,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type EmbeddedMpvSupport,
|
||||
type RecordingStartMetadata,
|
||||
type RecordingStoppedEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
getDiagnosticMeta,
|
||||
getDiagnosticTitleKey,
|
||||
} from '../playback-diagnostic-panel/playback-diagnostic-view.util';
|
||||
import { StubFullscreenChannelPanelComponent } from './web-player-view.spec-stubs';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
@@ -110,6 +112,8 @@ class StubArtPlayerComponent {
|
||||
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
|
||||
})
|
||||
class StubEmbeddedMpvPlayerComponent {
|
||||
/** Read by the view's channel-panel gate (`EmbeddedMpvEngineReporter`). */
|
||||
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
||||
readonly playback = input.required<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
@@ -158,6 +162,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
imports: [
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubFullscreenChannelPanelComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
PlaybackDiagnosticPanelComponent,
|
||||
@@ -195,6 +200,18 @@ describe('WebPlayerViewComponent', () => {
|
||||
expect(fixture.nativeElement.classList).toContain('web-player-view');
|
||||
});
|
||||
|
||||
it('stages the fullscreen channel panel on the same host element the engines fullscreen', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.fullscreenSurface).toBe(
|
||||
fixture.nativeElement
|
||||
);
|
||||
const panel = fixture.debugElement.query(
|
||||
By.directive(StubFullscreenChannelPanelComponent)
|
||||
).componentInstance as StubFullscreenChannelPanelComponent;
|
||||
expect(panel.stage()).toBe(fixture.componentInstance.fullscreenSurface);
|
||||
});
|
||||
|
||||
describe('resolvedMediaTitle', () => {
|
||||
it('prefers an explicit media title over the playback title', async () => {
|
||||
fixture.componentRef.setInput('mediaTitle', {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
type Signal,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
effect,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
type PlaybackDiagnostic,
|
||||
@@ -23,6 +25,7 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type Channel,
|
||||
type EmbeddedMpvSupport,
|
||||
type RecordingStartMetadata,
|
||||
type RecordingStoppedEvent,
|
||||
type ResolvedPortalPlayback,
|
||||
@@ -30,6 +33,7 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
||||
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
|
||||
import { FullscreenChannelPanelComponent } from '../fullscreen-channel-panel/fullscreen-channel-panel.component';
|
||||
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
|
||||
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
|
||||
import {
|
||||
@@ -61,6 +65,11 @@ import {
|
||||
toVideoPlayer,
|
||||
} from './web-player-recovery-policy';
|
||||
|
||||
/** What the view needs from a rendered Embedded MPV player: its engine. */
|
||||
interface EmbeddedMpvEngineReporter {
|
||||
readonly support: Signal<EmbeddedMpvSupport | null>;
|
||||
}
|
||||
|
||||
function resolveWebPlayerSharedControls(): boolean {
|
||||
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
|
||||
const fallback = WEB_PLAYER_SHARED_CONTROLS_ENABLED;
|
||||
@@ -75,6 +84,7 @@ function resolveWebPlayerSharedControls(): boolean {
|
||||
imports: [
|
||||
ArtPlayerComponent,
|
||||
EmbeddedMpvPlayerComponent,
|
||||
FullscreenChannelPanelComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
PlaybackDiagnosticPanelComponent,
|
||||
VjsPlayerComponent,
|
||||
@@ -96,10 +106,41 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
* episode, channel, or alternative-source switch. This host element
|
||||
* spans all applications of one mount: fullscreen entered on episode 1
|
||||
* is still in place when episode 2's engine mounts, and the fresh
|
||||
* controls pick it up through `ControlsFullscreen.sync()`.
|
||||
* controls pick it up through `ControlsFullscreen.sync()`. It is also
|
||||
* the stage the fullscreen channel panel tracks, so the panel sits inside
|
||||
* the fullscreen element and survives the same remounts.
|
||||
*/
|
||||
readonly fullscreenSurface: HTMLElement = inject(ElementRef<HTMLElement>)
|
||||
.nativeElement;
|
||||
private readonly embeddedMpvPlayer =
|
||||
viewChild<EmbeddedMpvEngineReporter>('embeddedMpvPlayer');
|
||||
/**
|
||||
* The fullscreen channel panel is DOM content over the video. Embedded
|
||||
* MPV's native-view engine paints a platform view above the page, where
|
||||
* no DOM layer can show, so the panel exists only while the rendered
|
||||
* engine is a web player or Embedded MPV's frame-copy canvas. Fails
|
||||
* closed until frame-copy has been confirmed. Retain that confirmation
|
||||
* while a replacement component probes support, so channel changes do
|
||||
* not reset the panel. A confirmed native/unsupported result revokes it.
|
||||
*/
|
||||
readonly channelPanelAvailable = linkedSignal<
|
||||
{ embedded: boolean; support: EmbeddedMpvSupport | null },
|
||||
boolean
|
||||
>({
|
||||
source: () => ({
|
||||
embedded: this.renderedApplications().some(
|
||||
(application) => application.embeddedMpv
|
||||
),
|
||||
support: this.embeddedMpvPlayer()?.support() ?? null,
|
||||
}),
|
||||
computation: ({ embedded, support }, previous) => {
|
||||
if (!embedded) return true;
|
||||
if (support === null) {
|
||||
return previous?.source.embedded ? previous.value : false;
|
||||
}
|
||||
return support.supported && support.engine === 'frame-copy';
|
||||
},
|
||||
});
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, {
|
||||
@@ -180,9 +221,7 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
});
|
||||
readonly selectedPlayer = computed<VideoPlayer>(() => {
|
||||
const temporary = this.recoverySession.temporaryPlayerOverride();
|
||||
return temporary
|
||||
? toVideoPlayer(temporary)
|
||||
: this.renderablePlayer();
|
||||
return temporary ? toVideoPlayer(temporary) : this.renderablePlayer();
|
||||
});
|
||||
private readonly applicationState = createWebPlayerApplicationState({
|
||||
playback: this.playback,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
|
||||
import type {
|
||||
EmbeddedMpvSupport,
|
||||
RecordingStartMetadata,
|
||||
RecordingStoppedEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -73,11 +74,21 @@ export class StubArtPlayerComponent {
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-fullscreen-channel-panel',
|
||||
template: '<div data-test-id="stub-fullscreen-channel-panel"></div>',
|
||||
})
|
||||
export class StubFullscreenChannelPanelComponent {
|
||||
readonly stage = input<HTMLElement | null>(null);
|
||||
readonly enabled = input(true);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-embedded-mpv-player',
|
||||
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
|
||||
})
|
||||
export class StubEmbeddedMpvPlayerComponent {
|
||||
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
||||
readonly playback = input.required<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
|
||||
Reference in new issue
Block a user