Dispatcharr/frontend/src/utils/cards/TimeshiftConnectionCardUtils.js
SergeantPanda 8bcc7c9ebe
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
feat(timeshift): enhance catch-up programme handling and UI updates
- Updated the catch-up stats UI to advance to the next EPG programme when playback continues past the original programme's end, improving user experience.
- Enhanced the `get_programme_info` function to resolve the guide entry based on the playhead position, ensuring accurate display of the current programme.
- Introduced new utility functions for computing catch-up playback position and checking if the playhead is outside the displayed programme window, optimizing session management.
- Added tests to validate the new functionality and ensure correct behaviour of the catch-up features.
2026-07-16 23:10:06 +00:00

176 lines
5.4 KiB
JavaScript

import { format, getNowMs, toFriendlyDuration } from '../dateTimeUtils.js';
/** Parse a catch-up URL timestamp (UTC wall clock) into epoch ms. */
export const parseCatchupTimestampMs = (timestampStr) => {
if (!timestampStr) {
return null;
}
const match = String(timestampStr).match(
/^(\d{4}-\d{2}-\d{2})[:_ ](\d{2})[-:](\d{2})/
);
if (!match) {
return null;
}
const [, datePart, hour, minute] = match;
const parsed = Date.parse(`${datePart}T${hour}:${minute}:00Z`);
return Number.isNaN(parsed) ? null : parsed;
};
/** Best-effort play position within the programme (seconds). */
export const computeCatchupPlaybackSeconds = ({
programmeStart,
programStartTime,
programDurationSecs,
positionAnchorAt,
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
capToDuration = true,
allowNegative = false,
}) => {
let elapsedSinceAnchor = 0;
if (!paused && positionAnchorAt != null) {
const anchor = Number(positionAnchorAt);
if (!Number.isNaN(anchor)) {
elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor);
}
}
// Byte-range / client-reported bases are relative to the programme that
// contained the original catch-up URL. When the stats card has advanced to a
// later guide entry, rebase onto that programme's start.
let effectiveBase = playbackBaseSecs;
if (effectiveBase != null && !Number.isNaN(Number(effectiveBase))) {
const urlMs = parseCatchupTimestampMs(programmeStart);
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
if (
urlMs != null &&
epgStartMs != null &&
!Number.isNaN(epgStartMs) &&
epgStartMs > urlMs
) {
effectiveBase = Number(effectiveBase) - (epgStartMs - urlMs) / 1000;
}
let position = Number(effectiveBase) + elapsedSinceAnchor;
if (!allowNegative && position < 0) {
position = 0;
}
if (capToDuration && programDurationSecs != null) {
position = Math.min(position, Number(programDurationSecs));
}
return position;
}
const urlMs = parseCatchupTimestampMs(programmeStart);
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
if (urlMs == null || epgStartMs == null || Number.isNaN(epgStartMs)) {
return null;
}
const urlOffsetSec = (urlMs - epgStartMs) / 1000;
let position = urlOffsetSec + elapsedSinceAnchor;
if (!allowNegative && position < 0) {
position = 0;
}
if (capToDuration && programDurationSecs != null) {
position = Math.min(position, Number(programDurationSecs));
}
return position;
};
/**
* True when the catch-up playhead is outside the displayed programme window.
* Used to fetch the next guide entry once, then cache until that show ends.
*/
export const isCatchupPlayheadOutsideProgram = ({
programmeStart,
programStartTime,
programDurationSecs,
positionAnchorAt,
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
}) => {
if (programDurationSecs == null || !programStartTime) {
return true;
}
const position = computeCatchupPlaybackSeconds({
programmeStart,
programStartTime,
programDurationSecs,
positionAnchorAt,
playbackBaseSecs,
paused,
nowMs,
capToDuration: false,
allowNegative: true,
});
if (position == null) {
return false;
}
return position < 0 || position >= Number(programDurationSecs);
};
/**
* Archive playhead relative to the programme containing ``programmeStart``.
* Uncapped so callers can detect when viewing has moved past that show.
* Does not rebase onto a later displayed programme.
*/
export const computeCatchupArchivePositionSecs = ({
programmeStart,
programStartTime,
positionAnchorAt,
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
}) => {
let elapsedSinceAnchor = 0;
if (!paused && positionAnchorAt != null) {
const anchor = Number(positionAnchorAt);
if (!Number.isNaN(anchor)) {
elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor);
}
}
// playback_base is always relative to the URL programme, even after the
// stats card has advanced to a later guide entry.
if (playbackBaseSecs != null && !Number.isNaN(Number(playbackBaseSecs))) {
return Math.max(0, Number(playbackBaseSecs) + elapsedSinceAnchor);
}
const urlMs = parseCatchupTimestampMs(programmeStart);
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
if (urlMs == null || epgStartMs == null || Number.isNaN(epgStartMs)) {
return null;
}
// Only valid while programStartTime is the programme that contains the URL.
// Once the card has advanced, omit position and let the API use Redis.
if (epgStartMs > urlMs) {
return null;
}
return Math.max(0, (urlMs - epgStartMs) / 1000 + elapsedSinceAnchor);
};
export const calculateConnectionDuration = (connection) => {
const seconds = getConnectionDurationSeconds(connection);
return toFriendlyDuration(seconds, 'seconds');
};
export const getConnectionDurationSeconds = (connection) => {
if (!connection) {
return 0;
}
if (connection.duration && connection.duration > 0) {
return connection.duration;
}
if (connection.connected_at) {
return Math.max(0, Math.floor(getNowMs() / 1000 - connection.connected_at));
}
return 0;
};
export const calculateConnectionStartTime = (connection, fullDateTimeFormat) => {
if (!connection?.connected_at) {
return 'Unknown';
}
return format(new Date(connection.connected_at * 1000), fullDateTimeFormat);
};