@nikelroot/video-player 0.3.1 → 0.3.4

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 CHANGED
@@ -8,6 +8,44 @@ Standalone React video player with HLS support, retry/recovery behavior, fullscr
8
8
  npm install @nikelroot/video-player
9
9
  ```
10
10
 
11
+ ## GitLab CI and automatic npm releases
12
+
13
+ The repository is hosted at https://gitlab.nikelroot.ru/home/nvideoplayer.
14
+ Every push to the default branch runs validation and publishes a new public
15
+ version of `@nikelroot/video-player` to npmjs.com. Merge requests and other
16
+ branches only run validation. Tag pipelines are disabled.
17
+
18
+ The release chooses the highest stable version from `package.json` and npm,
19
+ then increases its patch number (for example, `0.3.2` → `0.3.3`) inside the
20
+ CI workspace, builds the package, and publishes it directly to npm. The source
21
+ repository is not modified: CI does not push commits or create Git tags.
22
+ The published version is visible on npmjs.com; `package.json` in Git remains
23
+ the starting version. Releases are serialized to avoid version collisions.
24
+ The npm package records the source commit as `gitHead`; retrying a job for an
25
+ already-published commit skips publication. If publishing failed before the
26
+ version reached npm, retry the failed `publish` job to calculate the next
27
+ available version and publish again.
28
+
29
+ One-time setup in GitLab:
30
+
31
+ 1. Configure a Docker-compatible runner capable of using `node:24-bookworm`.
32
+ 2. Protect the default branch (usually `main`).
33
+ 3. In **Settings → CI/CD → Variables**, add this **Masked** and **Protected**
34
+ variable with variable expansion disabled:
35
+ - `NPM_TOKEN`: an npm granular access token with read/write permission for
36
+ `@nikelroot/video-player` and **Bypass two-factor authentication** enabled.
37
+ The package publishing settings must permit token publishing. Renew the
38
+ token before its expiration.
39
+ 4. Push the repository and CI configuration to GitLab. Do not add secrets to Git.
40
+
41
+ Only `NPM_TOKEN` is required. No GitLab push token or job-token push permission
42
+ is needed.
43
+
44
+ This self-hosted GitLab uses token authentication because npm trusted publishing
45
+ currently supports GitLab.com shared runners only. See the
46
+ [npm CI token guide](https://docs.npmjs.com/using-private-packages-in-a-ci-cd-workflow/)
47
+ and [trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/).
48
+
11
49
  Peer dependencies:
12
50
 
13
51
  ```bash
package/dist/index.cjs CHANGED
@@ -44,8 +44,9 @@ var import_react = require("react");
44
44
  function useAudioFallback(options) {
45
45
  const latest = (0, import_react.useRef)(options);
46
46
  latest.current = options;
47
- const [videoOnly, setVideoOnly] = (0, import_react.useState)(false);
48
- const mode = (0, import_react.useRef)(false);
47
+ const [videoOnly, setVideoOnly] = (0, import_react.useState)(Boolean(options.fallback));
48
+ const mode = (0, import_react.useRef)(Boolean(options.fallback));
49
+ const progress = (0, import_react.useRef)({ time: 0, at: Date.now(), failed: false });
49
50
  const cooldown = (0, import_react.useRef)(0);
50
51
  const audioErrors = (0, import_react.useRef)(0);
51
52
  const audioErrorTimer = (0, import_react.useRef)(null);
@@ -62,6 +63,7 @@ function useAudioFallback(options) {
62
63
  muted: element.muted
63
64
  };
64
65
  mode.current = next;
66
+ progress.current = { time: element.currentTime, at: Date.now(), failed: false };
65
67
  audioErrors.current = 0;
66
68
  if (audioErrorTimer.current) clearTimeout(audioErrorTimer.current);
67
69
  audioErrorTimer.current = null;
@@ -71,15 +73,10 @@ function useAudioFallback(options) {
71
73
  return true;
72
74
  }, []);
