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',