├── static
└── screenshot.png
├── LICENSE
├── .gitignore
├── README.md
├── index.html
└── plyr-plugin-thumbnail.js
/static/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zengde/plyr-plugin-thumbnail/HEAD/static/screenshot.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 zengde
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # next.js build output
61 | .next
62 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # plyr-plugin-thumbnail
2 | a easy to use preview thumbnail plugin of plyr player,not use the default preview thumbnail with vtt files
3 |
4 |
5 | [demo](https://zengde.github.io/plyr-plugin-thumbnail/)
6 |
7 | 
8 |
9 | # Use
10 | 1.use modern browser
11 |
12 | 2.add plyr-plugin-thumbnail.js after main plyr js
13 | ```html
14 |
15 |
16 |
19 | ```
20 |
21 | 3.add thumbnail config
22 | ```
23 | // your other configs
24 | thumbnail:{
25 | enabled:true,
26 | pic_num: 184,// total thumbnail numbers
27 | width: 178,// per thumbnail item width
28 | height: 100,// per thumbnail item height
29 | col: 7,// per thumbnail image columns
30 | row: 7,// per thumbnail image rows
31 | offsetX:0,
32 | offsetY:0,
33 | urls: ['https://cdn.plyr.io/static/demo/thumbs/100p-00001.jpg',
34 | 'https://cdn.plyr.io/static/demo/thumbs/100p-00002.jpg',
35 | 'https://cdn.plyr.io/static/demo/thumbs/100p-00003.jpg',
36 | 'https://cdn.plyr.io/static/demo/thumbs/100p-00004.jpg'] // thumbnail images
37 | },
38 | ```
39 |
40 | 4.mouse hover to progress to see preview image
41 |
42 | # Other Plugins
43 | 1. [video capture](https://github.com/zengde/plyr-plugin-capture)
44 | 2. [preview thumbnails](https://github.com/zengde/plyr-plugin-thumbnail)
45 | 3. [generate thumbnail files](https://github.com/zengde/plyr-thumbnail-generate)
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
13 |
14 | cors video crossorigin="anonymous"
15 |
--------------------------------------------------------------------------------
/plyr-plugin-thumbnail.js:
--------------------------------------------------------------------------------
1 | (function (document) {
2 | const getHours = value => Math.trunc((value / 60 / 60) % 60, 10);
3 | const getMinutes = value => Math.trunc((value / 60) % 60, 10);
4 | const getSeconds = value => Math.trunc(value % 60, 10);
5 |
6 | function isNumber (obj) {
7 | var type = typeof obj;
8 | return (type === "number" || type === "string") &&
9 | !isNaN(obj - parseFloat(obj));
10 | }
11 |
12 | // Format time to UI friendly string
13 | function formatTime (time = 0, displayHours = false, inverted = false) {
14 | // Bail if the value isn't a number
15 | if (!isNumber(time)) {
16 | return formatTime(null, displayHours, inverted);
17 | }
18 |
19 | // Format time component to add leading zero
20 | const format = value => `0${value}`.slice(-2);
21 |
22 | // Breakdown to hours, mins, secs
23 | let hours = getHours(time);
24 | const mins = getMinutes(time);
25 | const secs = getSeconds(time);
26 |
27 | // Do we need to display hours?
28 | if (displayHours || hours > 0) {
29 | hours = `${hours}:`;
30 | } else {
31 | hours = '';
32 | }
33 |
34 | // Render
35 | return `${inverted && time > 0 ? '-' : ''}${hours}${format(mins)}:${format(secs)}`;
36 | }
37 |
38 | class Thumbnails {
39 | /**
40 | * PreviewThumbnails constructor.
41 | * @param {Plyr} player
42 | * @return {PreviewThumbnails}
43 | */
44 | constructor(player) {
45 | this.player = player;
46 | this.loaded = false;
47 | this.lastMouseMoveTime = Date.now();
48 | this.mouseDown = false;
49 | this.loadedImages = [];
50 |
51 | this.elements = {
52 | thumb: {},
53 | scrubbing: {},
54 | };
55 |
56 | this.load();
57 | }
58 |
59 | get enabled () {
60 | return this.player.isHTML5 && this.player.isVideo && this.player.config.thumbnail.enabled;
61 | }
62 | get config () {
63 | return this.player.config.thumbnail;
64 | }
65 |
66 | load () {
67 | // Togglethe regular seek tooltip
68 | if (this.player.elements.display.seekTooltip) {
69 | this.player.elements.display.seekTooltip.hidden = this.enabled;
70 | }
71 |
72 | if (!this.enabled) {
73 | return;
74 | }
75 |
76 | // Render DOM elements
77 | this.render();
78 |
79 | // Check to see if thumb container size was specified manually in CSS
80 | this.determineContainerAutoSizing();
81 |
82 | this.loaded = true;
83 |
84 | this.listeners();
85 | }
86 |
87 | startMove (event) {
88 | if (!this.loaded) {
89 | return;
90 | }
91 |
92 | if (!event instanceof Event || !['touchmove', 'mousemove'].includes(event.type)) {
93 | return;
94 | }
95 |
96 | // Wait until media has a duration
97 | if (!this.player.media.duration) {
98 | return;
99 | }
100 |
101 | if (event.type === 'touchmove') {
102 | // Calculate seek hover position as approx video seconds
103 | this.seekTime = this.player.media.duration * (this.player.elements.inputs.seek.value / 100);
104 | } else {
105 | // Calculate seek hover position as approx video seconds
106 | const clientRect = this.player.elements.progress.getBoundingClientRect();
107 | const percentage = (100 / clientRect.width) * (event.pageX - clientRect.left);
108 | this.seekTime = this.player.media.duration * (percentage / 100);
109 |
110 | if (this.seekTime < 0) {
111 | // The mousemove fires for 10+px out to the left
112 | this.seekTime = 0;
113 | }
114 |
115 | if (this.seekTime > this.player.media.duration - 1) {
116 | // Took 1 second off the duration for safety, because different players can disagree on the real duration of a video
117 | this.seekTime = this.player.media.duration - 1;
118 | }
119 |
120 | this.mousePosX = event.pageX;
121 |
122 | // Set time text inside image container
123 | this.elements.thumb.time.innerText = formatTime(this.seekTime);
124 | }
125 |
126 | // Download and show image
127 | this.showImageAtCurrentTime();
128 | }
129 |
130 | endMove () {
131 | this.toggleThumbContainer(false, true);
132 | }
133 |
134 | startScrubbing (event) {
135 | // Only act on left mouse button (0), or touch device (event.button is false)
136 | if (event.button === false || event.button === 0) {
137 | this.mouseDown = true;
138 |
139 | // Wait until media has a duration
140 | if (this.player.media.duration) {
141 | this.toggleScrubbingContainer(true);
142 | this.toggleThumbContainer(false, true);
143 |
144 | // Download and show image
145 | this.showImageAtCurrentTime();
146 | }
147 | }
148 | }
149 |
150 | endScrubbing () {
151 | this.mouseDown = false;
152 |
153 | // Hide scrubbing preview. But wait until the video has successfully seeked before hiding the scrubbing preview
154 | if (Math.ceil(this.lastTime) === Math.ceil(this.player.media.currentTime)) {
155 | // The video was already seeked/loaded at the chosen time - hide immediately
156 | this.toggleScrubbingContainer(false);
157 | } else {
158 | // The video hasn't seeked yet. Wait for that
159 | this.player.media.addEventListener('timeupdate', () => {
160 | // Re-check mousedown - we might have already started scrubbing again
161 | if (!this.mouseDown) {
162 | this.toggleScrubbingContainer(false);
163 | }
164 | }, { once: true });
165 | }
166 | }
167 |
168 | /**
169 | * Setup hooks for Plyr and window events
170 | */
171 | listeners () {
172 | const { player } = this;
173 |
174 | // Hide thumbnail preview - on mouse click, mouse leave (in listeners.js for now), and video play/seek. All four are required, e.g., for buffering
175 | player.on('play', () => {
176 | this.toggleThumbContainer(false, true);
177 | });
178 |
179 | player.on('seeked', () => {
180 | this.toggleThumbContainer(false);
181 | });
182 |
183 | player.on('timeupdate', () => {
184 | this.lastTime = this.player.media.currentTime;
185 | });
186 |
187 | let elements = this.player.elements;
188 | // Preview thumbnails plugin
189 | // TODO: Really need to work on some sort of plug-in wide event bus or pub-sub for this
190 | ['mousemove', 'touchmove',
191 | 'mouseleave', 'click',
192 | 'mousedown', 'touchstart',
193 | 'mouseup', 'touchend'].forEach(item => {
194 | elements.progress.addEventListener(item, event => {
195 | const { thumbnails } = player;
196 |
197 | if (thumbnails && thumbnails.loaded) {
198 | switch (item) {
199 | case 'mousemove':
200 | case 'touchmove':
201 | thumbnails.startMove(event);
202 | break;
203 | case 'mouseleave':
204 | case 'click':
205 | thumbnails.endMove(false, true);
206 | break;
207 | case 'mousedown':
208 | case 'touchstart':
209 | thumbnails.startMove(event);
210 | break;
211 | case 'mouseup':
212 | case 'touchend':
213 | thumbnails.endScrubbing(event);
214 | break;
215 | }
216 | }
217 | });
218 | });
219 | }
220 |
221 | /**
222 | * Create HTML elements for image containers
223 | */
224 | render () {
225 | // Create HTML element: plyr__preview-thumbnail-container
226 | this.elements.thumb.container = document.createElement('div');
227 | this.elements.thumb.container.classList.add(this.player.config.classNames.previewThumbnails.thumbContainer);
228 |
229 | // Wrapper for the image for styling
230 | this.elements.thumb.imageContainer = document.createElement('div');
231 | this.elements.thumb.imageContainer.className = this.player.config.classNames.previewThumbnails.imageContainer;
232 |
233 | this.elements.thumb.container.appendChild(this.elements.thumb.imageContainer);
234 |
235 | // Create HTML element, parent+span: time text (e.g., 01:32:00)
236 | const timeContainer = document.createElement('div');
237 | timeContainer.className = this.player.config.classNames.previewThumbnails.timeContainer;
238 |
239 | this.elements.thumb.time = document.createElement('span');
240 | this.elements.thumb.time.textContent = '00:00';
241 |
242 | timeContainer.appendChild(this.elements.thumb.time);
243 |
244 | this.elements.thumb.container.appendChild(timeContainer);
245 |
246 | // Inject the whole thumb
247 | if (this.player.elements.progress instanceof Element) {
248 | this.player.elements.progress.appendChild(this.elements.thumb.container);
249 | }
250 |
251 | // Create HTML element: plyr__preview-scrubbing-container
252 | this.elements.scrubbing.container = document.createElement('div');
253 | this.elements.scrubbing.container.className = this.player.config.classNames.previewThumbnails.scrubbingContainer;
254 |
255 | this.player.elements.wrapper.appendChild(this.elements.scrubbing.container);
256 | }
257 |
258 | showImageAtCurrentTime () {
259 | if (this.mouseDown) {
260 | this.setScrubbingContainerSize();
261 | } else {
262 | this.setThumbContainerSizeAndPos();
263 | }
264 |
265 | // Find the desired thumbnail index
266 | // TODO: Handle a video longer than the thumbs where thumbNum is null
267 | let config = this.config;
268 | let interval = this.player.duration / config.pic_num;
269 | const thumbNum = Math.floor(this.seekTime / interval);
270 | // console.dir('thumbNum---'+thumbNum)
271 |
272 | let qualityIndex = Math.ceil((thumbNum + 1) / (config.col * config.row)) - 1;
273 |
274 | const hasThumb = thumbNum >= 0;
275 |
276 | // Show the thumb container if we're not scrubbing
277 | if (!this.mouseDown) {
278 | this.toggleThumbContainer(hasThumb);
279 | }
280 |
281 | // No matching thumb found
282 | if (!hasThumb) {
283 | return;
284 | }
285 |
286 | // Only proceed if either thumbnum or thumbfilename has changed
287 | if (thumbNum !== this.showingThumb) {
288 | this.showingThumb = thumbNum;
289 | this.loadImage(qualityIndex);
290 | }
291 | }
292 |
293 | // Show the image that's currently specified in this.showingThumb
294 | loadImage (qualityIndex = 0) {
295 | const thumbNum = this.showingThumb;
296 | const thumbUrl = this.config.urls[qualityIndex];
297 |
298 | if (!this.currentImageElement || this.currentImageElement.src !== thumbUrl) {
299 | // If we're already loading a previous image, remove its onload handler - we don't want it to load after this one
300 | // Only do this if not using sprites. Without sprites we really want to show as many images as possible, as a best-effort
301 | if (this.loadingImage) {
302 | this.loadingImage.onload = null;
303 | }
304 |
305 | // We're building and adding a new image. In other implementations of similar functionality (YouTube), background image
306 | // is instead used. But this causes issues with larger images in Firefox and Safari - switching between background
307 | // images causes a flicker. Putting a new image over the top does not
308 | const previewImage = new Image();
309 | previewImage.src = thumbUrl;
310 | previewImage.dataset.index = thumbNum;
311 |
312 | this.player.debug.log(`Loading image: ${thumbUrl}`);
313 |
314 | // For some reason, passing the named function directly causes it to execute immediately. So I've wrapped it in an anonymous function...
315 | previewImage.onload = () =>
316 | this.showImage(previewImage, qualityIndex, thumbNum, thumbUrl, true);
317 | this.loadingImage = previewImage;
318 | this.removeOldImages(previewImage);
319 | } else {
320 | // Update the existing image
321 | this.showImage(this.currentImageElement, qualityIndex, thumbNum, thumbUrl, false);
322 | this.currentImageElement.dataset.index = thumbNum;
323 | this.removeOldImages(this.currentImageElement);
324 | }
325 | }
326 |
327 | showImage (previewImage, qualityIndex, thumbNum, thumbFilename, newImage = true) {
328 | this.player.debug.log(
329 | `Showing thumb: ${thumbFilename}. num: ${thumbNum}. qual: ${qualityIndex}. newimg: ${newImage}`,
330 | );
331 | this.setImageSizeAndOffset(previewImage, thumbNum);
332 |
333 | if (newImage) {
334 | this.currentImageContainer.appendChild(previewImage);
335 | this.currentImageElement = previewImage;
336 |
337 | if (!this.loadedImages.includes(thumbFilename)) {
338 | this.loadedImages.push(thumbFilename);
339 | }
340 | }
341 | }
342 |
343 | // Remove all preview images that aren't the designated current image
344 | removeOldImages (currentImage) {
345 | // Get a list of all images, convert it from a DOM list to an array
346 | Array.from(this.currentImageContainer.children).forEach(image => {
347 | if (image.tagName.toLowerCase() !== 'img') {
348 | return;
349 | }
350 |
351 | const removeDelay = 500;
352 |
353 | if (image.dataset.index !== currentImage.dataset.index && !image.dataset.deleting) {
354 | // Wait 200ms, as the new image can take some time to show on certain browsers (even though it was downloaded before showing). This will prevent flicker, and show some generosity towards slower clients
355 | // First set attribute 'deleting' to prevent multi-handling of this on repeat firing of this function
356 | image.dataset.deleting = true;
357 | // This has to be set before the timeout - to prevent issues switching between hover and scrub
358 | const { currentImageContainer } = this;
359 |
360 | setTimeout(() => {
361 | currentImageContainer.removeChild(image);
362 | this.player.debug.log(`Removing thumb: ${image.dataset.filename}`);
363 | }, removeDelay);
364 | }
365 | });
366 | }
367 |
368 | get currentImageContainer () {
369 | if (this.mouseDown) {
370 | return this.elements.scrubbing.container;
371 | }
372 |
373 | return this.elements.thumb.imageContainer;
374 | }
375 |
376 | get thumbAspectRatio () {
377 | return this.config.width / this.config.height;
378 | }
379 |
380 | get thumbContainerHeight () {
381 | if (this.mouseDown) {
382 | // Can't use media.clientHeight - HTML5 video goes big and does black bars above and below
383 | return Math.floor(this.player.media.clientWidth / this.thumbAspectRatio);
384 | }
385 |
386 | return Math.floor(this.player.media.clientWidth / this.thumbAspectRatio / 4);
387 | }
388 |
389 | get currentImageElement () {
390 | if (this.mouseDown) {
391 | return this.currentScrubbingImageElement;
392 | }
393 |
394 | return this.currentThumbnailImageElement;
395 | }
396 |
397 | set currentImageElement (element) {
398 | if (this.mouseDown) {
399 | this.currentScrubbingImageElement = element;
400 | } else {
401 | this.currentThumbnailImageElement = element;
402 | }
403 | }
404 |
405 | toggleThumbContainer (toggle = false, clearShowing = false) {
406 | const className = this.player.config.classNames.previewThumbnails.thumbContainerShown;
407 | this.elements.thumb.container.classList.toggle(className, toggle);
408 |
409 | if (!toggle && clearShowing) {
410 | this.showingThumb = null;
411 | this.showingThumbFilename = null;
412 | }
413 | }
414 |
415 | toggleScrubbingContainer (toggle = false) {
416 | const className = this.player.config.classNames.previewThumbnails.scrubbingContainerShown;
417 | this.elements.scrubbing.container.classList.toggle(className, toggle);
418 |
419 | if (!toggle) {
420 | this.showingThumb = null;
421 | this.showingThumbFilename = null;
422 | }
423 | }
424 |
425 | determineContainerAutoSizing () {
426 | if (this.elements.thumb.imageContainer.clientHeight > 20) {
427 | // This will prevent auto sizing in this.setThumbContainerSizeAndPos()
428 | this.sizeSpecifiedInCSS = true;
429 | }
430 | }
431 |
432 | // Set the size to be about a quarter of the size of video. Unless option dynamicSize === false, in which case it needs to be set in CSS
433 | setThumbContainerSizeAndPos () {
434 | if (!this.sizeSpecifiedInCSS) {
435 | const thumbWidth = Math.floor(this.thumbContainerHeight * this.thumbAspectRatio);
436 | this.elements.thumb.imageContainer.style.height = `${this.thumbContainerHeight}px`;
437 | this.elements.thumb.imageContainer.style.width = `${thumbWidth}px`;
438 | }
439 |
440 | this.setThumbContainerPos();
441 | }
442 |
443 | setThumbContainerPos () {
444 | const seekbarRect = this.player.elements.progress.getBoundingClientRect();
445 | const plyrRect = this.player.elements.container.getBoundingClientRect();
446 | const { container } = this.elements.thumb;
447 |
448 | // Find the lowest and highest desired left-position, so we don't slide out the side of the video container
449 | const minVal = plyrRect.left - seekbarRect.left + 10;
450 | const maxVal = plyrRect.right - seekbarRect.left - container.clientWidth - 10;
451 |
452 | // Set preview container position to: mousepos, minus seekbar.left, minus half of previewContainer.clientWidth
453 | let previewPos = this.mousePosX - seekbarRect.left - container.clientWidth / 2;
454 |
455 | if (previewPos < minVal) {
456 | previewPos = minVal;
457 | }
458 |
459 | if (previewPos > maxVal) {
460 | previewPos = maxVal;
461 | }
462 |
463 | container.style.left = `${previewPos}px`;
464 | }
465 |
466 | // Can't use 100% width, in case the video is a different aspect ratio to the video container
467 | setScrubbingContainerSize () {
468 | this.elements.scrubbing.container.style.width = `${this.player.media.clientWidth}px`;
469 | // Can't use media.clientHeight - html5 video goes big and does black bars above and below
470 | this.elements.scrubbing.container.style.height = `${this.player.media.clientWidth / this.thumbAspectRatio}px`;
471 | }
472 |
473 | // Sprites need to be offset to the correct location
474 | setImageSizeAndOffset (previewImage, frame) {
475 | // Find difference between height and preview container height
476 | let config = this.config;
477 | let indexInPage = frame + 1 - (config.col * config.row) * (Math.ceil((frame + 1) / (config.col * config.row)) - 1)
478 | let tnaiRowIndex = Math.ceil(indexInPage / config.row) - 1
479 | let tnaiColIndex = indexInPage - tnaiRowIndex * config.row - 1
480 | // console.dir('indexinpage---'+indexInPage)
481 | // console.dir('tnaiRowIndex---'+tnaiRowIndex)
482 | // console.dir('tnaiColIndex---'+tnaiColIndex)
483 |
484 | const multiplier = this.thumbContainerHeight / config.height;
485 |
486 | previewImage.style.height = `${Math.floor(previewImage.naturalHeight * multiplier)}px`;
487 | previewImage.style.width = `${Math.floor(previewImage.naturalWidth * multiplier)}px`;
488 | previewImage.style.left = `-${tnaiColIndex * config.width * multiplier}px`;
489 | previewImage.style.top = `-${tnaiRowIndex * config.height * multiplier}px`;
490 | }
491 | }
492 |
493 | document.addEventListener('ready', event => {
494 | const curPlayer = event.detail.plyr;
495 |
496 | curPlayer.thumbnails = new Thumbnails(curPlayer);
497 | });
498 | })(document);
--------------------------------------------------------------------------------