feat(playback): slide-in channel list for fullscreen playback (#1519)

This commit is contained in:
4gray authored and GitHub committed 2026-09-05 17:00:46 +02:00
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.
+58 -1
View File
@@ -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
+2 -3
View File
@@ -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,
}) => {
+26
View File
@@ -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"]');
+10
View File
@@ -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,
+7
View File
@@ -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": "عرض الترجمة مبكرًا",
+7
View File
@@ -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": "ورّي الترجمة قبل",
+7
View File
@@ -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": "Паказваць субтытры раней",
+7
View File
@@ -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",
+7
View File
@@ -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": "Εμφάνιση υποτίτλων νωρίτερα",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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": "字幕を早く表示",
+7
View File
@@ -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": "자막을 더 빨리 표시",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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": "Показывать субтитры раньше",
+7
View File
@@ -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",
+7
View File
@@ -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": "提前显示字幕",
+7
View File
@@ -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
@@ -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()"
/>
@@ -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;
}
@@ -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);
});
});
@@ -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);
}
}
@@ -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
*/
@@ -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]);
});
});
@@ -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>
@@ -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);
}
}
@@ -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',
};
}
@@ -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);
});
});
@@ -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;
}
}
@@ -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>
@@ -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;
@@ -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();
@@ -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();
});
});
@@ -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();
});
});
@@ -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>
@@ -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);
@@ -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"
@@ -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();
});
});
@@ -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,
});
});
}
@@ -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.
@@ -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()) {
@@ -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);
@@ -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>>();
@@ -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()"
@@ -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(
@@ -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);
@@ -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">
@@ -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;
@@ -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'
@@ -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;
}
+1
View File
@@ -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';
@@ -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;
}
}
}
@@ -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>
}
@@ -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;
}
}
@@ -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('');
});
});
@@ -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';
@@ -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,
@@ -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);