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:
@@ -69,6 +69,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
|
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
|
||||||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||||
|
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -239,6 +240,8 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
|
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
|
||||||
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
||||||
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
||||||
|
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) 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 cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
|
||||||
|
| DR-089 | Continue Watching suppresses 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 dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Continue Watching stale-entry suppression tests.
|
||||||
|
*
|
||||||
|
* A partially-watched episode should drop off Continue Watching once the user
|
||||||
|
* has moved past it — i.e. when Next Up for that series points at a *later*
|
||||||
|
* episode. Otherwise skipping an episode leaves it lingering as a resume
|
||||||
|
* suggestion behind the episode the user is actually on.
|
||||||
|
*
|
||||||
|
* TRACES: UR-059 | DR-089
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
import { filterSupersededResumeItems } from "./continueWatchingFilter";
|
||||||
|
|
||||||
|
function episode(
|
||||||
|
id: string,
|
||||||
|
seriesId: string,
|
||||||
|
season: number | undefined,
|
||||||
|
index: number | undefined
|
||||||
|
): MediaItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Episode ${index}`,
|
||||||
|
kind: "episode",
|
||||||
|
seriesId,
|
||||||
|
parentIndexNumber: season,
|
||||||
|
indexNumber: index,
|
||||||
|
} as MediaItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function movie(id: string): MediaItem {
|
||||||
|
return { id, name: "A Movie", kind: "movie" } as MediaItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("filterSupersededResumeItems", () => {
|
||||||
|
it("drops a partially-watched episode when next up is later in the same season", () => {
|
||||||
|
const resume = [episode("s1e2", "series-a", 1, 2)];
|
||||||
|
const nextUp = [episode("s1e5", "series-a", 1, 5)];
|
||||||
|
|
||||||
|
const result = filterSupersededResumeItems(resume, nextUp);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops it when next up is in a later season", () => {
|
||||||
|
const resume = [episode("s1e9", "series-a", 1, 9)];
|
||||||
|
const nextUp = [episode("s2e1", "series-a", 2, 1)];
|
||||||
|
|
||||||
|
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the episode the user is actually mid-way through", () => {
|
||||||
|
const resume = [episode("s1e4", "series-a", 1, 4)];
|
||||||
|
const nextUp = [episode("s1e4", "series-a", 1, 4)];
|
||||||
|
|
||||||
|
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an episode ahead of next up (user jumped forward)", () => {
|
||||||
|
const resume = [episode("s1e7", "series-a", 1, 7)];
|
||||||
|
const nextUp = [episode("s1e3", "series-a", 1, 3)];
|
||||||
|
|
||||||
|
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only compares within the same series", () => {
|
||||||
|
const resume = [episode("a-s1e2", "series-a", 1, 2)];
|
||||||
|
const nextUp = [episode("b-s1e9", "series-b", 1, 9)];
|
||||||
|
|
||||||
|
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never suppresses movies", () => {
|
||||||
|
const resume = [movie("movie-1")];
|
||||||
|
const nextUp = [episode("s1e5", "series-a", 1, 5)];
|
||||||
|
|
||||||
|
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps items when ordering is unknown on either side", () => {
|
||||||
|
const resume = [episode("s1e2", "series-a", undefined, undefined)];
|
||||||
|
const nextUp = [episode("s1e5", "series-a", 1, 5)];
|
||||||
|
|
||||||
|
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing season number as season 1 only when both sides agree", () => {
|
||||||
|
// Flat series (no season folders): episode numbers alone must still order.
|
||||||
|
const resume = [episode("e2", "series-a", undefined, 2)];
|
||||||
|
const nextUp = [episode("e6", "series-a", undefined, 6)];
|
||||||
|
|
||||||
|
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when next up is empty", () => {
|
||||||
|
const resume = [episode("s1e2", "series-a", 1, 2)];
|
||||||
|
|
||||||
|
expect(filterSupersededResumeItems(resume, [])).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the original order of surviving items", () => {
|
||||||
|
const resume = [
|
||||||
|
episode("a-s1e2", "series-a", 1, 2),
|
||||||
|
episode("b-s1e1", "series-b", 1, 1),
|
||||||
|
episode("c-s1e3", "series-c", 1, 3),
|
||||||
|
];
|
||||||
|
const nextUp = [episode("b-s1e4", "series-b", 1, 4)];
|
||||||
|
|
||||||
|
const result = filterSupersededResumeItems(resume, nextUp);
|
||||||
|
|
||||||
|
expect(result.map(i => i.id)).toEqual(["a-s1e2", "c-s1e3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the furthest-ahead next-up entry for a series", () => {
|
||||||
|
const resume = [episode("s1e2", "series-a", 1, 2)];
|
||||||
|
const nextUp = [
|
||||||
|
episode("s1e1", "series-a", 1, 1),
|
||||||
|
episode("s1e8", "series-a", 1, 8),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Continue Watching stale-entry suppression.
|
||||||
|
//
|
||||||
|
// Continue Watching is built from raw resume positions, so an episode the user
|
||||||
|
// has moved past keeps showing up as a resume suggestion — most visibly after
|
||||||
|
// skipping an episode, which leaves a partial position behind. Next Up already
|
||||||
|
// tells us where the user actually is in each series, so an in-progress episode
|
||||||
|
// that sits *behind* its series' Next Up entry is stale and gets suppressed.
|
||||||
|
//
|
||||||
|
// This is presentation-layer de-duplication over two lists the frontend already
|
||||||
|
// holds — no Jellyfin taxonomy involved, so it stays in `src/`.
|
||||||
|
//
|
||||||
|
// TRACES: UR-059 | DR-089
|
||||||
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position of an episode within its series, as (season, episode).
|
||||||
|
*
|
||||||
|
* Returns null when the episode number is unknown — without it there is no
|
||||||
|
* defensible ordering and we must not suppress anything. A missing *season*
|
||||||
|
* number is normal for flat series (no season folders), so it is only usable
|
||||||
|
* when both sides are equally season-less; callers compare via `isAheadOf`.
|
||||||
|
*/
|
||||||
|
function episodeOrder(item: MediaItem): { season: number | null; index: number } | null {
|
||||||
|
if (item.indexNumber == null) return null;
|
||||||
|
return { season: item.parentIndexNumber ?? null, index: item.indexNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is `a` strictly later in series order than `b`? */
|
||||||
|
function isAheadOf(a: MediaItem, b: MediaItem): boolean {
|
||||||
|
const oa = episodeOrder(a);
|
||||||
|
const ob = episodeOrder(b);
|
||||||
|
if (!oa || !ob) return false;
|
||||||
|
|
||||||
|
// Mixed season-numbering (one side foldered, the other flat) is not safely
|
||||||
|
// comparable — leave the entry alone rather than hide something wrongly.
|
||||||
|
if ((oa.season == null) !== (ob.season == null)) return false;
|
||||||
|
|
||||||
|
if (oa.season != null && ob.season != null && oa.season !== ob.season) {
|
||||||
|
return oa.season > ob.season;
|
||||||
|
}
|
||||||
|
return oa.index > ob.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop resume entries the user has already moved past.
|
||||||
|
*
|
||||||
|
* An episode is suppressed when its series has a Next Up entry strictly later
|
||||||
|
* in series order. Movies, items without a series, and anything whose ordering
|
||||||
|
* is unknown are always kept — suppression must never hide something the user
|
||||||
|
* genuinely still wants to resume.
|
||||||
|
*/
|
||||||
|
export function filterSupersededResumeItems(
|
||||||
|
resumeItems: MediaItem[],
|
||||||
|
nextUpItems: MediaItem[]
|
||||||
|
): MediaItem[] {
|
||||||
|
if (nextUpItems.length === 0) return resumeItems;
|
||||||
|
|
||||||
|
// Furthest-ahead Next Up entry per series: Next Up can carry more than one
|
||||||
|
// entry for a series, and the latest is the true watch frontier.
|
||||||
|
const frontier = new Map<string, MediaItem>();
|
||||||
|
for (const item of nextUpItems) {
|
||||||
|
if (!item.seriesId) continue;
|
||||||
|
const current = frontier.get(item.seriesId);
|
||||||
|
if (!current || isAheadOf(item, current)) {
|
||||||
|
frontier.set(item.seriesId, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resumeItems.filter(item => {
|
||||||
|
if (item.kind !== "episode" || !item.seriesId) return true;
|
||||||
|
const ahead = frontier.get(item.seriesId);
|
||||||
|
if (!ahead) return true;
|
||||||
|
return !isAheadOf(ahead, item);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// Home screen data store - featured items, continue watching, recently added
|
// Home screen data store - featured items, continue watching, recently added
|
||||||
// TRACES: UR-023, UR-024, UR-034 | DR-026, DR-027, DR-038, DR-039
|
// TRACES: UR-023, UR-024, UR-034, UR-059 | DR-026, DR-027, DR-038, DR-039, DR-089
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
|
import { filterSupersededResumeItems } from "./continueWatchingFilter";
|
||||||
|
|
||||||
interface HomeState {
|
interface HomeState {
|
||||||
heroItems: MediaItem[];
|
heroItems: MediaItem[];
|
||||||
@@ -50,8 +51,12 @@ function createHomeStore() {
|
|||||||
const valueOr = <T>(i: number, fallback: T): T =>
|
const valueOr = <T>(i: number, fallback: T): T =>
|
||||||
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
|
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
|
||||||
|
|
||||||
const resume = valueOr(0, [] as typeof initialState.resumeItems);
|
const rawResume = valueOr(0, [] as typeof initialState.resumeItems);
|
||||||
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
|
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
|
||||||
|
// Drop episodes the user has already moved past (their series' Next Up
|
||||||
|
// points further ahead) so Continue Watching isn't cluttered with stale
|
||||||
|
// partial positions left behind by skipping.
|
||||||
|
const resume = filterSupersededResumeItems(rawResume, nextUp);
|
||||||
const latest = valueOr(2, [] as typeof initialState.latestItems);
|
const latest = valueOr(2, [] as typeof initialState.latestItems);
|
||||||
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
|
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
|
||||||
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
|
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// TV library landing page data store.
|
// TV library landing page data store.
|
||||||
// Powers the focused TV landing: hero + horizontal sliders.
|
// Powers the focused TV landing: hero + horizontal sliders.
|
||||||
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
|
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { buildHeroMix } from "$lib/utils/heroMix";
|
import { buildHeroMix } from "$lib/utils/heroMix";
|
||||||
|
import { filterSupersededResumeItems } from "./continueWatchingFilter";
|
||||||
|
|
||||||
/** A single "by genre" row: the genre name plus the series in it. */
|
/** A single "by genre" row: the genre name plus the series in it. */
|
||||||
export interface GenreRow {
|
export interface GenreRow {
|
||||||
@@ -81,7 +82,12 @@ function createTvStore() {
|
|||||||
|
|
||||||
// Resume items are already video-only from the server, but keep episodes
|
// Resume items are already video-only from the server, but keep episodes
|
||||||
// (and the occasional movie that lives in a mixed library) defensively.
|
// (and the occasional movie that lives in a mixed library) defensively.
|
||||||
const continueWatching = resume.filter(i => i.kind === "episode" || i.kind === "movie");
|
// Then drop episodes the user has moved past — a stale partial position
|
||||||
|
// behind the series' Next Up entry isn't something to continue.
|
||||||
|
const continueWatching = filterSupersededResumeItems(
|
||||||
|
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
|
||||||
|
nextUp
|
||||||
|
);
|
||||||
|
|
||||||
// Mix the hero: in-progress episodes first (most personal), then next-up,
|
// Mix the hero: in-progress episodes first (most personal), then next-up,
|
||||||
// recent additions, and random series from across the library.
|
// recent additions, and random series from across the library.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
reportPlaybackProgress,
|
reportPlaybackProgress,
|
||||||
reportPlaybackStopped,
|
reportPlaybackStopped,
|
||||||
} from "$lib/services/playbackReporting";
|
} from "$lib/services/playbackReporting";
|
||||||
|
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
|
||||||
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
|
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
|
||||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||||
|
|
||||||
@@ -536,7 +537,11 @@
|
|||||||
|
|
||||||
function handleReportStop(positionSeconds: number, reportId?: string) {
|
function handleReportStop(positionSeconds: number, reportId?: string) {
|
||||||
const id = reportId ?? itemId;
|
const id = reportId ?? itemId;
|
||||||
if (id) {
|
// A skipped episode was already recorded as fully watched. Its unmount stop
|
||||||
|
// report arrives after the skip navigation carrying the mid-episode
|
||||||
|
// position; letting it through would undo that and restore the partial
|
||||||
|
// progress bar.
|
||||||
|
if (id && !shouldSuppressStopReport(id)) {
|
||||||
reportPlaybackStopped(id, positionSeconds);
|
reportPlaybackStopped(id, positionSeconds);
|
||||||
}
|
}
|
||||||
// Intentionally do NOT emit a "stopped" player state here. This runs on both
|
// Intentionally do NOT emit a "stopped" player state here. This runs on both
|
||||||
@@ -592,6 +597,14 @@
|
|||||||
|
|
||||||
function handleSkipToNextEpisode() {
|
function handleSkipToNextEpisode() {
|
||||||
if (nextEpisode) {
|
if (nextEpisode) {
|
||||||
|
// Skipping means "I'm done with this one" — record the outgoing episode as
|
||||||
|
// fully watched rather than leaving a mid-episode resume point behind. This
|
||||||
|
// also arms suppression of the VideoPlayer's unmount stop report, which
|
||||||
|
// would otherwise fire after navigation and overwrite the 100% progress
|
||||||
|
// with the partial position (see skipReporting.ts).
|
||||||
|
const skippedId = currentMedia?.id ?? itemId ?? null;
|
||||||
|
void reportSkippedEpisode(skippedId);
|
||||||
|
|
||||||
// Use replaceState so "close/back" returns to the library, not the previous episode.
|
// 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,
|
// restart=true so advancing to the next episode always starts from the beginning,
|
||||||
// even if it was previously started or watched.
|
// even if it was previously started or watched.
|
||||||
|
|||||||
Reference in New Issue
Block a user