fix: prevent fullscreen hover and playlist refresh races (#1591)

This commit is contained in:
4gray authored and GitHub committed 2026-09-12 21:11:11 +02:00
1 parent 621436fb1d
commit 6e3f0d258f
16 files changed
+346 -25

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: playback
---
The fullscreen channel panel stays open under the pointer when its opening animation is delayed, instead of closing before the list appears.
@@ -0,0 +1,6 @@
---
type: fix
area: playlists
---
Opening an M3U playlist immediately after refreshing now waits for its pending save, so the channel list does not revert to the previous catalog.
+6 -1
View File
@@ -406,6 +406,9 @@ Key files:
## M3U URL User-Agent
- `PlaylistsService.getPlaylist()` joins the per-playlist mutation queue so a
route opened during refresh reads after its pending save. Mutation-internal
reads keep using `getPlaylistById()` directly to avoid queue re-entry.
- The URL import form accepts an optional User-Agent and stores it as
`Playlist.userAgent`. Electron sends it on initial download, manual refresh,
and startup auto-update. The self-hosted PWA sends it through the registered
@@ -553,7 +556,9 @@ Key files:
first unknown probe and confirmed native/unsupported results withhold it.
A live host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional
`panelTitle`) and the panel slides that list over the video: left-edge hover
dwell, a touch tap on that edge, or `C`. Nothing is drawn while it is closed
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
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
+4 -1
View File
@@ -855,6 +855,9 @@ panel's clock, not the viewer's").
#### M3U URL User-Agent
- `PlaylistsService.getPlaylist()` joins the per-playlist mutation queue so a
route opened during refresh reads after its pending save. Mutation-internal
reads keep using `getPlaylistById()` directly to avoid queue re-entry.
- The URL import form accepts an optional User-Agent and stores it as
`Playlist.userAgent`. Electron sends it on initial download, manual refresh,
and startup auto-update. The self-hosted PWA sends it through the registered
@@ -1274,7 +1277,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
@@ -426,6 +426,13 @@ for (const player of ['mpv', 'vlc']) {
const isFullscreen = () =>
page.evaluate(() => document.fullscreenElement !== null);
await expect.poll(isFullscreen).toBe(true);
// Model a delayed first fullscreen paint: the hover-open handoff
// must survive longer than the panel's mouse-leave grace period.
await page.addStyleTag({
content: `
.fullscreen-channel-panel--open { transition-delay: 600ms, 0s; }
`,
});
await page.getByTestId('fullscreen-channel-panel-hot-zone').hover();
const panel = page.getByTestId('fullscreen-channel-panel');
await expect(panel).toHaveAttribute('aria-hidden', 'false');
@@ -85,6 +85,8 @@ type ElectronFixtures = {
};
export type LaunchElectronAppOptions = {
/** Test bootstrap that loads the real main entry after installing probes. */
entryPoint?: string;
/** Electron/Chromium switches — they must precede the entry point. */
args?: readonly string[];
/**
@@ -199,9 +201,10 @@ export { expect };
*/
function buildElectronLaunchArgs(
extraArgs: readonly string[] = [],
appArgs: readonly string[] = []
appArgs: readonly string[] = [],
entryPoint = electronMainPath
): string[] {
const args = [...extraArgs, electronMainPath, ...appArgs];
const args = [...extraArgs, entryPoint, ...appArgs];
if (process.platform === 'linux' && process.env['CI']) {
args.unshift('--no-sandbox', '--disable-gpu');
@@ -221,7 +224,11 @@ export async function launchElectronApp(
}
assertPackagedRendererBuildIsElectronSafe();
const args = buildElectronLaunchArgs(options.args, options.appArgs);
const args = buildElectronLaunchArgs(
options.args,
options.appArgs,
options.entryPoint
);
const electronApp = await electron.launch({
args,
@@ -16,6 +16,11 @@ import {
waitForM3uCatalog,
} from './electron-test-fixtures';
import {
holdNextPlaylistWrite,
launchElectronWithWriteGate,
} from './playlist-refresh-write-gate';
const autoRefreshSourceName = 'auto-refresh-source.m3u';
function buildAutoRefreshM3u(channelName: string): string {
@@ -50,7 +55,7 @@ test.describe('Electron startup playlist auto-refresh', () => {
requiredUserAgent: userAgent,
}
);
const app = await launchElectronApp(dataDir);
const app = await launchElectronWithWriteGate(dataDir);
try {
expect((await fetch(urlServer.resourceUrl)).status).toBe(403);
await importM3uPlaylistFromUrl(
@@ -81,8 +86,17 @@ test.describe('Electron startup playlist auto-refresh', () => {
// A refresh result must keep the in-memory default as well as SQLite metadata.
await openSources(app.mainWindow);
urlServer.setBody(buildAutoRefreshM3u('Second UA Channel'));
const writeGate = await holdNextPlaylistWrite(app);
await refreshSource(app.mainWindow, autoRefreshSourceName);
await openAutoRefreshPlaylistCatalog(app.mainWindow);
await writeGate.waitUntilHeld();
// Open the catalog while its refreshed payload is still being saved.
// Waiting for idle here would hide a real route/persistence race.
await sourceRowByTitle(app.mainWindow, autoRefreshSourceName)
.first()
.click();
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+\/all$/);
await writeGate.release();
await waitForM3uCatalog(app.mainWindow);
await expect(
app.mainWindow
.getByTestId('channel-item')
@@ -0,0 +1,106 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { LaunchedElectronApp } from './electron-test-fixtures';
import {
electronMainPath,
expect,
launchElectronApp,
} from './electron-test-fixtures';
interface WriteGateState {
held: boolean;
release: () => void;
finished: Promise<void>;
gate: Promise<void>;
finish: () => void;
}
type GateGlobal = typeof globalThis & {
__playlistWriteGate?: WriteGateState;
__playlistWriteGateInstalled?: boolean;
};
// Serialized into the test bootstrap; all runtime dependencies are arguments.
function installWriteGate({
ipcMain,
}: Pick<typeof import('electron'), 'ipcMain'>): void {
const register = ipcMain.handle;
ipcMain.handle = function (channel, listener) {
if (channel !== 'DB_UPSERT_APP_PLAYLIST') {
return register.call(this, channel, listener);
}
// Capture registration through the public API, then restore it. No
// Electron-private handler registry is read or mutated.
ipcMain.handle = register;
(globalThis as GateGlobal).__playlistWriteGateInstalled = true;
return register.call(this, channel, async (...args) => {
const state = (globalThis as GateGlobal).__playlistWriteGate;
if (!state || state.held) return listener(...args);
state.held = true;
await state.gate;
try {
return await listener(...args);
} finally {
state.finish();
}
});
};
}
export function launchElectronWithWriteGate(
dataDir: string
): Promise<LaunchedElectronApp> {
const entryPoint = join(dataDir, 'playlist-write-gate.cjs');
writeFileSync(
entryPoint,
[
`(${installWriteGate.toString()})(require('electron'));`,
`require(${JSON.stringify(electronMainPath)});`,
].join('\n')
);
return launchElectronApp(dataDir, { entryPoint });
}
/** Hold one real SQLite write so navigation can race refresh deterministically. */
export async function holdNextPlaylistWrite(app: LaunchedElectronApp) {
await app.electronApp.evaluate(() => {
if (!(globalThis as GateGlobal).__playlistWriteGateInstalled) {
throw new Error('Playlist write gate bootstrap was not installed');
}
let release!: () => void;
let finish!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
const state: WriteGateState = {
held: false,
release,
finished,
gate,
finish,
};
(globalThis as GateGlobal).__playlistWriteGate = state;
});
return {
waitUntilHeld: () =>
expect
.poll(() =>
app.electronApp.evaluate(
() =>
(globalThis as GateGlobal).__playlistWriteGate?.held
)
)
.toBe(true),
release: () =>
app.electronApp.evaluate(async () => {
const state = (globalThis as GateGlobal).__playlistWriteGate;
if (!state) throw new Error('Playlist write gate is missing');
state.release();
await state.finished;
}),
};
}
@@ -129,6 +129,12 @@ async function pastProgramWindows(
credentials: { username: string; password: string }
): Promise<PastProgramWindow> {
const fixture = await fetchXtreamEpgFixture(request, credentials);
return pastProgramWindow(fixture);
}
function pastProgramWindow(
fixture: Awaited<ReturnType<typeof fetchXtreamEpgFixture>>
): PastProgramWindow {
const past = fixture.fullEpg.find(
(listing) => listing.title === PAST_PROGRAM
);
@@ -314,7 +320,9 @@ test('@epg @xtream @electron copies the archive URL without starting archive pla
expect(start).toBeTruthy();
await expectServerClock(
start!,
[await pastProgramWindows(request, credentials)],
// The mock caches this schedule until reset. Use the fixture that
// supplied the UI, without a second HTTP oracle after clipboard success.
[pastProgramWindow(fixture)],
0
);
// Main-process archive probes do not appear as renderer playback requests.
+7
View File
@@ -283,6 +283,13 @@ trace; raw profiles remain ignored.
## Playlist Refresh And Startup Auto-Update (Electron)
Catalog reads through `PlaylistsService.getPlaylist()` join the per-playlist
mutation queue. Refresh publishes its store action before persistence finishes;
opening the route in that interval must read after the queued save rather than
overwrite the refreshed channels with an older SQLite/IndexedDB snapshot.
Mutation-internal reads use `getPlaylistById()` directly to avoid queue re-entry.
Failed writes release the queue, and other playlist IDs remain independent.
Two paths re-download an M3U playlist from its original source:
- **Explicit refresh** — `PLAYLIST_REFRESH` runs in `playlist-refresh.worker.ts`, reports
@@ -508,7 +508,10 @@ 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
left edge opens the panel after a 160ms mouse dwell; a sweep across the edge
is ignored. The zone stops above the controls bar (`bottom: max(25%, 140px)`)
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
@@ -523,7 +526,7 @@ 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
the fullscreen overlay container outside the `<aside>`, so it counts as part
of the panel: while open, hover intent is tracked through a document-level
`pointerover` (inside the aside or the overlay container cancels a pending
`pointerover` (inside the aside, hot zone or the overlay container cancels a pending
close, anywhere else schedules one), the aside's own `pointerleave` ignores a
move into the overlay container, and Escape is left to an overlay with a
backdrop. The header is a single row: the search field, whose placeholder
+112 -1
View File
@@ -1,4 +1,4 @@
import { EMPTY, firstValueFrom, of } from 'rxjs';
import { EMPTY, firstValueFrom, from, of } from 'rxjs';
import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistsService, resolvePlaylistParser } from './playlists.service';
@@ -1799,6 +1799,117 @@ describe('PlaylistsService', () => {
return { store, electron };
}
it.each([false, true])(
'reads the catalog after a queued refresh settles (write fails: %s)',
async (fails) => {
const original = createBasePlaylist('catalog-refresh');
const { store, electron } =
createStatefulElectronStore(original);
testWindow.electron = electron;
let release!: () => void;
let started!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const writing = new Promise<void>((resolve) => {
started = resolve;
});
electron.dbUpsertAppPlaylist.mockImplementationOnce(
async (playlist) => {
started();
await gate;
if (fails) throw new Error('Write failed');
store.current = playlist;
}
);
const service = createService();
const updated = { ...original, title: 'Refreshed catalog' };
const write = firstValueFrom(
service.updatePlaylist(original._id, updated)
);
const outcome = write.catch((error: Error) => error);
await writing;
// A database read captures the snapshot at request time,
// even if its IPC response arrives after the write completes.
jest.spyOn(service, 'getPlaylistById').mockImplementation(() =>
of(store.current)
);
const read = firstValueFrom(service.getPlaylist(original._id));
release();
await outcome;
expect((await read).title).toBe(
fails ? original.title : updated.title
);
}
);
it('orders a browser catalog read after a queued IndexedDB refresh', async () => {
const original = createBasePlaylist('browser-catalog');
const state = { current: original };
testWindow.electron = undefined;
let release!: () => void;
let started!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const writing = new Promise<void>((resolve) => {
started = resolve;
});
const service = createService({
getByID: jest.fn(() => of(state.current)),
update: jest.fn((_store: string, playlist: Playlist) => {
started();
return from(
gate.then(() => {
state.current = playlist;
return playlist;
})
);
}),
});
const updated = { ...original, title: 'Refreshed browser catalog' };
const write = firstValueFrom(
service.updatePlaylist(original._id, updated)
);
await writing;
const read = firstValueFrom(service.getPlaylist(original._id));
release();
await write;
expect((await read).title).toBe(updated.title);
});
it('does not block a different catalog behind a pending write', async () => {
const original = createBasePlaylist('catalog-busy');
const { electron } = createStatefulElectronStore(original);
testWindow.electron = electron;
let release!: () => void;
let started!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const writing = new Promise<void>((resolve) => {
started = resolve;
});
electron.dbUpsertAppPlaylist.mockImplementationOnce(async () => {
started();
await gate;
});
const service = createService();
const write = firstValueFrom(
service.updatePlaylist(original._id, original)
);
await writing;
try {
await firstValueFrom(service.getPlaylist('catalog-other'));
expect(electron.dbGetAppPlaylist).toHaveBeenCalledWith(
'catalog-other'
);
} finally {
release();
await write;
}
});
it('keeps both changes when a favorite add overlaps a recently-viewed add (SQLite)', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-race-cross-field')
+7 -1
View File
@@ -520,7 +520,13 @@ export class PlaylistsService {
if (id === 'global-favorites') {
return this.getPlaylistWithGlobalFavorites();
}
return this.getPlaylistById(id);
// Refresh publishes its store action before its asynchronous write
// finishes. A route opened in that interval must not replace the new
// channels with the previous persisted snapshot. Join the same queue;
// mutation-internal reads keep using getPlaylistById to avoid re-entry.
return this.serializePlaylistWrite(id, () =>
firstValueFrom(this.getPlaylistById(id))
);
}
deletePlaylist(playlistId: string): Observable<{ success: boolean }> {
@@ -1,16 +1,5 @@
@if (active()) {
@if (!state.open()) {
<!-- Invisible: the only closed-state affordance. Mouse dwell or a
touch tap on the left edge opens the panel. -->
<div
class="fullscreen-channel-panel__hot-zone"
data-test-id="fullscreen-channel-panel-hot-zone"
aria-hidden="true"
(pointerenter)="onHotZoneEnter($event)"
(pointerleave)="state.hotZoneLeave()"
(pointerup)="onHotZonePointerUp($event)"
></div>
} @else {
@if (state.open()) {
<!-- Swallows the click that dismisses the panel so the player's
click-to-pause never sees it. -->
<div
@@ -21,6 +10,19 @@
></div>
}
<!-- Keep the hover target above the scrim until the animated panel covers
it. Removing it on open synthesizes a pointerover on the scrim and
starts closing before a delayed first fullscreen paint can finish. -->
<div
#hotZone
class="fullscreen-channel-panel__hot-zone"
data-test-id="fullscreen-channel-panel-hot-zone"
aria-hidden="true"
(pointerenter)="onHotZoneEnter($event)"
(pointerleave)="state.hotZoneLeave()"
(pointerup)="onHotZonePointerUp($event)"
></div>
<aside
#panel
class="fullscreen-channel-panel dark-theme"
@@ -277,7 +277,34 @@ describe('FullscreenChannelPanelComponent', () => {
expect(isOpen()).toBe(true);
expect(query('host-list')).not.toBeNull();
expect(query('fullscreen-channel-panel-scrim')).not.toBeNull();
expect(hotZone()).toBeNull();
expect(hotZone()).not.toBeNull();
});
it('retains the hover target above the scrim during a delayed opening paint', () => {
setFullscreen(stage);
const originalHotZone = hotZone();
openByHover();
const scrim = query('fullscreen-channel-panel-scrim');
if (!originalHotZone?.parentElement || !scrim) {
throw new Error('Fullscreen hover surfaces are missing');
}
expect(hotZone()).toBe(originalHotZone);
const children = Array.from(originalHotZone.parentElement.children);
expect(children.indexOf(originalHotZone)).toBeGreaterThan(
children.indexOf(scrim)
);
hotZone()?.dispatchEvent(pointerEvent('pointerover'));
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS + 1);
fixture.detectChanges();
expect(isOpen()).toBe(true);
// Moving away still closes, even if the panel has not painted yet.
query('fullscreen-channel-panel-scrim')?.dispatchEvent(
pointerEvent('pointerover')
);
jest.advanceTimersByTime(CHANNEL_PANEL_CLOSE_GRACE_MS);
fixture.detectChanges();
expect(isOpen()).toBe(false);
});
it('does not open for a mouse that merely sweeps across the edge', () => {
@@ -97,6 +97,8 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('searchInput');
private readonly panelElement = viewChild<ElementRef<HTMLElement>>('panel');
private readonly hotZoneElement =
viewChild<ElementRef<HTMLElement>>('hotZone');
readonly state = new FullscreenChannelPanelState();
readonly searchTerm = signal('');
@@ -218,6 +220,7 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
}
if (
this.isInsidePanel(event.target) ||
this.hotZoneElement()?.nativeElement === event.target ||
this.isInsideOverlay(event.target)
) {
this.state.panelEnter();