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
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Skip-to-next-episode reporting tests.
|
||||
*
|
||||
* Regression: pressing "skip to next episode" left the outgoing episode with a
|
||||
* mid-episode resume position, so it showed a partial progress bar and offered
|
||||
* to resume. A manual skip means the user is done with that episode — it must
|
||||
* be recorded as fully watched.
|
||||
*
|
||||
* TRACES: UR-059, UR-025 | DR-088
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const markAsPlayed = vi.fn(async (_itemId: string) => undefined);
|
||||
const reportPlaybackStopped = vi.fn(
|
||||
async (_itemId: string, _positionSeconds: number) => undefined
|
||||
);
|
||||
|
||||
vi.mock("./playbackReporting", () => ({
|
||||
markAsPlayed: (itemId: string) => markAsPlayed(itemId),
|
||||
reportPlaybackStopped: (itemId: string, positionSeconds: number) =>
|
||||
reportPlaybackStopped(itemId, positionSeconds),
|
||||
}));
|
||||
|
||||
import {
|
||||
shouldSuppressStopReport,
|
||||
markSkipped,
|
||||
reportSkippedEpisode,
|
||||
resetSkipState,
|
||||
} from "./skipReporting";
|
||||
|
||||
describe("skip reporting", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetSkipState();
|
||||
});
|
||||
|
||||
describe("reportSkippedEpisode", () => {
|
||||
it("marks the skipped episode as fully played", async () => {
|
||||
await reportSkippedEpisode("ep-1");
|
||||
|
||||
expect(markAsPlayed).toHaveBeenCalledWith("ep-1");
|
||||
});
|
||||
|
||||
it("does not stamp the mid-episode position as a resume point", async () => {
|
||||
await reportSkippedEpisode("ep-1");
|
||||
|
||||
expect(reportPlaybackStopped).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a null item id", async () => {
|
||||
await reportSkippedEpisode(null);
|
||||
|
||||
expect(markAsPlayed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSuppressStopReport", () => {
|
||||
it("suppresses the unmount stop report for the skipped episode", async () => {
|
||||
await reportSkippedEpisode("ep-1");
|
||||
|
||||
// VideoPlayer.onDestroy fires after navigation with the mid-episode time.
|
||||
expect(shouldSuppressStopReport("ep-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("only suppresses the episode that was actually skipped", async () => {
|
||||
await reportSkippedEpisode("ep-1");
|
||||
|
||||
expect(shouldSuppressStopReport("ep-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses only once, so a later real stop still reports", async () => {
|
||||
await reportSkippedEpisode("ep-1");
|
||||
|
||||
expect(shouldSuppressStopReport("ep-1")).toBe(true);
|
||||
expect(shouldSuppressStopReport("ep-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not suppress when nothing was skipped", () => {
|
||||
expect(shouldSuppressStopReport("ep-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not suppress a null item id", () => {
|
||||
markSkipped("ep-1");
|
||||
expect(shouldSuppressStopReport(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user