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>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-03 20:57:45 +02:00
1 parent 078bd07d94
commit ae85428065
18 files changed
+300 -7

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.
+1 -1
View File
@@ -996,7 +996,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. 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. 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
@@ -561,6 +561,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);
@@ -2051,6 +2051,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.
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)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.positionSeconds =
std::max(0.0, session->snapshot.positionSeconds + delta);
}
return env.Undefined();
}
Napi::Value SetVolume(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
@@ -2540,6 +2584,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(
@@ -2000,6 +2000,55 @@ 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.
#ifdef __linux__
std::string socketPath;
{
std::lock_guard<std::mutex> lock(session->mutex);
socketPath = session->mpvIpcSocketPath;
session->snapshot.positionSeconds =
std::max(0.0, session->snapshot.positionSeconds + delta);
}
if (!socketPath.empty()) {
sendLinuxMpvCommand(
socketPath,
"{\"command\":[\"seek\"," + seconds + ",\"relative+exact\"]}\n"
);
}
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));
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.positionSeconds =
std::max(0.0, session->snapshot.positionSeconds + delta);
}
return env.Undefined();
}
Napi::Value SetVolume(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
@@ -2360,6 +2409,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__
@@ -569,6 +569,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,
@@ -97,6 +98,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) =>
@@ -110,6 +110,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();
@@ -180,6 +180,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(),
@@ -543,6 +545,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();
@@ -75,6 +75,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;
@@ -544,6 +550,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`.
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`
@@ -1153,6 +1153,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);