From 8d73286d93114083d39628a09540dc7d7b963d70 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 13:35:51 +0200 Subject: [PATCH] fix(m3u): let the guide own the keyboard and gate its entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the programme guide is open the docked player carries `data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours alongside `[inert]` — the arrows moved the player's volume instead of the guide's row focus. The external-player strip loses its Collapse toggle (nothing to reveal, no preference to write), the header action and its palette command report `disabled` when the guide cannot open, the docked strip derives its programme from the active channel's own schedule instead of the retained NgRx value, switching playlists closes the guide, and the collapsed strip can reach 48 px on phones. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/m3u-playlist-module.md | 20 +++-- .../specs/2026-09-06-epg-guide-m3u-design.md | 2 +- .../video-player/video-player.component.html | 10 ++- .../video-player/video-player.component.scss | 4 + .../video-player.component.spec.ts | 88 +++++++++++++++++++ .../video-player/video-player.component.ts | 35 +++++++- .../video-player/video-player.spec-harness.ts | 23 +++++ .../video-player/video-player.spec-stubs.ts | 1 + .../lib/workspace-header-context.service.ts | 6 ++ .../src/lib/workspace-view-command.service.ts | 33 ++++--- .../epg-guide-now-playing.component.html | 28 +++--- .../epg-guide-now-playing.component.ts | 7 ++ .../controls-shortcuts.spec.ts | 28 ++++++ .../lib/player-controls/controls-shortcuts.ts | 22 +++-- .../workspace-shell-header.component.html | 1 + .../services/workspace-shell.facade.spec.ts | 33 +++++++ 16 files changed, 297 insertions(+), 44 deletions(-) diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 3eea63052..2db82f8a0 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1132,12 +1132,20 @@ Guide mode is host layout, not an overlay: `VideoPlayerComponent.guideOpen` hides the sidebar and the timeline, renders the guide, and CSS reflows the untouched `app-web-player-view` into a 128 px docked strip (`.content-container.is-guide`) beside `app-epg-guide-now-playing`; the strip -collapses to one line (`epg-guide:dock-collapsed`). Nothing remounts, so -playback and native-view Embedded MPV bounds survive. Entry points: the -workspace header action (`m3u-epg-guide`), the command palette, the Guide -button in the timeline toolbar (`EpgTimelineComponent.openGuide`) and the `G` -key on the player page. Player fullscreen, radio, recognised movies and the -PWA close or withhold it. Inside the guide: single click on a row or an +collapses to one 48 px line (`epg-guide:dock-collapsed`), which is also the +fixed height of the strip shown for an external MPV/VLC session — that one has +no video to reveal, so it hides the Collapse toggle (`collapsible=false`) and +writes no preference. Nothing remounts, so playback and native-view Embedded +MPV bounds survive. While the guide is open the docked `.video-player` carries +`data-player-shortcuts-suspended`, which makes `ControlsShortcuts` (shared and +legacy player shortcuts alike) yield ↑/↓, Space, F and M to the guide's own +keyboard controller. Entry points: the workspace header action +(`m3u-epg-guide`), the command palette, the Guide button in the timeline +toolbar (`EpgTimelineComponent.openGuide`) and the `G` key on the player page. +The header action reports `disabled` whenever the guide cannot open, which +greys out the header button and disables its palette command instead of +offering a no-op. Player fullscreen, radio, recognised movies, switching to +another playlist and the PWA close or withhold it. Inside the guide: single click on a row or an "on now" card switches the channel and keeps the guide open, double-click or Enter switches and closes, other cards open the programme dialog; ↑/↓ ←/→ navigate, I details, N now, PgUp/PgDn day, Esc close — the keyboard controller diff --git a/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md b/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md index 400eda707..8785e6773 100644 --- a/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md +++ b/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md @@ -189,7 +189,7 @@ token set; card radius is 8 px (timeline uses 11 px on 96 px cards). `guideOpen()` is true the sidebar and the `.epg` block are not rendered; `` takes their place; `.content-container` gets `is-guide` and CSS turns `.video-player` into a 128 px strip (16:9 video on the left, - `app-epg-guide-now-playing` beside it; 40 px single line when collapsed). + `app-epg-guide-now-playing` beside it; 48 px single line when collapsed). `app-web-player-view` keeps its DOM position, so HTML5/hls.js, Video.js, ArtPlayer and both Embedded MPV engines keep playing; native-view syncs bounds as on any resize. 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 0f9235e3a..729c4b9ea 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 @@ -88,7 +88,12 @@ activeChannel.radio === 'true' || shouldShowInlinePlayer(activeChannel) ) { -
+
@if (activeChannel.radio === 'true') {
} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss index ab9e3d469..b040432d4 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss @@ -153,6 +153,10 @@ .video-player { flex: 0 0 auto; height: 128px; + // The shared player mixin floors `.video-player` at 96px on phones; + // the docked strip is 128px expanded and 48px collapsed, so the floor + // has to go or the collapsed strip cannot shrink. + min-height: 0; display: flex; align-items: stretch; gap: 18px; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index f3293c7ac..a75eafb34 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -36,6 +36,7 @@ import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './vid import { activeChannel, activeEpgProgram, + buildAiringProgram, activePlaybackUrl, channels, channels$, @@ -400,6 +401,93 @@ describe('VideoPlayerComponent', () => { localStorage.removeItem('epg-guide:dock-collapsed'); }); + it('suspends the docked player shortcuts while the guide is open', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + fixture.detectChanges(); + const dock = fixture.nativeElement.querySelector( + '.video-player' + ) as HTMLElement; + expect(dock.hasAttribute('data-player-shortcuts-suspended')).toBe( + false + ); + + component.openGuide(); + fixture.detectChanges(); + expect(dock.hasAttribute('data-player-shortcuts-suspended')).toBe(true); + + component.closeGuide(); + fixture.detectChanges(); + expect(dock.hasAttribute('data-player-shortcuts-suspended')).toBe( + false + ); + }); + + it('derives the docked strip programme from the active channel schedule', () => { + // The NgRx `currentEpgProgram` retains the previous channel's value + // across a switch, so the strip must not read it. + currentEpgProgram.set(buildAiringProgram('Retained Show')); + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + epgPrograms$.next([buildAiringProgram('Sample Now')]); + fixture.detectChanges(); + component.openGuide(); + fixture.detectChanges(); + const stripProgram = () => + fixture.debugElement + .query(By.directive(StubEpgGuideNowPlayingComponent)) + .componentInstance.program(); + + expect(stripProgram()).toEqual( + expect.objectContaining({ title: 'Sample Now' }) + ); + + syncStoreState({ + ...sampleChannel, + id: 'channel-2', + url: 'http://localhost/other.m3u8', + name: 'Other TV', + tvg: { id: 'other-tvg-id', name: 'Other TV', logo: '' }, + } as Channel); + epgPrograms$.next([buildAiringProgram('Other Now', 'other-tvg-id')]); + fixture.detectChanges(); + + expect(stripProgram()).toEqual( + expect.objectContaining({ title: 'Other Now' }) + ); + + epgPrograms$.next([]); + fixture.detectChanges(); + + expect(stripProgram()).toBeNull(); + }); + + it('disables the guide header action while nothing can host the guide', () => { + syncStoreState(null); + fixture.detectChanges(); + + expect(headerContext.action()?.disabled?.()).toBe(true); + + syncStoreState(sampleChannel); + fixture.detectChanges(); + + expect(headerContext.action()?.disabled?.()).toBe(false); + }); + + it('closes the guide when the active playlist changes', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + fixture.detectChanges(); + component.openGuide(); + fixture.detectChanges(); + expect(component.guideOpen()).toBe(true); + + playlistId.set('playlist-2'); + fixture.detectChanges(); + + expect(component.guideOpen()).toBe(false); + }); + it('strips country prefixes from the timeline and player titles when enabled', () => { stripCountryPrefix.set(true); syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel); 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 330a0a2b0..5209fea00 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 @@ -322,9 +322,22 @@ export class VideoPlayerComponent !this.showMovieDetail() ); }); - /** Programme shown in the docked strip (catch-up selection wins). */ - readonly guideNowPlayingProgram = computed( - () => this.activeEpgProgramOrNull() ?? this.epgProgram() ?? null + /** + * Programme shown in the docked strip (catch-up selection wins). The + * live fallback is derived from the ACTIVE channel's own schedule and the + * 30 s clock, exactly like {@link recordingMetadata} — the NgRx + * `currentEpgProgram` keeps its last value across a channel switch and + * through EPG gaps, so reading it here left the strip advertising the + * previous channel's programme. + */ + readonly guideNowPlayingProgram = computed( + () => + this.activeEpgProgramOrNull() ?? + findCurrentEpgProgram( + this.epgPrograms(), + epgProviderClockMs(this.epgNowMs(), this.epgOffsetMinutes()) + ) ?? + null ); readonly archivePlaybackAvailable = computed(() => isM3uCatchupPlaybackSupported(this.activeChannel()) @@ -717,6 +730,19 @@ export class VideoPlayerComponent } }); + // Switching to another playlist keeps this component mounted, so an + // open guide would survive and show the new playlist's channels under + // the previous one's initial scope. Close it and let the user reopen. + let lastGuidePlaylistId = this.activePlaylistId(); + effect(() => { + const playlistId = this.activePlaylistId(); + if (playlistId === lastGuidePlaylistId) { + return; + } + lastGuidePlaylistId = playlistId; + untracked(() => this.closeGuide()); + }); + // React to settings changes effect(() => { this.playerSettings = { @@ -1607,6 +1633,9 @@ export class VideoPlayerComponent tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE', ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE', active: () => this.guideOpen(), + // Nothing to dock (no channel, radio, a recognised movie): the + // header button and the palette command would both open nothing. + disabled: () => !this.canOpenGuide(), palette: { labelKey: 'TOP_MENU.OPEN_EPG_GUIDE', descriptionKey: diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts index f3159c8e3..535ce6471 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts @@ -134,3 +134,26 @@ export function syncStoreState(channel: Channel | null): void { channels.set(channel ? [channel] : []); channels$.next(channel ? [channel] : []); } + +/** + * Programme covering "now" on `channel`. The docked guide strip and the + * recording snapshot both derive the live programme from the active channel's + * own schedule, so their assertions need a window the clock falls inside. + */ +export function buildAiringProgram( + title: string, + channel = 'sample-tvg-id' +): EpgProgram { + const startMs = Date.now() - 10 * 60_000; + const stopMs = startMs + 60 * 60_000; + return { + start: new Date(startMs).toISOString(), + stop: new Date(stopMs).toISOString(), + channel, + title, + desc: null, + category: null, + startTimestamp: Math.floor(startMs / 1000), + stopTimestamp: Math.floor(stopMs / 1000), + }; +} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts index 16511a5c2..9a155edab 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts @@ -135,6 +135,7 @@ export class StubEpgGuideNowPlayingComponent { readonly program = input(null); readonly offsetMinutes = input(0); readonly collapsed = input(false); + readonly collapsible = input(true); readonly closeRequested = output(); readonly collapsedChange = output(); } diff --git a/libs/portal/shared/util/src/lib/workspace-header-context.service.ts b/libs/portal/shared/util/src/lib/workspace-header-context.service.ts index cde039250..9d1986007 100644 --- a/libs/portal/shared/util/src/lib/workspace-header-context.service.ts +++ b/libs/portal/shared/util/src/lib/workspace-header-context.service.ts @@ -8,6 +8,12 @@ export interface WorkspaceHeaderAction { ariaLabelKey: string; /** Pressed/highlighted state for toggling actions (e.g. the guide). */ active?: () => boolean; + /** + * Whether the action currently cannot run (e.g. the guide with no live + * channel to dock). Disables the header button and its palette command, + * so neither entry point promises something the host would refuse. + */ + disabled?: () => boolean; run: () => void; palette?: WorkspaceHeaderCommandMetadata; } diff --git a/libs/portal/shared/util/src/lib/workspace-view-command.service.ts b/libs/portal/shared/util/src/lib/workspace-view-command.service.ts index 197b477d9..1bd7695b2 100644 --- a/libs/portal/shared/util/src/lib/workspace-view-command.service.ts +++ b/libs/portal/shared/util/src/lib/workspace-view-command.service.ts @@ -17,21 +17,26 @@ export class WorkspaceViewCommandService { [] ); - readonly commands = computed(() => { - const commands = this.registeredCommands().map( - (entry) => entry.command - ); - const headerAction = this.headerContext.action(); - const headerCommand = headerAction - ? this.toHeaderCommand(headerAction) - : null; + readonly commands = computed( + () => { + const commands = this.registeredCommands().map( + (entry) => entry.command + ); + const headerAction = this.headerContext.action(); + const headerCommand = headerAction + ? this.toHeaderCommand(headerAction) + : null; - return headerCommand ? [headerCommand, ...commands] : commands; - }); + return headerCommand ? [headerCommand, ...commands] : commands; + } + ); registerCommand(command: WorkspaceCommandContribution): () => void { const token = Symbol(command.id); - this.registeredCommands.update((entries) => [...entries, { token, command }]); + this.registeredCommands.update((entries) => [ + ...entries, + { token, command }, + ]); return () => { this.registeredCommands.update((entries) => @@ -56,7 +61,11 @@ export class WorkspaceViewCommandService { priority: palette?.priority ?? 0, keywords: palette?.keywords, visible: palette?.visible, - enabled: palette?.enabled, + // The header action's own gate wins: a disabled button must not + // leave a runnable command behind in the palette. + enabled: action.disabled + ? () => !action.disabled?.() + : palette?.enabled, run: () => action.run(), }; } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.html b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.html index 7d3fa03ad..1070d0d9c 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.html +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.html @@ -36,18 +36,20 @@ > {{ 'EPG.GUIDE.CLOSE' | translate }} Esc - + @if (collapsible()) { + + }
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts index 4f572b169..a92ba3551 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts @@ -31,6 +31,13 @@ export class EpgGuideNowPlayingComponent implements OnInit, OnDestroy { readonly program = input(null); readonly offsetMinutes = input(0); readonly collapsed = input(false); + /** + * Whether the strip offers its Collapse/Expand toggle. False where the + * strip is already at its minimum — the external-player dock has no video + * to collapse away, so the button would only write a preference the user + * cannot see the effect of. + */ + readonly collapsible = input(true); readonly closeRequested = output(); readonly collapsedChange = output(); diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts index 638b34c89..92d46ddf2 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts @@ -81,6 +81,34 @@ describe('ControlsShortcuts', () => { } }); + it('ignores every shortcut while the host surface suspends player shortcuts', () => { + const suspendedRegion = document.createElement('div'); + suspendedRegion.setAttribute('data-player-shortcuts-suspended', ''); + const host = document.createElement('div'); + suspendedRegion.appendChild(host); + document.body.appendChild(suspendedRegion); + ( + handlers as unknown as { hostElement: () => HTMLElement } + ).hostElement = () => host; + + try { + expect(dispatchKey(' ')).toBe(false); + expect(dispatchKey('ArrowUp')).toBe(false); + expect(dispatchKey('ArrowDown')).toBe(false); + expect(dispatchKey('f')).toBe(false); + expect(dispatchKey('m')).toBe(false); + dispatchKey('Escape'); + + expect(handlers.togglePaused).not.toHaveBeenCalled(); + expect(handlers.adjustVolume).not.toHaveBeenCalled(); + expect(handlers.toggleFullscreen).not.toHaveBeenCalled(); + expect(handlers.toggleMute).not.toHaveBeenCalled(); + expect(handlers.onEscape).not.toHaveBeenCalled(); + } finally { + suspendedRegion.remove(); + } + }); + it('handles shortcuts when the host is not inside an inert region', () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts index 058745b1e..0b2749d6d 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts @@ -3,8 +3,11 @@ export interface ControlsShortcutHandlers { /** * The player's host element, used to opt out of global shortcuts while * an ancestor is `inert` (e.g. behind the workspace's phone context - * drawer): inert strips pointer and Tab access, but document-level - * keydown listeners still fire, so shortcuts must check it themselves. + * drawer) or marked `data-player-shortcuts-suspended` (a host surface + * that owns the keyboard itself, such as the programme guide with the + * player docked): both strip the player of keyboard ownership, but + * document-level keydown listeners still fire, so shortcuts must check + * it themselves. */ hostElement?: () => HTMLElement | null; canTogglePaused: () => boolean; @@ -86,10 +89,17 @@ export class ControlsShortcuts { } // A player inside an inert region is outside the interaction model - // entirely — a modal surface above it owns the keyboard. This also - // covers Escape: the modal decides what Escape means, not the - // obscured player. - if (handlers.hostElement?.()?.closest('[inert]')) { + // entirely — a modal surface above it owns the keyboard. The same + // applies to a host surface that marks its player container + // `data-player-shortcuts-suspended` (the programme guide docks the + // player but keeps the arrows, Space and F for itself). This also + // covers Escape: the surface above decides what Escape means, not + // the player it obscures or demotes. + if ( + handlers + .hostElement?.() + ?.closest('[inert], [data-player-shortcuts-suspended]') + ) { return; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 27f0aead8..f199b88a5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -121,6 +121,7 @@ class="header-shortcut" mat-icon-button [class.is-active]="action.active?.() ?? false" + [disabled]="action.disabled?.() ?? false" [attr.aria-pressed]="action.active ? action.active() : null" (click)="onHeaderShortcutRequested()" [matTooltip]="action.tooltipKey | translate" diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 975821997..e0b056a7a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -922,6 +922,39 @@ describe('WorkspaceShellFacade', () => { ); }); + it('disables the programme guide command while the header action reports disabled', () => { + const headerContext = TestBed.inject(WorkspaceHeaderContextService); + + activePlaylistSignal.set({ + _id: 'pl-m3u', + title: 'Playlist M3U', + count: 10, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + }); + facade.currentUrl.set('/workspace/playlists/pl-m3u/groups'); + const guideCommandEnabled = () => + facade + .commandPaletteCommands() + .find((command) => command.id === 'm3u-epg-guide')?.enabled; + const registerGuideAction = (disabled: () => boolean) => + headerContext.setAction({ + id: 'm3u-epg-guide', + icon: 'grid_view', + tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE', + ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE', + disabled, + palette: { labelKey: 'TOP_MENU.OPEN_EPG_GUIDE' }, + run: jest.fn(), + }); + + registerGuideAction(() => true); + expect(guideCommandEnabled()).toBe(false); + + registerGuideAction(() => false); + expect(guideCommandEnabled()).toBe(true); + }); + it('opens the Xtream account dialog with credentials and session counts for the active playlist', () => { activePlaylistSignal.set({ _id: 'pl-xtream',