From d9d6f49757acd0abd0b923cea0b1539f48e6cfaa Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:00:46 +0200 Subject: [PATCH] feat(playback): slide-in channel list for fullscreen playback (#1519) --- .changes/playback-fullscreen-channel-panel.md | 12 + AGENTS.md | 59 +- CLAUDE.md | 5 +- .../src/dash-clearkey.e2e.ts | 121 ++++ apps/web-e2e/src/stalker.e2e.ts | 26 + apps/web/src/app/app.config.ts | 10 + .../src/app/settings/settings-form.utils.ts | 2 + ...ngs-playback-section.auto-failover.spec.ts | 1 + .../settings-playback-section.component.html | 28 + ...ettings-playback-section.component.spec.ts | 56 ++ .../settings-playback-section.component.ts | 16 + .../test-stubs/settings-test-harness.stub.ts | 1 + apps/web/src/assets/i18n/ar.json | 7 + apps/web/src/assets/i18n/ary.json | 7 + apps/web/src/assets/i18n/by.json | 7 + apps/web/src/assets/i18n/de.json | 7 + apps/web/src/assets/i18n/el.json | 7 + apps/web/src/assets/i18n/en.json | 7 + apps/web/src/assets/i18n/es.json | 7 + apps/web/src/assets/i18n/fr.json | 7 + apps/web/src/assets/i18n/hu.json | 7 + apps/web/src/assets/i18n/it.json | 7 + apps/web/src/assets/i18n/ja.json | 7 + apps/web/src/assets/i18n/ko.json | 7 + apps/web/src/assets/i18n/nl.json | 7 + apps/web/src/assets/i18n/pl.json | 7 + apps/web/src/assets/i18n/pt.json | 7 + apps/web/src/assets/i18n/ru.json | 7 + apps/web/src/assets/i18n/tr.json | 7 + apps/web/src/assets/i18n/zh.json | 7 + apps/web/src/assets/i18n/zhtw.json | 7 + docs/architecture/player-controls-contract.md | 175 +++++ ...m3u-fullscreen-channel-list.component.html | 36 ++ ...m3u-fullscreen-channel-list.component.scss | 70 ++ ...-fullscreen-channel-list.component.spec.ts | 128 ++++ .../m3u-fullscreen-channel-list.component.ts | 80 +++ ...-player.component.fullscreen-panel.spec.ts | 597 ++++++++++++++++++ .../video-player/video-player.component.html | 16 + .../video-player/video-player.component.ts | 167 ++++- ...unified-live-tab.channels-for-list.spec.ts | 36 ++ .../unified-live-tab.component.html | 21 + .../unified-live-tab.component.ts | 143 ++++- .../unified-live-tab.fullscreen-panel.spec.ts | 499 +++++++++++++++ .../panel-search-window.spec.ts | 167 +++++ .../panel-search-window.ts | 134 ++++ .../stalker-live-stream-layout.component.html | 206 +++--- .../stalker-live-stream-layout.component.scss | 9 + .../stalker-live-stream-layout.component.ts | 376 +++++++++-- ...er-live-stream-layout.panel-search.spec.ts | 392 ++++++++++++ ...stream-layout.playback-session-key.spec.ts | 18 + .../live-stream-layout.component.html | 12 + .../live-stream-layout.component.ts | 47 +- .../portal-channels-list.component.html | 2 +- .../portal-channels-list.component.spec.ts | 73 +++ .../portal-channels-list.component.ts | 31 + .../xtream-favorite-marks.service.ts | 32 + .../src/lib/settings-store.service.spec.ts | 18 + .../src/lib/settings-store.service.ts | 4 + .../interfaces/src/lib/settings.interface.ts | 8 + .../all-channels-view.component.html | 100 +-- .../all-channels-view.component.spec.ts | 36 ++ .../all-channels-view.component.ts | 6 + .../channel-list-container.component.html | 10 +- .../channel-list-container.component.spec.ts | 23 + .../channel-list-container.component.ts | 35 +- .../groups-view/groups-view.component.html | 151 ++--- .../groups-view/groups-view.component.scss | 14 + .../groups-view/groups-view.component.spec.ts | 49 ++ .../groups-view/groups-view.component.ts | 35 + libs/ui/playback/src/index.ts | 1 + .../fullscreen-channel-panel-state.spec.ts | 102 +++ .../fullscreen-channel-panel-state.ts | 115 ++++ .../fullscreen-channel-panel.component.html | 103 +++ .../fullscreen-channel-panel.component.scss | 215 +++++++ ...fullscreen-channel-panel.component.spec.ts | 499 +++++++++++++++ .../fullscreen-channel-panel.component.ts | 314 +++++++++ .../fullscreen-channel-panel.model.ts | 39 ++ .../src/lib/fullscreen-channel-panel/index.ts | 3 + ...r-view.component.external-recovery.spec.ts | 2 + .../web-player-view.component.html | 9 + ...web-player-view.component.recovery.spec.ts | 2 + ...yer-view.component.shared-controls.spec.ts | 80 +++ .../web-player-view.component.spec.ts | 19 +- .../web-player-view.component.ts | 47 +- .../web-player-view.spec-stubs.ts | 13 +- 85 files changed, 5664 insertions(+), 325 deletions(-) create mode 100644 .changes/playback-fullscreen-channel-panel.md create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.html create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.scss create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/portal-channels-list/xtream-favorite-marks.service.ts create mode 100644 libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel-state.spec.ts create mode 100644 libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel-state.ts create mode 100644 libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.html create mode 100644 libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss create mode 100644 libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.spec.ts create mode 100644 libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.ts create mode 100644 libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.model.ts create mode 100644 libs/ui/playback/src/lib/fullscreen-channel-panel/index.ts diff --git a/.changes/playback-fullscreen-channel-panel.md b/.changes/playback-fullscreen-channel-panel.md new file mode 100644 index 000000000..4ab8066fc --- /dev/null +++ b/.changes/playback-fullscreen-channel-panel.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 191931d5a..3c042bfea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 relative+exact` (addon export `seekBy`, helper diff --git a/CLAUDE.md b/CLAUDE.md index 335351e87..b649ecd8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` + } + + + diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.scss new file mode 100644 index 000000000..78e2e05f2 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.scss @@ -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; +} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.spec.ts new file mode 100644 index 000000000..355872917 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.spec.ts @@ -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('all'); + readonly recentItems = input([]); + readonly searchTerm = input(null); + readonly compact = input(false); + readonly resetActiveChannelOnDestroy = input(true); + readonly sidebarToggleRequested = output(); +} + +describe('M3uFullscreenChannelListComponent', () => { + let fixture: ComponentFixture; + 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); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.ts new file mode 100644 index 000000000..62ed1af20 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.ts @@ -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([]); + readonly channelsLoading = input(false); + /** The page's routed view; the panel opens on it and switches locally. */ + readonly initialView = input('all'); + readonly recentItems = input([]); + readonly searchTerm = input(''); + readonly closeRequested = output(); + + readonly views = M3U_FULLSCREEN_CHANNEL_VIEWS; + readonly view = linkedSignal(() => + toM3uFullscreenChannelView(this.initialView()) + ); + + selectView(view: M3uFullscreenChannelView): void { + this.view.set(view); + } +} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts new file mode 100644 index 000000000..a3215ac21 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts @@ -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; + 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(sampleChannel); + const channels = signal([sampleChannel, nextChannel]); + const activePlaylistMeta = signal(null); + const fullscreenChannelPanelSetting = signal( + 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([ + sampleChannel, + nextChannel, + ]); + const activeChannel$ = new BehaviorSubject(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([]), + fullscreenChannelPanel: fullscreenChannelPanelSetting, + }, + }, + { + provide: StorageMap, + useValue: { + get: jest.fn(() => of({ player: VideoPlayer.VideoJs })), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal( + null + ), + }, + }, + ], + }) + .overrideComponent(VideoPlayerComponent, { + set: { + imports: [], + 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) + ); + }); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 409496588..679f54e71 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -213,3 +213,19 @@ /> } + + + + + diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 12db92a26..9ce078e24 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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); 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 + >('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 `