fix: Autoplay now resets time to zero and ignores trigger if episode already started #3

Merged
dtourolle merged 2 commits from fix/autoplay-issues into master 2026-06-23 21:12:01 +00:00
3 changed files with 59 additions and 26 deletions
Showing only changes of commit fa7cb6e908 - Show all commits

View File

@ -1,6 +1,6 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 -->
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { onMount, onDestroy, untrack } from "svelte";
import { commands } from "$lib/api/bindings";
import { listen } from "@tauri-apps/api/event";
import Hls from "hls.js";
@ -20,9 +20,13 @@
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
onClose: () => void;
onSeek?: (positionSeconds: number, audioStreamIndex?: number) => Promise<string>; // Returns new stream URL for transcoded seeking
onReportProgress?: (positionSeconds: number, isPaused: boolean) => void;
onReportStart?: (positionSeconds: number) => void;
onReportStop?: (positionSeconds: number) => void;
// Reporting callbacks pass the played media's id explicitly so a late
// reportStop (fired from onDestroy during autoplay navigation) is attributed
// to the episode this player actually played, not the next episode whose URL
// is already active on the page.
onReportProgress?: (positionSeconds: number, isPaused: boolean, reportId?: string) => void;
onReportStart?: (positionSeconds: number, reportId?: string) => void;
onReportStop?: (positionSeconds: number, reportId?: string) => void;
onEnded?: () => void; // Called when video playback ends naturally
onNext?: () => void; // Called when user clicks next episode button
hasNext?: boolean; // Whether there is a next episode available
@ -30,6 +34,12 @@
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props();
// The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay
// navigation) is always attributed to the episode this player played.
// untrack() makes the intent explicit: capture the initial value only.
const reportMediaId = untrack(() => media?.id);
let videoElement: HTMLVideoElement | null = $state(null);
let isPlaying = $state(false);
let currentTime = $state(0);
@ -470,7 +480,7 @@
// Report progress every 10 seconds while playing
progressInterval = setInterval(() => {
if (isPlaying && !isSeeking && onReportProgress) {
onReportProgress(currentTime, false);
onReportProgress(currentTime, false, reportMediaId);
}
}, 10000);
@ -536,7 +546,7 @@
// Report stop when component is destroyed
if (onReportStop && currentTime > 0) {
onReportStop(currentTime);
onReportStop(currentTime, reportMediaId);
}
});
@ -730,7 +740,7 @@
startTimeUpdates(); // Start RAF loop for smooth time updates
// Report playback start on first play
if (!hasReportedStart && onReportStart) {
onReportStart(currentTime);
onReportStart(currentTime, reportMediaId);
hasReportedStart = true;
}
}
@ -740,7 +750,7 @@
stopTimeUpdates(); // Stop RAF loop when paused
// Report progress when paused
if (onReportProgress) {
onReportProgress(currentTime, true);
onReportProgress(currentTime, true, reportMediaId);
}
}
@ -749,7 +759,7 @@
stopTimeUpdates(); // Stop RAF loop when ended
// Report stop when video ends
if (onReportStop) {
onReportStop(currentTime);
onReportStop(currentTime, reportMediaId);
}
// Notify parent that video has ended (for next episode popup)
if (onEnded) {

View File

@ -39,6 +39,10 @@ export async function cancelAutoPlay() {
/**
* Navigate to the next episode via goto().
* Uses replaceState to prevent history buildup when auto-advancing.
*
* Advancing to a next episode always starts that episode from the
* beginning, even if it was previously started or watched. The `restart`
* query param signals the player page to skip the resume-progress check.
*/
function navigateToEpisode(episode: MediaItem) {
if (isNavigating) {
@ -48,7 +52,7 @@ function navigateToEpisode(episode: MediaItem) {
isNavigating = true;
console.log("[NextEpisode] Navigating to next episode:", episode.id, episode.name);
nextEpisode.hidePopup();
goto(`/player/${episode.id}`, { replaceState: true }).finally(() => {
goto(`/player/${episode.id}?restart=true`, { replaceState: true }).finally(() => {
isNavigating = false;
});
}

View File

@ -25,6 +25,9 @@
const itemId = $derived($page.params.id);
const queueParam = $derived($page.url.searchParams.get("queue"));
const shuffleParam = $derived($page.url.searchParams.get("shuffle") === "true");
// When advancing to a next episode we always start from the beginning,
// even if the episode was previously started or watched.
const restartParam = $derived($page.url.searchParams.get("restart") === "true");
// Derive playback context from URL query params
const playbackContext = $derived.by(() => {
@ -80,9 +83,12 @@
// Load when itemId changes (handles both initial load and navigation)
$effect(() => {
const id = itemId;
const restart = restartParam;
if (id && id !== loadedItemId) {
console.log("[AutoPlay] $effect triggered: loading new item", id, "(was:", loadedItemId, ")");
loadAndPlay(id);
console.log("[AutoPlay] $effect triggered: loading new item", id, "(was:", loadedItemId, ") restart:", restart);
// restart=true (advancing to next episode) forces start-from-beginning,
// bypassing the resume-progress check.
loadAndPlay(id, restart ? 0 : undefined, restart);
}
});
@ -96,7 +102,7 @@
}
});
async function loadAndPlay(id: string, startPosition?: number) {
async function loadAndPlay(id: string, startPosition?: number, forceRestart = false) {
loading = true;
error = null;
loadedItemId = id;
@ -118,9 +124,11 @@
}
// If this track is already playing in the backend, just show the UI
// without restarting playback (e.g., when expanding from MiniPlayer)
// without restarting playback (e.g., when expanding from MiniPlayer).
// forceRestart bypasses this so advancing to the next episode always
// restarts from the beginning even if it were already loaded.
const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition) {
if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
console.log("loadAndPlay: Track already playing, showing UI without restarting");
isVideo = item.type === "Movie" || item.type === "Episode";
isPlaying = true;
@ -155,11 +163,13 @@
}
}
// Check for saved progress if no start position specified
// Check for saved progress if no start position specified.
// When forceRestart is set (advancing to a next episode) we always start
// from the beginning, skipping the resume check and resume dialog.
const userId = auth.getUserId();
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition);
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
if (!startPosition && userId) {
if (!startPosition && !forceRestart && userId) {
try {
const progress = await commands.storageGetPlaybackProgress(userId, id);
console.log("Resume check - retrieved progress:", progress);
@ -471,23 +481,30 @@
}
// Playback reporting callbacks
function handleReportStart(positionSeconds: number) {
const id = itemId;
//
// These receive the reporting item id from the VideoPlayer (its own media.id),
// NOT the live URL param (itemId). During autoplay the URL flips to the next
// episode before the outgoing VideoPlayer's onDestroy fires its final
// reportStop. Keying off itemId would stamp the old episode's near-end
// position onto the new episode, making it resume at ~99% (or pop the resume
// dialog). The VideoPlayer always knows which media it actually played.
function handleReportStart(positionSeconds: number, reportId?: string) {
const id = reportId ?? itemId;
const context = playbackContext; // playbackContext is a derived value, not a function
if (id) {
reportPlaybackStart(id, positionSeconds, context.type, context.id);
}
}
function handleReportProgress(positionSeconds: number, isPaused: boolean) {
const id = itemId;
function handleReportProgress(positionSeconds: number, isPaused: boolean, reportId?: string) {
const id = reportId ?? itemId;
if (id) {
reportPlaybackProgress(id, positionSeconds, isPaused);
}
}
function handleReportStop(positionSeconds: number) {
const id = itemId;
function handleReportStop(positionSeconds: number, reportId?: string) {
const id = reportId ?? itemId;
if (id) {
reportPlaybackStopped(id, positionSeconds);
}
@ -538,8 +555,10 @@
function handleSkipToNextEpisode() {
if (nextEpisode) {
// Use replaceState so "close/back" returns to the library, not the previous episode
goto(`/player/${nextEpisode.id}`, { replaceState: true });
// Use replaceState so "close/back" returns to the library, not the previous episode.
// restart=true so advancing to the next episode always starts from the beginning,
// even if it was previously started or watched.
goto(`/player/${nextEpisode.id}?restart=true`, { replaceState: true });
}
}