fix(playback): make the fullscreen channel panel discoverable and calmer to close (#1619)

* fix(playback): make the fullscreen channel panel discoverable and calmer to close

The left-edge hot zone was an invisible 28px strip with nothing telling the
user where the list lived, so "hovering the left side" rarely reached it while
`C` always worked. Mouse movement over the stage now reveals a slim edge hint
tab (CSS chevron, fades after 2.5 s idle, lit while the pointer rests in the
zone), the zone grows to 40px (48px coarse), and a click or tap on the edge
opens at once without the dwell.

Closing follows the pointer more honestly: the mouse-leave grace grows from
420 ms to 1 s, and it applies only once the pointer has engaged with the
panel, so a `C`-opened list no longer closes while the mouse merely roams over
the video under the user's typing. Clicks inside the panel never close it.

Found on the way: closing with Escape while the mouse still rests on the edge
reopened the panel 160 ms later, because Chromium synthesizes a `pointerenter`
on the hot zone the aside slid away from. An explicit close now re-arms the
zone only on the next real pointer move.

Verified live over CDP on HTML5, Video.js, ArtPlayer and Embedded MPV
frame-copy; the hover mechanism itself was sound on every engine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(playback): open the panel only on a primary click begun on the edge

Review follow-up. The hot zone opened on every pointerup, so a drag released
over the edge, a right or middle click and a pen barrel button all opened the
panel; now pointerdown records the primary pointer and pointerup must match
it (a leave or cancel forgets the press). The synthetic pointerenter that
follows an explicit close no longer arms the hint either: the edge stays
clear until the next real pointer move, which arms it and starts the dwell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-18 21:12:00 +02:00
1 parent ab239b5043
commit c110519412
10 files changed
+574 -58

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: playback
---
The fullscreen channel list is easier to find and calmer to use: moving the mouse shows a small tab on the left edge, a click on that edge opens the list at once, hover still opens it after a short rest, and the list now stays open for a full second after the mouse leaves. Opened with `C`, it no longer closes while the mouse only wanders over the video.
+9 -5
View File
@@ -576,12 +576,16 @@ unchanged. Contract: `docs/architecture/m3u-playlist-module.md`
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`. The hot zone stays mounted above the
scrim and below the panel during opening, so a delayed paint cannot turn
dwell, a click or tap on that edge, or `C`. The hot zone stays mounted above
the scrim and below the panel during opening, so a delayed paint cannot turn
stationary hover into a synthetic leave. 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
and the pointer rests — mouse movement over the stage reveals a slim edge
hint tab that fades after 2.5 s idle — the hot zone stops above the controls
bar, and scrim/Escape/mouse-leave close it, mouse-leave after 1 s and only
once the pointer has been inside the panel (a `C`-opened panel survives the
mouse roaming over the video) — 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
+1 -1
View File
@@ -1303,7 +1303,7 @@ engine` (restart required) or
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
contracts: `docs/architecture/embedded-mpv-native.md` and
`tools/embedded-mpv/README.md`.
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked `<button>`, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. A completed pointer click then releases the focus it left on the control (`onBarClick` → `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`: non-empty click `pointerType`, else a recent press inside the clicked element): a focused control captures the keyboard — Space and Enter re-activated the clicked button and `ControlsShortcuts` yields to any interactive element in the key's path, so after a click on fullscreen Space left fullscreen instead of pausing. Keyboard activation (empty `pointerType`) keeps focus, only buttons and range sliders are released, Chromium keeps its sequential-focus starting point at the blurred control so Tab continues from it, and the volume popover ignores the release's `focusout` (`wasPointerFocusRelease`). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the Line truncated
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked `<button>`, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. A completed pointer click then releases the focus it left on the control (`onBarClick` → `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`: non-empty click `pointerType`, else a recent press inside the clicked element): a focused control captures the keyboard — Space and Enter re-activated the clicked button and `ControlsShortcuts` yields to any interactive element in the key's path, so after a click on fullscreen Space left fullscreen instead of pausing. Keyboard activation (empty `pointerType`) keeps focus, only buttons and range sliders are released, Chromium keeps its sequential-focus starting point at the blurred control so Tab continues from it, and the volume popover ignores the release's `focusout` (`wasPointerFocusRelease`). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the Line truncated
- Shared web picture-in-picture stays inside that default-on rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
+35 -15
View File
@@ -505,22 +505,42 @@ engine on first entry; a confirmed frame-copy capability survives an unknown
probe during an MPV application remount, preserving the open panel and its
search/scroll state. A confirmed native/unsupported result revokes it, and
switching to a web engine clears the remembered MPV capability). Nothing is
drawn over the video while the panel is closed — there is
no handle or hint. An invisible 28px hot zone (40px on coarse pointers) on the
drawn over the video while the panel is closed and the pointer rests. Mouse
movement over the stage (the fullscreen element) reveals a slim, pointer-
transparent hint tab on the left edge — a CSS chevron, no icon glyph or text
— that fades `CHANNEL_PANEL_HINT_IDLE_MS` (2.5 s) after the last move, so it
comes and goes with the controls chrome; it lights up (`--armed`) while the
pointer rests in the hot zone, and it is not rendered while the panel is
open. Touch movement never reveals it. The hint answered the first field
report: the zone was an invisible strip with nothing telling the user where
the list lived. An invisible 40px hot zone (48px on coarse pointers) on the
left edge opens the panel after a 160ms mouse dwell; a sweep across the edge
is ignored. The zone remains mounted above the scrim and below the panel while
open, preserving the pointer target until the opening animation covers it.
A delayed fullscreen paint therefore cannot turn a stationary edge hover into
a synthetic mouse-leave that closes the panel. The zone stops above the controls bar (`bottom: max(25%, 140px)`)
so the leftmost transport button never loses a click or tap to it. The `C` key opens it too and focuses the search field (hover does
not steal focus). Touch has neither hover nor a `C` key, so a tap on the hot
zone opens the panel at once: the handler is bound to `pointerup`, not
`pointerdown`, so the hot zone is still the tap's click target and the click
that follows dies on it instead of reaching the video. It closes when the
mouse leaves the panel for 420ms, on the close button (tooltip names Escape),
on Escape, on the host's `close`, or through a transparent scrim
over the video that swallows the click so the player's click-to-pause never
sees it. The Escape that closes the panel is consumed (`preventDefault`):
is ignored, and a click or tap on the zone opens at once without the dwell —
only a primary press that began inside the zone and is released there
(`pointerdown` records the pointer, `pointerup` must match it; a drag
released over the edge, a right or middle button, or a pen barrel button
never opens). The synthetic `pointerenter` that follows an explicit close
neither opens nor arms the hint: the zone re-arms on the next real
`pointermove`. The zone remains mounted above the
scrim and below the panel while open, preserving the pointer target until
the opening animation covers it. A delayed fullscreen paint therefore cannot
turn a stationary edge hover into a synthetic mouse-leave that closes the
panel. The zone stops above the controls bar (`bottom: max(25%, 140px)`) so
the leftmost transport button never loses a click or tap to it. The `C` key
opens it too and focuses the search field (hover does not steal focus).
Touch has neither hover nor a `C` key, so the tap path above is its way in:
the handler is bound to `pointerup`, not `pointerdown`, so the hot zone is
still the tap's click target and the click that follows dies on it instead
of reaching the video. It closes when the mouse leaves the panel for
`CHANNEL_PANEL_CLOSE_GRACE_MS` (1 s) — but only once the pointer has engaged
with the panel: a hover-opened panel counts as engaged from the start, while
a `C`-opened one ignores the mouse roaming over the video until it has
visited the list, so the shortcut never leaves the user typing into a closing
search field (`FullscreenChannelPanelState.show(opener)`) — on the close
button (tooltip names Escape), on Escape, on the host's `close`, or through
a transparent scrim over the video that swallows the click so the player's
click-to-pause never sees it. Clicks inside the panel never close it: the
panel sits above the scrim, so no in-panel hit can reach it. The Escape that closes the panel is consumed (`preventDefault`):
Electron leaves HTML fullscreen on an unhandled Escape, and the close
shortcut must only slide the panel away; a closed panel leaves Escape alone,
so the key still exits fullscreen then. A CDK overlay the list opens (sort menu, row context menu) renders in
@@ -1,5 +1,6 @@
import {
CHANNEL_PANEL_CLOSE_GRACE_MS,
CHANNEL_PANEL_HINT_IDLE_MS,
CHANNEL_PANEL_OPEN_DWELL_MS,
FullscreenChannelPanelState,
} from './fullscreen-channel-panel-state';
@@ -62,6 +63,113 @@ describe('FullscreenChannelPanelState', () => {
expect(state.open()).toBe(true);
});
it('lets a keyboard-opened panel ignore the mouse until it has visited the panel', () => {
state.show('keyboard');
// Roaming over the video: no close.
state.panelLeave();
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
expect(state.open()).toBe(true);
// Once the pointer has been inside, leaving closes as usual.
state.panelEnter();
state.panelLeave();
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
expect(state.open()).toBe(false);
});
it('treats a hover-opened panel as engaged from the start', () => {
state.hotZoneEnter();
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
expect(state.open()).toBe(true);
state.panelLeave();
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
expect(state.open()).toBe(false);
});
it('shows the edge hint while the pointer moves over the stage and hides it once idle', () => {
state.stageActivity();
expect(state.hintVisible()).toBe(true);
jest.advanceTimersByTime(CHANNEL_PANEL_HINT_IDLE_MS - 1);
state.stageActivity();
jest.advanceTimersByTime(CHANNEL_PANEL_HINT_IDLE_MS - 1);
expect(state.hintVisible()).toBe(true);
jest.advanceTimersByTime(1);
expect(state.hintVisible()).toBe(false);
});
it('arms the hint while the pointer rests in the hot zone and drops both on open', () => {
state.hotZoneEnter();
expect(state.hotZoneHover()).toBe(true);
state.hotZoneLeave();
expect(state.hotZoneHover()).toBe(false);
state.stageActivity();
state.hotZoneEnter();
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
expect(state.open()).toBe(true);
expect(state.hintVisible()).toBe(false);
expect(state.hotZoneHover()).toBe(false);
// Activity over the stage while open draws no hint.
state.stageActivity();
expect(state.hintVisible()).toBe(false);
});
it('does not reopen from the synthetic hot-zone enter that follows an explicit close', () => {
state.hotZoneEnter();
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
expect(state.open()).toBe(true);
// Escape while the mouse still rests on the edge: the panel slides
// away and the browser reports the zone under the pointer again.
state.hide();
state.hotZoneEnter();
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
expect(state.open()).toBe(false);
// The disarmed edge shows no armed hint either.
expect(state.hotZoneHover()).toBe(false);
// A real move inside the zone arms the hint and starts the dwell.
state.stageActivity();
expect(state.hotZoneHover()).toBe(true);
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS - 1);
expect(state.open()).toBe(false);
jest.advanceTimersByTime(1);
expect(state.open()).toBe(true);
});
it('does not arm from a move once the pointer has left the disarmed zone', () => {
state.show();
state.hide();
state.hotZoneEnter();
state.hotZoneLeave();
state.stageActivity();
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
expect(state.open()).toBe(false);
expect(state.hotZoneHover()).toBe(false);
});
it('re-arms the hot zone on a move made before the pointer reaches it', () => {
state.show();
state.hide();
state.stageActivity();
state.hotZoneEnter();
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
expect(state.open()).toBe(true);
});
it('does not restart a running dwell on every move inside the zone', () => {
state.hotZoneEnter();
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS - 10);
state.stageActivity();
jest.advanceTimersByTime(10);
expect(state.open()).toBe(true);
});
it('ignores a panel leave while the panel is closed', () => {
state.panelLeave();
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
@@ -95,7 +203,10 @@ describe('FullscreenChannelPanelState', () => {
expect(state.mounted()).toBe(false);
state.hotZoneEnter();
state.stageActivity();
state.reset();
expect(state.hotZoneHover()).toBe(false);
expect(state.hintVisible()).toBe(false);
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
expect(state.open()).toBe(false);
});
@@ -9,15 +9,38 @@ export const CHANNEL_PANEL_OPEN_DWELL_MS = 160;
/**
* Grace period after the mouse leaves the panel before it closes. Brushing
* past the panel's edge while scrolling the list must not slam it shut.
* past the panel's edge while scrolling the list, or reaching for the video
* to glance at it, must not slam the panel shut.
*/
export const CHANNEL_PANEL_CLOSE_GRACE_MS = 420;
export const CHANNEL_PANEL_CLOSE_GRACE_MS = 1000;
/**
* How long the edge hint stays visible after the last pointer movement over
* the stage. Roughly the controls bar's own auto-hide, so the hint comes and
* goes with the rest of the player chrome.
*/
export const CHANNEL_PANEL_HINT_IDLE_MS = 2500;
/** What brought the panel up; decides whether hover-away may close it. */
export type ChannelPanelOpener = 'pointer' | 'keyboard';
/**
* Framework-light owner of the fullscreen channel panel's open/closed state
* and the hover-intent timers around it. The component binds pointer events
* to these methods; the timers here are the only place that decides when a
* hover becomes an open or a close.
*
* Hover-away closes the panel only once the pointer has engaged with it: a
* hover-opened panel counts as engaged from the start (the pointer is on the
* edge), while a keyboard-opened one ignores the mouse roaming over the
* video until it has visited the panel. Otherwise pressing `C` and nudging
* the mouse would close the list under the user's typing.
*
* An explicit close (Escape, close button, `C`) re-arms the hot zone only on
* the next real pointer movement: when the panel slides out from under a
* mouse that is still resting on the edge, the browser synthesizes a
* `pointerenter` on the zone the panel had covered, and honouring it would
* reopen the list 160ms after the user dismissed it.
*/
export class FullscreenChannelPanelState {
readonly open = signal(false);
@@ -27,53 +50,76 @@ export class FullscreenChannelPanelState {
* position and search text between two openings of the same session.
*/
readonly mounted = signal(false);
/**
* The edge hint is showing: the pointer moved over the stage recently
* while the panel was closed, so the user is looking for something.
*/
readonly hintVisible = signal(false);
/**
* The pointer is resting in an armed hot zone, waiting out the dwell.
* Stays false for the synthetic enter that follows an explicit close, so
* the hint does not light up on an edge that cannot open yet.
*/
readonly hotZoneHover = signal(false);
private pointerEngaged = false;
/** False between an explicit close and the next pointer move. */
private pointerMovedSinceHide = true;
/** Where the pointer physically is, armed or not. */
private pointerInHotZone = false;
private openTimer: number | null = null;
private closeTimer: number | null = null;
private hintTimer: number | null = null;
show(): void {
show(opener: ChannelPanelOpener = 'pointer'): void {
this.clearTimers();
this.pointerEngaged = opener === 'pointer';
this.hintVisible.set(false);
this.hotZoneHover.set(false);
this.mounted.set(true);
this.open.set(true);
}
hide(): void {
this.clearTimers();
this.pointerEngaged = false;
this.pointerMovedSinceHide = false;
this.open.set(false);
}
toggle(): void {
toggle(opener: ChannelPanelOpener = 'pointer'): void {
if (this.open()) {
this.hide();
} else {
this.show();
this.show(opener);
}
}
/** Mouse entered the edge hot zone: open once it has rested there. */
hotZoneEnter(): void {
if (this.open()) {
this.pointerInHotZone = true;
if (this.open() || !this.pointerMovedSinceHide) {
return;
}
this.clearOpenTimer();
this.openTimer = window.setTimeout(() => {
this.openTimer = null;
this.show();
}, CHANNEL_PANEL_OPEN_DWELL_MS);
this.hotZoneHover.set(true);
this.startDwell();
}
hotZoneLeave(): void {
this.pointerInHotZone = false;
this.hotZoneHover.set(false);
this.clearOpenTimer();
}
/** Mouse is over the panel: a pending close no longer applies. */
panelEnter(): void {
this.pointerEngaged = true;
this.clearCloseTimer();
}
/** Mouse left the panel: close after the grace period. */
panelLeave(): void {
if (!this.open()) {
if (!this.open() || !this.pointerEngaged) {
return;
}
this.clearCloseTimer();
@@ -83,9 +129,37 @@ export class FullscreenChannelPanelState {
}, CHANNEL_PANEL_CLOSE_GRACE_MS);
}
/**
* Pointer moved over the stage while the panel is closed: show the edge
* hint until the pointer goes idle. Nothing is drawn while it rests.
*/
stageActivity(): void {
this.pointerMovedSinceHide = true;
if (this.open()) {
return;
}
this.hintVisible.set(true);
this.clearHintTimer();
this.hintTimer = window.setTimeout(() => {
this.hintTimer = null;
this.hintVisible.set(false);
}, CHANNEL_PANEL_HINT_IDLE_MS);
// The pointer was already resting in the zone when it re-armed: this
// move arms the hint and starts the dwell.
if (this.pointerInHotZone && this.openTimer === null) {
this.hotZoneHover.set(true);
this.startDwell();
}
}
/** Fullscreen ended: forget everything, including the mounted list. */
reset(): void {
this.clearTimers();
this.pointerEngaged = false;
this.pointerMovedSinceHide = true;
this.pointerInHotZone = false;
this.hintVisible.set(false);
this.hotZoneHover.set(false);
this.open.set(false);
this.mounted.set(false);
}
@@ -94,9 +168,18 @@ export class FullscreenChannelPanelState {
this.clearTimers();
}
private startDwell(): void {
this.clearOpenTimer();
this.openTimer = window.setTimeout(() => {
this.openTimer = null;
this.show('pointer');
}, CHANNEL_PANEL_OPEN_DWELL_MS);
}
private clearTimers(): void {
this.clearOpenTimer();
this.clearCloseTimer();
this.clearHintTimer();
}
private clearOpenTimer(): void {
@@ -112,4 +195,11 @@ export class FullscreenChannelPanelState {
this.closeTimer = null;
}
}
private clearHintTimer(): void {
if (this.hintTimer !== null) {
window.clearTimeout(this.hintTimer);
this.hintTimer = null;
}
}
}
@@ -19,9 +19,27 @@
data-test-id="fullscreen-channel-panel-hot-zone"
aria-hidden="true"
(pointerenter)="onHotZoneEnter($event)"
(pointerleave)="state.hotZoneLeave()"
(pointerleave)="onHotZoneLeave()"
(pointerdown)="onHotZonePointerDown($event)"
(pointerup)="onHotZonePointerUp($event)"
></div>
(pointercancel)="onHotZoneLeave()"
>
<!-- Purely visual (the zone itself is the target): a tab that shows
where the list lives while the pointer is moving, and lights up
while it rests on the edge. Keyboard users have C. -->
@if (!state.open()) {
<div
class="fullscreen-channel-panel__edge-hint"
[class.fullscreen-channel-panel__edge-hint--visible]="
state.hintVisible() || state.hotZoneHover()
"
[class.fullscreen-channel-panel__edge-hint--armed]="
state.hotZoneHover()
"
data-test-id="fullscreen-channel-panel-edge-hint"
></div>
}
</div>
<aside
#panel
@@ -11,7 +11,7 @@
// ─── Closed state ────────────────────────────────────────────────────────────
// Invisible by design: nothing sits over the video until the mouse rests here.
// Invisible by design: nothing sits over the video until the mouse moves.
// It stops well above the shared controls bar (up to ~130px tall in its
// two-row layout), whose leftmost button starts 16px from the edge — the
// strip must never intercept a click or tap meant for play/pause.
@@ -21,7 +21,59 @@
bottom: max(25%, 140px);
left: 0;
z-index: 6;
width: 28px;
width: 40px;
}
// Slim tab on the edge, drawn only while the pointer is moving over the
// stage (`--visible`) or resting in the hot zone (`--armed`). A CSS chevron
// rather than an icon glyph keeps the zone free of text and of the icon
// font. Pointer-transparent: the hot zone stays the event target.
.fullscreen-channel-panel__edge-hint {
position: absolute;
top: 50%;
left: 0;
width: 22px;
height: 96px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-left: 0;
border-radius: 0 12px 12px 0;
background: rgba(13, 15, 18, 0.72);
box-shadow: 4px 0 18px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(12px);
opacity: 0;
transform: translate(-100%, -50%);
pointer-events: none;
transition:
opacity 180ms ease-out,
transform 180ms ease-out,
background-color 150ms ease-out,
border-color 150ms ease-out;
}
.fullscreen-channel-panel__edge-hint::after {
content: '';
position: absolute;
top: 50%;
left: 5px;
width: 8px;
height: 8px;
border-top: 2px solid rgba(255, 255, 255, 0.75);
border-right: 2px solid rgba(255, 255, 255, 0.75);
transform: translateY(-50%) rotate(45deg);
}
.fullscreen-channel-panel__edge-hint--visible {
opacity: 1;
transform: translate(0, -50%);
}
.fullscreen-channel-panel__edge-hint--armed {
border-color: var(--app-selection-color, #78adff);
background: rgba(13, 15, 18, 0.9);
}
.fullscreen-channel-panel__edge-hint--armed::after {
border-color: #ffffff;
}
// ─── Open state ──────────────────────────────────────────────────────────────
@@ -198,7 +250,7 @@
@media (pointer: coarse) {
// A finger needs a wider target than a resting mouse.
.fullscreen-channel-panel__hot-zone {
width: 40px;
width: 48px;
}
}
@@ -209,7 +261,8 @@
}
@media (prefers-reduced-motion: reduce) {
.fullscreen-channel-panel {
.fullscreen-channel-panel,
.fullscreen-channel-panel__edge-hint {
transition: none;
}
}
@@ -12,6 +12,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
CHANNEL_PANEL_CLOSE_GRACE_MS,
CHANNEL_PANEL_HINT_IDLE_MS,
CHANNEL_PANEL_OPEN_DWELL_MS,
} from './fullscreen-channel-panel-state';
import { FullscreenChannelPanelComponent } from './fullscreen-channel-panel.component';
@@ -68,13 +69,21 @@ class NoHostComponent {}
function pointerEvent(
type: string,
pointerType = 'mouse',
init: { relatedTarget?: EventTarget | null } = {}
init: {
relatedTarget?: EventTarget | null;
button?: number;
pointerId?: number;
} = {}
): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'pointerType', { value: pointerType });
Object.defineProperty(event, 'relatedTarget', {
value: init.relatedTarget ?? null,
});
Object.defineProperty(event, 'button', { value: init.button ?? 0 });
Object.defineProperty(event, 'pointerId', {
value: init.pointerId ?? 1,
});
return event;
}
@@ -90,6 +99,11 @@ describe('FullscreenChannelPanelComponent', () => {
const hotZone = () => query('fullscreen-channel-panel-hot-zone');
const isOpen = () =>
panel()?.classList.contains('fullscreen-channel-panel--open') === true;
const edgeHint = () => query('fullscreen-channel-panel-edge-hint');
const isHintVisible = () =>
edgeHint()?.classList.contains(
'fullscreen-channel-panel__edge-hint--visible'
) === true;
const setFullscreen = (element: Element | null) => {
fullscreenElement = element;
@@ -97,7 +111,10 @@ describe('FullscreenChannelPanelComponent', () => {
fixture.detectChanges();
};
// A real mouse moves over the stage before it reaches the edge; the move
// is what re-arms the hot zone after an explicit close.
const openByHover = () => {
stage.dispatchEvent(pointerEvent('pointermove'));
hotZone()?.dispatchEvent(pointerEvent('pointerenter'));
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
fixture.detectChanges();
@@ -248,7 +265,7 @@ describe('FullscreenChannelPanelComponent', () => {
});
});
it('draws nothing over the video while closed: only the hot zone beside the inert, off-screen panel', () => {
it('draws nothing over the video while closed and idle: only the hot zone with its hidden hint beside the inert, off-screen panel', () => {
setFullscreen(stage);
const children = Array.from(
@@ -264,12 +281,133 @@ describe('FullscreenChannelPanelComponent', () => {
]);
expect(children[0]).toBe(hotZone());
expect(hotZone()?.textContent?.trim()).toBe('');
expect(hotZone()?.children.length).toBe(1);
expect(hotZone()?.firstElementChild).toBe(edgeHint());
expect(isHintVisible()).toBe(false);
expect(isOpen()).toBe(false);
expect(panel()?.getAttribute('aria-hidden')).toBe('true');
expect(panel()?.hasAttribute('inert')).toBe(true);
expect(query('host-list')).toBeNull();
});
it('reveals the edge hint while the mouse moves over the stage and hides it once idle', () => {
setFullscreen(stage);
stage.dispatchEvent(pointerEvent('pointermove'));
fixture.detectChanges();
expect(isHintVisible()).toBe(true);
jest.advanceTimersByTime(CHANNEL_PANEL_HINT_IDLE_MS);
fixture.detectChanges();
expect(isHintVisible()).toBe(false);
// Touch has no pointer to hint at.
stage.dispatchEvent(pointerEvent('pointermove', 'touch'));
fixture.detectChanges();
expect(isHintVisible()).toBe(false);
// Resting on the edge lights the hint up before the dwell completes.
hotZone()?.dispatchEvent(pointerEvent('pointerenter'));
fixture.detectChanges();
expect(isHintVisible()).toBe(true);
expect(
edgeHint()?.classList.contains(
'fullscreen-channel-panel__edge-hint--armed'
)
).toBe(true);
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
fixture.detectChanges();
expect(isOpen()).toBe(true);
expect(edgeHint()).toBeNull();
});
it('opens at once on a primary click on the edge, without the dwell', () => {
setFullscreen(stage);
hotZone()?.dispatchEvent(pointerEvent('pointerdown'));
hotZone()?.dispatchEvent(pointerEvent('pointerup'));
fixture.detectChanges();
expect(isOpen()).toBe(true);
expect(query('host-list')).not.toBeNull();
});
it('ignores a release that is not a primary click begun on the edge', () => {
setFullscreen(stage);
const expectClosed = () => {
fixture.detectChanges();
expect(isOpen()).toBe(false);
};
// A drag started elsewhere and released over the edge.
hotZone()?.dispatchEvent(pointerEvent('pointerup'));
expectClosed();
// Right and middle buttons.
for (const button of [1, 2]) {
hotZone()?.dispatchEvent(
pointerEvent('pointerdown', 'mouse', { button })
);
hotZone()?.dispatchEvent(
pointerEvent('pointerup', 'mouse', { button })
);
expectClosed();
}
// A press that left the zone before its release.
hotZone()?.dispatchEvent(pointerEvent('pointerdown'));
hotZone()?.dispatchEvent(pointerEvent('pointerleave'));
hotZone()?.dispatchEvent(pointerEvent('pointerup'));
expectClosed();
// A different pointer releasing than the one that pressed.
hotZone()?.dispatchEvent(
pointerEvent('pointerdown', 'mouse', { pointerId: 1 })
);
hotZone()?.dispatchEvent(
pointerEvent('pointerup', 'mouse', { pointerId: 2 })
);
expectClosed();
});
it('keeps a C-opened panel while the mouse roams the video until it has visited the list', () => {
setFullscreen(stage);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' }));
fixture.detectChanges();
expect(isOpen()).toBe(true);
query('fullscreen-channel-panel-scrim')?.dispatchEvent(
pointerEvent('pointerover')
);
panel()?.dispatchEvent(pointerEvent('pointerleave'));
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
fixture.detectChanges();
expect(isOpen()).toBe(true);
panel()?.dispatchEvent(pointerEvent('pointerenter'));
panel()?.dispatchEvent(pointerEvent('pointerleave'));
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
fixture.detectChanges();
expect(isOpen()).toBe(false);
});
it('never closes on a click inside the panel, even where the click target is replaced', () => {
setFullscreen(stage);
openByHover();
const list = query('host-list');
if (!list) {
throw new Error('host list missing');
}
// A click that re-renders what was under the pointer: the pointer's
// next target is still inside the panel, so no close is scheduled.
list.dispatchEvent(new MouseEvent('click', { bubbles: true }));
panel()?.dispatchEvent(pointerEvent('pointerover'));
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
fixture.detectChanges();
expect(isOpen()).toBe(true);
});
it('slides in after the mouse rests on the left edge and stamps the host list', () => {
setFullscreen(stage);
openByHover();
@@ -325,6 +463,7 @@ describe('FullscreenChannelPanelComponent', () => {
fixture.detectChanges();
expect(isOpen()).toBe(false);
hotZone()?.dispatchEvent(pointerEvent('pointerdown', 'touch'));
hotZone()?.dispatchEvent(pointerEvent('pointerup', 'touch'));
fixture.detectChanges();
expect(isOpen()).toBe(true);
@@ -467,6 +606,30 @@ describe('FullscreenChannelPanelComponent', () => {
expect(isOpen()).toBe(true);
});
it('stays closed after Escape until the resting mouse actually moves', () => {
setFullscreen(stage);
openByHover();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
fixture.detectChanges();
expect(isOpen()).toBe(false);
// The panel slid out from under the pointer: the browser reports the
// hot zone under it again without any movement. The edge stays clear.
hotZone()?.dispatchEvent(pointerEvent('pointerenter'));
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
fixture.detectChanges();
expect(isOpen()).toBe(false);
expect(isHintVisible()).toBe(false);
stage.dispatchEvent(pointerEvent('pointermove'));
fixture.detectChanges();
expect(isHintVisible()).toBe(true);
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
fixture.detectChanges();
expect(isOpen()).toBe(true);
});
it('closes on Escape', () => {
setFullscreen(stage);
openByHover();
@@ -52,15 +52,19 @@ function targetsEditable(event: KeyboardEvent): boolean {
* can also switch it off through `enabled` for an engine that paints above
* the DOM (native-view Embedded MPV), where no DOM panel could show.
*
* Nothing is drawn over the video while the panel is closed: opening is
* resting the mouse on the left edge (a tap on that edge for touch, which has
* no hover) or pressing `C`. Closing: moving the mouse away, clicking the
* video (a scrim swallows that click so it never pauses playback), the close
* button, Escape, or leaving fullscreen. A CDK overlay opened from the list
* (sort menu, row context menu) counts as part of the panel: the pointer
* moving into it does not start the close, and Escape closes that overlay
* first. The list stays mounted between openings of one fullscreen session
* so its scroll position and search survive.
* Nothing is drawn over the video while the panel is closed and the pointer
* rests. Moving the mouse over the stage reveals a slim hint tab on the left
* edge (it fades with the rest of the chrome once the pointer goes idle), and
* opening is resting the mouse on that edge, clicking or tapping it, or
* pressing `C`. Closing: moving the mouse away after it has been inside the
* panel, clicking the video (a scrim swallows that click so it never pauses
* playback), the close button, Escape, or leaving fullscreen. A panel opened
* with `C` ignores the mouse roaming over the video until it has visited the
* list, so the key never leaves the user typing into a closing search field.
* A CDK overlay opened from the list (sort menu, row context menu) counts as
* part of the panel: the pointer moving into it does not start the close, and
* Escape closes that overlay first. The list stays mounted between openings
* of one fullscreen session so its scroll position and search survive.
*/
@Component({
selector: 'app-fullscreen-channel-panel',
@@ -125,6 +129,9 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
close: () => this.state.hide(),
};
/** Pointer whose primary press began inside the hot zone, if any. */
private hotZonePressPointerId: number | null = null;
private readonly onDocumentKeydown = (event: KeyboardEvent) =>
this.handleKeydown(event);
@@ -142,6 +149,25 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
}
untracked(() => this.resetSession());
});
// The edge hint follows pointer activity over the stage, the element
// that owns fullscreen: any mouse movement over the video reveals it.
effect((onCleanup) => {
const stage = this.stage();
if (!this.active() || !stage) {
return;
}
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== 'touch') {
this.state.stageActivity();
}
};
stage.addEventListener('pointermove', onPointerMove, {
passive: true,
});
onCleanup(() =>
stage.removeEventListener('pointermove', onPointerMove)
);
});
// While open, hover intent is tracked document-wide: a CDK overlay the
// list opens (sort menu, context menu) renders outside the <aside>, so
// the aside's own pointerleave alone would close the panel under the
@@ -176,16 +202,32 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
}
/**
* Touch has no hover and no `C` key, so a tap on the edge is its way in.
* Bound to pointerup, not pointerdown: the hot zone must still be the
* click target when the tap completes, so the click that follows dies on
* it instead of reaching the video.
* A click or tap on the edge opens at once, without the hover dwell:
* touch has no hover and no `C` key, and a mouse user who has found the
* hint tab should not have to hold still. Only a primary press that
* began inside the zone counts: a drag released over the edge, a right
* or middle click and a pen barrel button are not a click. Completed on
* pointerup, not pointerdown: the hot zone must still be the click
* target when the press completes, so the click that follows dies on it
* instead of reaching the video's click-to-pause.
*/
onHotZonePointerDown(event: PointerEvent): void {
this.hotZonePressPointerId =
event.button === 0 ? event.pointerId : null;
}
onHotZonePointerUp(event: PointerEvent): void {
if (event.pointerType !== 'touch') {
const pressed = this.hotZonePressPointerId;
this.hotZonePressPointerId = null;
if (pressed === null || pressed !== event.pointerId) {
return;
}
this.state.show();
this.state.show('pointer');
}
onHotZoneLeave(): void {
this.hotZonePressPointerId = null;
this.state.hotZoneLeave();
}
onPanelPointerEnter(event: PointerEvent): void {
@@ -220,7 +262,7 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
}
if (
this.isInsidePanel(event.target) ||
this.hotZoneElement()?.nativeElement === event.target ||
this.isInsideHotZone(event.target) ||
this.isInsideOverlay(event.target)
) {
this.state.panelEnter();
@@ -229,6 +271,15 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
}
}
private isInsideHotZone(target: EventTarget | null): boolean {
const hotZone = this.hotZoneElement()?.nativeElement;
return (
hotZone !== undefined &&
target instanceof Node &&
hotZone.contains(target)
);
}
private isInsidePanel(target: EventTarget | null): boolean {
const panel = this.panelElement()?.nativeElement;
return (
@@ -300,7 +351,7 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
this.state.hide();
return;
}
this.state.show();
this.state.show('keyboard');
this.focusSearch();
}