All files / src/lib playback.ts

100% Statements 14/14
100% Branches 12/12
100% Functions 4/4
100% Lines 12/12

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39              2x   522x                   2x 545x 522x 244x 244x   278x             2x 148x     2x 20x  
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { seriesTitleOf } from "./channels";
import type { Channel } from "./types";
 
/** Positions earlier than this aren't worth offering a resume for. */
export const MIN_RESUME_SECONDS = 30;
 
const normalize = (s: string): string => s.trim().toLowerCase().replace(/\s+/g, " ");
 
/**
 * Stable key identifying a movie or episode for resume tracking.
 *
 * Channel IDs are index-based (`{provider}-ch-{n}`) and shift when a playlist
 * is refreshed, and the URL changes when the user picks a different source, so
 * neither is a reliable key. The title (plus season/episode for series) is.
 * Returns null for live channels, which have no resumable position.
 */
export const playbackKey = (ch: Channel): string | null => {
	if (ch.contentType === "live") return null;
	if (ch.contentType === "series" && ch.season != null && ch.episode != null) {
		const title = normalize(seriesTitleOf(ch));
		return `series:${title}:s${ch.season}e${ch.episode}`;
	}
	return `${ch.contentType}:${normalize(ch.name)}`;
};
 
/**
 * True when the position is close enough to the end that the item counts as
 * watched: within the last 5% (end credits) or the last 30 seconds.
 */
export const isFinished = (position: number, duration: number): boolean =>
	duration > 0 && (position >= duration * 0.95 || duration - position < 30);
 
/** Whether a saved position should trigger the "resume or start over" prompt. */
export const shouldOfferResume = (position: number, duration: number): boolean =>
	position >= MIN_RESUME_SECONDS && !isFinished(position, duration);