fix(playback): seek Embedded MPV steps relative to mpv's own position (#1518)

* fix(playback): seek Embedded MPV steps relative to mpv's own position

Arrow keys and the ±10 s buttons in the Embedded MPV player advanced only
about a second per press when pressed repeatedly or held. The shortcuts
already asked for 5 s steps, but `EmbeddedMpvCommandRunner.seekBy` turned
each step into an absolute `seek` computed from `session.positionSeconds`,
which is floored to whole seconds, polled every 500 ms (helper snapshots at
most every 250 ms) and not refreshed by the seek reply. Every press inside
that window therefore landed on the same target.

Steps now go through a new `EMBEDDED_MPV_SEEK_BY` IPC / `seekEmbeddedMpvBy`
bridge method that every backend forwards as mpv `seek <delta>
relative+exact`: `seekBy` exports in the macOS addon and the Windows/Linux
`wid` addon (Linux over its JSON IPC socket), and a `seek-by` stdin command
in the frame-copy helper. mpv resolves the delta against its own position
and merges queued relative seeks, so presses accumulate as in mpv itself.
The absolute form survives only as a fallback for a preload without the
method or an addon binary without `seekBy`; the timeline scrub still
commits an absolute target.

Validated with a real mpv 0.39 IPC probe: three relative seeks in a burst
advance +15 s, three absolute seeks from one stale base advance +5 s.

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

* fix(playback): drop speculative position update from relative Embedded MPV seeks

Review follow-up for the relative seek path.

The macOS and Windows/Linux `seekBy` exports advanced `snapshot.positionSeconds`
by the delta after dispatching the mpv command. That is not idempotent the way
the absolute seek's optimistic write is: the observer (mpv event thread, or
the Linux IPC poll) can already have stored the post-seek `time-pos` under the
same mutex, so adding the delta on top counted the step twice, and while paused
nothing corrected it. On Linux it also advertised a position that a failed
socket delivery never reached. Relative steps now leave the snapshot alone;
only the observed `time-pos` updates the position.

The packaged Linux frame-copy smoke now drives `seekEmbeddedMpvBy` through the
built app: a burst of three +2 s steps issued without waiting for snapshots has
to land on 6 s, and a -60 s step has to clamp at 0. The generated Y4M fixture
grows from 2 s to 12 s (about 415 KB) so the burst and the playing section that
follows stay inside the clip. Replayed against a local mpv 0.39 with the same
fixture and media server: burst -> 6.0, -60 -> 0.

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

* docs(agents): mirror the Embedded MPV relative-seek contract into AGENTS.md

Review follow-up: the Shared Player Controls section documents the frame-copy
commands and shortcuts, so the relative seekEmbeddedMpvBy invariant lives
there too, next to the CLAUDE.md note.

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

* fix(playback): reject a Linux relative seek the mpv IPC socket did not accept

