Files
jellytau/src/lib/services/skipReporting.ts
dtourolle eb76c96e94 feat(player): skipping an episode marks it watched, not paused
Skipping to the next episode left a mid-episode resume point behind, so
the skipped episode reappeared in Continue Watching with a partial
progress bar. Skipping means "done with this one", not "stopped here".

- reportSkippedEpisode marks the outgoing episode played instead of
  reporting a stop position, and arms a one-shot suppression consumed by
  the player's stop handler, so VideoPlayer's post-navigation unmount
  stop report can't overwrite the 100% progress with the partial one.
- Continue Watching drops resume entries superseded by Next Up: an
  in-progress episode whose series has a next-up entry strictly later in
  series order (season, then episode) is hidden from the Home and TV
  rows. Movies, series without a next-up entry, and items with unknown
  or mixed ordering are always kept.

Adds UR-059, DR-088, DR-089.

TRACES: UR-059 | DR-088, DR-089
2026-07-25 09:20:56 +02:00

65 lines
2.2 KiB
TypeScript

// Skip-to-next-episode reporting.
//
// Skipping an episode is a "done with it" signal, not a "stopped here" one:
// the user is moving on because they've already seen it. So a manual skip
// records the outgoing episode as fully played rather than saving the
// mid-episode position as a resume point.
//
// The suppression handshake exists because VideoPlayer.onDestroy fires its
// final reportStop *after* the skip navigation, with the mid-episode time. If
// that landed, it would overwrite the just-written 100% progress and the
// episode would look partially watched again. markSkipped() arms a one-shot
// suppression that the stop handler consumes.
//
// TRACES: UR-059, UR-025 | DR-088
import { markAsPlayed, reportPlaybackStopped } from "./playbackReporting";
/** Item id whose next stop report should be dropped, if any. */
let suppressedItemId: string | null = null;
/**
* Arm suppression of the next stop report for `itemId`.
*
* Exported separately from `reportSkippedEpisode` so callers that already
* handled their own reporting can still silence the unmount stop.
*/
export function markSkipped(itemId: string | null): void {
if (!itemId) return;
suppressedItemId = itemId;
}
/**
* Should the pending stop report for `itemId` be dropped?
*
* One-shot: consumes the armed suppression, so a later genuine stop on the
* same episode still reports its position normally.
*/
export function shouldSuppressStopReport(itemId: string | null): boolean {
if (!itemId) return false;
if (suppressedItemId !== itemId) return false;
suppressedItemId = null;
return true;
}
/**
* Record a manually skipped episode as fully watched.
*
* Deliberately does NOT call `reportPlaybackStopped` — that would write the
* partial position we are trying to avoid.
*/
export async function reportSkippedEpisode(itemId: string | null): Promise<void> {
if (!itemId) return;
markSkipped(itemId);
await markAsPlayed(itemId);
}
/** Test hook: clear armed suppression between cases. */
export function resetSkipState(): void {
suppressedItemId = null;
}
// Re-exported so the module owns the full skip story; callers that need the
// normal stop path keep importing it from playbackReporting directly.
export { reportPlaybackStopped };