mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
1 parent
078bd07d94
commit
ae85428065
18 files changed
+300
-7
No files matched your search
@@ -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);
|
||||
|
||||
+11
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user