73
75
  const onAudioError = (0, import_react.useCallback)((data) => {
74
- var _a, _b, _c;
76
+ var _a, _b;
75
77
  if (!latest.current.fallback || mode.current) return false;
76
78
  const isAudio = ((_a = data == null ? void 0 : data.frag) == null ? void 0 : _a.type) === "audio" || (data == null ? void 0 : data.parent) === "audio" || ((_b = data == null ? void 0 : data.context) == null ? void 0 : _b.type) === "audioTrack" || /^audioTrack/.test((data == null ? void 0 : data.details) || "");
77
79
  if (!isAudio) return false;
78
- if ((data == null ? void 0 : data.details) === "fragGap" && ((_c = latest.current.ranges) == null ? void 0 : _c.some((range) => {
79
- var _a2, _b2;
80
- const time = (_b2 = (_a2 = latest.current.video.current) == null ? void 0 : _a2.currentTime) != null ? _b2 : 0;
81
- return time >= range.start - 0.05 && time < range.end - 0.05;
82
- }))) return true;
83
80
  audioErrors.current++;
84
81
  if ((data == null ? void 0 : data.fatal) || audioErrors.current >= 2) return switchSource(true, true);
85
82
  if (!audioErrorTimer.current) {
@@ -96,8 +93,9 @@ function useAudioFallback(options) {
96
93
  audioErrorTimer.current = null;
97
94
  }, []);
98
95
  (0, import_react.useEffect)(() => {
99
- mode.current = false;
100
- setVideoOnly(false);
96
+ mode.current = Boolean(options.fallback);
97
+ setVideoOnly(Boolean(options.fallback));
98
+ progress.current = { time: 0, at: Date.now(), failed: false };
101
99
  snapshot.current = null;
102
100
  cooldown.current = 0;
103
101
  onAudioProgress();
@@ -105,14 +103,27 @@ function useAudioFallback(options) {
105
103
  }, [options.primary, options.fallback, onAudioProgress]);
106
104
  (0, import_react.useEffect)(() => {
107
105
  const check = () => {
108
- const { ranges, video, fallback } = latest.current;
109
- if (!fallback || !ranges || snapshot.current) return;
106
+ const { ranges, video, fallback, playingIntent, onStall } = latest.current;
107
+ if (!fallback) return;
110
108
  const element = video.current;
111
109
  if (!element) return;
112
110
  const time = element.currentTime;
113
- const available = ranges.some((range) => time >= range.start - 0.05 && time < range.end - 0.05);
111
+ const now = Date.now();
112
+ const previous = progress.current;
113
+ if (!playingIntent.current || element.ended || Math.abs(time - previous.time) > 0.05) {
114
+ progress.current = { time, at: now, failed: false };
115
+ } else if (!previous.failed && now - previous.at >= 8e3) {
116
+ if (!mode.current) switchSource(true, true);
117
+ else {
118
+ progress.current.failed = true;
119
+ onStall == null ? void 0 : onStall();
120
+ }
121
+ return;
122
+ }
123
+ if (!ranges || snapshot.current) return;
124
+ const available = ranges.some((range) => time >= range.start + 0.1 && time < range.end - 0.1);
114
125
  if (!mode.current && !available) switchSource(true);
115
- else if (mode.current && available && Date.now() >= cooldown.current && ranges.some((range) => time >= range.start - 0.05 && time + 4 <= range.end)) switchSource(false);
126
+ else if (mode.current && available && now >= cooldown.current && ranges.some((range) => time >= range.start + 0.1 && time + 4 <= range.end)) switchSource(false);
116
127
  };
117
128
  const timer = setInterval(check, 500);
118
129
  return () => clearInterval(timer);
@@ -947,6 +958,7 @@ var VideoPlayerBase = (props, ref) => {
947
958
  videoOnlySrc,
948
959
  audioRanges,
949
960
  onAudioFallbackChange,
961
+ afterScroll,
950
962
  ...videoProps
951
963
  } = props;
952
964
  const primarySrc = rawVideoSrc.replaceAll(" ", "%20").replaceAll("#", "%23");
@@ -956,7 +968,9 @@ var VideoPlayerBase = (props, ref) => {
956
968
  const [, isMobile, mobileType] = useWindowWidth();
957
969
  const videoRef = (0, import_react4.useRef)(null);
958
970
  const playbackIntent = (0, import_react4.useRef)(autoPlay || defaultPlaying || Boolean(playingProp));
959
- const audioFallback = useAudioFallback({ primary: primarySrc, fallback: videoOnlySrc, ranges: audioRanges, video: videoRef, playingIntent: playbackIntent, onChange: onAudioFallbackChange });
971
+ const stallReporter = (0, import_react4.useRef)(() => {
972
+ });
973
+ const audioFallback = useAudioFallback({ primary: primarySrc, fallback: videoOnlySrc, ranges: audioRanges, video: videoRef, playingIntent: playbackIntent, onChange: onAudioFallbackChange, onStall: () => stallReporter.current() });
960
974
  const videoSrc = audioFallback.source;
961
975
  const { snapshot: resumeSnapshot, onAudioError, onAudioProgress, switchSource } = audioFallback;
962
976
  const hlsRef = (0, import_react4.useRef)(null);
@@ -1089,6 +1103,16 @@ var VideoPlayerBase = (props, ref) => {
1089
1103
  },
1090
1104
  [messages.defaultError, setNextPlaying]
1091
1105
  );
1106
+ stallReporter.current = () => {
1107
+ var _a, _b;
1108
+ playbackIntent.current = false;
1109
+ pendingPlay.current = false;
1110
+ reportError("Playback did not advance for 8 seconds", {
1111
+ type: "playback-stalled",
1112
+ src: videoSrc,
1113
+ reason: `readyState=${(_a = videoRef.current) == null ? void 0 : _a.readyState}; buffered=${(_b = videoRef.current) == null ? void 0 : _b.buffered.length}`
1114
+ }, messages.streamLoadFailed);
1115
+ };
1092
1116
  const notifyActiveChange = (0, import_react4.useCallback)(
1093
1117
  (video, reason) => {
1094
1118
  var _a, _b;
@@ -1194,6 +1218,7 @@ var VideoPlayerBase = (props, ref) => {
1194
1218
  block: "center",
1195
1219
  inline: "nearest"
1196
1220
  });
1221
+ afterScroll && afterScroll();
1197
1222
  }
1198
1223
  }
1199
1224
  }, [
package/dist/index.d.cts CHANGED
@@ -90,6 +90,7 @@ interface VideoPlayerProps extends Omit<React.VideoHTMLAttributes<HTMLVideoEleme
90
90
  onPointerUp?: PointerHandler;
91
91
  onPointerCancel?: PointerHandler;
92
92
  accentColor?: string;
93
+ afterScroll?: () => void;
93
94
  }
94
95
  declare function hasNativeHlsSupport(): boolean;
95
96
  declare const VideoPlayer: React.NamedExoticComponent<VideoPlayerProps & React.RefAttributes<VideoPlayerHandle>>;
package/dist/index.d.ts CHANGED
@@ -90,6 +90,7 @@ interface VideoPlayerProps extends Omit<React.VideoHTMLAttributes<HTMLVideoEleme
90
90
  onPointerUp?: PointerHandler;
91
91
  onPointerCancel?: PointerHandler;
92
92
  accentColor?: string;
93
+ afterScroll?: () => void;
93
94
  }
94
95
  declare function hasNativeHlsSupport(): boolean;
95
96
  declare const VideoPlayer: React.NamedExoticComponent<VideoPlayerProps & React.RefAttributes<VideoPlayerHandle>>;
package/dist/index.js CHANGED
@@ -15,8 +15,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
15
15
  function useAudioFallback(options) {
16
16
  const latest = useRef(options);
17
17
  latest.current = options;
18
- const [videoOnly, setVideoOnly] = useState(false);
19
- const mode = useRef(false);
18
+ const [videoOnly, setVideoOnly] = useState(Boolean(options.fallback));
19
+ const mode = useRef(Boolean(options.fallback));
20
+ const progress = useRef({ time: 0, at: Date.now(), failed: false });
20
21
  const cooldown = useRef(0);
21
22
  const audioErrors = useRef(0);
22
23
  const audioErrorTimer = useRef(null);
@@ -33,6 +34,7 @@ function useAudioFallback(options) {
33
34
  muted: element.muted
34
35
  };
35
36
  mode.current = next;
37
+ progress.current = { time: element.currentTime, at: Date.now(), failed: false };
36
38
  audioErrors.current = 0;
37
39
  if (audioErrorTimer.current) clearTimeout(audioErrorTimer.current);
38
40
  audioErrorTimer.current = null;
@@ -42,15 +44,10 @@ function useAudioFallback(options) {
42
44
  return true;
43
45
  }, []);
44
46
  const onAudioError = useCallback((data) => {
45
- var _a, _b, _c;
47
+ var _a, _b;
46
48
  if (!latest.current.fallback || mode.current) return false;
47
49
  const isAudio = ((_a = data == null ? void 0 : data.frag) == null ? void 0 : _a.type) === "audio" || (data == null ? void 0 : data.parent) === "audio" || ((_b = data == null ? void 0 : data.context) == null ? void 0 : _b.type) === "audioTrack" || /^audioTrack/.test((data == null ? void 0 : data.details) || "");
48
50
  if (!isAudio) return false;
49
- if ((data == null ? void 0 : data.details) === "fragGap" && ((_c = latest.current.ranges) == null ? void 0 : _c.some((range) => {
50
- var _a2, _b2;
51
- const time = (_b2 = (_a2 = latest.current.video.current) == null ? void 0 : _a2.currentTime) != null ? _b2 : 0;
52
- return time >= range.start - 0.05 && time < range.end - 0.05;
53
- }))) return true;
54
51
  audioErrors.current++;
55
52
  if ((data == null ? void 0 : data.fatal) || audioErrors.current >= 2) return switchSource(true, true);
56
53
  if (!audioErrorTimer.current) {
@@ -67,8 +64,9 @@ function useAudioFallback(options) {
67
64
  audioErrorTimer.current = null;
68
65
  }, []);
69
66
  useEffect(() => {
70
- mode.current = false;
71
- setVideoOnly(false);
67
+ mode.current = Boolean(options.fallback);
68
+ setVideoOnly(Boolean(options.fallback));
69
+ progress.current = { time: 0, at: Date.now(), failed: false };
72
70
  snapshot.current = null;
73
71
  cooldown.current = 0;
74
72
  onAudioProgress();
@@ -76,14 +74,27 @@ function useAudioFallback(options) {
76
74
  }, [options.primary, options.fallback, onAudioProgress]);
77
75
  useEffect(() => {
78
76
  const check = () => {
79
- const { ranges, video, fallback } = latest.current;
80
- if (!fallback || !ranges || snapshot.current) return;
77
+ const { ranges, video, fallback, playingIntent, onStall } = latest.current;
78
+ if (!fallback) return;
81
79
  const element = video.current;
82
80
  if (!element) return;
83
81
  const time = element.currentTime;
84
- const available = ranges.some((range) => time >= range.start - 0.05 && time < range.end - 0.05);
82
+ const now = Date.now();
83
+ const previous = progress.current;
84
+ if (!playingIntent.current || element.ended || Math.abs(time - previous.time) > 0.05) {
85
+ progress.current = { time, at: now, failed: false };
86
+ } else if (!previous.failed && now - previous.at >= 8e3) {
87
+ if (!mode.current) switchSource(true, true);
88
+ else {
89
+ progress.current.failed = true;
90
+ onStall == null ? void 0 : onStall();
91
+ }
92
+ return;
93
+ }
94
+ if (!ranges || snapshot.current) return;
95
+ const available = ranges.some((range) => time >= range.start + 0.1 && time < range.end - 0.1);
85
96
  if (!mode.current && !available) switchSource(true);
86
- else if (mode.current && available && Date.now() >= cooldown.current && ranges.some((range) => time >= range.start - 0.05 && time + 4 <= range.end)) switchSource(false);
97
+ else if (mode.current && available && now >= cooldown.current && ranges.some((range) => time >= range.start + 0.1 && time + 4 <= range.end)) switchSource(false);
87
98
  };
88
99
  const timer = setInterval(check, 500);
89
100
  return () => clearInterval(timer);
@@ -918,6 +929,7 @@ var VideoPlayerBase = (props, ref) => {
918
929
  videoOnlySrc,
919
930
  audioRanges,
920
931
  onAudioFallbackChange,
932
+ afterScroll,
921
933
  ...videoProps
922
934
  } = props;
923
935
  const primarySrc = rawVideoSrc.replaceAll(" ", "%20").replaceAll("#", "%23");
@@ -927,7 +939,9 @@ var VideoPlayerBase = (props, ref) => {
927
939
  const [, isMobile, mobileType] = useWindowWidth();
928
940
  const videoRef = useRef3(null);
929
941
  const playbackIntent = useRef3(autoPlay || defaultPlaying || Boolean(playingProp));
930
- const audioFallback = useAudioFallback({ primary: primarySrc, fallback: videoOnlySrc, ranges: audioRanges, video: videoRef, playingIntent: playbackIntent, onChange: onAudioFallbackChange });
942
+ const stallReporter = useRef3(() => {
943
+ });
944
+ const audioFallback = useAudioFallback({ primary: primarySrc, fallback: videoOnlySrc, ranges: audioRanges, video: videoRef, playingIntent: playbackIntent, onChange: onAudioFallbackChange, onStall: () => stallReporter.current() });
931
945
  const videoSrc = audioFallback.source;
932
946
  const { snapshot: resumeSnapshot, onAudioError, onAudioProgress, switchSource } = audioFallback;
933
947
  const hlsRef = useRef3(null);
@@ -1060,6 +1074,16 @@ var VideoPlayerBase = (props, ref) => {
1060
1074
  },
1061
1075
  [messages.defaultError, setNextPlaying]
1062
1076
  );
1077
+ stallReporter.current = () => {
1078
+ var _a, _b;
1079
+ playbackIntent.current = false;
1080
+ pendingPlay.current = false;
1081
+ reportError("Playback did not advance for 8 seconds", {
1082
+ type: "playback-stalled",
1083
+ src: videoSrc,
1084
+ reason: `readyState=${(_a = videoRef.current) == null ? void 0 : _a.readyState}; buffered=${(_b = videoRef.current) == null ? void 0 : _b.buffered.length}`
1085
+ }, messages.streamLoadFailed);
1086
+ };
1063
1087
  const notifyActiveChange = useCallback2(
1064
1088
  (video, reason) => {
1065
1089
  var _a, _b;
@@ -1165,6 +1189,7 @@ var VideoPlayerBase = (props, ref) => {
1165
1189
  block: "center",
1166
1190
  inline: "nearest"
1167
1191
  });
1192
+ afterScroll && afterScroll();
1168
1193
  }
1169
1194
  }
1170
1195
  }, [
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "@nikelroot/video-player",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
4
  "description": "Standalone React video player with HLS support.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://gitlab.nikelroot.ru/home/nvideoplayer.git"
8
+ },
5
9
  "type": "module",
6
10
  "main": "./dist/index.cjs",
7
11
  "module": "./dist/index.js",
@@ -41,6 +45,8 @@
41
45
  "typescript": "^5.9.3"
42
46
  },
43
47
  "publishConfig": {
44
- "access": "public"
45
- }
48
+ "access": "public",
49
+ "registry": "https://registry.npmjs.org/"
50
+ },
51
+ "gitHead": "7397c01305b40debca1523d771784247db250de0"
46
52
  }