mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/fervent-cannon-196cb2
This commit is contained in:
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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
+60
-1
@@ -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));
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+6
-2
@@ -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'
|
||||
"
|
||||
|
||||
+29
@@ -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);
|
||||
|
||||
+14
-5
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user