mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
fix: prevent fullscreen hover and playlist refresh races (#1591)
This commit is contained in:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
+14
-12
@@ -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"
|
||||
|
||||
+28
-1
@@ -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', () => {
|
||||
|
||||
+3
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user