@eluvio/elv-player-js 2.1.3 → 2.1.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.
@@ -4,6 +4,7 @@ import Cast from "./Cast";
4
4
  import {Utils} from "@eluvio/elv-client-js";
5
5
  import PlayerControls from "./Controls.js";
6
6
  import {MergeDefaultParameters} from "../ui/Common";
7
+ import ThumbnailHandler from "./ThumbnailHandler";
7
8
 
8
9
  const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
9
10
 
@@ -247,7 +248,7 @@ export class EluvioPlayer {
247
248
  }
248
249
  }
249
250
 
250
- const { playoutUrl, drms } = this.sourceOptions.playoutOptions[protocol].playoutMethods[drm];
251
+ const { playoutUrl, drms, thumbnailTrack } = this.sourceOptions.playoutOptions[protocol].playoutMethods[drm];
251
252
 
252
253
  const versionHash = playoutUrl.split("/").find(segment => segment.startsWith("hq__"));
253
254
 
@@ -259,6 +260,7 @@ export class EluvioPlayer {
259
260
  protocol,
260
261
  drm,
261
262
  playoutUrl,
263
+ thumbnailTrackUrl: thumbnailTrack,
262
264
  versionHash,
263
265
  drms,
264
266
  availableDRMs,
@@ -449,7 +451,7 @@ export class EluvioPlayer {
449
451
  this.__RegisterVideoEventListener("ended", () => this.controls && this.controls.CollectionPlayNext({autoplay: true}));
450
452
  }
451
453
 
452
- let { versionHash, playoutUrl, protocol, drm, drms, multiviewOptions, playoutParameters } = await this.__PlayoutOptions();
454
+ let { versionHash, playoutUrl, thumbnailTrackUrl, protocol, drm, drms, multiviewOptions, playoutParameters } = await this.__PlayoutOptions();
453
455
 
454
456
  this.contentHash = versionHash;
455
457
 
@@ -463,6 +465,7 @@ export class EluvioPlayer {
463
465
  this.authorizationToken = authorizationToken;
464
466
 
465
467
  this.playoutUrl = playoutUrl.toString();
468
+ this.thumbnailTrackUrl = thumbnailTrackUrl;
466
469
 
467
470
  if(this.castHandler) {
468
471
  this.castHandler.SetMedia({
@@ -522,6 +525,15 @@ export class EluvioPlayer {
522
525
 
523
526
  this.initialized = true;
524
527
  this.restartParameters = undefined;
528
+
529
+ if(this.thumbnailTrackUrl) {
530
+ this.thumbnailHandler = new ThumbnailHandler(this.thumbnailTrackUrl);
531
+ this.thumbnailHandler.LoadThumbnails()
532
+ .then(() => {
533
+ this.thumbnailsLoaded = true;
534
+ this.__SettingsUpdate();
535
+ });
536
+ }
525
537
  } catch (error) {
526
538
  // If playout failed due to a permission issue, check the content to see if there is a message to display
527
539
  let permissionErrorMessage;
@@ -1210,6 +1222,10 @@ export class EluvioPlayer {
1210
1222
  this.dashPlayer = undefined;
1211
1223
  this.player = undefined;
1212
1224
  this.initTimeLogged = false;
1225
+ this.playoutUrl = undefined;
1226
+ this.thumbnailTrackUrl = undefined;
1227
+ this.thumbnailHandler = undefined;
1228
+ this.thumbnailsLoaded = undefined;
1213
1229
  this.canPlay = false;
1214
1230
  this.isLive = false;
1215
1231
  this.behindLiveEdge = false;
@@ -0,0 +1,214 @@
1
+ import {Utils} from "@eluvio/elv-client-js";
2
+ import UrlJoin from "url-join";
3
+ import {IntervalTree} from "node-interval-tree";
4
+
5
+ let _tagId = 1;
6
+ export const Cue = ({tagType, tagId, label, startTime, endTime, text, tag, ...extra}) => {
7
+ let content;
8
+ if(Array.isArray(text)) {
9
+ text = text.join(", ");
10
+ } else if(typeof text === "object") {
11
+ content = text;
12
+
13
+ try {
14
+ text = JSON.stringify(text, null, 2);
15
+ } catch(error) {
16
+ text = "";
17
+ }
18
+ }
19
+
20
+ return {
21
+ tagId,
22
+ tagType,
23
+ label,
24
+ startTime,
25
+ endTime,
26
+ text,
27
+ content,
28
+ tag,
29
+ ...extra
30
+ };
31
+ };
32
+
33
+ const FormatVTTCue = ({label, cue}) => {
34
+ // VTT Cues are weird about being inspected and copied
35
+ // Manually copy all relevant values
36
+ const cueAttributes = [
37
+ "align",
38
+ "endTime",
39
+ "id",
40
+ "line",
41
+ "lineAlign",
42
+ "position",
43
+ "positionAlign",
44
+ "region",
45
+ "size",
46
+ "snapToLines",
47
+ "startTime",
48
+ "text",
49
+ "vertical"
50
+ ];
51
+
52
+ const cueCopy = {};
53
+ cueAttributes.forEach(attr => cueCopy[attr] = cue[attr]);
54
+
55
+ const tagId = _tagId;
56
+ _tagId += 1;
57
+
58
+ return Cue({
59
+ tagId,
60
+ tagType: "vtt",
61
+ label,
62
+ startTime: cue.startTime,
63
+ endTime: cue.endTime,
64
+ text: cue.text,
65
+ tag: cueCopy
66
+ });
67
+ };
68
+
69
+ export const ParseVTTTrack = async (track) => {
70
+ const videoElement = document.createElement("video");
71
+ const trackElement = document.createElement("track");
72
+
73
+ const dataURL = "data:text/plain;base64," + Utils.B64(track.vttData);
74
+
75
+ const textTrack = trackElement.track;
76
+
77
+ videoElement.append(trackElement);
78
+ trackElement.src = dataURL;
79
+
80
+ textTrack.mode = "hidden";
81
+
82
+ await new Promise(resolve => setTimeout(resolve, 500));
83
+
84
+ let cues = {};
85
+ Array.from(textTrack.cues)
86
+ .forEach(cue => {
87
+ const parsedCue = FormatVTTCue({label: track.label, cue});
88
+ cues[parsedCue.tagId] = parsedCue;
89
+ });
90
+
91
+ return cues;
92
+ };
93
+
94
+ export const CreateTrackIntervalTree = (tags, label) => {
95
+ const intervalTree = new IntervalTree();
96
+
97
+ Object.values(tags).forEach(tag => {
98
+ try {
99
+ intervalTree.insert({low: tag.startTime, high: tag.startTime + 1, name: tag.tagId});
100
+ } catch(error) {
101
+ // eslint-disable-next-line no-console
102
+ console.warn(`Invalid tag in track '${label}'`);
103
+ // eslint-disable-next-line no-console
104
+ console.warn(JSON.stringify(tag, null, 2));
105
+ // eslint-disable-next-line no-console
106
+ console.warn(error);
107
+ }
108
+ });
109
+
110
+ return intervalTree;
111
+ };
112
+
113
+ class ThumbnailHandler {
114
+ constructor(thumbnailTrackUrl) {
115
+ this.thumbnailTrackUrl = thumbnailTrackUrl;
116
+ this.loaded = false;
117
+ }
118
+
119
+ async LoadThumbnails() {
120
+ const thumbnailTrackUrl = new URL(this.thumbnailTrackUrl);
121
+ const authToken = thumbnailTrackUrl.searchParams.get("authorization");
122
+ thumbnailTrackUrl.searchParams.delete("authorization");
123
+ const vttData = await (await fetch(thumbnailTrackUrl, {headers: {Authorization: `Bearer ${authToken}`}})).text();
124
+
125
+ let tags = await ParseVTTTrack({label: "Thumbnails", vttData});
126
+
127
+ // Determine the maximum time between thumbnails
128
+ let maxInterval = 0;
129
+ let lastStartTime = 0;
130
+ Object.keys(tags || {}).forEach(key => {
131
+ tags[key].endTime = tags[key].startTime;
132
+
133
+ maxInterval = Math.max(maxInterval, tags[key].startTime - lastStartTime);
134
+ lastStartTime = tags[key].startTime;
135
+ });
136
+
137
+ let imageUrls = {};
138
+ Object.keys(tags).map(id => {
139
+ const [path, rest] = tags[id].tag.text.split("\n")[0].split("?");
140
+ const [query, hash] = rest.split("#");
141
+ const positionParams = hash.split("=")[1].split(",").map(n => parseInt(n));
142
+ const queryParams = new URLSearchParams(`?${query}`);
143
+ const url = new URL(thumbnailTrackUrl);
144
+ url.searchParams.set("authorization", authToken);
145
+ url.pathname = UrlJoin(url.pathname.split("/").slice(0, -1).join("/"), path);
146
+ queryParams.forEach((key, value) =>
147
+ url.searchParams.set(key, value)
148
+ );
149
+
150
+ tags[id].imageUrl = url.toString();
151
+ tags[id].thumbnailPosition = positionParams;
152
+
153
+ imageUrls[url.toString()] = true;
154
+
155
+ delete tags[id].tag.text;
156
+ delete tags[id].text;
157
+ });
158
+
159
+ await Promise.all(
160
+ Object.keys(imageUrls).map(async url => {
161
+ const image = new Image();
162
+
163
+ await new Promise(resolve => {
164
+ image.src = url;
165
+ image.crossOrigin = "anonymous";
166
+ image.onload = () => {
167
+ resolve();
168
+ };
169
+ });
170
+
171
+ imageUrls[url] = image;
172
+ })
173
+ );
174
+
175
+ this.maxInterval = Math.ceil(maxInterval);
176
+ this.thumbnailImages = imageUrls;
177
+ this.thumbnails = tags;
178
+ this.intervalTree = CreateTrackIntervalTree(tags, "Thumbnails");
179
+
180
+ this.loaded = true;
181
+ }
182
+
183
+ ThumbnailImage(startTime) {
184
+ if(!this.intervalTree) { return; }
185
+
186
+ let record = (this.intervalTree.search(startTime, startTime + this.maxInterval))[0];
187
+ let thumbnailIndex = record && record.name;
188
+
189
+ if(!thumbnailIndex) { return; }
190
+
191
+ const tag = this.thumbnails?.[thumbnailIndex?.toString()];
192
+
193
+ if(!tag) {
194
+ return;
195
+ }
196
+
197
+ if(!this.thumbnailCanvas) {
198
+ this.thumbnailCanvas = document.createElement("canvas");
199
+ }
200
+
201
+ const image = this.thumbnailImages[tag?.imageUrl];
202
+
203
+ if(image) {
204
+ const [x, y, w, h] = tag.thumbnailPosition;
205
+ this.thumbnailCanvas.height = h;
206
+ this.thumbnailCanvas.width = w;
207
+ const context = this.thumbnailCanvas.getContext("2d");
208
+ context.drawImage(image, x, y, w, h, 0, 0, w, h);
209
+ return this.thumbnailCanvas.toDataURL("image/png");
210
+ }
211
+ }
212
+ }
213
+
214
+ export default ThumbnailHandler;
@@ -73,8 +73,8 @@
73
73
 
74
74
  .seek-container {
75
75
  --progress-height: 3px;
76
- --progress-height-expanded: 10px;
77
- --progress-height-expanded-mobile: 8px;
76
+ --progress-height-expanded: 12px;
77
+ --progress-height-expanded-mobile: 12px;
78
78
  --color-seek-background: rgba(255, 255, 255, 10%);
79
79
  --color-seek-buffer: rgba(255, 255, 255, 10%);
80
80
  --color-seek-active: rgba(255, 255, 255, 50%);
@@ -93,6 +93,28 @@
93
93
  transition-delay: 0.25s;
94
94
  }
95
95
 
96
+ .thumbnail {
97
+ animation: 0.5s fadein ease;
98
+ bottom: 100%;
99
+ color: transparent;
100
+ opacity: 0;
101
+ pointer-events: none;
102
+ position: absolute;
103
+ transition-delay: 0s;
104
+ user-select: none;
105
+ width: 250px;
106
+
107
+ &--visible {
108
+ opacity: 1;
109
+ }
110
+
111
+ img {
112
+ border-radius: 5px;
113
+ height: auto;
114
+ width: 250px;
115
+ }
116
+ }
117
+
96
118
  &:hover,
97
119
  &:active,
98
120
  &:focus,
@@ -129,12 +151,6 @@
129
151
  transition-delay: unset;
130
152
  }
131
153
  }
132
-
133
- &:focus-visible,
134
- &:has(:focus-visible),
135
- &:hover {
136
- filter: drop-shadow(0 0 5px var(--color-seek-active-focused));
137
- }
138
154
  }
139
155
 
140
156
  .seek-playhead,
@@ -691,6 +707,7 @@
691
707
 
692
708
  .seek-container {
693
709
  height: calc(var(--progress-height-expanded) + 5px);
710
+ position: relative;
694
711
 
695
712
  &:hover,
696
713
  &:active,
@@ -74,11 +74,48 @@ export const UserActionIndicator = ({action}) => {
74
74
  );
75
75
  };
76
76
 
77
+ const Thumbnail = ({player, time, progress, videoState, visible}) => {
78
+ const [ref, setRef] = useState(null);
79
+
80
+ if(!player.thumbnailsLoaded) {
81
+ return null;
82
+ }
83
+
84
+ time = typeof time !== "undefined" ? time :
85
+ progress * videoState.duration;
86
+
87
+ progress = typeof progress !== "undefined" ? progress :
88
+ time / videoState.duration;
89
+
90
+ let maxPercent = 100;
91
+ if(ref) {
92
+ const { width } = ref.parentElement.getBoundingClientRect();
93
+ maxPercent = (width - 250) / width;
94
+ }
95
+
96
+ const thumbnailImage = player.thumbnailHandler.ThumbnailImage(time);
97
+
98
+ return (
99
+ <div
100
+ ref={setRef}
101
+ style={{
102
+ left: `${Math.min(progress, maxPercent) * 100}%`
103
+ }}
104
+ className={`${CommonStyles["thumbnail"]} ${visible ? CommonStyles["thumbnail--visible"] : ""}`}
105
+ >
106
+ <img src={thumbnailImage} alt="Thumbnail" className={CommonStyles["thumbnail__image"]} />
107
+ </div>
108
+ );
109
+ };
110
+
77
111
  export const SeekBar = ({player, videoState, setRecentUserAction, className=""}) => {
78
112
  const [currentTime, setCurrentTime] = useState(player.controls.GetCurrentTime());
79
113
  const [bufferFraction, setBufferFraction] = useState(0);
80
114
  const [seekKeydownHandler, setSeekKeydownHandler] = useState(undefined);
81
115
  const [dvrEnabled, setDVREnabled] = useState(player.controls.IsDVREnabled());
116
+ const [hoverPosition, setHoverPosition] = useState(0);
117
+ const [hovering, setHovering] = useState(false);
118
+ const [focused, setFocused] = useState(false);
82
119
 
83
120
  useEffect(() => {
84
121
  setSeekKeydownHandler(SeekSliderKeyDown(player, setRecentUserAction));
@@ -103,7 +140,20 @@ export const SeekBar = ({player, videoState, setRecentUserAction, className=""})
103
140
  }
104
141
 
105
142
  return (
106
- <div className={`${className} ${CommonStyles["seek-container"]} ${className}`}>
143
+ <div
144
+ onMouseEnter={() => setHovering(true)}
145
+ onMouseLeave={() => {
146
+ setFocused(false);
147
+ setHovering(false);
148
+ }}
149
+ onFocus={() => setFocused(true)}
150
+ onBlur={() => setFocused(false)}
151
+ onMouseMove={event => {
152
+ const { left, width } = event.currentTarget.getBoundingClientRect();
153
+ setHoverPosition(event.clientX / (width - left));
154
+ }}
155
+ className={`${className} ${CommonStyles["seek-container"]} ${className}`}
156
+ >
107
157
  <progress
108
158
  max={1}
109
159
  value={player.casting ? 0 : bufferFraction}
@@ -121,10 +171,27 @@ export const SeekBar = ({player, videoState, setRecentUserAction, className=""})
121
171
  max={1}
122
172
  step={0.00001}
123
173
  value={currentTime / videoState.duration || 0}
124
- onInput={event => player.controls.Seek({fraction: event.currentTarget.value})}
174
+ onInput={event => {
175
+ player.controls.Seek({fraction: event.currentTarget.value});
176
+ }}
177
+ onTouchStart={() => setHovering(true)}
178
+ onTouchMove={event => {
179
+ const { left, width } = event.currentTarget.getBoundingClientRect();
180
+ const progress = event.touches[0].pageX / (width - left);
181
+ setHoverPosition(progress);
182
+
183
+ player.controls.Seek({fraction: progress});
184
+ }}
185
+ onTouchEnd={() => setHovering(false)}
125
186
  onKeyDown={seekKeydownHandler}
126
187
  className={CommonStyles["seek-input"]}
127
188
  />
189
+ <Thumbnail
190
+ player={player}
191
+ progress={focused ? currentTime / videoState.duration : hoverPosition}
192
+ videoState={videoState}
193
+ visible={hovering || focused}
194
+ />
128
195
  </div>
129
196
  );
130
197
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eluvio/elv-player-js",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "![Eluvio Logo](lib/static/images/Logo.png \"Eluvio Logo\")",
5
5
  "main": "dist/elv-player-js.es.js",
6
6
  "license": "MIT",
@@ -36,11 +36,12 @@
36
36
  "package-lock.json"
37
37
  ],
38
38
  "dependencies": {
39
- "@eluvio/elv-client-js": "^4.2.4",
39
+ "@eluvio/elv-client-js": "^4.2.5",
40
40
  "dashjs": "git+https://github.com/elv-zenia/dash.js.git#text-track-fix",
41
41
  "focus-visible": "^5.2.0",
42
42
  "hls.js": "1.6.13",
43
43
  "mux-embed": "^5.9.0",
44
+ "node-interval-tree": "^2.1.2",
44
45
  "react": "^18.2.0",
45
46
  "react-dom": "^18.2.0",
46
47
  "resize-observer-polyfill": "^1.5.1",