fix(m3u): let the guide own the keyboard and gate its entry points

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:35:51 +02:00
1 parent 376fde096d
commit 8d73286d93
16 files changed
+297 -44

No files matched your search

+14 -6
View File
@@ -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
@@ -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;
`<app-epg-guide>` 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.
@@ -88,7 +88,12 @@
activeChannel.radio === 'true' ||
shouldShowInlinePlayer(activeChannel)
) {
<div class="video-player">
<div
class="video-player"
[attr.data-player-shortcuts-suspended]="
guideOpen() ? '' : null
"
>
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
@@ -118,7 +123,6 @@
}
@if (guideOpen()) {
<app-epg-guide-now-playing
class="guide-dock__info"
[channelName]="timelineChannelName()"
[program]="guideNowPlayingProgram()"
[offsetMinutes]="epgOffsetMinutes()"
@@ -137,8 +141,8 @@
[program]="guideNowPlayingProgram()"
[offsetMinutes]="epgOffsetMinutes()"
[collapsed]="true"
[collapsible]="false"
(closeRequested)="closeGuide()"
(collapsedChange)="setGuideDockCollapsed($event)"
/>
</div>
}
@@ -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;
@@ -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);
@@ -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<EpgProgram | null>(
() =>
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:
@@ -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),
};
}
@@ -135,6 +135,7 @@ export class StubEpgGuideNowPlayingComponent {
readonly program = input<EpgProgram | null>(null);
readonly offsetMinutes = input(0);
readonly collapsed = input(false);
readonly collapsible = input(true);
readonly closeRequested = output<void>();
readonly collapsedChange = output<boolean>();
}
@@ -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;
}
@@ -17,21 +17,26 @@ export class WorkspaceViewCommandService {
[]
);
readonly commands = computed<readonly WorkspaceCommandContribution[]>(() => {
const commands = this.registeredCommands().map(
(entry) => entry.command
);
const headerAction = this.headerContext.action();
const headerCommand = headerAction
? this.toHeaderCommand(headerAction)
: null;
readonly commands = computed<readonly WorkspaceCommandContribution[]>(
() => {
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(),
};
}
@@ -36,18 +36,20 @@
>
{{ 'EPG.GUIDE.CLOSE' | translate }} <kbd>Esc</kbd>
</button>
<button
type="button"
mat-button
(click)="collapsedChange.emit(!collapsed())"
[attr.aria-expanded]="!collapsed()"
>
{{
(collapsed()
? 'EPG.GUIDE.EXPAND_PLAYER'
: 'EPG.GUIDE.COLLAPSE_PLAYER'
) | translate
}}
</button>
@if (collapsible()) {
<button
type="button"
mat-button
(click)="collapsedChange.emit(!collapsed())"
[attr.aria-expanded]="!collapsed()"
>
{{
(collapsed()
? 'EPG.GUIDE.EXPAND_PLAYER'
: 'EPG.GUIDE.COLLAPSE_PLAYER'
) | translate
}}
</button>
}
</div>
</div>
@@ -31,6 +31,13 @@ export class EpgGuideNowPlayingComponent implements OnInit, OnDestroy {
readonly program = input<EpgProgram | null>(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<void>();
readonly collapsedChange = output<boolean>();
@@ -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);
@@ -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;
}
@@ -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"
@@ -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',