Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12,272 changes: 6,203 additions & 6,069 deletions src/embedded_web_data.h

Large diffs are not rendered by default.

212 changes: 202 additions & 10 deletions web-ui/src/components/player/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ interface PlaybackErrorDisplay {
suggestion?: string;
}

const MAX_RETRIES = 3;
// [local patch] A freeze retries immediately now, so the budget must not run out
// after a few hiccups. The stock player resets the baseline after 30s of stable
// playback, so this only bounds a truly hopeless stream.
const MAX_RETRIES = 100;
const INACTIVE_RENDER_STATE: PlayerRenderState = { active: false, deinterlacing: false };

type SlotId = "a" | "b";
Expand Down Expand Up @@ -303,6 +306,18 @@ function VideoPlayerComponent({
const pendingTransitionRef = useRef<PendingTransition | null>(null);
const hasStartedPlaybackRef = useRef(false);
const prevStreamRef = useRef<{ channelId: string; sourceIndex: number } | null>(null);
// [local patch] Set while a watchdog-driven recovery is loading a hidden slot:
// that slot's buffering must not paint "buffering" over the live picture.
// Slot hand-off tuning: how far behind live the replacement is placed, how
// much audio is dropped on the alignment, and how far the picture then jumps
// ahead to avoid re-showing content the viewer already watched.
const HANDOFF_DEFAULT_LATENCY = 2;
const HANDOFF_MAX_LATENCY = 3.5;
const HANDOFF_AUDIO_SKIP = 0.9;
const HANDOFF_PICTURE_SKIP = 0.9;
const silentRecoveryRef = useRef(false);
/** Slot player that already received its hand-off placement. */
const handedOverPlayerRef = useRef<PlaybackBackend | null>(null);
/** Skip one segments effect after inline retry reload (parent may emit same URL). */
const skipNextSegmentsLoadRef = useRef(false);

Expand Down Expand Up @@ -571,6 +586,20 @@ function VideoPlayerComponent({
};
}, [playerPortalHost]);

// [local patch] Report the swap itself (the frontend owns the A/B slots), so
// the server log shows how long the replacement took to come up.
function reportSwap(ev: string, fields: Record<string, string | number> = {}): void {
try {
const q = new URLSearchParams({ ev });
for (const [k, v] of Object.entries(fields)) {
q.set(k, typeof v === "number" ? v.toFixed(1) : String(v));
}
void fetch(`/__r2h/recovery?${q.toString()}`, { cache: "no-store", keepalive: true }).catch(() => {});
} catch {
/* ignore */
}
}

const cancelPendingTransition = useEffectEvent(() => {
pendingTransitionRef.current = null;
});
Expand Down Expand Up @@ -600,22 +629,72 @@ function VideoPlayerComponent({
player.stop();
});

/**
* Place a freshly revealed slot. It is aligned a little behind its live edge,
* audio is skipped forward because its PCM timeline trails the picture, and the
* picture lands slightly ahead so the viewer never re-watches content it has
* already seen while the stalled slot was stuck.
*/
const handOffSlot = useEffectEvent((slotId: SlotId, player: PlaybackBackend, latency: number) => {
const target = latency > 0 ? Math.min(latency, HANDOFF_MAX_LATENCY) : HANDOFF_DEFAULT_LATENCY;
player.alignToLiveLatency?.(target);
player.skipAudioAhead?.(HANDOFF_AUDIO_SKIP);
const video = slotVideoRef(slotId).current;
const ranges = video?.buffered;
if (video && ranges && ranges.length > 0) {
const edge = ranges.end(ranges.length - 1);
const ahead = Math.min(edge, video.currentTime + HANDOFF_PICTURE_SKIP);
if (ahead - video.currentTime > 0.05) {
player.seek(ahead);
}
}
handedOverPlayerRef.current = player;
reportSwap("handoff", { lat: target, was: latency });
});

const completeTransition = useEffectEvent((newActiveId: SlotId) => {
const oldActiveId = getActiveSlotId();
const oldPlayer = slotPlayerRef(oldActiveId).current;
// How far behind its own live edge the replaced slot was playing. The
// replacement starts at the same distance so the picture continues instead
// of jumping back to the head of its backfill.
let replacedLatency = 0;
{
const replacedVideo = slotVideoRef(oldActiveId).current;
const ranges = replacedVideo?.buffered;
if (replacedVideo && ranges && ranges.length > 0) {
const lat = ranges.end(ranges.length - 1) - replacedVideo.currentTime;
if (Number.isFinite(lat) && lat > 0.2) replacedLatency = lat;
}
}
const oldState = oldPlayer?.getState();
const savedVolume = oldState?.volume ?? volume;
const savedMuted = oldState?.muted ?? isMuted;

// The replacement must not be audible while the slot it replaces is still
// playing: both carry the same programme a few hundred ms apart, which is
// heard as the audio repeating itself.
if (oldActiveId !== newActiveId && oldPlayer) {
oldPlayer.setMuted(true);
}

const newPlayer = slotPlayerRef(newActiveId).current;
if (newPlayer) {
newPlayer.setVolume(savedVolume);
newPlayer.setMuted(savedMuted);
applyPlayerSettings(newPlayer);
handOffSlot(newActiveId, newPlayer, replacedLatency);
}

// Hard switch: reveal new stream first, then tear down the old slot
{
const pending = pendingTransitionRef.current;
if (pending) {
reportSwap("swap", { cost: (performance.now() - pending.startedAt) / 1000, from: oldActiveId, to: newActiveId });
}
}
activeSlotIdRef.current = newActiveId;
silentRecoveryRef.current = false;
setVisibleSlotId(newActiveId);
setIsLive(slotLiveStateRef.current[newActiveId]);
setIsLoading(false);
Expand Down Expand Up @@ -1056,7 +1135,8 @@ function VideoPlayerComponent({
);

// Load segments whenever they change (channel/source switch, seek, retry — all go through here)
const handleLoadSegments = useEffectEvent((newSegments: PlayerSegment[], forceHardSwitch = false) => {
const handleLoadSegments = useEffectEvent(
(newSegments: PlayerSegment[], forceHardSwitch = false, silent = false) => {
if (!newSegments.length) return;

const activeId = getActiveSlotId();
Expand All @@ -1070,8 +1150,12 @@ function VideoPlayerComponent({
stablePlaybackTimeoutRef.current = 0;
}

showControlsImmediately();
setIsLoading(true);
// [local patch] A silent recovery must not pop the control bar or the
// loading veil over the picture the user is still looking at.
if (!silent) {
showControlsImmediately();
setIsLoading(true);
}
setError(null);
setWarning(null);

Expand All @@ -1081,15 +1165,28 @@ function VideoPlayerComponent({
(channel.id !== prevStreamRef.current.channelId || activeSourceIndex !== prevStreamRef.current.sourceIndex);
const activeVideo = slotVideoRef(activeId).current;
const activeState = activePlayer.getState();
const useSeamlessSwitch =
// [local patch] A silent recovery must never reload the slot the viewer is
// watching: reloading detaches its MSE (media "emptied") and the picture stays
// black for the whole rebuild. Prepare the replacement on the hidden slot and
// only hard-switch once it is actually playing.
const silentRecoverySwitch =
silent &&
!forceHardSwitch &&
seamlessSwitch &&
!isAnyPictureInPictureActive() &&
hasStartedPlaybackRef.current &&
isStreamSwitch &&
playMode === "live" &&
hasStartedPlaybackRef.current &&
!isAnyPictureInPictureActive() &&
shouldAutoPlayRef.current &&
!activeState.paused;
const useSeamlessSwitch =
silentRecoverySwitch ||
(!forceHardSwitch &&
seamlessSwitch &&
!isAnyPictureInPictureActive() &&
hasStartedPlaybackRef.current &&
isStreamSwitch &&
playMode === "live" &&
shouldAutoPlayRef.current &&
!activeState.paused);

if (channel) {
prevStreamRef.current = { channelId: channel.id, sourceIndex: activeSourceIndex };
Expand Down Expand Up @@ -1190,7 +1287,12 @@ function VideoPlayerComponent({
});

const handleVideoWaiting = useEffectEvent((slotId: SlotId) => {
if (slotId !== getActiveSlotId() && pendingTransitionRef.current?.slotId !== slotId) return;
const isPending = pendingTransitionRef.current?.slotId === slotId;
if (slotId !== getActiveSlotId() && !isPending) return;
if (isPending && silentRecoveryRef.current) {
// Invisible recovery: the user is still watching the old picture.
return;
}
setIsLoading(true);
if (stablePlaybackTimeoutRef.current) {
window.clearTimeout(stablePlaybackTimeoutRef.current);
Expand All @@ -1206,6 +1308,15 @@ function VideoPlayerComponent({

if (slotId !== getActiveSlotId()) return;

{
// A recovery replaces the connection, so each new player lands on its own
// live edge once, which skips the GOP head it re-sends on connect.
const player = slotPlayerRef(slotId).current;
if (player && handedOverPlayerRef.current !== player) {
handOffSlot(slotId, player, 0);
}
}

hasStartedPlaybackRef.current = true;
setIsLoading(false);
setIsPlaying(true);
Expand Down Expand Up @@ -1252,6 +1363,87 @@ function VideoPlayerComponent({
}
});

// [local patch] Two-stage recovery. The watchdog fires "prefetch" as soon as
// the clock stalls ~0.5s and "stall" at ~1s. Prefetching into the hidden slot
// means the hard switch (identical to tapping the channel) happens with the
// replacement already buffered, and the wedged connection is released at the
// switch instead of stacking a retry on top of it.
const recoverOnHiddenSlot = useEffectEvent((): boolean => {
if (playMode !== "live") {
return false;
}
const segments = getRetrySegments();
if (!segments.length) {
return false;
}
// Pretend the stream just changed so handleLoadSegments takes the seamless
// switch branch (hidden slot + hard switch) instead of reloading in place.
prevStreamRef.current = { channelId: "__r2h_restall__", sourceIndex: -1 };
shouldAutoPlayRef.current = true;
silentRecoveryRef.current = true;
handleLoadSegments(segments, false, true);
return true;
});

useEffect(() => {
const onPrefetch = () => {
// A replacement is already loading; never stack a second one.
if (pendingTransitionRef.current) {
return;
}
recoverOnHiddenSlot();
};
const onRecover = () => {
// The frozen slot started moving again: throw the spare connection away so
// it does not steal a server slot or bandwidth.
const pending = pendingTransitionRef.current;
if (!pending) return;
// [local patch] Minimum lifetime for the spare slot. The data path goes
// quiet for 0.3-0.6s between segments, which used to fire a prefetch and
// drop it 20-40ms later, before the request had even reached the server
// (37 prefetch events vs 10 real /IPTV requests). Give the replacement a
// full second to either commit or prove itself useless.
const elapsed = performance.now() - pending.startedAt;
if (elapsed < 1000) {
window.setTimeout(() => {
if (pendingTransitionRef.current !== pending) return;
stopPendingTransition();
silentRecoveryRef.current = false;
reportSwap("drop_spare_ui");
}, 1000 - elapsed);
return;
}
stopPendingTransition();
silentRecoveryRef.current = false;
reportSwap("drop_spare_ui");
};
const onStallRestart = (event: Event) => {
// Cancel the default "no listener" behaviour: we handled it.
event.preventDefault?.();
if (playMode !== "live") {
return;
}
const pending = pendingTransitionRef.current;
if (pending) {
// The prefetched slot is still coming up; let it commit. Only fall back
// to a cold rebuild if it never produces video.
if (performance.now() - pending.startedAt < 3000) {
return;
}
stopPendingTransition();
}
recoverOnHiddenSlot();
};
window.addEventListener("r2h-playback-prefetch", onPrefetch);
window.addEventListener("r2h-playback-recover", onRecover);
window.addEventListener("r2h-playback-stall", onStallRestart);
return () => {
window.removeEventListener("r2h-playback-prefetch", onPrefetch);
window.removeEventListener("r2h-playback-recover", onRecover);
window.removeEventListener("r2h-playback-stall", onStallRestart);
};
}, [playMode]);

const handleVideoEnterPiP = useEffectEvent((slotId: SlotId) => {
if (slotId !== getActiveSlotId()) return;
setIsPiP(true);
Expand Down
Loading