Review follow-up: the Linux branch of SeekBy discarded the socket transaction
result and returned normally, so a step that never reached mpv looked like a
seek still awaiting observation. It now throws like a failed mpv_command_async
on the in-process engines; the renderer swallows the rejection and resyncs
from the next snapshot, and the main process logs it.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-04 17:36:54 +02:00
1 parent ad81fbfc45
commit b2ca85172c
21 files changed
+392 -11

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: playback
---
Arrow keys and the ±10 s buttons in the Embedded MPV player now move by their
full step every time. Pressing an arrow repeatedly, or holding it, used to
advance only about a second per press because each step was computed from a
stale position; steps are now relative seeks executed by mpv itself, so rapid
presses add up.
+10
View File
@@ -289,6 +289,16 @@ Key files:
commands are cancelled. Same-session IPC replies also yield to a broadcast
snapshot received while the command was pending, preventing a successful
recording acknowledgement from being rolled back by a stale reply.
- Embedded MPV seek steps (arrow keys, ±10 s buttons, `PlayerController.seekBy`)
go through the relative `seekEmbeddedMpvBy` IPC: every backend forwards the
delta as mpv `seek <delta> relative+exact` (addon export `seekBy`, helper
stdin command `seek-by`, Linux JSON IPC) and never advances the snapshot
position itself. Do not derive an absolute target from the renderer's
`positionSeconds`: it is floored to whole seconds, polled every 500 ms, and
a seek reply does not carry the new position, so rapid presses computed from
it collapse onto one target. Only the timeline scrub commits an absolute
`seek`. Contract: `docs/architecture/embedded-mpv-native.md` ("Resume And
Track Handling").
- DASH (`.mpd`) sources play through a lazily imported Shaka Player source
engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and
ArtPlayer components; ClearKey keys come from KODIPROP-derived
+1 -1
View File
@@ -1008,7 +1008,7 @@ app as a real argument, so it is not an option.
API. Radio's `<audio>` deliberately never blocks display sleep. Embedded
MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC
inhibit the screensaver themselves.
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. Two per-session knobs are captured at session creation from the main-process settings mirror (`readEmbeddedMpvSessionOptions()`, read in the `EMBEDDED_MPV_CREATE_SESSION` handler — never inside the service, whose specs would otherwise construct electron-conf): `Settings.embeddedMpvExtraOptions` (free-form `key=value` libmpv lines, forbidden embed-critical keys refused by the form, network defaults `network-timeout=10` + ffmpeg `reconnect` prepended, applied on every engine after its built-ins — Windows/macOS via `mpv_set_option_string`, Linux through a user-only `--include` config file, frame-copy as the helper's first stdin line — never on a command line, where `ps` could read a credential-bearing header option) and `Settings.embeddedMpvAutoReconnect` (default on: `EmbeddedMpvReconnectCoordinator` in `embedded-mpv-reconnect.ts` reloads the last playback on `error`, or `ended` for live, only if it had played, with 2 s→30 s backoff, six attempts per outage, budget reset after 30 s stable playing, cancelled by user loads/pause/dispose; a recording running at the drop is finalized as an interrupted partial when the reload actually replaces the stream (a stream that recovers on its own keeps recording) and restarted into a new file once that reload plays, and an external subtitle file added through `sub-add` is re-added once the reload plays unless the user picked another track or loaded something else since; the renderer only displays `EmbeddedMpvSession.reconnect`). Contract: `docs/architecture/embedded-mpv-native.md` ("Session Options", "Network Auto-Reconnect"). macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. Two per-session knobs are captured at session creation from the main-process settings mirror (`readEmbeddedMpvSessionOptions()`, read in the `EMBEDDED_MPV_CREATE_SESSION` handler — never inside the service, whose specs would otherwise construct electron-conf): `Settings.embeddedMpvExtraOptions` (free-form `key=value` libmpv lines, forbidden embed-critical keys refused by the form, network defaults `network-timeout=10` + ffmpeg `reconnect` prepended, applied on every engine after its built-ins — Windows/macOS via `mpv_set_option_string`, Linux through a user-only `--include` config file, frame-copy as the helper's first stdin line — never on a command line, where `ps` could read a credential-bearing header option) and `Settings.embeddedMpvAutoReconnect` (default on: `EmbeddedMpvReconnectCoordinator` in `embedded-mpv-reconnect.ts` reloads the last playback on `error`, or `ended` for live, only if it had played, with 2 s→30 s backoff, six attempts per outage, budget reset after 30 s stable playing, cancelled by user loads/pause/dispose; a recording running at the drop is finalized as an interrupted partial when the reload actually replaces the stream (a stream that recovers on its own keeps recording) and restarted into a new file once that reload plays, and an external subtitle file added through `sub-add` is re-added once the reload plays unless the user picked another track or loaded something else since; the renderer only displays `EmbeddedMpvSession.reconnect`). Contract: `docs/architecture/embedded-mpv-native.md` ("Session Options", "Network Auto-Reconnect"). macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. Arrow-key and ±10 s button steps go through the relative `seekEmbeddedMpvBy` IPC (mpv `seek <delta> relative+exact`; addon export `seekBy`, helper stdin command `seek-by`, Linux JSON IPC), never an absolute target computed from the renderer's whole-second, 500 ms-polled `positionSeconds` — that stale base collapsed rapid presses onto one target (about 1 s of progress per press); only the timeline scrub commits an absolute `seek`. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
engine` (restart required) or
@@ -27,11 +27,19 @@ export type LocalMediaServer = {
url: string;
};
function createTwoSecondY4mFixture(): Buffer {
/**
* Length of the generated Y4M clip. Long enough for the relative-seek burst
* in the packaged smoke (three +2 s steps land at 6 s) plus the playing
* section that follows it without hitting EOF; at 64x36 / 10 fps this is
* still well under half a megabyte.
*/
export const Y4M_FIXTURE_DURATION_SECONDS = 12;
function createY4mFixture(): Buffer {
const width = 64;
const height = 36;
const framesPerSecond = 10;
const frameCount = framesPerSecond * 2;
const frameCount = framesPerSecond * Y4M_FIXTURE_DURATION_SECONDS;
const yPlaneBytes = width * height;
const chromaPlaneBytes = (width / 2) * (height / 2);
const chunks: Buffer[] = [
@@ -84,7 +92,7 @@ async function closeServer(server: Server): Promise<void> {
}
export async function createLocalMediaServer(): Promise<LocalMediaServer> {
const body = createTwoSecondY4mFixture();
const body = createY4mFixture();
const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m';
const server = createServer((request, response) => {
const pathname = (request.url ?? '').split('?')[0];
@@ -134,7 +134,7 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
await window.electron.setEmbeddedMpvPaused(sessionId, true);
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
streamUrl,
title: 'Two-second generated Y4M fixture',
title: 'Generated Y4M fixture',
isLive: false,
});
},
@@ -180,6 +180,59 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
})
.toBeGreaterThan(0);
// Relative seek steps (arrow keys, ±10 s buttons) must reach mpv
// as `seek <delta> relative+exact` through the real preload →
// main-process → helper `seek-by` path, and a burst issued without
// waiting for snapshots has to accumulate. Absolute targets
// computed from the stale renderer snapshot collapsed such bursts
// onto one target (about one second of progress per press).
await launchedFrameCopyApp.mainWindow.evaluate(
async (sessionId) => {
const seekBy = window.electron.seekEmbeddedMpvBy;
if (!seekBy) {
throw new Error(
'seekEmbeddedMpvBy is missing from the preload bridge'
);
}
await Promise.all([
seekBy(sessionId, 2),
seekBy(sessionId, 2),
seekBy(sessionId, 2),
]);
},
created.id
);
await expect
.poll(
async () =>
(
await getLatestSession(
launchedFrameCopyApp,
created.id
)
)?.positionSeconds,
{ timeout: 15000 }
)
.toBe(6);
// Stepping back past the start clamps at zero instead of failing.
await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.seekEmbeddedMpvBy?.(sessionId, -60),
created.id
);
await expect
.poll(
async () =>
(
await getLatestSession(
launchedFrameCopyApp,
created.id
)
)?.positionSeconds,
{ timeout: 15000 }
)
.toBe(0);
await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvPaused(sessionId, false),
@@ -572,6 +572,17 @@ void handleCommand(const Command& command) {
const std::string value = std::to_string(seconds);
const char* args[] = {"seek", value.c_str(), "absolute", nullptr};
mpv_command(g_state.mpv, args);
} else if (command.name == "seek-by") {
/* Relative step (arrow keys, ±10 s buttons). mpv resolves the delta
* against its own playback position and merges relative seeks that
* are still queued, so a burst of presses accumulates instead of
* collapsing onto one target computed from the renderer's stale
* snapshot. */
const double delta = command.getDouble("seconds", 0);
const std::string value = std::to_string(delta);
const char* args[] = {"seek", value.c_str(), "relative+exact",
nullptr};
mpv_command(g_state.mpv, args);
} else if (command.name == "volume") {
double percent =
std::clamp(command.getDouble("value", 1) * 100.0, 0.0, 100.0);
@@ -2097,6 +2097,50 @@ Napi::Value Seek(const Napi::CallbackInfo& info)
return env.Undefined();
}
Napi::Value SeekBy(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
throw Napi::TypeError::New(env, "Expected session id and seek delta.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(env, sessionId);
const auto delta = info[1].As<Napi::Number>().DoubleValue();
const std::string deltaValue = std::to_string(delta);
// Relative step (arrow keys, ±10 s buttons): mpv resolves the delta
// against its own playback position and merges relative seeks that are
// still queued, so a burst of presses accumulates instead of collapsing
// onto one target computed from the renderer's stale snapshot.
//
// Unlike the absolute Seek above, the snapshot is deliberately NOT
// advanced here: the event thread may already have stored the observed
// post-seek `time-pos` under the same mutex, and adding the delta on top
// of that would count the step twice with nothing to correct it while
// paused. Only the observed `time-pos` updates the position.
const char* command[] = {
"seek",
deltaValue.c_str(),
"relative+exact",
nullptr,
};
const int result = mpv_command_async(
session->handle,
nextAsyncRequestId(),
command
);
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to seek playback: ") +
mpv_error_string(result)
);
}
return env.Undefined();
}
Napi::Value SetVolume(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
@@ -2600,6 +2644,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports)
exports.Set("setBounds", Napi::Function::New(env, SetBounds));
exports.Set("setPaused", Napi::Function::New(env, SetPaused));
exports.Set("seek", Napi::Function::New(env, Seek));
exports.Set("seekBy", Napi::Function::New(env, SeekBy));
exports.Set("setVolume", Napi::Function::New(env, SetVolume));
exports.Set("setAudioTrack", Napi::Function::New(env, SetAudioTrack));
exports.Set(
@@ -2058,6 +2058,72 @@ Napi::Value Seek(const Napi::CallbackInfo& info)
return env.Undefined();
}
Napi::Value SeekBy(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
throw Napi::TypeError::New(env, "Expected session id and seek delta.");
}
const auto session =
getSessionOrThrow(env, info[0].As<Napi::String>().Utf8Value());
const double delta = info[1].As<Napi::Number>().DoubleValue();
const std::string seconds = formatInvariantDouble(delta);
// Relative step (arrow keys, ±10 s buttons): mpv resolves the delta
// against its own playback position and merges relative seeks that are
// still queued, so a burst of presses accumulates instead of collapsing
// onto one target computed from the renderer's stale snapshot.
//
// Unlike the absolute Seek above, the snapshot is deliberately NOT
// advanced here: the observer (event thread or Linux IPC poll) may
// already have stored the post-seek `time-pos`, and adding the delta on
// top of that would count the step twice with nothing to correct it
// while paused; on Linux it would also advertise a position that a
// failed socket delivery never reached. Only the observed `time-pos`
// updates the position.
//
// A step that cannot be delivered rejects the IPC, like a failed
// `mpv_command_async` on the in-process engines: the renderer swallows
// the rejection and resyncs from the next snapshot, and the main process
// logs it, instead of a silent no-op that looks like a seek still
// awaiting observation.
#ifdef __linux__
std::string socketPath;
{
std::lock_guard<std::mutex> lock(session->mutex);
socketPath = session->mpvIpcSocketPath;
}
if (socketPath.empty()) {
throw Napi::Error::New(
env,
"Failed to seek playback: the mpv IPC socket is not available yet."
);
}
if (!sendLinuxMpvCommand(
socketPath,
"{\"command\":[\"seek\"," + seconds + ",\"relative+exact\"]}\n"
)) {
throw Napi::Error::New(
env,
"Failed to seek playback: the mpv IPC socket did not accept the "
"seek command."
);
}
return env.Undefined();
#endif
const char* command[] = {
"seek", seconds.c_str(), "relative+exact", nullptr
};
const int result = mpv_command_async(
session->handle,
nextAsyncRequestId(),
command
);
if (result < 0) {
throw Napi::Error::New(env, mpv_error_string(result));
}
return env.Undefined();
}
Napi::Value SetVolume(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
@@ -2429,6 +2495,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports)
exports.Set("setBounds", Napi::Function::New(env, SetBounds));
exports.Set("setPaused", Napi::Function::New(env, SetPaused));
exports.Set("seek", Napi::Function::New(env, Seek));
exports.Set("seekBy", Napi::Function::New(env, SeekBy));
exports.Set("setVolume", Napi::Function::New(env, SetVolume));
exports.Set("setAudioTrack", Napi::Function::New(env, SetAudioTrack));
#ifndef __linux__
@@ -571,6 +571,11 @@ const electronApi: ElectronBridgeApi = {
seconds: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SEEK', sessionId, seconds),
seekEmbeddedMpvBy: (
sessionId: string,
deltaSeconds: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SEEK_BY', sessionId, deltaSeconds),
setEmbeddedMpvVolume: (
sessionId: string,
volume: number
@@ -7,6 +7,7 @@ import {
EMBEDDED_MPV_LOAD_PLAYBACK,
EMBEDDED_MPV_PREPARE,
EMBEDDED_MPV_SEEK,
EMBEDDED_MPV_SEEK_BY,
EMBEDDED_MPV_SELECT_SUBTITLE_FILE,
EMBEDDED_MPV_SET_ASPECT,
EMBEDDED_MPV_SET_AUDIO_TRACK,
@@ -102,6 +103,12 @@ handleEmbeddedMpv(EMBEDDED_MPV_SEEK, (sessionId: string, seconds: number) =>
getService().seek(sessionId, seconds)
);
handleEmbeddedMpv(
EMBEDDED_MPV_SEEK_BY,
(sessionId: string, deltaSeconds: number) =>
getService().seekBy(sessionId, deltaSeconds)
);
handleEmbeddedMpv(
EMBEDDED_MPV_SET_VOLUME,
(sessionId: string, volume: number) =>
@@ -234,6 +234,19 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
expect(line).toContain('opt.http-header-fields=X-Token: abc');
});
it('sends absolute and relative seeks as distinct protocol commands', () => {
const sessionId = createSession();
adapter.seek(sessionId, 42.5);
expect(child.stdin.written.at(-1)).toBe('seek\tseconds=42.5\n');
// Arrow/button steps are relative so mpv resolves and merges them
// itself; an absolute target from the stale snapshot would collapse
// rapid presses onto one position.
adapter.seekBy(sessionId, -5);
expect(child.stdin.written.at(-1)).toBe('seek-by\tseconds=-5\n');
});
it('sends the subtitle protocol commands over stdin', () => {
const sessionId = createSession();
@@ -220,6 +220,10 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
this.send(sessionId, `seek\tseconds=${seconds}`);
}
seekBy(sessionId: string, deltaSeconds: number): void {
this.send(sessionId, `seek-by\tseconds=${deltaSeconds}`);
}
setVolume(sessionId: string, volume: number): void {
this.send(sessionId, `volume\tvalue=${volume}`);
}
@@ -102,6 +102,7 @@ interface MockAddon {
setBounds: jest.Mock<void, [string, EmbeddedMpvBounds]>;
setPaused: jest.Mock<void, [string, boolean]>;
seek: jest.Mock<void, [string, number]>;
seekBy?: jest.Mock<void, [string, number]>;
setVolume: jest.Mock<void, [string, number]>;
setAudioTrack: jest.Mock<void, [string, number]>;
startRecording: jest.Mock<void, [string, string]>;
@@ -118,6 +119,7 @@ function createMockAddon(): MockAddon {
setBounds: jest.fn(),
setPaused: jest.fn(),
seek: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
startRecording: jest.fn(),
@@ -551,6 +553,45 @@ describe('EmbeddedMpvNativeService power blocker', () => {
});
});
it('seekBy forwards the delta to the addon as a relative seek and refreshes the snapshot', () => {
startSession('s1', snapshot('playing', { positionSeconds: 10 }));
addon.getSessionSnapshot.mockReturnValue(
snapshot('playing', { positionSeconds: 15.4 })
);
const updated = service.seekBy('s1', 5);
expect(addon.seekBy).toHaveBeenCalledWith('s1', 5);
expect(addon.seek).not.toHaveBeenCalled();
expect(updated?.positionSeconds).toBe(15);
});
it('seekBy falls back to a zero-clamped absolute seek from the addon snapshot when the addon lacks seekBy', () => {
delete addon.seekBy;
startSession('s1', snapshot('playing', { positionSeconds: 10 }));
addon.getSessionSnapshot.mockReturnValue(
snapshot('playing', { positionSeconds: 12.5 })
);
service.seekBy('s1', -30);
expect(addon.seek).toHaveBeenCalledWith('s1', 0);
service.seekBy('s1', 5);
expect(addon.seek).toHaveBeenLastCalledWith('s1', 17.5);
});
it('seekBy ignores a non-finite delta instead of sending it to mpv', () => {
startSession('s1', snapshot('playing', { positionSeconds: 10 }));
addon.getSessionSnapshot.mockReturnValue(
snapshot('playing', { positionSeconds: 10 })
);
service.seekBy('s1', Number.NaN);
expect(addon.seekBy).not.toHaveBeenCalled();
expect(addon.seek).not.toHaveBeenCalled();
});
it('does not acquire a blocker for a loading session', () => {
startSession('s1', snapshot('loading'));
expect(powerSaveBlockerMock.start).not.toHaveBeenCalled();
@@ -103,6 +103,12 @@ export interface NativeEmbeddedMpvAddon {
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void;
setPaused(sessionId: string, paused: boolean): void;
seek(sessionId: string, seconds: number): void;
/**
* Relative seek executed by mpv (`seek <delta> relative+exact`). Optional
* so an addon binary built before it existed keeps working through the
* absolute fallback in `EmbeddedMpvNativeService.seekBy`.
*/
seekBy?(sessionId: string, deltaSeconds: number): void;
setVolume(sessionId: string, volume: number): void;
setAudioTrack(sessionId: string, trackId: number): void;
setSubtitleTrack?(sessionId: string, trackId: number): void;
@@ -689,6 +695,31 @@ export class EmbeddedMpvNativeService {
return this.refreshSession(sessionId);
}
/**
* Seeks relative to mpv's own playback position. The renderer must not
* derive an absolute target from its `positionSeconds`: that value is a
* whole-second snapshot refreshed at most every 500 ms and a seek reply
* does not carry the new position yet, so rapid arrow presses computed
* from it all land on the same target. mpv merges queued relative seeks,
* so presses accumulate the way they do in mpv itself. An addon without
* `seekBy` falls back to an absolute seek from its own, fresher snapshot.
*/
seekBy(sessionId: string, deltaSeconds: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (!Number.isFinite(deltaSeconds)) {
return this.refreshSession(sessionId);
}
if (typeof addon.seekBy === 'function') {
addon.seekBy(sessionId, deltaSeconds);
} else {
const position =
addon.getSessionSnapshot(sessionId)?.positionSeconds ?? 0;
addon.seek(sessionId, Math.max(0, position + deltaSeconds));
}
return this.refreshSession(sessionId);
}
setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setVolume(sessionId, volume);
+3 -1
View File
@@ -166,7 +166,7 @@ The renderer never gets direct native-module access. It can only call the preloa
- load playback
- set bounds
- play/pause
- seek
- seek (absolute target) and seek by (relative step)
- set volume
- set audio track
- start/stop live stream recording
@@ -476,6 +476,8 @@ VOD and episode payloads carry `contentInfo` and are treated as non-live unless
Live catchup is different: the catchup URL already encodes the archive window, so live catchup playback must not pass an absolute Unix timestamp as `startTime`.
Seeking has two IPC shapes. The timeline scrub commits one absolute target (`seekEmbeddedMpv` → mpv `seek <t> absolute`). Arrow-key and ±10 s button steps go through `seekEmbeddedMpvBy`, which every backend forwards as a relative mpv seek (`seek <delta> relative+exact`): the macOS and Windows addons via their `seekBy` export, the frame-copy helper via the `seek-by\tseconds=<delta>` stdin command, and Linux over the MPV JSON IPC socket. The renderer must never derive an absolute target for a step from `session.positionSeconds`: that value is floored to whole seconds and refreshed at most every 500 ms (the helper emits snapshots at most every 250 ms), and a seek reply does not carry the new position yet, so every press inside that window landed on the same target and a burst of presses advanced by roughly one second each. mpv resolves relative seeks against its own position and merges the ones still queued, so presses accumulate exactly as they do in mpv itself. `EmbeddedMpvNativeService.seekBy` keeps an absolute fallback computed from the addon's own snapshot only for an addon binary built before `seekBy` existed, and `EmbeddedMpvCommandRunner.seekBy` keeps the same fallback for a preload without `seekEmbeddedMpvBy`. Unlike the absolute seek, a relative step never speculates about the resulting position in the snapshot: only mpv's observed `time-pos` updates it, because an optimistic `position + delta` could land on top of an observer write that already reflects the completed seek and count the step twice, with nothing to correct it while paused (on Linux it would also advertise a position that a failed socket delivery never reached). The packaged Linux frame-copy smoke (`electron-backend-e2e:packaged-frame-copy-smoke`) drives a burst of `seekEmbeddedMpvBy` calls through the built app and asserts the accumulated position.
Audio tracks are discovered from MPV's `track-list` property. The selected track is controlled through MPV's `aid` property. Switching tracks must not reload the stream.
Subtitle tracks mirror the audio-track contract: same `track-list` source, same parsing pipeline, but selected through MPV's `sid` property. A `trackId` of `-1` from the renderer is interpreted as "disable subtitles" and translated to `sid=no` at the addon boundary. Playback speed is observed and set through MPV's `speed` property, clamped at the addon to `[0.25, 4.0]`. Aspect override uses MPV's `video-aspect-override` property as a passthrough string ("no", "16:9", "4:3", "21:9", "2.35:1"). All four properties (`sid`, `speed`, `video-aspect-override`, plus `aid`) are observed at session init so renderer state stays in sync with the native side without needing extra round-trips.
@@ -215,7 +215,9 @@ owner.
`PlayerControlsCommands` is an imperative, fire-and-forget surface:
- `togglePlay`
- `seekTo` / `seekBy`
- `seekTo` / `seekBy` — `seekBy` is a relative command; Embedded MPV forwards
the delta to mpv itself instead of adding it to the snapshot position (see
`embedded-mpv-native.md`, "Resume And Track Handling")
- `setVolume`
- `setAudioTrack` / `setSubtitleTrack`
- `addExternalSubtitleFile` / `setSubtitleDelay` / `setSubtitleStyle`
@@ -1159,6 +1159,19 @@ export interface ElectronBridgeApi {
sessionId: string,
seconds: number
) => Promise<EmbeddedMpvSession | null>;
/**
* Relative seek by `deltaSeconds` (negative = backwards), resolved by mpv
* against its own playback position. Keyboard and button steps must use
* this instead of `seekEmbeddedMpv(position + delta)`: the renderer's
* `positionSeconds` is a whole-second snapshot refreshed at most every
* 500 ms and a seek reply does not carry the new position yet, so rapid
* presses computed from it collapse onto one target. mpv merges queued
* relative seeks instead, so presses accumulate.
*/
seekEmbeddedMpvBy?: (
sessionId: string,
deltaSeconds: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvVolume: (
sessionId: string,
volume: number
@@ -71,6 +71,7 @@ export const EMBEDDED_MPV_LOAD_PLAYBACK = 'EMBEDDED_MPV_LOAD_PLAYBACK';
export const EMBEDDED_MPV_SET_BOUNDS = 'EMBEDDED_MPV_SET_BOUNDS';
export const EMBEDDED_MPV_SET_PAUSED = 'EMBEDDED_MPV_SET_PAUSED';
export const EMBEDDED_MPV_SEEK = 'EMBEDDED_MPV_SEEK';
export const EMBEDDED_MPV_SEEK_BY = 'EMBEDDED_MPV_SEEK_BY';
export const EMBEDDED_MPV_SET_VOLUME = 'EMBEDDED_MPV_SET_VOLUME';
export const EMBEDDED_MPV_SET_AUDIO_TRACK = 'EMBEDDED_MPV_SET_AUDIO_TRACK';
export const EMBEDDED_MPV_SET_SUBTITLE_TRACK =
@@ -48,6 +48,9 @@ describe('EmbeddedMpvCommandRunner', () => {
seekEmbeddedMpv: jest
.fn()
.mockResolvedValue(createSession({ positionSeconds: 42 })),
seekEmbeddedMpvBy: jest
.fn()
.mockResolvedValue(createSession({ positionSeconds: 15 })),
setEmbeddedMpvVolume: jest
.fn()
.mockResolvedValue(createSession({ volume: 0.3 })),
@@ -108,6 +111,7 @@ describe('EmbeddedMpvCommandRunner', () => {
expect(await runner.stopRecording()).toBeNull();
expect(electron.setEmbeddedMpvPaused).not.toHaveBeenCalled();
expect(electron.seekEmbeddedMpv).not.toHaveBeenCalled();
expect(electron.seekEmbeddedMpvBy).not.toHaveBeenCalled();
});
it('guards session-dependent commands when the session snapshot is missing', async () => {
@@ -116,6 +120,7 @@ describe('EmbeddedMpvCommandRunner', () => {
expect(await runner.seekBy(10)).toBe(false);
expect(electron.setEmbeddedMpvPaused).not.toHaveBeenCalled();
expect(electron.seekEmbeddedMpv).not.toHaveBeenCalled();
expect(electron.seekEmbeddedMpvBy).not.toHaveBeenCalled();
});
it('guards every command when the bridge method is unavailable', async () => {
@@ -139,7 +144,31 @@ describe('EmbeddedMpvCommandRunner', () => {
expect(session()?.positionSeconds).toBe(42);
});
it('seekBy clamps to zero and reports that it ran', async () => {
it('seekBy sends the delta as a relative seek instead of a snapshot-derived target', async () => {
// Regression: the snapshot position is a whole-second value refreshed
// every 500 ms and a seek reply does not carry the new position, so
// two presses inside that window computed as `position + delta` both
// landed on the same absolute target (+5 instead of +10).
electron.seekEmbeddedMpvBy.mockResolvedValue(
createSession({ positionSeconds: 10 })
);
expect(await runner.seekBy(5)).toBe(true);
expect(await runner.seekBy(5)).toBe(true);
expect(electron.seekEmbeddedMpvBy.mock.calls).toEqual([
['mpv-1', 5],
['mpv-1', 5],
]);
expect(electron.seekEmbeddedMpv).not.toHaveBeenCalled();
});
it('seekBy reconciles the relative-seek reply into the session', async () => {
expect(await runner.seekBy(-5)).toBe(true);
expect(electron.seekEmbeddedMpvBy).toHaveBeenCalledWith('mpv-1', -5);
expect(session()?.positionSeconds).toBe(15);
});
it('seekBy falls back to a zero-clamped absolute seek when the bridge lacks the relative method', async () => {
delete electron.seekEmbeddedMpvBy;
expect(await runner.seekBy(-999)).toBe(true);
expect(electron.seekEmbeddedMpv).toHaveBeenCalledWith('mpv-1', 0);
expect(session()?.positionSeconds).toBe(42);
@@ -168,7 +197,7 @@ describe('EmbeddedMpvCommandRunner', () => {
it('swallows IPC errors and leaves the session untouched', async () => {
const current = session();
electron.seekEmbeddedMpv.mockRejectedValueOnce(
electron.seekEmbeddedMpvBy.mockRejectedValueOnce(
new Error('session disposed')
);
expect(await runner.seekBy(10)).toBe(true);
@@ -38,11 +38,30 @@ export class EmbeddedMpvCommandRunner {
);
}
/**
* Relative seek. mpv resolves the delta against its own playback position
* (`seek <delta> relative+exact`) and merges relative seeks still waiting
* in its queue, so a burst of arrow presses accumulates. Computing an
* absolute target here from `session.positionSeconds` is wrong: that is a
* whole-second snapshot refreshed at most every 500 ms, and a seek reply
* does not carry the new position yet, so every press inside that window
* landed on the same target and the user saw about one second of
* progress per press. The absolute form survives only as a fallback for
* a bridge without `seekEmbeddedMpvBy`.
*/
async seekBy(deltaSeconds: number): Promise<boolean> {
const id = this.ctx.sessionId();
const session = this.ctx.session();
const electron = this.bridge();
if (!id || !session || !electron?.seekEmbeddedMpv) {
if (!id || !session) {
return false;
}
const seekEmbeddedMpvBy = electron?.seekEmbeddedMpvBy;
if (seekEmbeddedMpvBy) {
await this.run(id, () => seekEmbeddedMpvBy(id, deltaSeconds));
return true;
}
if (!electron?.seekEmbeddedMpv) {
return false;
}
const next = Math.max(0, session.positionSeconds + deltaSeconds);
@@ -17,6 +17,7 @@ describe('EmbeddedMpvSessionController', () => {
onEmbeddedMpvSessionUpdate: jest.Mock;
setEmbeddedMpvPaused: jest.Mock;
seekEmbeddedMpv: jest.Mock;
seekEmbeddedMpvBy: jest.Mock;
setEmbeddedMpvVolume: jest.Mock;
};
let sessionUpdate: ((session: EmbeddedMpvSession) => void) | null;
@@ -63,6 +64,12 @@ describe('EmbeddedMpvSessionController', () => {
positionSeconds: 15,
})
),
seekEmbeddedMpvBy: jest.fn().mockResolvedValue(
createSession({
id: 'mpv-1',
positionSeconds: 15,
})
),
setEmbeddedMpvVolume: jest.fn().mockResolvedValue(
createSession({
id: 'mpv-1',
@@ -302,7 +309,10 @@ describe('EmbeddedMpvSessionController', () => {
expect(controller.session()?.status).toBe('paused');
await controller.seekBy(-30);
expect(electron.seekEmbeddedMpv).toHaveBeenCalledWith('mpv-1', 0);
// Relative: the delta goes to mpv as-is, never a snapshot-derived
// absolute target.
expect(electron.seekEmbeddedMpvBy).toHaveBeenCalledWith('mpv-1', -30);
expect(electron.seekEmbeddedMpv).not.toHaveBeenCalled();
expect(controller.session()?.positionSeconds).toBe(15);
await controller.applyVolume(0.25);