Skip to content
93 changes: 93 additions & 0 deletions web-ui/src/components/player/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export function VideoPlayer({
const stablePlaybackTimeoutRef = useRef<number>(0);
// Whether to auto-play after player recreation (true for initial load and "go live")
const shouldAutoPlayRef = useRef(true);
// Whether the pause was an explicit user action (vs. the OS pausing on backgrounding).
// Used to decide if playback should auto-resume when the page returns to foreground.
const userPausedRef = useRef(false);

// Digit input state
const [digitBuffer, setDigitBuffer] = useState("");
Expand Down Expand Up @@ -107,6 +110,7 @@ export function VideoPlayer({

if (goingLive && playMode === "live" && video) {
// "Go Live": jump to the end of buffered range, resume playback and liveSync
userPausedRef.current = false;
video.play();
const buffered = video.buffered;
if (buffered.length > 0) {
Expand Down Expand Up @@ -137,8 +141,10 @@ export function VideoPlayer({
const video = videoRef.current;
if (video) {
if (video.paused) {
userPausedRef.current = false;
video.play();
} else {
userPausedRef.current = true;
video.pause();
}
}
Expand Down Expand Up @@ -278,6 +284,42 @@ export function VideoPlayer({
player?.setLiveSync(playMode === "live");
}, [playMode, player]);

// Media Session: lock screen / control center metadata (esp. useful during PiP playback)
useEffect(() => {
if (!("mediaSession" in navigator)) return;
if (!channel) {
navigator.mediaSession.metadata = null;
return;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: currentProgram?.title || channel.name,
artist: currentProgram?.title ? channel.name : channel.group,
artwork: channel.logo ? [{ src: channel.logo }] : [],
});
}, [channel, currentProgram]);

useEffect(() => {
if (!("mediaSession" in navigator)) return;
navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused";
}, [isPlaying]);

// Media Session action handlers (lock screen / control center play & pause)
useEffect(() => {
if (!("mediaSession" in navigator)) return;
navigator.mediaSession.setActionHandler("play", () => {
userPausedRef.current = false;
videoRef.current?.play();
});
navigator.mediaSession.setActionHandler("pause", () => {
userPausedRef.current = true;
videoRef.current?.pause();
});
return () => {
navigator.mediaSession.setActionHandler("play", null);
navigator.mediaSession.setActionHandler("pause", null);
};
}, []);

// Load segments whenever they change (channel switch, seek, retry — all go through here)
const handleLoadSegments = useEffectEvent((newSegments: PlayerSegment[]) => {
if (!newSegments.length || !player) return;
Expand All @@ -298,6 +340,7 @@ export function VideoPlayer({
if (shouldAutoPlayRef.current) {
const video = videoRef.current;
if (video) {
userPausedRef.current = false;
const playPromise = video.play();
if (playPromise) {
playPromise
Expand Down Expand Up @@ -397,6 +440,55 @@ export function VideoPlayer({
setIsPiP(false);
});

// Foreground recovery: iOS pauses web media when the page goes to background
// without PiP, and may even tear down the whole media pipeline (MediaSource
// close + decode error). When the page becomes visible again, resume playback —
// rebuilding the stream when the old session is dead or stale.
const handleVisibilityChange = useEffectEvent(() => {
if (document.visibilityState !== "visible") return;
const video = videoRef.current;
if (!video || !player || error || needsUserInteraction) return;
// PiP keeps playing in background; nothing to recover
if (document.pictureInPictureElement) return;
// Respect an explicit user pause; only recover from OS-initiated interruptions
if (userPausedRef.current) return;

// Media element died in background (MediaSource closed / decode error).
// Note: video.paused may still report false in this state.
const mediaDead = video.error !== null;
const behindLiveMs = Date.now() - (streamStartTime.getTime() + currentVideoTime * 1000);

if (playMode === "live" && (mediaDead || behindLiveMs > 10000)) {
// Dead session or stale buffer — rebuild the stream at the live edge
console.log("Reloading at live edge after background suspension");
shouldAutoPlayRef.current = true;
onSeek?.(new Date());
return;
}

if (mediaDead) {
// Catchup: rebuild the stream at the current position
console.log("Reloading at current position after background suspension");
shouldAutoPlayRef.current = true;
onSeek?.(new Date(streamStartTime.getTime() + currentVideoTime * 1000));
return;
}

if (video.paused) {
video.play()?.catch((err: Error) => {
if (err.name === "NotAllowedError") {
setNeedsUserInteraction(true);
}
});
}
});

useEffect(() => {
const handler = () => handleVisibilityChange();
document.addEventListener("visibilitychange", handler);
return () => document.removeEventListener("visibilitychange", handler);
}, []);

const handleKeyDown = useEffectEvent((e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
Expand Down Expand Up @@ -614,6 +706,7 @@ export function VideoPlayer({
if (!videoRef.current) return;
setNeedsUserInteraction(false);
setIsPlaying(true);
userPausedRef.current = false;
videoRef.current.play()?.catch((err: Error) => {
console.error("Play error after user interaction:", err);
setError(`${t("failedToPlay")}: ${err.message}`);
Expand Down
4 changes: 2 additions & 2 deletions web-ui/src/mpegts/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export interface PlayerConfig {

export const defaultConfig: PlayerConfig = {
liveSync: true,
liveSyncMaxLatency: 1.2,
liveSyncTargetLatency: 0.8,
liveSyncMaxLatency: 3,
liveSyncTargetLatency: 1.5,
liveSyncPlaybackRate: 1.2,

fixAudioTimestampGap: true,
Expand Down
82 changes: 0 additions & 82 deletions web-ui/src/mpegts/core/media-info.ts

This file was deleted.

51 changes: 0 additions & 51 deletions web-ui/src/mpegts/demux/base-demuxer.ts

This file was deleted.

40 changes: 0 additions & 40 deletions web-ui/src/mpegts/demux/klv.ts

This file was deleted.

38 changes: 0 additions & 38 deletions web-ui/src/mpegts/demux/pat-pmt-pes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ export enum StreamType {
kLOASAAC = 0x11,
kAC3 = 0x81,
kEAC3 = 0x87,
kMetadata = 0x15,
kSCTE35 = 0x86,
kPGS = 0x90,
kH264 = 0x1b,
kH265 = 0x24,
}
Expand All @@ -38,55 +35,20 @@ export class PMT {
common_pids: {
h264: number | undefined;
h265: number | undefined;
av1: number | undefined;
adts_aac: number | undefined;
loas_aac: number | undefined;
opus: number | undefined;
ac3: number | undefined;
eac3: number | undefined;
mp3: number | undefined;
} = {
h264: undefined,
h265: undefined,
av1: undefined,
adts_aac: undefined,
loas_aac: undefined,
opus: undefined,
ac3: undefined,
eac3: undefined,
mp3: undefined,
};

pes_private_data_pids: {
[pid: number]: boolean;
} = {};

timed_id3_pids: {
[pid: number]: boolean;
} = {};

pgs_pids: {
[pid: number]: boolean;
} = {};
pgs_langs: {
[pid: number]: string;
} = {};

synchronous_klv_pids: {
[pid: number]: boolean;
} = {};

asynchronous_klv_pids: {
[pid: number]: boolean;
} = {};

scte_35_pids: {
[pid: number]: boolean;
} = {};

smpte2038_pids: {
[oid: number]: boolean;
} = {};
}

export interface ProgramToPMTMap {
Expand Down
Loading
Loading