Merge remote-tracking branch 'origin/master' into claude/fervent-cannon-196cb2

This commit is contained in:
4gray committed 2026-10-12 20:30:09 +02:00
commit eef1e58142
33 files changed
+1595 -36

No files matched your search

@@ -0,0 +1,9 @@
---
type: fix
area: playback
issues: [1902]
---
Embedded MPV no longer skips to the next episode when the provider drops the
connection in the middle of an episode. It now reconnects and continues the
episode where it stopped; a movie interrupted the same way resumes as well.
+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: workspace
---
Opening a source from the dashboard no longer makes the page jump: the content
slides aside for the category panel, and the header controls slide over for
the Back button. The category list's search and sort buttons are in place
while the list loads.
@@ -0,0 +1,200 @@
import type { APIRequestContext, Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForXtreamImportToFinish,
xtreamMockServer,
} from './electron-test-fixtures';
import { installEmbeddedMpvSessionCapture } from './embedded-mpv-frame-copy-packaged-fixtures';
import {
fetchXtreamSeriesFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
/**
* Issue #1902: an episode whose connection drops reaches mpv as EOF in the
* middle of the episode, and series autoplay used to take that for the end
* and start the next episode. The `streamdrop` mock cuts every episode a
* third of the way into its 60 s clip and refuses it for a few seconds, so
* ffmpeg's own reconnect fails and only the app-level reconnect can resume.
* Needs the native-view Embedded MPV runtime; skips where it is missing.
*/
const credentials = { password: 'streamdrop', username: 'streamdrop' };
/** Past the ~21 s cut: the resumed episode has played on. */
const RESUMED_POSITION_SECONDS = 25;
/** Keyframe rounding and the 500 ms poll before the drop. */
const RESUME_TOLERANCE_SECONDS = 3;
type SessionSample = {
episodeId: number | null;
positionSeconds: number;
reconnecting: boolean;
status: string;
};
async function fetchSeasonOneEpisodeIds(
request: APIRequestContext,
seriesId: number
): Promise<number[]> {
const params = new URLSearchParams({
action: 'get_series_info',
series_id: String(seriesId),
...credentials,
});
const response = await request.get(
`${xtreamMockServer}/player_api.php?${params}`
);
expect(response.ok()).toBeTruthy();
const info = (await response.json()) as {
episodes?: Record<string, { id: string; episode_num: number }[]>;
};
return [...(info.episodes?.['1'] ?? [])]
.sort((a, b) => a.episode_num - b.episode_num)
.map((episode) => Number(episode.id));
}
/**
* Session updates in arrival order, each tagged with the episode its session
* last loaded: the `loading` update carries the requested URL, while later
* snapshots report mpv's `path`, which not every engine fills in reliably.
*/
function readSessions(page: Page): Promise<SessionSample[]> {
return page.evaluate(() => {
const episodeBySession = new Map<string, number>();
return (window.__packagedEmbeddedMpvSessions ?? []).map((session) => {
const match = /\/series\/[^/]+\/[^/]+\/(\d+)\.\w+$/.exec(
session.streamUrl
);
if (match) {
episodeBySession.set(session.id, Number(match[1]));
}
return {
episodeId: episodeBySession.get(session.id) ?? null,
positionSeconds: session.positionSeconds,
reconnecting: !!session.reconnect,
status: session.status,
};
});
});
}
/**
* What followed the first episode's mid-episode EOF, in update order. Every
* position played after the drop must stay near the drop: a reload that
* restarted the episode would otherwise reach the resumed position as well.
* A zero is the engine's placeholder before the reload's first `time-pos`.
*/
function outcomeAfterDrop(
sessions: SessionSample[],
[first, second]: number[]
): 'pending' | 'resumed' | 'restarted' | 'skipped' {
const dropped = sessions.findIndex(
(session) => session.episodeId === first && session.status === 'ended'
);
if (dropped < 0) {
return 'pending';
}
const dropPosition = sessions[dropped].positionSeconds;
for (const session of sessions.slice(dropped)) {
if (session.episodeId === second) {
return 'skipped';
}
if (session.episodeId !== first || session.status !== 'playing') {
continue;
}
if (
session.positionSeconds > 0 &&
session.positionSeconds < dropPosition - RESUME_TOLERANCE_SECONDS
) {
return 'restarted';
}
if (session.positionSeconds >= RESUMED_POSITION_SECONDS) {
return 'resumed';
}
}
return 'pending';
}
test('@playback @xtream @electron @embedded-mpv resumes an episode whose connection drops instead of skipping to the next one', async ({
dataDir,
request,
}) => {
test.setTimeout(180_000);
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamSeriesFixture(request, credentials);
const series = fixture.items[0];
if (!series) {
throw new Error('The stream-drop scenario has no series.');
}
const episodeIds = await fetchSeasonOneEpisodeIds(
request,
Number(series.series_id)
);
expect(episodeIds).toHaveLength(3);
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '0',
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
},
});
try {
const support = await app.mainWindow.evaluate(() =>
window.electron.getEmbeddedMpvSupport()
);
test.skip(
!support.supported || support.engine !== 'native',
`Native-view Embedded MPV unavailable: ${JSON.stringify(support)}`
);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await app.mainWindow.getByTestId('select-video-player').click();
await app.mainWindow.getByTestId('embedded-mpv').click();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: 'Stream Drop',
});
await waitForXtreamImportToFinish(app.mainWindow);
await installEmbeddedMpvSessionCapture(app);
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await clickGridListCardByTitle(app.mainWindow, getXtreamTitle(series));
const episode = app.mainWindow
.locator('.episode-card, .episode-list-item')
.first();
await expect(episode).toBeVisible({ timeout: 20_000 });
await episode.click();
await expect
.poll(
async () =>
outcomeAfterDrop(
await readSessions(app.mainWindow),
episodeIds
),
{ timeout: 90_000 }
)
.not.toBe('pending');
const sessions = await readSessions(app.mainWindow);
expect(outcomeAfterDrop(sessions, episodeIds)).toBe('resumed');
// The drop was shown as a reconnect, not as the episode's end.
expect(sessions.some((session) => session.reconnecting)).toBe(true);
} finally {
await closeElectronApp(app);
}
});
@@ -142,6 +142,7 @@ describe('EmbeddedMpvNativeService reconnect', () => {
};
let status: EmbeddedMpvSessionStatus;
let positionSeconds: number;
let durationSeconds: number | null;
let recordingActive: boolean;
let errorOrigin: 'playback' | 'engine' | undefined;
let rejectedOptionKeys: string[] | undefined;
@@ -179,6 +180,7 @@ describe('EmbeddedMpvNativeService reconnect', () => {
service = new EmbeddedMpvNativeService();
status = 'idle';
positionSeconds = 0;
durationSeconds = null;
recordingActive = false;
errorOrigin = undefined;
rejectedOptionKeys = undefined;
@@ -193,7 +195,7 @@ describe('EmbeddedMpvNativeService reconnect', () => {
getSessionSnapshot: jest.fn<MockSnapshot | null, [string]>(() => ({
status,
positionSeconds,
durationSeconds: null,
durationSeconds,
volume: 1,
streamUrl: LIVE.streamUrl,
recording: { active: recordingActive },
@@ -596,6 +598,63 @@ describe('EmbeddedMpvNativeService reconnect', () => {
});
});
it('treats EOF in the middle of an episode as a drop, not as its end (#1902)', () => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: [],
autoReconnect: true,
});
service.loadPlayback('session-1', VOD);
poll('loading');
positionSeconds = 1_260;
durationSeconds = 2_700;
poll('playing');
// mpv drained its cache after the server closed the connection.
poll('ended');
expect(lastUpdate()?.status).toBe('ended');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
status = 'loading';
jest.advanceTimersByTime(2_000);
expect(addon.loadPlayback).toHaveBeenLastCalledWith('session-1', {
...VOD,
startTime: 1_260,
});
// The genuine end of the episode stays the end.
positionSeconds = 2_700;
poll('playing');
poll('ended');
jest.advanceTimersByTime(60_000);
expect(lastUpdate()?.status).toBe('ended');
expect(lastUpdate()?.reconnect).toBeUndefined();
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
});
it('forgets where the reload resumed once the user seeks', () => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: [],
autoReconnect: true,
});
service.loadPlayback('session-1', VOD);
poll('loading');
positionSeconds = 1_260;
durationSeconds = 2_700;
poll('playing');
poll('ended');
status = 'loading';
jest.advanceTimersByTime(2_000);
poll('playing');
// A seek away and back: the next early EOF near the old resume
// point is a new drop, not the reload ending where it resumed.
service.seek('session-1', 600);
service.seekBy('session-1', 662);
poll('ended');
expect(lastUpdate()?.reconnect?.attempt).toBe(2);
});
it('treats EOF on a live stream as a drop', () => {
startPlayingLive();
@@ -741,6 +741,7 @@ export class EmbeddedMpvNativeService {
seek(sessionId: string, seconds: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.onUserSeek(sessionId);
this.getAddon().seek(sessionId, seconds);
return this.refreshSession(sessionId);
}
@@ -760,6 +761,7 @@ export class EmbeddedMpvNativeService {
if (!Number.isFinite(deltaSeconds)) {
return this.refreshSession(sessionId);
}
this.onUserSeek(sessionId);
if (typeof addon.seekBy === 'function') {
addon.seekBy(sessionId, deltaSeconds);
} else {
@@ -770,6 +772,14 @@ export class EmbeddedMpvNativeService {
return this.refreshSession(sessionId);
}
/** A seek invalidates the reconnect's resume point (embedded-mpv-reconnect.ts). */
private onUserSeek(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
this.reconnect.onUserSeek(session.reconnect);
}
}
setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setVolume(sessionId, volume);
@@ -1333,7 +1343,8 @@ export class EmbeddedMpvNativeService {
payload.status,
previousStatus,
payload.positionSeconds,
snapshot.errorOrigin
snapshot.errorOrigin,
payload.durationSeconds
);
if (reconnect) {
payload.reconnect = reconnect;
@@ -197,6 +197,328 @@ describe('EmbeddedMpvReconnectCoordinator', () => {
expect(reload).not.toHaveBeenCalled();
});
it('treats a VOD EOF well before the end as a drop and resumes there (#1902)', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_260,
undefined,
2_700
);
// The server closed the connection 21 min into a 45 min episode.
const info = coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_261,
undefined,
2_700
);
expect(info?.attempt).toBe(1);
// The poll keeps reading the same EOF until the attempt fires.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
1_261,
undefined,
2_700
)
).toBe(info);
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 1_260,
});
});
it('keeps a VOD EOF at the end, or with no known duration, as the end', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
2_670,
undefined,
2_700
);
// 4 s short of a duration rounded down to whole seconds.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
2_696,
undefined,
2_700
)
).toBeNull();
// A long film's closing seconds fall inside the 1 % margin.
state = createEmbeddedMpvReconnectState(true);
startPlaying(VOD);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
7_150,
undefined,
7_200
)
).toBeNull();
// Without a duration nothing says the EOF came early.
state = createEmbeddedMpvReconnectState(true);
startPlaying(VOD);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
600,
undefined,
null
)
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
});
it('accepts the end when the reload cannot get past where it resumed', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
2_400,
undefined,
2_700
);
// The duration was estimated from the bitrate; the file ends at 2 401 s.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
2_401,
undefined,
2_700
)?.attempt
).toBe(1);
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 2_400,
});
coordinator.observe(SESSION, state, 'loading', 'ended');
expect(
coordinator.observe(
SESSION,
state,
'ended',
'loading',
2_401,
undefined,
2_700
)
).toBeNull();
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
2_401,
undefined,
2_700
)
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).toHaveBeenCalledTimes(1);
});
it('reconnects again when the resumed episode drops further on', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
600,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
600,
undefined,
2_700
);
jest.advanceTimersByTime(2_000);
coordinator.observe(SESSION, state, 'loading', 'ended');
coordinator.observe(
SESSION,
state,
'playing',
'loading',
601,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
900,
undefined,
2_700
);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
901,
undefined,
2_700
)?.attempt
).toBe(2);
jest.advanceTimersByTime(4_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 900,
});
});
it('keeps retrying an early EOF when the addon refuses the reload', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_200,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_200,
undefined,
2_700
);
reload.mockImplementationOnce(() => {
throw new Error('addon exploded');
});
jest.advanceTimersByTime(2_000);
// The refused reload left the original EOF in the snapshot: it is
// still the drop, not a reload that ended where it resumed.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
1_200,
undefined,
2_700
)?.attempt
).toBe(2);
jest.advanceTimersByTime(4_000);
expect(reload).toHaveBeenCalledTimes(2);
});
it('judges an EOF after a user seek on its position alone', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_200,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_200,
undefined,
2_700
);
jest.advanceTimersByTime(2_000);
coordinator.observe(SESSION, state, 'loading', 'ended');
coordinator.observe(
SESSION,
state,
'playing',
'loading',
1_201,
undefined,
2_700
);
// Back to 10 min; the connection drops again a few minutes later.
coordinator.onUserSeek(state);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
600,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_202,
undefined,
2_700
);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_203,
undefined,
2_700
)?.attempt
).toBe(2);
});
it('resumes a VOD reload where it dropped but keeps live at the live edge', () => {
startPlaying(VOD);
coordinator.observe(SESSION, state, 'playing', 'playing', 95);
@@ -16,8 +16,13 @@ import { isEmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
* - Only a load that already reached `playing` is retried. A URL that never
* worked (404, refused credentials, unsupported container) keeps the manual
* Retry; hammering a panel six times with the same bad request helps nobody.
* - EOF counts as a loss only for live playback — a broadcast never ends on
* its own, a movie does.
* - EOF counts as a loss for live playback — a broadcast never ends on its
* own. A movie or episode does, so on VOD only an EOF well before the
* known duration counts: mpv reports a connection that the server closed
* and ffmpeg could not reopen as EOF, and taking it for the end made
* series autoplay skip the rest of the episode (#1902). A reload that
* ends again where it resumed proves the file ends there (a duration
* estimated from the bitrate can overshoot), and is left as the end.
* - The attempt budget belongs to one outage: it resets after
* {@link EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS} of uninterrupted playing,
* not on the first `playing`, so a stream that flaps every few seconds runs
@@ -48,6 +53,15 @@ export const EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS = 6;
export const EMBEDDED_MPV_RECONNECT_BASE_DELAY_MS = 2_000;
export const EMBEDDED_MPV_RECONNECT_MAX_DELAY_MS = 30_000;
export const EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS = 30_000;
/**
* A VOD EOF is the end of the file when less than this much remained
* (seconds, or the ratio of the duration when that is longer): the margin
* absorbs frame and container rounding and duration estimates.
*/
export const EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_SECONDS = 10;
export const EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_RATIO = 0.01;
/** Playback a resumed reload must make before another early EOF counts. */
export const EMBEDDED_MPV_EARLY_EOF_PROGRESS_SECONDS = 5;
export interface EmbeddedMpvReconnectState {
/** `Settings.embeddedMpvAutoReconnect`, captured at session creation. */
@@ -74,6 +88,12 @@ export interface EmbeddedMpvReconnectState {
* is trusted as a real seek-to-start only once that has happened.
*/
positionReported: boolean;
/**
* Where the last VOD reload resumed, until playback gets past it or the
* user seeks. An early EOF before then ended where the file does, not
* where it dropped.
*/
resumedAtSeconds: number | null;
timer: NodeJS.Timeout | null;
/** What the renderer is shown while an attempt is scheduled or in flight. */
pending: EmbeddedMpvReconnectInfo | null;
@@ -100,6 +120,7 @@ export function createEmbeddedMpvReconnectState(
attemptInFlight: false,
lastPositionSeconds: null,
positionReported: false,
resumedAtSeconds: null,
timer: null,
pending: null,
};
@@ -131,6 +152,25 @@ export function isEmbeddedMpvStreamLoss(
return status === 'error' || (status === 'ended' && isLive);
}
/** Whether a VOD EOF at `positionSeconds` came well before the end. */
export function isEmbeddedMpvEarlyEof(
positionSeconds: number,
durationSeconds: number | null | undefined
): boolean {
if (
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return false;
}
const margin = Math.max(
EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_SECONDS,
durationSeconds * EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_RATIO
);
return durationSeconds - positionSeconds > margin;
}
export class EmbeddedMpvReconnectCoordinator {
private readonly now: () => number;
private readonly log: Pick<Console, 'log' | 'warn'>;
@@ -158,9 +198,18 @@ export class EmbeddedMpvReconnectCoordinator {
? playback.startTime
: null;
state.positionReported = false;
state.resumedAtSeconds = null;
state.pending = null;
}
/**
* The user moved the playhead: an EOF after that is not the reload
* ending where it resumed, so it is judged on its position alone.
*/
onUserSeek(state: EmbeddedMpvReconnectState): void {
state.resumedAtSeconds = null;
}
cancel(state: EmbeddedMpvReconnectState): void {
this.clearTimer(state);
state.attemptInFlight = false;
@@ -188,7 +237,8 @@ export class EmbeddedMpvReconnectCoordinator {
status: EmbeddedMpvSessionStatus,
previousStatus: EmbeddedMpvSessionStatus | null,
positionSeconds?: number,
errorOrigin?: 'playback' | 'engine'
errorOrigin?: 'playback' | 'engine',
durationSeconds?: number | null
): EmbeddedMpvReconnectInfo | null {
const now = this.now();
@@ -211,6 +261,14 @@ export class EmbeddedMpvReconnectCoordinator {
// the seeded `startTime` stays the better resume point.
state.lastPositionSeconds = 0;
}
if (
state.resumedAtSeconds !== null &&
positionSeconds >
state.resumedAtSeconds +
EMBEDDED_MPV_EARLY_EOF_PROGRESS_SECONDS
) {
state.resumedAtSeconds = null;
}
}
if (status === 'playing') {
@@ -240,9 +298,13 @@ export class EmbeddedMpvReconnectCoordinator {
const isLive =
state.playback !== null &&
isLiveEmbeddedMpvPlayback(state.playback);
if (!isEmbeddedMpvStreamLoss(status, isLive)) {
if (
!isEmbeddedMpvStreamLoss(status, isLive) &&
!this.isDroppedVod(state, status, positionSeconds, durationSeconds)
) {
// paused/idle are user actions, closed is teardown, ended on
// VOD is the movie finishing — none of them is an outage.
// VOD near its end is the movie finishing — none of them is an
// outage.
if (status === 'paused') {
// Disarmed until the stream plays again: reloading a stream
// the user paused would resume it without them asking.
@@ -299,6 +361,9 @@ export class EmbeddedMpvReconnectCoordinator {
state.attemptInFlight = true;
try {
this.hooks.reload(sessionId, playback);
// Only a reload that started can end where it resumed; a
// refused one leaves the original EOF, still a drop.
state.resumedAtSeconds = playback.startTime ?? null;
} catch (error) {
state.attemptInFlight = false;
if (isEmbeddedMpvSessionGoneError(error)) {
@@ -325,6 +390,39 @@ export class EmbeddedMpvReconnectCoordinator {
return state.pending;
}
/**
* A VOD `ended` well before the duration is a dropped connection, unless
* it is the reload that resumed there ending again: then the file itself
* ends there and the duration was only an estimate.
*/
private isDroppedVod(
state: EmbeddedMpvReconnectState,
status: EmbeddedMpvSessionStatus,
positionSeconds: number | undefined,
durationSeconds: number | null | undefined
): boolean {
if (status !== 'ended' || state.playback === null) {
return false;
}
const position =
typeof positionSeconds === 'number' &&
Number.isFinite(positionSeconds) &&
positionSeconds > 0
? positionSeconds
: state.lastPositionSeconds;
if (
position === null ||
!isEmbeddedMpvEarlyEof(position, durationSeconds)
) {
return false;
}
return (
state.resumedAtSeconds === null ||
position >
state.resumedAtSeconds + EMBEDDED_MPV_EARLY_EOF_PROGRESS_SECONDS
);
}
/**
* Live streams reload at the live edge. Anything else resumes where the
* connection dropped: the stored playback still carries the offset the
@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Request, Response } from 'express';
/** Share of the clip after which the provider loses the first connection. */
export const DROPPING_EPISODE_CUT_RATIO = 0.35;
/**
* How long the stream is refused after the cut: long enough to defeat
* ffmpeg's own HTTP reconnect, short enough for an app-level retry.
*/
export const DROPPING_EPISODE_REFUSE_MS = 4_000;
let fixture: Buffer | null = null;
/** Stream id → when its connection was cut. Every episode drops once. */
const droppedAt = new Map<string, number>();
export function resetDroppingEpisodes(): void {
droppedAt.clear();
}
/**
* `dropping-episode` fixture: every episode plays a local 60 s clip with
* byte ranges, but the first response that reaches a third of the file is
* cut there, and the stream answers 403 for a few seconds — a panel still
* holding the dropped connection's slot. mpv reports such a drop as EOF in
* the middle of the episode (issue #1902).
*/
export function streamDroppingEpisode(
request: Request,
response: Response
): void {
const body = (fixture ??= readFileSync(
join(process.cwd(), 'apps/xtream-mock-server/src/fixtures/episode.mkv')
));
const streamId = String(request.params['streamId']);
const cutAt = droppedAt.get(streamId);
response.set('Cache-Control', 'no-store');
if (
cutAt !== undefined &&
Date.now() - cutAt < DROPPING_EPISODE_REFUSE_MS
) {
response.status(403).end();
return;
}
const range = /^bytes=(\d+)-(\d*)$/.exec(request.get('range') ?? '');
const start = range ? Number(range[1]) : 0;
const end = range?.[2]
? Math.min(Number(range[2]), body.length - 1)
: body.length - 1;
if (start > end) {
response
.status(416)
.set('Content-Range', `bytes */${body.length}`)
.end();
return;
}
response
.status(range ? 206 : 200)
.type('video/x-matroska')
.set('Accept-Ranges', 'bytes')
.set('Content-Length', String(end - start + 1));
if (range) {
response.set('Content-Range', `bytes ${start}-${end}/${body.length}`);
}
const cut = Math.floor(body.length * DROPPING_EPISODE_CUT_RATIO);
if (cutAt === undefined && start < cut && end >= cut) {
response.write(body.subarray(start, cut), () => {
droppedAt.set(streamId, Date.now());
response.socket?.destroy();
});
return;
}
response.end(body.subarray(start, end + 1));
}
+16 -1
View File
@@ -43,8 +43,10 @@ export interface ScenarioConfig {
* episodes slowly from generated bytes so download screenshots complete
* without any request leaving the machine (the release capture's network
* gate rejects the public HLS stub every other scenario redirects to).
* `dropping-episode` plays a local clip whose connection is lost
* mid-episode (see dropping-episode.ts).
*/
downloadStreamFixture?: 'slow-series' | 'local-media';
downloadStreamFixture?: 'slow-series' | 'local-media' | 'dropping-episode';
/**
* Optional `player_api.php` actions the portal accepts and then never
* answers: the connection stays open until the client gives up. Models a
@@ -172,6 +174,19 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
expiryDate: '2099-12-31',
downloadStreamFixture: 'slow-series',
},
'streamdrop:streamdrop': {
name: 'stream-drop',
description:
'One series whose episodes lose their connection mid-playback — embedded MPV reconnect tests',
seed: 8181,
categoryCount: { live: 0, vod: 0, series: 1 },
itemsPerCategory: 1,
seasonsPerSeries: 1,
episodesPerSeason: 3,
accountStatus: 'Active',
expiryDate: '2099-12-31',
downloadStreamFixture: 'dropping-episode',
},
'epg:epg': {
name: 'epg-fixture',
description:
+106 -4
View File
@@ -1,6 +1,13 @@
import { statSync } from 'node:fs';
import { connect } from 'node:net';
import { join } from 'node:path';
import express from 'express';
import { resetAll } from './data-store.js';
import {
DROPPING_EPISODE_CUT_RATIO,
DROPPING_EPISODE_REFUSE_MS,
resetDroppingEpisodes,
} from './dropping-episode.js';
import { LONG_PROGRAMME_TITLE } from './scenarios.js';
import {
createXtreamMockApp,
@@ -46,9 +53,37 @@ jest.mock('@faker-js/faker', () => {
jest.setTimeout(60_000);
const DROPPING_EPISODE_URL = '/series/streamdrop/streamdrop/80000.mkv';
const droppingFixtureSize = statSync(
join(process.cwd(), 'apps/xtream-mock-server/src/fixtures/episode.mkv')
).size;
const droppingCut = Math.floor(
droppingFixtureSize * DROPPING_EPISODE_CUT_RATIO
);
/** Bytes received before the body ended, and whether it ended cleanly. */
async function readDroppingBody(
response: Response
): Promise<{ bytes: number; complete: boolean }> {
const reader = response.body?.getReader();
let bytes = 0;
try {
for (;;) {
const chunk = await reader?.read();
if (!chunk || chunk.done) {
return { bytes, complete: true };
}
bytes += chunk.value.length;
}
} catch {
return { bytes, complete: false };
}
}
describe('Xtream mock server factory', () => {
afterEach(() => {
resetAll();
resetDroppingEpisodes();
jest.restoreAllMocks();
});
@@ -192,7 +227,9 @@ describe('Xtream mock server factory', () => {
expect(xml).toContain('<tv ');
expect(xml).toContain('<display-name>Aurora News</display-name>');
expect(xml).toContain('channel="aurora-news.fictional"');
expect(xml).toContain(`${running.origin}/assets/marketing/logo/aurora-news.svg`);
expect(xml).toContain(
`${running.origin}/assets/marketing/logo/aurora-news.svg`
);
expect(xml).not.toMatch(/https?:\/\/(?!127\.0\.0\.1|localhost)/);
} finally {
await running.close();
@@ -259,9 +296,9 @@ describe('Xtream mock server factory', () => {
}
// Movies must finish quickly while episodes stay slow enough to
// keep a progress row in frame.
expect(
Number(movie.headers.get('content-length'))
).toBeGreaterThan(Number(episode.headers.get('content-length')));
expect(Number(movie.headers.get('content-length'))).toBeGreaterThan(
Number(episode.headers.get('content-length'))
);
expect(ordinaryMovie.status).toBe(302);
} finally {
@@ -301,6 +338,71 @@ describe('Xtream mock server factory', () => {
}
});
it('drops stream-drop episodes a third of the way in, refuses the retry, then serves ranges again', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
);
try {
const first = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`,
{
headers: { range: 'bytes=0-' },
}
);
expect(first.status).toBe(206);
expect(Number(first.headers.get('content-length'))).toBe(
droppingFixtureSize
);
expect(await readDroppingBody(first)).toEqual({
bytes: droppingCut,
complete: false,
});
// ffmpeg's own reconnect right after the drop.
const retry = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`,
{
headers: { range: `bytes=${droppingCut}-` },
}
);
expect(retry.status).toBe(403);
await retry.body?.cancel();
const later = Date.now() + DROPPING_EPISODE_REFUSE_MS + 1;
jest.spyOn(Date, 'now').mockReturnValue(later);
const resumed = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`,
{
headers: { range: `bytes=${droppingCut}-` },
}
);
expect(resumed.status).toBe(206);
expect(resumed.headers.get('content-range')).toBe(
`bytes ${droppingCut}-${droppingFixtureSize - 1}/${droppingFixtureSize}`
);
expect(await readDroppingBody(resumed)).toEqual({
bytes: droppingFixtureSize - droppingCut,
complete: true,
});
// A reset arms the drop again for the next test.
expect(
(await fetch(`${running.origin}/reset`, { method: 'POST' }))
.status
).toBe(200);
const afterReset = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`
);
expect(afterReset.status).toBe(200);
expect(await readDroppingBody(afterReset)).toEqual({
bytes: droppingCut,
complete: false,
});
} finally {
await running.close();
}
});
it('never answers the silent scenario detail actions while the rest of the portal works', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
@@ -9,6 +9,10 @@ import express, {
import { installLiveFormatFixture } from './live-format-fixture.js';
import { resolveXtreamMockPortString } from './mock-port.js';
import { resetAll } from './data-store.js';
import {
resetDroppingEpisodes,
streamDroppingEpisode,
} from './dropping-episode.js';
import { renderMarketingAssetSvg } from './generators/marketing.generator.js';
import { installPerformanceControlRoutes } from './performance-control-routes.js';
import {
@@ -97,6 +101,7 @@ export function createXtreamMockApp(
return;
}
resetAll();
resetDroppingEpisodes();
response.json({ status: 'reset' });
});
app.get('/playlist.m3u', (_request, response) => {
@@ -316,6 +321,10 @@ function installStreamRoutes(
);
return;
}
if (fixture === 'dropping-episode') {
streamDroppingEpisode(request, response);
return;
}
response.redirect(HLS_STUB);
};
app.get('/live/:username/:password/:streamId.m3u8', streamResponse);
@@ -10,6 +10,13 @@ Regenerate from the repository root with FFmpeg:
ffmpeg -f lavfi -i testsrc2=size=160x90:rate=25 -f lavfi -i sine=frequency=440:sample_rate=48000 -t 6 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 25 -c:a aac -b:a 48k -f mpegts apps/xtream-mock-server/src/fixtures/live.mpegts
```
The mock returns the fixture only for synthetic live-format account media;
`episode.mkv` is sixty seconds of the same pattern, H.264 only, keyframes every
second, played by the `streamdrop` account's episodes:
```sh
ffmpeg -f lavfi -i testsrc2=size=160x90:rate=10 -t 60 -c:v libx264 -preset veryslow -crf 40 -pix_fmt yuv420p -g 10 -fflags +bitexact -flags:v +bitexact -map_metadata -1 apps/xtream-mock-server/src/fixtures/episode.mkv
```
The mock returns `live.mpegts` only for synthetic live-format account media;
manifest and denied-segment routes return controlled 200/403 responses. Web and
Electron E2E tests check advancing playback, not HTTP status alone.
Binary file not shown.
+20 -2
View File
@@ -636,6 +636,13 @@ button disabled state at current-season boundaries.
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
`ended` alone does not prove the episode is over: mpv reports a connection the
server closed (and ffmpeg could not reopen) as EOF too, once its cache runs
dry. `EmbeddedMpvPlayerComponent` therefore emits `playbackEnded` only for an
`ended` session that carries no `reconnect`; while the main process reloads a
VOD that ended well before its duration (see Network Auto-Reconnect), the
episode continues instead of autoplay skipping its rest (issue #1902).
## Session Options (Extra libmpv Options)
`Settings > Playback > Extra embedded MPV options` is a free-form textarea,
@@ -701,8 +708,9 @@ included.
`EmbeddedMpvReconnectCoordinator`
(`apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts`) reloads
the last user-requested playback when a session reports a stream loss: an
`error` status, or `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses). It is driven by
`error` status, `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses), or `ended` on VOD well
before the known duration. It is driven by
`refreshSession()` status transitions, so it covers every engine, and it is
captured per session from `Settings.embeddedMpvAutoReconnect` (default on;
mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
@@ -716,6 +724,16 @@ mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
the IPC socket so a stream that ended reports `ended` rather than the
`paused` that keep-open would otherwise look like, and an mpv process that
exits abnormally reports `error`.
- A VOD `ended` counts only when more than `max(10 s, 1 % of the duration)`
remained (`isEmbeddedMpvEarlyEof`): the margin absorbs container rounding
and the whole-second snapshot values, and with no known duration an EOF is
always the end. A reload the engine accepted records where it resumed; an
early EOF before playback gets 5 s past that point (and with no user seek
in between) ended where the file does (a duration mpv estimated from the
bitrate can overshoot) and is left as the end, so autoplay still continues
after one attempt instead of a reload loop. A refused reload records
nothing, so the original EOF keeps its backoff.
With the setting off, an early EOF stays a plain `ended`.
- Backoff is 2 s, 4 s, 8 s, 16 s, 30 s, 30 s, at most six attempts per
outage. The budget resets only after 30 s of uninterrupted `playing`, so a
stream that flaps every few seconds runs out of attempts instead of being
+38 -4
View File
@@ -1293,15 +1293,49 @@ measured iterations of the 11 `master` runs from 2026-10-03 08:25 to
| `launch/renderer.layoutShiftScoreSettled` | 0 | 0.235 in some iterations of 8 runs up to 2026-10-02 (hero flicker, #1782) |
| `open-source/main.mockHttpRequestsToSettled` | 1 | identical |
| `open-source/renderer.ipcCallsToFirstPage` | 17 | identical |
| `open-source/renderer.layoutShiftScore` | 0.233 | 0.221, then 0.222; 0.233 since #1814, never mixed within a run |
| `open-source/renderer.layoutShiftScore` | 0 | 0.221, then 0.222; 0.233 since #1814; 0 since #1895 (see below) |
| `playback/renderer.httpRequestsToPlaying` | 2 | identical |
| `playback/renderer.layoutShiftScore` | 0.001 | identical |
`open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233
in every iteration of every `master` run from 84aef83a6 (#1814, page Back
buttons moved into the header) on, so its value is 0.233 with `evidenceRun`
37372780064 (b78224376). The 0.011 that #1814 added is not explained yet;
lowering it back is a separate change.
buttons moved into the header) on, so its value was 0.233 with `evidenceRun`
37372780064 (b78224376). Since #1895 it is 0 (`evidenceRun` 38147072417:
all 5 measured iterations on the runner read 0 with no shift recorded).
Where the 0.233 came from (`evidence.layoutShift.shifts`, a 1280×720 window;
the score of a shift is the area of every node that moved times the largest
distance any of them moved, as a fraction of the viewport):
| Shift, ms after the click | Nodes that moved | Score |
| ------------------------- | ---------------------------------------------------------- | ------ |
| ~40 (page swap) | `main.workspace-content` x 60 → 383, width −323 | 0.222 |
| | switcher, `label.search-field`, `div.header-actions` x +44 | 0.011 |
| ~50-70 (categories) | `div.context-header__actions` x −60, width +60 | 0.0001 |
The first two are one frame: the frame that replaces the dashboard with the
portal page also inserts the 323 px context panel the dashboard has no room
for, and the history Back that #1814 shows once an in-app previous entry
exists. The header items move only 44 px, but a shift is scored with the
largest distance in it, the content pane's 323 px, which is why Back added
0.011 rather than about 0.0015. The third is the category search and sort
buttons appearing when the categories arrive. The skeletons of the category
list and the grid are replaced by content without any shift. Nothing was
hidden behind the startup overlay: the "Syncing playlist" overlay of the
cached catalog load flashes for about two frames after the shift and covers
no further one.
The content frame and the header controls now slide into place instead of
jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout
applies in the same frame and only a transform animates, which Chromium does
not record as a layout shift. The category search and sort buttons render
disabled while the categories load. Locally (macOS, 2026-10-11) and on the runner
J2 then read 0 in every iteration with no shift recorded, J1 0 / 0, J3 0.001
and J4 0;
`renderer.domMutationsToFirstPage` rose by 22 (the slide's class and style
changes) and the wall-clock entries did not move. Under
`prefers-reduced-motion` there is no slide, so a user with that preference
still sees the jump that the journey no longer measures.
Being deterministic is not the same as being validated. Principle 3 of the
plan promotes a counter to a guardrail once a PR has shown that lowering it
+59 -4
View File
@@ -127,6 +127,57 @@ synthesized mask, unlike any content layer) may span half the content area,
and the backdrop-filtered controls must really be composited, so the check
cannot pass on a page with nothing to clip.
### Layout slides
When a route change adds or removes the context panel, the content frame
moves by the panel's width (323 px on desktop), and when Back or the phone
drawer toggle appears or leaves, the header controls after it move by the
slot's width (44 px). Both happen in the frame that swaps the page, so they
used to be one jump: a layout shift of 0.233 in the open-source journey,
0.222 of it the content frame (see
[performance journeys](./performance-journeys.md#j2-open-source-open-a-source-to-a-browsable-list)).
`LayoutSlideDirective` (`workspace-shell/layout-slide.directive.ts`) turns
these jumps into a 180 ms slide, the duration of the context panel's own
width transition. The new layout applies at once; each target starts
translated back to the position it had and animates its transform to none
(FLIP), so nothing waits for the animation and Chromium records no layout
shift for a transform. The shell's `.workspace-body` slides
`.workspace-content-frame` when `showContextPanel` changes; the header slides
the drawer toggle, switcher, search and action groups when its leading
navigation (Back, drawer toggle) changes.
1. **Previous positions.** `offsetLeft` values, which ignore transforms, are
recorded after every render in which the slide key or the record key
changed (the header's record key covers the other inputs that move its
controls: settings route, no playlists, parental lock, shortcuts) and
when the host or a target resizes (window resize, a context panel folding
or being dragged wider). A slide therefore starts where the target was
last laid out, and a target that has no recorded position or did not move
is left alone.
2. **The uncovered strip.** The frame is narrower beside the panel than
without it, so the side it moves away from would show the body colour
during the slide. While a slide runs the target carries
`layout-slide-active` and the uncovered widths as `--layout-slide-start`
and `--layout-slide-end`; the frame's stylesheet extends the content
surface over that strip with an `::after` below the scroller.
3. **Back fades in** over the same 180 ms, so the switcher still sliding away
from its slot does not draw over the arrow.
4. **Reduced motion.** Under `prefers-reduced-motion` there is no animation:
the jump (and its layout shift) remains.
5. **Compositing.** While it runs, each slid target is its own composited
layer (`ActiveTransformAnimation`): the frame at its width plus the
covered strip (1220×664 CSS pixels at 1280×720), the header controls a
few small ones. No synthesized clip mask appears, because no ancestor of
the frame clips with a radius ([content surface
corner](#content-surface-corner)), and the layers go away when the slide
ends: `perf:compositing` reads the same layer and mask counts on every
route as before the slides (#1895).
6. **Not covered.** A slide does not change what the route renders. Going
back from a portal to the dashboard still shows one frame with the new
dashboard below the old page before the old page leaves; that jump is a
routing issue, not a layout slide.
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
services:
@@ -220,9 +271,10 @@ return logic (`backClicked`).
When there is nowhere to go, the slot is empty rather than a disabled arrow.
Sessions often start on a page that never navigates (an M3U playlist or live
TV), where a disabled arrow would stay for the whole session. The cost is one
shift of the switcher and search when Back first appears or leaves, which
happens only at the start of the history and together with a route change.
TV), where a disabled arrow would stay for the whole session. When Back first
appears or leaves, which happens only at the start of the history and together
with a route change, the switcher and search slide by the slot's width instead
of jumping (see [Layout slides](#layout-slides)).
There is no Forward button: Stalker inline details are store state, not
history entries, so Forward would skip them.
@@ -268,7 +320,10 @@ Xtream and Stalker category panels preserve provider/server category order by
default. The panel header exposes a sort menu next to category search with
`Server sorting`, `A-Z`, and `Z-A`; when alphabetical sorting is active,
synthetic "all categories" entries stay pinned before sorted provider
categories.
categories. Search and sort render disabled while the categories load (beside
the disabled Manage categories button), so the header row has its final width
under the skeleton instead of growing by two buttons when the list arrives;
they are hidden only once a load ended with no categories.
On live sections the category panel can be folded away independently of the
channels list; the folded panel is reachable as a popover from the channels
+12
View File
@@ -405,6 +405,7 @@ sometimes only respond to that misspelled action.
| `tzoffset:tzoffset` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active |
| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active |
| `streamdrop:streamdrop` | 8181 | series:1 | 1 | active |
| `expired:expired` | 4004 | 4 each | 10 | Expired |
| `inactive:inactive` | 5005 | 4 each | 10 | Disabled |
| `<any other>` | hash | 6 each | 30 | active |
@@ -428,6 +429,17 @@ Timeshift URLs of `epg` and `epglong` play the local MPEG-TS clip
the catch-up seek bar's label bounds; `epg:epg` keeps its short titles,
which other tests assert verbatim.
### `streamdrop:streamdrop` fixture details
Its three episodes play the local 60 s Matroska clip
(`src/fixtures/episode.mkv`, generation command in the fixtures README) with
byte ranges, but `dropping-episode.ts` cuts the first response that reaches
35 % of the file and answers 403 for the next 4 s, so ffmpeg's own HTTP
reconnect fails and mpv reports EOF mid-episode. `POST /reset` re-arms the
drop. The Electron E2E `embedded-mpv-series-stream-drop.e2e.ts` uses it to
check that Embedded MPV resumes the episode instead of autoplaying the next
one (issue #1902).
### `marketing:marketing` fixture details
This scenario is reserved for release screenshots and marketing materials:
@@ -166,6 +166,57 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
expect(player.isPlaying()).toBe(false);
});
it('does not end the episode while the main process reloads a dropped stream', () => {
// A connection lost mid-episode reaches mpv as EOF (#1902); the
// reconnect owns it, and autoplay would skip the rest of the episode.
const reconnect = {
attempt: 1,
maxAttempts: 6,
nextAttemptAt: '2026-06-06T12:00:52Z',
};
controller.session.update((session) =>
session
? {
...session,
status: 'ended',
positionSeconds: 50,
reconnect,
updatedAt: '2026-06-06T12:00:50Z',
}
: session
);
fixture.detectChanges();
controller.session.update((session) =>
session
? {
...session,
status: 'loading',
updatedAt: '2026-06-06T12:00:52Z',
}
: session
);
fixture.detectChanges();
expect(fixture.componentInstance.endedCount).toBe(0);
expect(player.statusLabel()).toContain('RECONNECTING');
// The reloaded episode later finishes for real.
controller.session.update((session) =>
session
? {
...session,
status: 'ended',
positionSeconds: 120,
reconnect: undefined,
updatedAt: '2026-06-06T12:02:00Z',
}
: session
);
fixture.detectChanges();
expect(fixture.componentInstance.endedCount).toBe(1);
});
it('hides episode navigation for live playback', () => {
fixture.destroy();
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
@@ -578,7 +578,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
effect(() => {
const session = this.session();
if (!session || session.status !== 'ended') {
// An `ended` the main process is reloading is a dropped
// connection, not the end: autoplay would skip the rest of the
// episode (#1902).
if (!session || session.status !== 'ended' || session.reconnect) {
return;
}
if (this.lastEndedSessionId === session.id) {
@@ -3,12 +3,15 @@
<div class="context-header__top">
<h2>{{ title() | translate }}</h2>
<div class="context-header__actions">
@if (canSearchCategories()) {
<!-- Rendered disabled while the categories load, so the
row has its final width before they arrive. -->
@if (showCategoryListActions()) {
<button
#firstHeaderAction
type="button"
mat-icon-button
class="context-header__action"
[disabled]="!canSearchCategories()"
[class.context-header__action--active]="isSearchOpen()"
(click)="toggleCategorySearch()"
[matTooltip]="'WORKSPACE.CONTEXT.SEARCH' | translate"
@@ -19,11 +22,12 @@
<mat-icon>search</mat-icon>
</button>
}
@if (canSearchCategories()) {
@if (showCategoryListActions()) {
<button
type="button"
mat-icon-button
class="context-header__action"
[disabled]="!canSearchCategories()"
[class.context-header__action--active]="
categorySortMode() !== 'server'
"
@@ -240,6 +240,35 @@ describe('WorkspaceContextPanelComponent', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('renders the category search and sort actions disabled while the categories load', () => {
// Present from the skeleton on, so the header row keeps its width
// when the categories arrive instead of growing by two buttons.
const loaded = xtreamCategories();
fixture.componentRef.setInput('section', 'vod');
xtreamCategories.set([]);
xtreamIsLoadingCategories.set(true);
fixture.detectChanges();
const actions = () =>
Array.from(
fixture.nativeElement.querySelectorAll(
'.context-header__action'
)
) as HTMLButtonElement[];
expect(actions()).toHaveLength(3);
expect(actions().every((button) => button.disabled)).toBe(true);
expect(fixture.componentInstance.focusTarget()).toBeNull();
xtreamCategories.set(loaded);
xtreamIsLoadingCategories.set(false);
fixture.detectChanges();
expect(actions()).toHaveLength(3);
expect(actions()[0].disabled).toBe(false);
expect(actions()[1].disabled).toBe(false);
expect(fixture.componentInstance.focusTarget()).toBe(actions()[0]);
});
it('keeps local xtream loading states quiet when no import is running', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamIsImporting.set(false);
@@ -384,6 +384,13 @@ export class WorkspaceContextPanelComponent {
(this.isXtreamCategories() && this.xtreamCategories().length > 0) ||
(this.isStalkerCategories() && this.stalkerCategories().length > 0)
);
/** Search and sort show while the list loads (disabled) or has items. */
readonly showCategoryListActions = computed(
() =>
this.canSearchCategories() ||
this.isXtreamCategoryLoading() ||
(this.isStalkerCategories() && this.isStalkerCategoryLoading())
);
readonly filteredXtreamCategories = computed(() => {
this.languageTick();
@@ -472,15 +479,17 @@ export class WorkspaceContextPanelComponent {
* The control the shell sidebar hands focus to once this rail has
* unfolded (the button the user activated to bring it back is gone by
* then): the hide chevron when it is offered, else the first header
* action. `null` while neither is rendered — categories still loading,
* or a failed Stalker load — and the sidebar falls back to the rail
* itself.
* action. `null` while neither is usable — categories still loading
* (the action is rendered but disabled), or a failed Stalker load — and
* the sidebar falls back to the rail itself.
*/
focusTarget(): HTMLElement | null {
const firstAction = this.firstHeaderAction()?.nativeElement;
return (
this.hideCategoriesButton()?.nativeElement ??
this.firstHeaderAction()?.nativeElement ??
null
(firstAction && !firstAction.hasAttribute('disabled')
? firstAction
: null)
);
}
@@ -1,4 +1,10 @@
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
<header
class="workspace-header"
[class.is-settings-route]="isSettingsRoute()"
[appLayoutSlide]="leadingNavigationKey()"
[layoutSlideRecordOn]="controlsLayoutKey()"
layoutSlideTargets=".context-drawer-toggle, .playlist-switcher, .search-field, .header-actions"
>
<!-- The page's Back, or browser history while an in-app previous page
exists, at the leading edge where desktop apps and Material's top
app bar keep navigation (beside the macOS traffic lights). The
@@ -32,6 +32,19 @@
flex-shrink: 0;
margin-inline-start: -8px;
app-region: no-drag;
// Fades in while the controls after it slide aside (LayoutSlideDirective)
// instead of showing under the switcher that is still moving away.
animation: header-back-enter 180ms ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
@keyframes header-back-enter {
from {
opacity: 0;
}
}
// Above the phone breakpoint the context panel is always visible beside the
@@ -20,6 +20,7 @@ import {
WorkspaceBackTarget,
} from '@iptvnator/portal/shared/util';
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
import { LayoutSlideDirective } from '../../layout-slide.directive';
@Component({
selector: 'app-playlist-switcher',
@@ -67,6 +68,7 @@ describe('WorkspaceShellHeaderComponent', () => {
.overrideComponent(WorkspaceShellHeaderComponent, {
set: {
imports: [
LayoutSlideDirective,
MatIcon,
MatIconButton,
MatTooltip,
@@ -18,10 +18,12 @@ import {
} from '@iptvnator/portal/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { WorkspaceHeaderSidebarToggle } from '../../services/helpers/workspace-shell-constants';
import { LayoutSlideDirective } from '../../layout-slide.directive';
@Component({
selector: 'app-workspace-shell-header',
imports: [
LayoutSlideDirective,
MatIcon,
MatIconButton,
MatTooltip,
@@ -119,6 +121,19 @@ export class WorkspaceShellHeaderComponent {
readonly backYieldsToDrawerToggle = computed(
() => this.showDrawerToggle() && this.backPhoneSlot() === 'yield'
);
/**
* The leading navigation: when Back or the drawer toggle appears or
* leaves, the controls after it slide instead of jumping.
*/
readonly leadingNavigationKey = computed(
() =>
`${this.backTarget() !== null}|${this.showDrawerToggle()}|${this.backYieldsToDrawerToggle()}`
);
/** Everything else that moves the controls; recorded, not slid. */
readonly controlsLayoutKey = computed(
() =>
`${this.isSettingsRoute()}|${this.hasNoPlaylists()}|${this.isElectron()}|${this.parentalLockState()}|${this.headerSidebarToggle() !== null}|${this.headerShortcut() !== null}`
);
readonly backRequested = output<void>();
readonly searchChanged = output<string>();
@@ -0,0 +1,187 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
LAYOUT_SLIDE_ACTIVE_CLASS,
LAYOUT_SLIDE_DURATION_MS,
LayoutSlideDirective,
translateX,
} from './layout-slide.directive';
@Component({
imports: [LayoutSlideDirective],
template: `
<div
[appLayoutSlide]="slideKey()"
[layoutSlideRecordOn]="recordKey()"
layoutSlideTargets=".target"
>
<span class="target"></span>
</div>
`,
})
class HostComponent {
readonly slideKey = signal('panel');
readonly recordKey = signal(0);
}
interface FakeAnimation {
readonly keyframes: Keyframe[];
readonly options: KeyframeAnimationOptions;
cancelled: boolean;
finish(): void;
}
describe('LayoutSlideDirective', () => {
let fixture: ComponentFixture<HostComponent>;
let target: HTMLElement;
let left: number;
let animations: FakeAnimation[];
let reducedMotion: boolean;
const originalMatchMedia = window.matchMedia;
beforeEach(() => {
left = 60;
animations = [];
reducedMotion = false;
window.matchMedia = ((query: string) => ({
matches: reducedMotion && query.includes('reduce'),
})) as unknown as typeof window.matchMedia;
fixture = TestBed.createComponent(HostComponent);
target = fixture.nativeElement.querySelector('.target');
Object.defineProperty(target, 'offsetLeft', {
configurable: true,
get: () => left,
});
target.animate = ((
keyframes: Keyframe[],
options: KeyframeAnimationOptions
) => {
const listeners: (() => void)[] = [];
const animation = {
keyframes,
options,
cancelled: false,
addEventListener: (_type: string, listener: () => void) =>
listeners.push(listener),
finish: () => listeners.forEach((listener) => listener()),
cancel: () => {
animation.cancelled = true;
animation.finish();
},
};
animations.push(animation);
return animation;
}) as unknown as HTMLElement['animate'];
render();
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
function render(): void {
fixture.detectChanges();
TestBed.tick();
}
it('does not animate the first render', () => {
expect(animations).toEqual([]);
});
it('slides a target from its previous position when the key changes', () => {
left = 383;
fixture.componentInstance.slideKey.set('single-pane');
render();
expect(animations).toHaveLength(1);
expect(animations[0].keyframes).toEqual([
{ transform: 'translateX(-323px)' },
{ transform: 'none' },
]);
expect(animations[0].options.duration).toBe(LAYOUT_SLIDE_DURATION_MS);
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
expect(target.style.getPropertyValue('--layout-slide-start')).toBe(
'0px'
);
expect(target.style.getPropertyValue('--layout-slide-end')).toBe(
'323px'
);
animations[0].finish();
expect(target.classList).not.toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
expect(target.style.getPropertyValue('--layout-slide-end')).toBe('');
});
it('records moves of other changes instead of sliding them', () => {
left = 120;
fixture.componentInstance.recordKey.set(1);
render();
expect(animations).toEqual([]);
left = 80;
fixture.componentInstance.slideKey.set('single-pane');
render();
expect(animations[0].keyframes[0]).toEqual({
transform: 'translateX(40px)',
});
});
it('keeps a finished slide from clearing a newer one', () => {
left = 383;
fixture.componentInstance.slideKey.set('single-pane');
render();
left = 60;
fixture.componentInstance.slideKey.set('panel');
render();
animations[0].finish();
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
expect(target.style.getPropertyValue('--layout-slide-start')).toBe(
'323px'
);
});
it('starts an interrupted slide from where the target is drawn', () => {
left = 383;
fixture.componentInstance.slideKey.set('single-pane');
render();
// 40 ms in, the first slide draws the target at 172 px.
target.style.transform = 'matrix(1, 0, 0, 1, -211, 0)';
left = 60;
fixture.componentInstance.slideKey.set('panel');
render();
expect(animations).toHaveLength(2);
expect(animations[0].cancelled).toBe(true);
expect(animations[1].keyframes[0]).toEqual({
transform: 'translateX(112px)',
});
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
});
it('reads the x translation of computed transforms', () => {
expect(translateX('none')).toBe(0);
expect(translateX('matrix(1, 0, 0, 1, -211.5, 0)')).toBe(-211.5);
expect(
translateX(
'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 44, 0, 0, 1)'
)
).toBe(44);
});
it('does not animate when the target did not move', () => {
fixture.componentInstance.slideKey.set('single-pane');
render();
expect(animations).toEqual([]);
});
it('jumps under prefers-reduced-motion', () => {
reducedMotion = true;
left = 383;
fixture.componentInstance.slideKey.set('single-pane');
render();
expect(animations).toEqual([]);
expect(target.classList).not.toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
});
});
@@ -0,0 +1,178 @@
import {
afterRenderEffect,
DestroyRef,
Directive,
ElementRef,
inject,
input,
} from '@angular/core';
/** Matches the context panel's own width transition. */
export const LAYOUT_SLIDE_DURATION_MS = 180;
export const LAYOUT_SLIDE_ACTIVE_CLASS = 'layout-slide-active';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/**
* Slides shell chrome from where it was to where a layout change put it
* (FLIP) instead of letting it jump in one frame. The new layout applies at
* once: each target only starts translated back to its previous position
* and animates its transform to none, so nothing waits for the animation,
* and Chromium records no layout shift for a transform.
*
* A change of `appLayoutSlide` slides the targets (`layoutSlideTargets`,
* selectors inside the host; the host itself when empty). A change of
* `layoutSlideRecordOn`, and a resize of the host or a target, only records
* where the targets are, so the next slide starts from where they are
* really drawn. Positions are `offsetLeft` values, which ignore transforms.
* Under `prefers-reduced-motion` the change stays an instant jump.
* Contract: docs/architecture/workspace-shell.md ("Layout slides").
*/
@Directive({
selector: '[appLayoutSlide]',
})
export class LayoutSlideDirective {
readonly appLayoutSlide = input.required<unknown>();
readonly layoutSlideRecordOn = input<unknown>(null);
readonly layoutSlideTargets = input('');
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly positions = new WeakMap<HTMLElement, number>();
private readonly running = new WeakMap<HTMLElement, Animation>();
constructor() {
let resizeObserver: ResizeObserver | null = null;
let observed: readonly HTMLElement[] = [];
let previousKey: unknown;
let initialized = false;
afterRenderEffect({
read: () => {
const key = this.appLayoutSlide();
this.layoutSlideRecordOn();
const targets = this.targets();
if (initialized && key !== previousKey) {
this.slide(targets);
} else {
this.record(targets);
}
previousKey = key;
initialized = true;
if (typeof ResizeObserver === 'undefined') {
return;
}
resizeObserver ??= new ResizeObserver(() =>
this.record(this.targets())
);
const next = [this.host.nativeElement, ...targets];
if (!sameElements(observed, next)) {
resizeObserver.disconnect();
next.forEach((element) => resizeObserver?.observe(element));
observed = next;
}
},
});
inject(DestroyRef).onDestroy(() => resizeObserver?.disconnect());
}
private targets(): HTMLElement[] {
const host = this.host.nativeElement;
const selector = this.layoutSlideTargets();
return selector
? Array.from(host.querySelectorAll<HTMLElement>(selector))
: [host];
}
private record(targets: readonly HTMLElement[]): void {
for (const target of targets) {
this.positions.set(target, target.offsetLeft);
}
}
private slide(targets: readonly HTMLElement[]): void {
// Every position is read before the first animation starts, so the
// layout is computed once.
const moves: [HTMLElement, number][] = [];
for (const target of targets) {
const previous = this.positions.get(target);
const next = target.offsetLeft;
this.positions.set(target, next);
// A slide still running is interrupted: the new one starts where
// the target is drawn now, not where the old one was heading.
const drawn = this.running.has(target)
? translateX(getComputedStyle(target).transform)
: 0;
if (previous !== undefined && previous + drawn !== next) {
moves.push([target, previous + drawn - next]);
}
}
if (prefersReducedMotion()) {
return;
}
for (const [target, offset] of moves) {
if (typeof target.animate === 'function') {
this.animate(target, offset);
}
}
}
/**
* While it runs the target carries `layout-slide-active`, and the width
* it uncovers on either side as `--layout-slide-start` /
* `--layout-slide-end`, so its stylesheet can fill that strip.
*/
private animate(target: HTMLElement, offset: number): void {
const superseded = this.running.get(target);
const animation = target.animate(
[{ transform: `translateX(${offset}px)` }, { transform: 'none' }],
{ duration: LAYOUT_SLIDE_DURATION_MS, easing: 'ease' }
);
this.running.set(target, animation);
superseded?.cancel();
target.classList.add(LAYOUT_SLIDE_ACTIVE_CLASS);
target.style.setProperty(
'--layout-slide-start',
`${Math.max(offset, 0)}px`
);
target.style.setProperty(
'--layout-slide-end',
`${Math.max(-offset, 0)}px`
);
const settle = () => {
if (this.running.get(target) !== animation) {
return;
}
this.running.delete(target);
target.classList.remove(LAYOUT_SLIDE_ACTIVE_CLASS);
target.style.removeProperty('--layout-slide-start');
target.style.removeProperty('--layout-slide-end');
};
animation.addEventListener('finish', settle);
animation.addEventListener('cancel', settle);
}
}
function sameElements(
a: readonly HTMLElement[],
b: readonly HTMLElement[]
): boolean {
return a.length === b.length && a.every((element, i) => element === b[i]);
}
/** The x translation of a computed `transform` value (0 for `none`). */
export function translateX(transform: string): number {
const values = /^matrix(3d)?\((.+)\)$/.exec(transform.trim());
if (!values) {
return 0;
}
const parts = values[2].split(',').map(Number);
const x = values[1] ? parts[12] : parts[4];
return Number.isFinite(x) ? x : 0;
}
function prefersReducedMotion(): boolean {
return (
typeof matchMedia === 'function' &&
matchMedia(REDUCED_MOTION_QUERY).matches
);
}
@@ -71,9 +71,13 @@
(accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)"
/>
<!-- The content frame slides to make room for the context panel
instead of jumping (see LayoutSlideDirective). -->
<div
class="workspace-body"
[class.single-pane]="!facade.showContextPanel()"
[appLayoutSlide]="facade.showContextPanel()"
layoutSlideTargets=".workspace-content-frame"
>
@if (facade.showContextPanel()) {
<!-- Phone-only (CSS-hidden above 640px): tapping the scrim is
@@ -114,6 +114,20 @@ app-workspace-shell-context-sidebar {
clip-path: inset(0);
pointer-events: none;
}
// While the frame slides into place beside a context panel that appeared
// or left (LayoutSlideDirective), its old width is wider than the new
// one, so the side it moves away from would show the body colour. The
// content surface extends over that strip until the slide ends.
&.layout-slide-active::after {
content: '';
position: absolute;
inset: 0 calc(-1 * var(--layout-slide-end, 0px)) 0
calc(-1 * var(--layout-slide-start, 0px));
z-index: -1;
background: var(--app-content-bg, var(--mat-sys-surface));
pointer-events: none;
}
}
.workspace-content {
@@ -18,6 +18,7 @@ import {
WorkspacePortalContext,
WorkspaceShellContextPanel,
} from '@iptvnator/workspace/shell/util';
import { LayoutSlideDirective } from './layout-slide.directive';
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
import { WorkspaceShellComponent } from './workspace-shell.component';
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
@@ -265,6 +266,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -361,6 +363,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -439,6 +442,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -513,6 +517,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -613,6 +618,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -21,6 +21,7 @@ import { WorkspaceShellContextSidebarComponent } from './components/workspace-sh
import { WorkspaceShellHeaderComponent } from './components/workspace-shell-header/workspace-shell-header.component';
import { WorkspaceShellImportOverlayComponent } from './components/workspace-shell-import-overlay/workspace-shell-import-overlay.component';
import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component';
import { LayoutSlideDirective } from './layout-slide.directive';
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service';
@@ -39,6 +40,7 @@ import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
imports: [
CdkTrapFocus,
ExternalPlaybackDockComponent,
LayoutSlideDirective,
TranslatePipe,
PlaylistDropOverlayComponent,
PlaylistDropZoneDirective,
+4 -4
View File
@@ -81,14 +81,14 @@
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04"
},
"renderer.layoutShiftScore": {
"value": 0.233,
"value": 0,
"unit": "score",
"slack": 0,
"note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)",
"updatedAt": "2026-10-06",
"updatedAt": "2026-10-11",
"evidencePr": null,
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37372780064",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in every measured iteration of the 7 master runs from 84aef83a6 (#1814, which moved it from 0.222) to b78224376"
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38147072417",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), 0 with no shift recorded in all 5 measured iterations of PR #1895 (d2cb9445d), which slides the content frame and header controls instead of jumping (was 0.233)"
}
},
"playback": {