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
@@ -721,6 +721,41 @@
// No-op for the native adapter, which owns no DOM element. // No-op for the native adapter, which owns no DOM element.
playerAdapter.attach(videoElement); playerAdapter.attach(videoElement);
playerController.setActiveAdapter(playerAdapter); playerController.setActiveAdapter(playerAdapter);
// The native (ExoPlayer) path has no <video> element, so `canplay`
// never fires and the handleCanPlay initial-seek below never runs —
// resume-at-position played from the beginning on Android. Hand the
// resume point to the adapter, which issues the backend seek.
//
// HTML5 keeps its existing element-driven seek: seeking before the
// element has metadata is clamped back to 0, which is precisely what
// handleCanPlay waits for.
// TRACES: UR-005 | DR-004, DR-028
if (!useHtml5Element) {
hasPerformedInitialSeek = true; // native path owns the initial seek
lastAppliedInitialPosition = initialPosition;
await playerAdapter.load(currentStreamUrl, {
mediaId: media.id,
mediaSourceId: mediaSourceId ?? null,
needsTranscoding,
initialPosition: initialPosition ?? 0,
isLive,
audioTrackIndex: null,
knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
// ExoPlayer already received these as SubtitleConfigurations via
// player_play_item; mapped to the adapter shape for the contract.
subtitleTracks: sentSubtitleTracks.map((t) => ({
index: t.streamIndex,
url: t.url,
language: t.srclang,
label: t.label,
mimeType: "text/vtt",
})),
});
if (initialPosition && initialPosition > 0 && !isLive) {
currentTime = initialPosition;
}
}
} }
if (!useHtml5Element) { if (!useHtml5Element) {
@@ -11,6 +11,7 @@ const playerToggle = vi.fn((..._a: any[]): any => ({ state: "playing" }));
const playerSetVolume = vi.fn((..._a: any[]): any => ({})); const playerSetVolume = vi.fn((..._a: any[]): any => ({}));
const playerToggleMute = vi.fn((..._a: any[]): any => ({})); const playerToggleMute = vi.fn((..._a: any[]): any => ({}));
const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({})); const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({}));
const playerSeek = vi.fn((..._a: any[]): any => ({}));
vi.mock("$lib/api/bindings", () => ({ vi.mock("$lib/api/bindings", () => ({
commands: { commands: {
@@ -20,6 +21,7 @@ vi.mock("$lib/api/bindings", () => ({
playerSetVolume: (...a: any[]) => playerSetVolume(...a), playerSetVolume: (...a: any[]) => playerSetVolume(...a),
playerToggleMute: (...a: any[]) => playerToggleMute(...a), playerToggleMute: (...a: any[]) => playerToggleMute(...a),
playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a), playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a),
playerSeek: (...a: any[]) => playerSeek(...a),
}, },
})); }));
@@ -72,6 +74,39 @@ describe("NativePlayerAdapter", () => {
expect(adapter.getPosition()).toBe(90); 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", () => { it("setVolume clamps and delegates; setMuted toggles mute", () => {
adapter.setVolume(2); adapter.setVolume(2);
expect(playerSetVolume).toHaveBeenCalledWith(1); 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> { async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
// player_play_item already initiated native playback before this adapter is // player_play_item already initiated native playback before this adapter is
// created; nothing further to do. Seed a resume position if requested (the // created, so there is no stream to load here — but it carries no start
// native backend performs the actual seek internally). // position, and ExoPlayer always begins at 0. The resume seek must be
if (options.initialPosition > 0) { // 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; this.position = options.initialPosition;
await commands.playerSeek(options.initialPosition);
} }
} }