mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
ad81fbfc45
commit
b2ca85172c
21 files changed
+392
-11
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user