fix(player): resume at saved position on the Android native path

The native (ExoPlayer) video path never applied the resume position, so
"resume from where you left off" always played from the start on Android.

Two layers each assumed the other did the seek:

- The only code acting on `initialPosition` was handleCanPlay, an HTML5
  <video> event handler. The native path has no <video> element, so
  `canplay` never fires and that seek never ran.
- NativePlayerAdapter.load() had an initialPosition branch, but it only
  recorded the number, claiming "the native backend performs the actual
  seek internally". It does not: PlayItemRequest carries no start
  position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0.
- VideoPlayer never called adapter.load() at all, so even that branch was
  unreachable.

The frontend therefore believed it had resumed (the seek bar showed the
resume point) while ExoPlayer played from the beginning.

NativePlayerAdapter.load() now issues the backend seek, excluding live
streams (no resume point; seeking knocks the HLS window off its live
edge). VideoPlayer calls it on the native branch and marks the initial
seek as performed so the existing $effect does not fire a duplicate.
The HTML5 path is untouched: seeking before metadata is clamped to 0,
which is exactly what handleCanPlay waits for.

Verified red->green: the new test failed with "Number of calls: 0"
before the fix. Full frontend suite passes (933 tests); svelte-check
and check:boundary are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 18:51:37 +02:00
co-authored by Claude Opus 5
parent 3619f71aba
commit 2d50744320
3 changed files with 84 additions and 3 deletions
@@ -11,6 +11,7 @@ const playerToggle = vi.fn((..._a: any[]): any => ({ state: "playing" }));
const playerSetVolume = vi.fn((..._a: any[]): any => ({}));
const playerToggleMute = vi.fn((..._a: any[]): any => ({}));
const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({}));
const playerSeek = vi.fn((..._a: any[]): any => ({}));
vi.mock("$lib/api/bindings", () => ({
commands: {
@@ -20,6 +21,7 @@ vi.mock("$lib/api/bindings", () => ({
playerSetVolume: (...a: any[]) => playerSetVolume(...a),
playerToggleMute: (...a: any[]) => playerToggleMute(...a),
playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a),
playerSeek: (...a: any[]) => playerSeek(...a),
},
}));
@@ -72,6 +74,39 @@ describe("NativePlayerAdapter", () => {
expect(adapter.getPosition()).toBe(90);
});
// Regression: resume-at-position was broken on Android. player_play_item
// carries no start position, and ExoPlayer always begins at 0, so recording
// the number frontend-side left the backend playing from the beginning. The
// adapter must actually *issue* the seek.
it("load() issues the resume seek to the backend, not just records it", async () => {
await adapter.load("url", {
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
initialPosition: 90, isLive: false, audioTrackIndex: null,
knownDuration: 0, subtitleTracks: [],
});
expect(playerSeek).toHaveBeenCalledWith(90);
});
it("load() does not seek when starting from the beginning", async () => {
await adapter.load("url", {
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
initialPosition: 0, isLive: false, audioTrackIndex: null,
knownDuration: 0, subtitleTracks: [],
});
expect(playerSeek).not.toHaveBeenCalled();
});
// A live stream has no meaningful resume point; seeking one is at best a
// no-op and at worst knocks the HLS window off its live edge.
it("load() never seeks a live stream", async () => {
await adapter.load("url", {
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
initialPosition: 90, isLive: true, audioTrackIndex: null,
knownDuration: 0, subtitleTracks: [],
});
expect(playerSeek).not.toHaveBeenCalled();
});
it("setVolume clamps and delegates; setMuted toggles mute", () => {
adapter.setVolume(2);
expect(playerSetVolume).toHaveBeenCalledWith(1);
+14 -3
View File
@@ -43,10 +43,21 @@ export class NativePlayerAdapter implements PlayerAdapter {
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
// player_play_item already initiated native playback before this adapter is
// created; nothing further to do. Seed a resume position if requested (the
// native backend performs the actual seek internally).
if (options.initialPosition > 0) {
// created, so there is no stream to load here — but it carries no start
// position, and ExoPlayer always begins at 0. The resume seek must be
// issued explicitly or "resume at position" silently plays from the top.
//
// Recording the position without seeking (what this used to do) is what
// broke Android resume: the frontend believed it had resumed while
// ExoPlayer played from the beginning.
//
// Live streams have no resume point — seeking one knocks the HLS window off
// its live edge, so they are excluded.
//
// TRACES: UR-005 | DR-004, DR-028
if (options.initialPosition > 0 && !options.isLive) {
this.position = options.initialPosition;
await commands.playerSeek(options.initialPosition);
}
}