@ssgc/hls-player 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # @ssgc/hls-player
2
+
3
+ An HLS video player for live and recorded camera streams, with a playback
4
+ speed control and an optional Go Live button.
5
+
6
+ It draws its own controls rather than using the browser's, so it looks the
7
+ same everywhere and so the browser's own speed menu does not compete with
8
+ this one. All styling is inline — there is no stylesheet to load.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @ssgc/hls-player
14
+ ```
15
+
16
+ `react` and `react-dom` are peer dependencies. `hls.js` and `lucide-react`
17
+ are installed with the package.
18
+
19
+ ## Use
20
+
21
+ ```tsx
22
+ import { HlsPlayer } from "@ssgc/hls-player";
23
+
24
+ <HlsPlayer src="https://example.com/camera-3/index.m3u8" />;
25
+ ```
26
+
27
+ ## Go Live
28
+
29
+ There are two ways a system serves live footage, so there are two modes.
30
+
31
+ ### "source" - live and recorded are different addresses
32
+
33
+ This is the usual case with a camera system, and the default. You are watching
34
+ a recording; going live means loading a different `.m3u8`.
35
+
36
+ The player does not fetch anything. It shows the button while a recording is
37
+ playing, tells you it was pressed, and your app fetches the live address and
38
+ hands it back as a new `src`. Once the stream is live the button goes away on
39
+ its own.
40
+
41
+ ```tsx
42
+ const [url, setUrl] = useState(clipUrl);
43
+
44
+ <HlsPlayer
45
+ src={url}
46
+ showGoLive
47
+ goLiveMode="source"
48
+ onGoLive={async () => {
49
+ const { m3u8 } = await api.getLiveStream(cameraId);
50
+ setUrl(m3u8);
51
+ }}
52
+ onExitLive={async () => {
53
+ const { m3u8 } = await api.getRecording(cameraId, timestamp);
54
+ setUrl(m3u8);
55
+ }}
56
+ />;
57
+ ```
58
+
59
+ ### Getting back to the recording
60
+
61
+ Once the stream is live the same button turns into the way back, labelled
62
+ "Back to recording", and calls `onExitLive`. The app fetches the recorded address and hands it over as a new `src`.
63
+
64
+ Leave `onExitLive` out and the button simply disappears once the stream is
65
+ live, which suits a screen that has its own timeline for picking a moment to
66
+ go back to.
67
+
68
+ ### "seek" - one stream holds both
69
+
70
+ Some streams keep a rolling window of recent footage, so going live just means
71
+ jumping to the newest moment. The player can do that itself.
72
+
73
+ ```tsx
74
+ <HlsPlayer src={streamUrl} showGoLive goLiveMode="seek" liveEdgeToleranceSeconds={5} />
75
+ ```
76
+
77
+ Here the button appears only on a live stream, and stays disabled until the
78
+ viewer is further behind than `liveEdgeToleranceSeconds`.
79
+
80
+ That tolerance matters. An HLS player sits several seconds behind the newest
81
+ moment by design, so treating any gap at all as "behind" leaves the button
82
+ flickering on and off while the viewer is, in practice, live. Five seconds is
83
+ the default; lower it for low-latency setups.
84
+
85
+ Nothing on the server is needed for the "seek" mode. The newest playable
86
+ moment is read from the stream itself, through the browser's own `seekable`
87
+ range.
88
+
89
+ ## Options
90
+
91
+ | Prop | Default | What it does |
92
+ | --- | --- | --- |
93
+ | `src` | — | Address of the `.m3u8` stream. Required. |
94
+ | `autoPlay` | `true` | Start playing as soon as it loads. |
95
+ | `muted` | `true` | Start muted. Browsers block autoplay with sound. |
96
+ | `height` | `480` | Height of the video. |
97
+ | `className` | — | Class on the wrapper element. |
98
+ | `showControls` | `true` | The play/pause and seek bar. |
99
+ | `showSpeedMenu` | `true` | The speed button. |
100
+ | `speedOptions` | `[1, 2, 3, 5, 10]` | Speeds the menu offers. |
101
+ | `initialSpeed` | `1` | Speed to start at. |
102
+ | `showGoLive` | `false` | Offer the Go Live button. |
103
+ | `goLiveMode` | `"source"` | `"source"` fetches a new address, `"seek"` jumps within the stream. |
104
+ | `goLiveLabel` | `"Go Live"` | Wording on it. |
105
+ | `exitLiveLabel` | `"Back to recording"` | Wording once live. |
106
+ | `onExitLive` | — | Called to leave live. Omit it and the button hides once live. |
107
+ | `allowSpeedWhenLive` | `false` | Let the speed menu stay usable on a live stream. |
108
+ | `liveEdgeToleranceSeconds` | `5` | `"seek"` mode only. How far behind counts as behind. |
109
+ | `onGoLive` | — | Called when the button is pressed. |
110
+ | `onSpeedChange` | — | Called when the speed changes. |
111
+ | `onError` | — | Called on a playback failure. |
112
+ | `onLiveChange` | — | Called once it knows live or recorded. |
113
+ | `onEngineChange` | — | Called with the engine it used. |
114
+ | `debug` | `false` | Print engine selection and recovery steps to the console. |
115
+
116
+ ## Speed on a live stream
117
+
118
+ The speed menu is greyed out while a live stream is playing, and the rate
119
+ drops back to 1x. There are no frames ahead of live to fast-forward into, so
120
+ a higher rate only runs the player into the end of the stream and stalls.
121
+
122
+ Pass `allowSpeedWhenLive` if your stream keeps enough of a rolling window
123
+ behind live for that to be useful.
124
+
125
+ ## How playback works
126
+
127
+ Browsers do not agree on HLS. Safari plays it directly; everything else needs
128
+ the hls.js library. The player picks whichever applies, and falls back to
129
+ hls.js if the native path repeatedly fails — which happens on Chrome when an
130
+ extension makes the browser claim HLS support it does not have.
131
+
132
+ High speeds are not what they look like. `playbackRate` does not make the
133
+ browser skip frames, it still decodes every one, and at 5x or 10x that
134
+ outruns the decoder and playback stalls. So above 5x the player runs at a
135
+ rate the decoder can sustain and skips forward on a timer to make up the
136
+ difference, only ever into video already downloaded. It is the same trick
137
+ DVR and VMS players use.
138
+
139
+ Buffer targets scale with the selected speed, in both seconds and bytes,
140
+ since consuming video N times faster leaves the network 1/N as long to keep
141
+ up.
142
+
143
+ ## Building on it
144
+
145
+ The parts are exported too, for an app that wants its own controls:
146
+
147
+ ```ts
148
+ import {
149
+ useHlsEngine, // loads and recovers the stream
150
+ usePlaybackSpeed, // speed selection, including the high-speed trick
151
+ useVideoPlaybackState, // playing, position, length
152
+ useLiveEdge, // how far behind live, and the jump
153
+ PlaybackControls,
154
+ SpeedMenu,
155
+ GoLiveButton,
156
+ } from "@ssgc/hls-player";
157
+ ```
158
+
159
+ ## Development
160
+
161
+ ```bash
162
+ npm install
163
+ npm run dev # demo page on http://localhost:5175
164
+ npm run build
165
+ npm run lint
166
+ ```
167
+
168
+ The demo page takes any stream address and lets you toggle Go Live and its
169
+ tolerance, to see the button enable as you scrub backwards.
@@ -0,0 +1,96 @@
1
+ import { ReactNode } from 'react';
2
+ import { PlaybackEngine, PlayerError, RecordingChunk, RecordingSearch } from './types';
3
+ export interface HlsPlayerProps {
4
+ /**
5
+ * Address of the stream to play. Leave it out, or pass an empty string,
6
+ * and the player shows `emptyMessage` in place of the video.
7
+ */
8
+ src?: string;
9
+ /** Shown instead of the video while there is no `src`. */
10
+ emptyMessage?: ReactNode;
11
+ /**
12
+ * The host's recordings search. Called when there is no `src`, and whatever
13
+ * it returns is rebuilt into a playable playlist — see `useRecordingSearch`.
14
+ * This package has no API client of its own, so the call is injected.
15
+ */
16
+ recordingSearch?: RecordingSearch;
17
+ /**
18
+ * Epoch ms of the moment being investigated. The chunk covering it is the
19
+ * one played; without it playback starts at the earliest chunk.
20
+ */
21
+ recordingAt?: number | null;
22
+ /** Seconds of footage per rebuilt chunk. Defaults to 120. */
23
+ recordingChunkSeconds?: number;
24
+ /** Handed every chunk built from the search response, oldest first. */
25
+ onRecordingChunks?: (chunks: RecordingChunk[]) => void;
26
+ /** Shown while the recordings search is in flight. */
27
+ loadingMessage?: ReactNode;
28
+ autoPlay?: boolean;
29
+ muted?: boolean;
30
+ /** Height of the video in pixels */
31
+ height?: number | string;
32
+ className?: string;
33
+ /** Hide the play/pause and seek bar. */
34
+ showControls?: boolean;
35
+ /** Hide the speed menu. */
36
+ showSpeedMenu?: boolean;
37
+ /** Speeds the menu offers. */
38
+ speedOptions?: number[];
39
+ /** Speed to start at. */
40
+ initialSpeed?: number;
41
+ /** Offer a Go Live button. */
42
+ showGoLive?: boolean;
43
+ /**
44
+ * What going live means for this stream.
45
+ *
46
+ * "source" is for a camera system that serves live footage and recordings
47
+ * at two different addresses. The button shows while a recording is
48
+ * playing, calls `onGoLive`, and the app is expected to fetch the live
49
+ * address and hand it back as a new `src`. The button then goes away,
50
+ * because the stream is live.
51
+ *
52
+ * "seek" is for a single stream holding both, where going live only means
53
+ * jumping to its newest moment.
54
+ */
55
+ goLiveMode?: "source" | "seek";
56
+ /** Wording on the button. */
57
+ goLiveLabel?: string;
58
+ /**
59
+ * Wording once the stream is live and the button offers the way back.
60
+ * Only used by "source" mode, and only when `onExitLive` is given.
61
+ */
62
+ exitLiveLabel?: string;
63
+ /**
64
+ * Called when the viewer wants the recording back. The app fetches the
65
+ * recorded address and hands it over as a new `src`, the mirror of
66
+ * `onGoLive`. Leave it out and the button simply disappears once live.
67
+ */
68
+ onExitLive?: () => void;
69
+ /**
70
+ * Let the speed menu stay usable on a live stream. Off by default: there is
71
+ * nothing ahead of live to fast-forward into, so a higher speed only runs
72
+ * the player into the end of the stream and stalls.
73
+ */
74
+ allowSpeedWhenLive?: boolean;
75
+ /**
76
+ * Only used by "seek" mode. How many seconds behind the newest moment
77
+ * counts as behind. A player sits a few seconds back by design, so a very
78
+ * small number makes the button flicker on and off while the viewer is
79
+ * effectively live.
80
+ */
81
+ liveEdgeToleranceSeconds?: number;
82
+ /**
83
+ * Called when Go Live is pressed. In "source" mode this is where the app
84
+ * fetches the live address and updates `src`.
85
+ */
86
+ onGoLive?: () => void;
87
+ onSpeedChange?: (speed: number) => void;
88
+ onError?: (error: PlayerError) => void;
89
+ /** Print what the player is doing to the console. Off by default. */
90
+ debug?: boolean;
91
+ /** Called once the player knows whether this is live or a recorded clip. */
92
+ onLiveChange?: (isLive: boolean | null) => void;
93
+ /** Called with the engine that ended up being used. */
94
+ onEngineChange?: (engine: PlaybackEngine | null) => void;
95
+ }
96
+ export default function HlsPlayer({ src, emptyMessage, recordingSearch, recordingAt, recordingChunkSeconds, onRecordingChunks, loadingMessage, autoPlay, muted, height, className, showControls, showSpeedMenu, speedOptions, initialSpeed, showGoLive, goLiveMode, goLiveLabel, exitLiveLabel, onExitLive, allowSpeedWhenLive, liveEdgeToleranceSeconds, onGoLive, onSpeedChange, onError, onLiveChange, onEngineChange, debug, }: Readonly<HlsPlayerProps>): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ interface GoLiveButtonProps {
2
+ enabled: boolean;
3
+ label: string;
4
+ /** Hover text explaining the current state. */
5
+ hint: string;
6
+ /**
7
+ * True when the stream is already live and this button is the way back to
8
+ * the recording. It is drawn plainly then, because red with a dot reads as
9
+ * "you are live" rather than as a way out of it.
10
+ */
11
+ live?: boolean;
12
+ onGoLive: () => void;
13
+ }
14
+ export default function GoLiveButton({ enabled, label, hint, live, onGoLive, }: Readonly<GoLiveButtonProps>): import("react").JSX.Element;
15
+ export {};
@@ -0,0 +1,11 @@
1
+ import { ReactNode, RefObject } from 'react';
2
+ interface PlaybackControlsProps {
3
+ videoRef: RefObject<HTMLVideoElement | null>;
4
+ isPlaying: boolean;
5
+ currentTime: number;
6
+ duration: number;
7
+ isLive: boolean;
8
+ goLive?: ReactNode;
9
+ }
10
+ export default function PlaybackControls({ videoRef, isPlaying, currentTime, duration, isLive, goLive, }: Readonly<PlaybackControlsProps>): import("react").JSX.Element;
11
+ export {};
@@ -0,0 +1,14 @@
1
+ interface SpeedMenuProps {
2
+ speed: number;
3
+ setSpeed: (speed: number) => void;
4
+ showSpeedMenu: boolean;
5
+ setShowSpeedMenu: React.Dispatch<React.SetStateAction<boolean>>;
6
+ speedMenuRef: React.RefObject<HTMLDivElement | null>;
7
+ options?: number[];
8
+ /** Greyed out and unclickable while a live stream is playing. */
9
+ disabled?: boolean;
10
+ /** Hover text explaining why it is greyed out. */
11
+ disabledHint?: string;
12
+ }
13
+ export default function SpeedMenu({ speed, setSpeed, showSpeedMenu, setShowSpeedMenu, speedMenuRef, options, disabled, disabledHint, }: Readonly<SpeedMenuProps>): import("react").JSX.Element;
14
+ export {};
@@ -0,0 +1,2 @@
1
+ import { RefObject } from 'react';
2
+ export declare function useClickOutside(ref: RefObject<HTMLElement | null>, active: boolean, onOutside: () => void): void;
@@ -0,0 +1,23 @@
1
+ import { RefObject } from 'react';
2
+ import { default as Hls } from 'hls.js';
3
+ import { PlaybackEngine, PlayerError } from '../types';
4
+ interface UseHlsEngineArgs {
5
+ src?: string;
6
+ videoRef: RefObject<HTMLVideoElement | null>;
7
+ hlsRef: RefObject<Hls | null>;
8
+ engineRef: RefObject<PlaybackEngine | null>;
9
+ speedRef: RefObject<number>;
10
+ applyPlaybackSpeed: (video: HTMLVideoElement, targetSpeed: number) => void;
11
+ clearJumpScan: () => void;
12
+ autoPlay: boolean;
13
+ onError?: (error: PlayerError) => void;
14
+ /** Print what the player is doing to the console. Off by default. */
15
+ debug?: boolean;
16
+ }
17
+ export interface HlsEngineApi {
18
+ url: string;
19
+ isLive: boolean | null;
20
+ engine: PlaybackEngine | null;
21
+ }
22
+ export declare function useHlsEngine({ src, videoRef, hlsRef, engineRef, speedRef, applyPlaybackSpeed, clearJumpScan, autoPlay, onError, debug, }: UseHlsEngineArgs): HlsEngineApi;
23
+ export {};
@@ -0,0 +1,18 @@
1
+ import { RefObject } from 'react';
2
+ /**
3
+ * How far behind counts as behind, in seconds.
4
+ *
5
+ * An HLS player sits a few segments back from the newest moment by design,
6
+ * so treating any gap at all as "behind" leaves the button flickering on and
7
+ * off while the viewer is, for all practical purposes, live.
8
+ */
9
+ export declare const DEFAULT_LIVE_EDGE_TOLERANCE_SECONDS = 5;
10
+ export interface LiveEdgeApi {
11
+ /** Seconds between the viewer and the newest moment. Null while unknown. */
12
+ secondsBehind: number | null;
13
+ /** True when the viewer is far enough back for Go Live to be worth offering. */
14
+ behindLiveEdge: boolean;
15
+ /** Jump to the newest moment and resume playing. */
16
+ goLive: () => void;
17
+ }
18
+ export declare function useLiveEdge(videoRef: RefObject<HTMLVideoElement | null>, isLive: boolean | null, toleranceSeconds?: number): LiveEdgeApi;
@@ -0,0 +1,23 @@
1
+ import { RefObject } from 'react';
2
+ import { default as Hls } from 'hls.js';
3
+ import { PlaybackEngine } from '../types';
4
+ interface UsePlaybackSpeedArgs {
5
+ videoRef: RefObject<HTMLVideoElement | null>;
6
+ hlsRef: RefObject<Hls | null>;
7
+ engineRef: RefObject<PlaybackEngine | null>;
8
+ /** Starting speed. */
9
+ initialSpeed?: number;
10
+ onSpeedChange?: (speed: number) => void;
11
+ }
12
+ export interface PlaybackSpeedApi {
13
+ speed: number;
14
+ setSpeed: (speed: number) => void;
15
+ showSpeedMenu: boolean;
16
+ setShowSpeedMenu: React.Dispatch<React.SetStateAction<boolean>>;
17
+ speedMenuRef: RefObject<HTMLDivElement | null>;
18
+ speedRef: RefObject<number>;
19
+ applyPlaybackSpeed: (video: HTMLVideoElement, targetSpeed: number) => void;
20
+ clearJumpScan: () => void;
21
+ }
22
+ export declare function usePlaybackSpeed({ videoRef, hlsRef, engineRef, initialSpeed, onSpeedChange, }: UsePlaybackSpeedArgs): PlaybackSpeedApi;
23
+ export {};
@@ -0,0 +1,22 @@
1
+ import { PlayerError, RecordingChunk, RecordingSearch } from '../types';
2
+ interface UseRecordingSearchArgs {
3
+ /** The host's call. Leave it out and the hook stays idle. */
4
+ search?: RecordingSearch;
5
+ /** Epoch ms of the moment to watch; picks the chunk covering it. */
6
+ at?: number | null;
7
+ /** Seconds of footage per chunk. */
8
+ chunkSeconds?: number;
9
+ onError?: (error: PlayerError) => void;
10
+ }
11
+ export interface RecordingSearchApi {
12
+ /** Every chunk built from the response, oldest first. */
13
+ chunks: RecordingChunk[];
14
+ /** The chunk being watched. */
15
+ chunk: RecordingChunk | null;
16
+ /** Address for `chunk`, or null when there is nothing to play. */
17
+ url: string | null;
18
+ loading: boolean;
19
+ error: PlayerError | null;
20
+ }
21
+ export declare function useRecordingSearch({ search, at, chunkSeconds, onError, }: UseRecordingSearchArgs): RecordingSearchApi;
22
+ export {};
@@ -0,0 +1,9 @@
1
+ import { RefObject } from 'react';
2
+ export interface VideoPlaybackState {
3
+ isPlaying: boolean;
4
+ /** Seconds from the start of the stream. */
5
+ currentTime: number;
6
+ /** Seconds. Zero for a live stream, which has no fixed length. */
7
+ duration: number;
8
+ }
9
+ export declare function useVideoPlaybackState(videoRef: RefObject<HTMLVideoElement | null>, url: string, autoPlay: boolean): VideoPlaybackState;
package/dist/index.cjs ADDED
@@ -0,0 +1,4 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const m=require("react/jsx-runtime"),o=require("react"),ce=require("lucide-react"),L=require("hls.js");function pe({enabled:e,label:r,hint:n,live:t=!1,onGoLive:i}){const l=e&&!t;return m.jsxs("button",{type:"button",onClick:a=>{a.stopPropagation(),i()},onMouseDown:a=>a.stopPropagation(),disabled:!e,title:n,style:{display:"flex",alignItems:"center",gap:6,padding:"5px 10px",background:l?"rgba(211,47,47,0.85)":"rgba(0,0,0,0.55)",color:e?"#fff":"rgba(255,255,255,0.45)",border:"1px solid rgba(255,255,255,0.22)",borderRadius:6,cursor:e?"pointer":"default",fontSize:12,fontWeight:600,letterSpacing:"0.02em",whiteSpace:"nowrap",backdropFilter:"blur(4px)",WebkitBackdropFilter:"blur(4px)"},children:[!t&&m.jsx("span",{style:{width:7,height:7,borderRadius:"50%",background:e?"#fff":"rgba(255,255,255,0.45)"}}),r]})}const ge=[1,2,3,5,10],Ve=30,qe=600;function te(e){return Math.min(Ve*e,qe)}const Ke=60*1e3*1e3,Ye=600*1e3*1e3;function re(e){return Math.min(Ke*e,Ye)}const me=5,Y=2,Z=250;function he(e){const r=e.currentTime;for(let n=0;n<e.buffered.length;n+=1)if(e.buffered.start(n)<=r+.01&&r<=e.buffered.end(n))return Math.max(0,e.buffered.end(n)-r);return 0}function ne(e){return e.seekable.length===0?null:e.seekable.end(e.seekable.length-1)}function Q(e){const r=ne(e);return r===null?null:Math.max(0,r-e.currentTime)}function F(e){const r=ne(e);r!==null&&(e.currentTime=Math.max(0,r-1))}function Se(){return/^((?!chrome|android).)*safari/i.test(navigator.userAgent)}function G(e){if(!e||!isFinite(e)||e<=0)return"0:00";const r=Math.floor(e),n=Math.floor(r/60),t=r%60;return`${n}:${t.toString().padStart(2,"0")}`}function ee(e){return`${e.replace(/#reload\d*$/,"")}#reload${Date.now()}`}function be(e){return e.replace(/#reload\d*$/,"")}const de=300,Ze=210;function Ee({videoRef:e,isPlaying:r,currentTime:n,duration:t,isLive:i,goLive:l}){const a=o.useRef(null),[g,f]=o.useState(0);o.useEffect(()=>{const d=a.current;if(!d||typeof ResizeObserver>"u")return;const s=new ResizeObserver(([h])=>{f(h.contentRect.width)});return s.observe(d),()=>s.disconnect()},[]);const c=t>0,p=g>0,S=!p||g>=de,E=!p||g>=Ze,b=()=>{const d=e.current;d&&(d.paused?d.play().catch(()=>{}):d.pause())},x=d=>{const s=e.current;!s||!isFinite(s.duration)||(s.currentTime=Math.max(0,Math.min(1,d))*s.duration)};return m.jsxs("div",{ref:a,style:{position:"absolute",left:12,right:12,bottom:12,display:"flex",alignItems:"center",gap:p&&g<de?8:12,minWidth:0,pointerEvents:"auto"},children:[m.jsx("button",{type:"button",onClick:d=>{d.stopPropagation(),b()},onMouseDown:d=>d.stopPropagation(),style:{display:"flex",flex:"0 0 auto",background:"rgba(0,0,0,0.6)",color:"#fff",border:"none",padding:"6px 10px",borderRadius:6,cursor:"pointer"},"aria-label":r?"Pause":"Play",title:r?"Pause":"Play",children:r?m.jsx(ce.Pause,{size:16}):m.jsx(ce.Play,{size:16})}),c?m.jsx("div",{onClick:d=>{d.stopPropagation();const s=d.currentTarget.getBoundingClientRect();x((d.clientX-s.left)/s.width)},onMouseDown:d=>d.stopPropagation(),onKeyDown:d=>{const s=e.current;if(!s||!isFinite(s.duration))return;const h=s.duration>0?s.duration*.02:5;d.key==="ArrowRight"?(d.stopPropagation(),s.currentTime=Math.min(s.duration,s.currentTime+h)):d.key==="ArrowLeft"&&(d.stopPropagation(),s.currentTime=Math.max(0,s.currentTime-h))},role:"slider",tabIndex:0,"aria-label":"Seek","aria-valuemin":0,"aria-valuemax":t,"aria-valuenow":n,style:{flex:"1 1 0",minWidth:0,height:8,background:"rgba(255,255,255,0.12)",borderRadius:6,position:"relative",overflow:"hidden",cursor:"pointer"},title:"Seek",children:m.jsx("div",{style:{position:"absolute",left:0,top:0,bottom:0,width:`${t>0?Math.min(100,Math.max(0,n/t*100)):0}%`,background:"rgba(66,153,225,0.85)",borderRadius:6}})}):m.jsx("span",{style:{flex:"1 1 0",minWidth:0}}),(E||!c&&i)&&m.jsx("div",{style:{flex:"0 0 auto",color:"#fff",fontSize:12,fontVariantNumeric:"tabular-nums",whiteSpace:"nowrap"},children:c?S?`${G(n)}`:G(n):i?"Live":G(n)}),l&&m.jsx("div",{style:{display:"flex",flex:"0 0 auto"},children:l})]})}function Qe(){return m.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round","aria-hidden":"true",children:[m.jsx("path",{d:"M5 16a7 7 0 0 1 14 0"}),m.jsx("line",{x1:"12",y1:"16",x2:"9",y2:"9"}),m.jsx("circle",{cx:"12",cy:"16",r:"1.5",fill:"currentColor",stroke:"none"})]})}function ye({speed:e,setSpeed:r,showSpeedMenu:n,setShowSpeedMenu:t,speedMenuRef:i,options:l=ge,disabled:a=!1,disabledHint:g="Speed is not available on a live stream"}){return m.jsxs("div",{ref:i,style:{position:"absolute",top:10,right:10,zIndex:2},onMouseDown:f=>f.stopPropagation(),children:[m.jsxs("button",{type:"button",onClick:f=>{f.stopPropagation(),t(c=>!c)},disabled:a,title:a?g:"Playback speed","aria-haspopup":"menu","aria-expanded":n&&!a,style:{display:"flex",alignItems:"center",gap:5,padding:"5px 10px",background:"rgba(0,0,0,0.60)",color:a?"rgba(255,255,255,0.40)":"#fff",border:"1px solid rgba(255,255,255,0.22)",borderRadius:6,cursor:a?"default":"pointer",fontSize:13,fontWeight:600,backdropFilter:"blur(4px)",WebkitBackdropFilter:"blur(4px)"},children:[m.jsx(Qe,{}),e,"×"]}),n&&!a&&m.jsx("div",{role:"menu",style:{position:"absolute",top:"calc(100% + 6px)",right:0,background:"rgba(18,18,18,0.92)",border:"1px solid rgba(255,255,255,0.14)",borderRadius:8,padding:"4px 0",minWidth:90,backdropFilter:"blur(10px)",WebkitBackdropFilter:"blur(10px)",boxShadow:"0 4px 16px rgba(0,0,0,0.45)"},children:l.map(f=>m.jsxs("button",{type:"button",role:"menuitemradio","aria-checked":f===e,onClick:c=>{c.stopPropagation(),r(f),t(!1)},style:{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"7px 14px",background:f===e?"rgba(26,115,232,0.55)":"transparent",border:"none",color:f===e?"#fff":"rgba(255,255,255,0.75)",fontWeight:f===e?700:400,fontSize:13,cursor:"pointer",textAlign:"left"},children:[m.jsx("span",{style:{width:12,fontSize:10,color:"#4caf50"},children:f===e?"✓":""}),f,"×"]},f))})]})}function xe({src:e,videoRef:r,hlsRef:n,engineRef:t,speedRef:i,applyPlaybackSpeed:l,clearJumpScan:a,autoPlay:g,onError:f,debug:c=!1}){const p=o.useRef(c);p.current=c;const S=(...u)=>{p.current&&console.warn(...u)},E=(...u)=>{p.current&&console.error(...u)},[b,x]=o.useState(e??""),[d,s]=o.useState(null),[h,T]=o.useState(null),M=o.useRef(null),I=o.useRef(!1),_=o.useRef(f);return _.current=f,o.useEffect(()=>{x(e??"")},[e]),o.useEffect(()=>{const u=r.current;if(!u||!b)return;const D=y=>{M.current=y,s(y)},j=y=>{t.current=y,T(y)};D(null),t.current=null,T(null),a(),n.current&&(n.current.destroy(),n.current=null);const O=()=>{t.current==="Native HLS"&&M.current===null&&D(!isFinite(u.duration)),l(u,i.current)};u.addEventListener("loadedmetadata",O);const B=()=>{u.removeEventListener("loadedmetadata",O)},H=be(b);if(!I.current&&Se()&&u.canPlayType("application/vnd.apple.mpegurl")){j("Native HLS"),u.src=H,u.playbackRate=i.current,u.play().catch(()=>{});let y=0,k=0,w=0;const N=setInterval(()=>{u.paused||(u.currentTime===y&&u.readyState>=2?(k+=1,k>=2&&(M.current===!0?(S("[HlsPlayer/Native] Stall — jumping to live edge"),F(u)):S("[HlsPlayer/Native] Stall — resuming recorded playback"),u.playbackRate=i.current,u.play().catch(()=>{}),k=0)):k=0,y=u.currentTime)},3e3),A=()=>{if(u.error?.code===4&&/empty src/i.test(u.error?.message||"")){S("[HlsPlayer/Native] Ignoring spurious empty-src error",u.error);return}if(w+=1,S("[HlsPlayer/Native] Media error",u.error),_.current?.(u.error),w>5){S("[HlsPlayer/Native] Giving up after repeated errors — canPlayType claimed HLS support but playback never worked. Falling back to HLS.js."),I.current=!0,clearInterval(N),u.removeEventListener("error",A),x(C=>ee(C));return}const R=u.src;u.src="",setTimeout(()=>{u.src=R,M.current===!0&&F(u),u.playbackRate=i.current,u.play().catch(()=>{})},1500)};return u.addEventListener("error",A),()=>{clearInterval(N),u.removeEventListener("error",A),B(),u.src=""}}if(L.isSupported()){j("HLS.js");let y=0,k=null;const w=C=>{k||(S("[HlsPlayer/HLS.js] Hard reload:",C),k=setTimeout(()=>{k=null,x(v=>ee(v))},1500))},N=te(i.current),A={enableWorker:!0,lowLatencyMode:!1,liveSyncDurationCount:3,liveMaxLatencyDurationCount:6,fragLoadingMaxRetry:2,fragLoadingRetryDelay:500,fragLoadingMaxRetryTimeout:4e3,manifestLoadingMaxRetry:3,levelLoadingMaxRetry:3,maxBufferLength:N,maxMaxBufferLength:N,maxBufferSize:re(i.current)},R=new L(A);return n.current=R,R.loadSource(H),R.attachMedia(u),R.on(L.Events.LEVEL_LOADED,(C,v)=>{D(v.details.live)}),R.on(L.Events.MANIFEST_PARSED,()=>{l(u,i.current),g&&u.play().catch(()=>{})}),R.on(L.Events.ERROR,(C,v)=>{if((v.details===L.ErrorDetails.FRAG_LOAD_ERROR||v.details===L.ErrorDetails.FRAG_LOAD_TIMEOUT)&&!v.fatal){setTimeout(()=>{M.current===!0&&F(u),l(u,i.current),u.play().catch(()=>{})},500);return}if(v.fatal)switch(E("[HlsPlayer/HLS.js] Fatal error",v.type,v.details),_.current?.(v),v.type){case L.ErrorTypes.MEDIA_ERROR:y<3?(y+=1,R.recoverMediaError()):w("repeated media errors");break;case L.ErrorTypes.NETWORK_ERROR:v.details===L.ErrorDetails.MANIFEST_LOAD_ERROR||v.details===L.ErrorDetails.MANIFEST_LOAD_TIMEOUT?w("manifest unreachable"):setTimeout(()=>R.startLoad(),2e3);break;default:w("unrecoverable error");break}}),()=>{k&&clearTimeout(k),a(),B(),R.destroy(),n.current=null}}return S("[HlsPlayer] HLS is not supported in this browser"),_.current?.(new Error("HLS playback is not supported in this browser.")),()=>{B()}},[b]),{url:b,isLive:d,engine:h}}const se=5,et=1e3;function ve(e,r,n=se){const[t,i]=o.useState(null);o.useEffect(()=>{if(r!==!0){i(null);return}const a=()=>{const f=e.current;i(f?Q(f):null)};a();const g=setInterval(a,et);return()=>clearInterval(g)},[r,e]);const l=o.useCallback(()=>{const a=e.current;a&&(F(a),a.play().catch(()=>{}),i(Q(a)))},[e]);return{secondsBehind:t,behindLiveEdge:t!==null&&t>n,goLive:l}}function ke(e,r,n){const t=o.useRef(n);o.useEffect(()=>{t.current=n}),o.useEffect(()=>{if(!r)return;const i=l=>{e.current&&!e.current.contains(l.target)&&t.current()};return document.addEventListener("mousedown",i),()=>document.removeEventListener("mousedown",i)},[r,e])}function Re({videoRef:e,hlsRef:r,engineRef:n,initialSpeed:t=1,onSpeedChange:i}){const[l,a]=o.useState(t),[g,f]=o.useState(!1),c=o.useRef(t),p=o.useRef(null),S=o.useRef(null),E=o.useRef(i);E.current=i;const b=o.useCallback(()=>{S.current&&(clearInterval(S.current),S.current=null)},[]),x=o.useCallback((s,h)=>{if(b(),n.current!=="HLS.js"||h<me){s.playbackRate=h;return}s.playbackRate=Y;const T=(h-Y)*(Z/1e3);S.current=setInterval(()=>{if(s.paused||s.seeking)return;const I=Math.max(0,he(s)-2),_=Math.min(T,I);if(_<=0)return;const u=s.seekable.length>0?s.seekable.end(s.seekable.length-1):s.duration,D=s.currentTime+_;if(isFinite(u)&&D>=u-.5){b();return}s.currentTime=D},Z)},[b,n]),d=o.useCallback(s=>{a(s)},[]);return o.useEffect(()=>{c.current=l,E.current?.(l)},[l]),o.useEffect(()=>{const s=e.current;if(!s)return;x(s,l);const h=r.current;if(h){const T=te(l);h.config.maxBufferLength=T,h.config.maxMaxBufferLength=T,h.config.maxBufferSize=re(l)}return()=>b()},[l,x,b,r,e]),ke(p,g,()=>f(!1)),{speed:l,setSpeed:d,showSpeedMenu:g,setShowSpeedMenu:f,speedMenuRef:p,speedRef:c,applyPlaybackSpeed:x,clearJumpScan:b}}const J=120,tt=/^#EXTINF:([\d.]+),?/,rt=/seg_(\d{8})_(\d{6})_/;function Le(e){const r=rt.exec(e);if(!r)return null;const[,n,t]=r,i=Number(n.slice(0,4)),l=Number(n.slice(4,6)),a=Number(n.slice(6,8)),g=Number(t.slice(0,2)),f=Number(t.slice(2,4)),c=Number(t.slice(4,6)),p=new Date(i,l-1,a,g,f,c);return Number.isNaN(p.getTime())||p.getFullYear()!==i||p.getMonth()!==l-1||p.getDate()!==a||p.getHours()!==g||p.getMinutes()!==f||p.getSeconds()!==c?null:p.getTime()}function Te(e){const r=(e??"").split(/\r?\n/).filter(a=>a.trim()!==""),n=[],t=[];let i=null,l=!1;for(const a of r){const g=tt.exec(a);if(g){l=!0,i=Number(g[1]);continue}if(a.startsWith("#")){l||n.push(a);continue}i!==null&&(t.push({durationSec:i,uri:a,timestamp:Le(a)}),i=null)}return{headerLines:n,segments:t}}function Me(e,r=J){const n=[];let t=[],i=0;for(const l of e)t.push(l),i+=l.durationSec,i>=r&&(n.push(t),t=[],i=0);return t.length>0&&n.push(t),n}function _e(e,r){const n=[...e];for(const t of r)n.push(`#EXTINF:${t.durationSec},`,t.uri);return n.push("#EXT-X-ENDLIST"),`${n.join(`
2
+ `)}
3
+ `}const fe=e=>e===null?"--:--":new Date(e).toTimeString().slice(0,5);function we(e,r=J){return(e??[]).flatMap((t,i)=>{const{spaceId:l,cameraId:a,feed:g}=t,{headerLines:f,segments:c}=Te(g);return c.length===0?[]:Me(c,r).map((p,S)=>{const E=p[0].timestamp,b=p.reduce((d,s)=>d+s.durationSec,0),x=E===null?null:E+b*1e3;return{id:`${l}-${a}-${i}-${S}`,spaceId:l,cameraId:a,startTime:E,endTime:x,durationSec:b,startLabel:fe(E),endLabel:fe(x),segmentCount:p.length,playlistText:_e(f,p)}})}).sort((t,i)=>t.startTime===null||i.startTime===null?0:t.startTime-i.startTime)}function Pe(e){const r=new Blob([new TextEncoder().encode(e)],{type:"application/vnd.apple.mpegurl"});return URL.createObjectURL(r)}function De(e,r){return e.length===0?null:r==null?e[0]:e.find(t=>t.startTime!==null&&t.endTime!==null&&r>=t.startTime&&r<t.endTime)??e[0]}function Ne({search:e,at:r,chunkSeconds:n=J,onError:t}){const[i,l]=o.useState([]),[a,g]=o.useState(!1),[f,c]=o.useState(null),[p,S]=o.useState(null),E=o.useRef(t);E.current=t,o.useEffect(()=>{if(!e){l([]),g(!1),c(null);return}let s=!0;return g(!0),c(null),e().then(h=>{s&&l(Array.isArray(h)?h:[])}).catch(h=>{s&&(l([]),c(h),E.current?.(h))}).finally(()=>{s&&g(!1)}),()=>{s=!1}},[e]);const b=o.useMemo(()=>we(i,n),[i,n]),x=o.useMemo(()=>De(b,r),[b,r]),d=x?.playlistText??null;return o.useEffect(()=>{if(d===null){S(null);return}const s=Pe(d);return S(s),()=>{URL.revokeObjectURL(s),S(null)}},[d]),{chunks:b,chunk:x,url:p,loading:a,error:f}}function Ae(e,r,n){const[t,i]=o.useState(n),[l,a]=o.useState(0),[g,f]=o.useState(0);return o.useEffect(()=>{const c=e.current;if(!c)return;const p=()=>a(c.currentTime||0),S=()=>f(isFinite(c.duration)?c.duration:0),E=()=>i(!0),b=()=>i(!1);return c.addEventListener("timeupdate",p),c.addEventListener("loadedmetadata",S),c.addEventListener("playing",E),c.addEventListener("pause",b),a(c.currentTime||0),f(isFinite(c.duration)?c.duration:0),()=>{c.removeEventListener("timeupdate",p),c.removeEventListener("loadedmetadata",S),c.removeEventListener("playing",E),c.removeEventListener("pause",b)}},[r]),{isPlaying:t,currentTime:l,duration:g}}function nt({src:e,emptyMessage:r="No video to display",recordingSearch:n,recordingAt:t,recordingChunkSeconds:i,onRecordingChunks:l,loadingMessage:a="Loading recording…",autoPlay:g=!0,muted:f=!0,height:c=480,className:p,showControls:S=!0,showSpeedMenu:E=!0,speedOptions:b,initialSpeed:x=1,showGoLive:d=!1,goLiveMode:s="source",goLiveLabel:h="Go Live",exitLiveLabel:T="Back to recording",onExitLive:M,allowSpeedWhenLive:I=!1,liveEdgeToleranceSeconds:_=se,onGoLive:u,onSpeedChange:D,onError:j,onLiveChange:O,onEngineChange:B,debug:H=!1}){const y=o.useRef(null),k=o.useRef(null),w=o.useRef(null),N=typeof e=="string"&&e.trim()!=="",{chunks:A,url:R,loading:C}=Ne({search:N?void 0:n,at:t,chunkSeconds:i,onError:j}),v=o.useRef(l);v.current=l,o.useEffect(()=>{v.current?.(A)},[A]);const U=N?e:R??void 0,{speed:Ce,setSpeed:X,showSpeedMenu:Ie,setShowSpeedMenu:Be,speedMenuRef:je,speedRef:V,applyPlaybackSpeed:Oe,clearJumpScan:Fe}=Re({videoRef:y,hlsRef:k,engineRef:w,initialSpeed:x,onSpeedChange:D}),{url:He,isLive:P,engine:oe}=xe({src:U,videoRef:y,hlsRef:k,engineRef:w,speedRef:V,applyPlaybackSpeed:Oe,clearJumpScan:Fe,autoPlay:g,onError:j,debug:H}),{isPlaying:Ue,currentTime:We,duration:$e}=Ae(y,He,g),{secondsBehind:ie,behindLiveEdge:ze,goLive:Ge}=ve(y,P,_),ae=o.useRef(O);ae.current=O,o.useEffect(()=>{ae.current?.(P)},[P]);const ue=o.useRef(B);ue.current=B,o.useEffect(()=>{ue.current?.(oe)},[oe]);const q=s==="source"&&P===!0;let W=!1,$=!1,z=h,le=h;d&&(s==="source"?q?(W=!!M,$=!0,le=T,z=`${T} — leave the live stream`):(W=!0,$=P===!1,z=P===null?"Checking the stream…":`${h} — switch to the live stream`):(W=P===!0,$=ze,z=ie===null?h:`${h} — ${Math.round(ie)}s behind`));const Je=()=>{if(q){M?.();return}s==="seek"&&Ge(),u?.()},K=!I&&P===!0;return o.useEffect(()=>{K&&V.current!==1&&X(1)},[K,X,V]),typeof U=="string"&&U.trim()!==""?m.jsxs("div",{className:p,style:{position:"relative",width:"100%"},"data-testid":"hls-player",children:[m.jsx("video",{ref:y,controls:!1,autoPlay:g,muted:f,playsInline:!0,onContextMenu:Xe=>Xe.preventDefault(),controlsList:"nodownload nofullscreen noremoteplayback noplaybackrate",disablePictureInPicture:!0,disableRemotePlayback:!0,style:{width:"100%",height:c,display:"block",background:"#000"},children:m.jsx("track",{kind:"captions"})}),S&&m.jsx(Ee,{videoRef:y,isPlaying:Ue,currentTime:We,duration:$e,isLive:P===!0,goLive:W?m.jsx(pe,{enabled:$,label:le,hint:z,live:q,onGoLive:Je}):void 0}),E&&m.jsx(ye,{speed:Ce,setSpeed:X,showSpeedMenu:Ie,setShowSpeedMenu:Be,speedMenuRef:je,options:b,disabled:K})]}):m.jsx("div",{className:p,style:{position:"relative",width:"100%"},"data-testid":"hls-player",children:m.jsx("div",{"data-testid":C?"hls-player-loading":"hls-player-empty",style:{width:"100%",height:c,display:"flex",alignItems:"center",justifyContent:"center",background:"#000",color:"#94a3b8",fontSize:14,textAlign:"center",padding:16,boxSizing:"border-box"},children:C?a:r})})}exports.CHUNK_DURATION_SECONDS=J;exports.DEFAULT_LIVE_EDGE_TOLERANCE_SECONDS=se;exports.GoLiveButton=pe;exports.HlsPlayer=nt;exports.JUMP_SCAN_BASE_RATE=Y;exports.JUMP_SCAN_INTERVAL_MS=Z;exports.JUMP_SCAN_THRESHOLD=me;exports.PlaybackControls=Ee;exports.SPEED_OPTIONS=ge;exports.SpeedMenu=ye;exports.buildChunkPlaylistText=_e;exports.buildRecordingChunks=we;exports.chunkAt=De;exports.chunkSegments=Me;exports.formatTime=G;exports.getBufferedAhead=he;exports.getLiveEdge=ne;exports.getScaledBufferSeconds=te;exports.getScaledBufferSizeBytes=re;exports.getSecondsBehindLive=Q;exports.isSafariBrowser=Se;exports.jumpToLiveEdge=F;exports.makeBlobUrl=Pe;exports.parseM3u8=Te;exports.parseSegmentTimestamp=Le;exports.useClickOutside=ke;exports.useHlsEngine=xe;exports.useLiveEdge=ve;exports.usePlaybackSpeed=Re;exports.useRecordingSearch=Ne;exports.useVideoPlaybackState=Ae;exports.withReloadMarker=ee;exports.withoutReloadMarker=be;
4
+ //# sourceMappingURL=index.cjs.map