aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src
diff options
context:
space:
mode:
Diffstat (limited to 'client/src')
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html14
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts2
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts12
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts45
-rw-r--r--client/src/app/shared/form-validators/custom-config-validators.ts17
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts24
-rw-r--r--client/src/assets/player/peertube-player-manager.ts11
-rw-r--r--client/src/assets/player/shared/control-bar/index.ts1
-rw-r--r--client/src/assets/player/shared/control-bar/storyboard-plugin.ts184
-rw-r--r--client/src/assets/player/types/manager-options.ts3
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts18
-rw-r--r--client/src/sass/player/control-bar.scss15
-rw-r--r--client/src/sass/player/mobile.scss25
13 files changed, 348 insertions, 23 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html
index bbf946df0..9701e7f85 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html
@@ -52,6 +52,20 @@
52 52
53 <div *ngIf="formErrors.cache.torrents.size" class="form-error">{{ formErrors.cache.torrents.size }}</div> 53 <div *ngIf="formErrors.cache.torrents.size" class="form-error">{{ formErrors.cache.torrents.size }}</div>
54 </div> 54 </div>
55
56 <div class="form-group" formGroupName="torrents">
57 <label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
58
59 <div class="number-with-unit">
60 <input
61 type="number" min="0" id="cacheStoryboardsSize" class="form-control"
62 formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.storyboards.size'] }"
63 >
64 <span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
65 </div>
66
67 <div *ngIf="formErrors.cache.storyboards.size" class="form-error">{{ formErrors.cache.storyboards.size }}</div>
68 </div>
55 </ng-container> 69 </ng-container>
56 70
57 </div> 71 </div>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts
index 79a98f288..06c5e6221 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts
@@ -10,7 +10,7 @@ export class EditAdvancedConfigurationComponent {
10 @Input() form: FormGroup 10 @Input() form: FormGroup
11 @Input() formErrors: any 11 @Input() formErrors: any
12 12
13 getCacheSize (type: 'captions' | 'previews' | 'torrents') { 13 getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
14 return this.form.value['cache'][type]['size'] 14 return this.form.value['cache'][type]['size']
15 } 15 }
16} 16}
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 2c3b7560d..9219d608b 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -9,8 +9,7 @@ import { Notifier } from '@app/core'
9import { ServerService } from '@app/core/server/server.service' 9import { ServerService } from '@app/core/server/server.service'
10import { 10import {
11 ADMIN_EMAIL_VALIDATOR, 11 ADMIN_EMAIL_VALIDATOR,
12 CACHE_CAPTIONS_SIZE_VALIDATOR, 12 CACHE_SIZE_VALIDATOR,
13 CACHE_PREVIEWS_SIZE_VALIDATOR,
14 CONCURRENCY_VALIDATOR, 13 CONCURRENCY_VALIDATOR,
15 INDEX_URL_VALIDATOR, 14 INDEX_URL_VALIDATOR,
16 INSTANCE_NAME_VALIDATOR, 15 INSTANCE_NAME_VALIDATOR,
@@ -120,13 +119,16 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
120 }, 119 },
121 cache: { 120 cache: {
122 previews: { 121 previews: {
123 size: CACHE_PREVIEWS_SIZE_VALIDATOR 122 size: CACHE_SIZE_VALIDATOR
124 }, 123 },
125 captions: { 124 captions: {
126 size: CACHE_CAPTIONS_SIZE_VALIDATOR 125 size: CACHE_SIZE_VALIDATOR
127 }, 126 },
128 torrents: { 127 torrents: {
129 size: CACHE_CAPTIONS_SIZE_VALIDATOR 128 size: CACHE_SIZE_VALIDATOR
129 },
130 storyboards: {
131 size: CACHE_SIZE_VALIDATOR
130 } 132 }
131 }, 133 },
132 signup: { 134 signup: {
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index aba3ee086..43744789d 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -33,6 +33,7 @@ import {
33 LiveVideo, 33 LiveVideo,
34 PeerTubeProblemDocument, 34 PeerTubeProblemDocument,
35 ServerErrorCode, 35 ServerErrorCode,
36 Storyboard,
36 VideoCaption, 37 VideoCaption,
37 VideoPrivacy, 38 VideoPrivacy,
38 VideoState 39 VideoState
@@ -69,6 +70,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
69 videoCaptions: VideoCaption[] = [] 70 videoCaptions: VideoCaption[] = []
70 liveVideo: LiveVideo 71 liveVideo: LiveVideo
71 videoPassword: string 72 videoPassword: string
73 storyboards: Storyboard[] = []
72 74
73 playlistPosition: number 75 playlistPosition: number
74 playlist: VideoPlaylist = null 76 playlist: VideoPlaylist = null
@@ -285,9 +287,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
285 forkJoin([ 287 forkJoin([
286 videoAndLiveObs, 288 videoAndLiveObs,
287 this.videoCaptionService.listCaptions(videoId, videoPassword), 289 this.videoCaptionService.listCaptions(videoId, videoPassword),
290 this.videoService.getStoryboards(videoId),
288 this.userService.getAnonymousOrLoggedUser() 291 this.userService.getAnonymousOrLoggedUser()
289 ]).subscribe({ 292 ]).subscribe({
290 next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { 293 next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => {
291 const queryParams = this.route.snapshot.queryParams 294 const queryParams = this.route.snapshot.queryParams
292 295
293 const urlOptions = { 296 const urlOptions = {
@@ -309,6 +312,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
309 video, 312 video,
310 live, 313 live,
311 videoCaptions: captionsResult.data, 314 videoCaptions: captionsResult.data,
315 storyboards,
312 videoFileToken, 316 videoFileToken,
313 videoPassword, 317 videoPassword,
314 loggedInOrAnonymousUser, 318 loggedInOrAnonymousUser,
@@ -414,6 +418,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
414 video: VideoDetails 418 video: VideoDetails
415 live: LiveVideo 419 live: LiveVideo
416 videoCaptions: VideoCaption[] 420 videoCaptions: VideoCaption[]
421 storyboards: Storyboard[]
417 videoFileToken: string 422 videoFileToken: string
418 videoPassword: string 423 videoPassword: string
419 424
@@ -421,7 +426,17 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
421 loggedInOrAnonymousUser: User 426 loggedInOrAnonymousUser: User
422 forceAutoplay: boolean 427 forceAutoplay: boolean
423 }) { 428 }) {
424 const { video, live, videoCaptions, urlOptions, videoFileToken, videoPassword, loggedInOrAnonymousUser, forceAutoplay } = options 429 const {
430 video,
431 live,
432 videoCaptions,
433 storyboards,
434 urlOptions,
435 videoFileToken,
436 videoPassword,
437 loggedInOrAnonymousUser,
438 forceAutoplay
439 } = options
425 440
426 this.subscribeToLiveEventsIfNeeded(this.video, video) 441 this.subscribeToLiveEventsIfNeeded(this.video, video)
427 442
@@ -430,6 +445,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
430 this.liveVideo = live 445 this.liveVideo = live
431 this.videoFileToken = videoFileToken 446 this.videoFileToken = videoFileToken
432 this.videoPassword = videoPassword 447 this.videoPassword = videoPassword
448 this.storyboards = storyboards
433 449
434 // Re init attributes 450 // Re init attributes
435 this.playerPlaceholderImgSrc = undefined 451 this.playerPlaceholderImgSrc = undefined
@@ -485,6 +501,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
485 const params = { 501 const params = {
486 video: this.video, 502 video: this.video,
487 videoCaptions: this.videoCaptions, 503 videoCaptions: this.videoCaptions,
504 storyboards: this.storyboards,
488 liveVideo: this.liveVideo, 505 liveVideo: this.liveVideo,
489 videoFileToken: this.videoFileToken, 506 videoFileToken: this.videoFileToken,
490 videoPassword: this.videoPassword, 507 videoPassword: this.videoPassword,
@@ -636,6 +653,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
636 video: VideoDetails 653 video: VideoDetails
637 liveVideo: LiveVideo 654 liveVideo: LiveVideo
638 videoCaptions: VideoCaption[] 655 videoCaptions: VideoCaption[]
656 storyboards: Storyboard[]
639 657
640 videoFileToken: string 658 videoFileToken: string
641 videoPassword: string 659 videoPassword: string
@@ -646,7 +664,17 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
646 forceAutoplay: boolean 664 forceAutoplay: boolean
647 user?: AuthUser // Keep for plugins 665 user?: AuthUser // Keep for plugins
648 }) { 666 }) {
649 const { video, liveVideo, videoCaptions, videoFileToken, videoPassword, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params 667 const {
668 video,
669 liveVideo,
670 videoCaptions,
671 storyboards,
672 videoFileToken,
673 videoPassword,
674 urlOptions,
675 loggedInOrAnonymousUser,
676 forceAutoplay
677 } = params
650 678
651 const getStartTime = () => { 679 const getStartTime = () => {
652 const byUrl = urlOptions.startTime !== undefined 680 const byUrl = urlOptions.startTime !== undefined
@@ -673,6 +701,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
673 src: environment.apiUrl + c.captionPath 701 src: environment.apiUrl + c.captionPath
674 })) 702 }))
675 703
704 const storyboard = storyboards.length !== 0
705 ? {
706 url: environment.apiUrl + storyboards[0].storyboardPath,
707 height: storyboards[0].spriteHeight,
708 width: storyboards[0].spriteWidth,
709 interval: storyboards[0].spriteDuration
710 }
711 : undefined
712
676 const liveOptions = video.isLive 713 const liveOptions = video.isLive
677 ? { latencyMode: liveVideo.latencyMode } 714 ? { latencyMode: liveVideo.latencyMode }
678 : undefined 715 : undefined
@@ -734,6 +771,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
734 videoPassword: () => videoPassword, 771 videoPassword: () => videoPassword,
735 772
736 videoCaptions: playerCaptions, 773 videoCaptions: playerCaptions,
774 storyboard,
737 775
738 videoShortUUID: video.shortUUID, 776 videoShortUUID: video.shortUUID,
739 videoUUID: video.uuid, 777 videoUUID: video.uuid,
@@ -767,6 +805,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
767 else mode = 'webtorrent' 805 else mode = 'webtorrent'
768 } 806 }
769 807
808 // FIXME: remove, we don't support these old web browsers anymore
770 // p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available 809 // p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available
771 if (typeof TextEncoder === 'undefined') { 810 if (typeof TextEncoder === 'undefined') {
772 mode = 'webtorrent' 811 mode = 'webtorrent'
diff --git a/client/src/app/shared/form-validators/custom-config-validators.ts b/client/src/app/shared/form-validators/custom-config-validators.ts
index ff0813f7d..3672e5610 100644
--- a/client/src/app/shared/form-validators/custom-config-validators.ts
+++ b/client/src/app/shared/form-validators/custom-config-validators.ts
@@ -22,21 +22,12 @@ export const SERVICES_TWITTER_USERNAME_VALIDATOR: BuildFormValidator = {
22 } 22 }
23} 23}
24 24
25export const CACHE_PREVIEWS_SIZE_VALIDATOR: BuildFormValidator = { 25export const CACHE_SIZE_VALIDATOR: BuildFormValidator = {
26 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], 26 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
27 MESSAGES: { 27 MESSAGES: {
28 required: $localize`Previews cache size is required.`, 28 required: $localize`Cache size is required.`,
29 min: $localize`Previews cache size must be greater than 1.`, 29 min: $localize`Cache size must be greater than 1.`,
30 pattern: $localize`Previews cache size must be a number.` 30 pattern: $localize`Cache size must be a number.`
31 }
32}
33
34export const CACHE_CAPTIONS_SIZE_VALIDATOR: BuildFormValidator = {
35 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
36 MESSAGES: {
37 required: $localize`Captions cache size is required.`,
38 min: $localize`Captions cache size must be greater than 1.`,
39 pattern: $localize`Captions cache size must be a number.`
40 } 31 }
41} 32}
42 33
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts
index d67a2e192..c2e3d7511 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -11,6 +11,7 @@ import {
11 FeedFormat, 11 FeedFormat,
12 NSFWPolicyType, 12 NSFWPolicyType,
13 ResultList, 13 ResultList,
14 Storyboard,
14 UserVideoRate, 15 UserVideoRate,
15 UserVideoRateType, 16 UserVideoRateType,
16 UserVideoRateUpdate, 17 UserVideoRateUpdate,
@@ -344,6 +345,25 @@ export class VideoService {
344 ) 345 )
345 } 346 }
346 347
348 // ---------------------------------------------------------------------------
349
350 getStoryboards (videoId: string | number) {
351 return this.authHttp
352 .get<{ storyboards: Storyboard[] }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/storyboards')
353 .pipe(
354 map(({ storyboards }) => storyboards),
355 catchError(err => {
356 if (err.status === 404) {
357 return of([])
358 }
359
360 this.restExtractor.handleError(err)
361 })
362 )
363 }
364
365 // ---------------------------------------------------------------------------
366
347 getSource (videoId: number) { 367 getSource (videoId: number) {
348 return this.authHttp 368 return this.authHttp
349 .get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source') 369 .get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source')
@@ -358,6 +378,8 @@ export class VideoService {
358 ) 378 )
359 } 379 }
360 380
381 // ---------------------------------------------------------------------------
382
361 setVideoLike (id: string, videoPassword: string) { 383 setVideoLike (id: string, videoPassword: string) {
362 return this.setVideoRate(id, 'like', videoPassword) 384 return this.setVideoRate(id, 'like', videoPassword)
363 } 385 }
@@ -370,6 +392,8 @@ export class VideoService {
370 return this.setVideoRate(id, 'none', videoPassword) 392 return this.setVideoRate(id, 'none', videoPassword)
371 } 393 }
372 394
395 // ---------------------------------------------------------------------------
396
373 getUserVideoRating (id: string) { 397 getUserVideoRating (id: string) {
374 const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating' 398 const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating'
375 399
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index 2781850b9..66d9c7298 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -6,6 +6,7 @@ import './shared/stats/stats-plugin'
6import './shared/bezels/bezels-plugin' 6import './shared/bezels/bezels-plugin'
7import './shared/peertube/peertube-plugin' 7import './shared/peertube/peertube-plugin'
8import './shared/resolutions/peertube-resolutions-plugin' 8import './shared/resolutions/peertube-resolutions-plugin'
9import './shared/control-bar/storyboard-plugin'
9import './shared/control-bar/next-previous-video-button' 10import './shared/control-bar/next-previous-video-button'
10import './shared/control-bar/p2p-info-button' 11import './shared/control-bar/p2p-info-button'
11import './shared/control-bar/peertube-link-button' 12import './shared/control-bar/peertube-link-button'
@@ -42,6 +43,12 @@ CaptionsButton.prototype.controlText_ = 'Subtitles/CC'
42// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) 43// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
43CaptionsButton.prototype.label_ = ' ' 44CaptionsButton.prototype.label_ = ' '
44 45
46// TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged
47const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any
48if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) {
49 PlayProgressBar.prototype.options_.children.push('timeTooltip')
50}
51
45export class PeertubePlayerManager { 52export class PeertubePlayerManager {
46 private static playerElementClassName: string 53 private static playerElementClassName: string
47 private static playerElementAttributes: { name: string, value: string }[] = [] 54 private static playerElementAttributes: { name: string, value: string }[] = []
@@ -135,6 +142,10 @@ export class PeertubePlayerManager {
135 p2pEnabled: options.common.p2pEnabled 142 p2pEnabled: options.common.p2pEnabled
136 }) 143 })
137 144
145 if (options.common.storyboard) {
146 player.storyboard(options.common.storyboard)
147 }
148
138 player.on('p2pInfo', (_, data: PlayerNetworkInfo) => { 149 player.on('p2pInfo', (_, data: PlayerNetworkInfo) => {
139 if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return 150 if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return
140 151
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts
index e71e90713..24877c267 100644
--- a/client/src/assets/player/shared/control-bar/index.ts
+++ b/client/src/assets/player/shared/control-bar/index.ts
@@ -3,4 +3,5 @@ export * from './p2p-info-button'
3export * from './peertube-link-button' 3export * from './peertube-link-button'
4export * from './peertube-live-display' 4export * from './peertube-live-display'
5export * from './peertube-load-progress-bar' 5export * from './peertube-load-progress-bar'
6export * from './storyboard-plugin'
6export * from './theater-button' 7export * from './theater-button'
diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts
new file mode 100644
index 000000000..c1843f595
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts
@@ -0,0 +1,184 @@
1import videojs from 'video.js'
2import { StoryboardOptions } from '../../types'
3
4// Big thanks to this beautiful plugin: https://github.com/phloxic/videojs-sprite-thumbnails
5// Adapted to respect peertube player style
6
7const Plugin = videojs.getPlugin('plugin')
8
9class StoryboardPlugin extends Plugin {
10 private url: string
11 private height: number
12 private width: number
13 private interval: number
14
15 private cached: boolean
16
17 private mouseTimeTooltip: videojs.MouseTimeDisplay
18 private seekBar: { el(): HTMLElement, mouseTimeDisplay: any, playProgressBar: any }
19 private progress: any
20
21 private spritePlaceholder: HTMLElement
22
23 private readonly sprites: { [id: string]: HTMLImageElement } = {}
24
25 private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip
26
27 constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) {
28 super(player, options)
29
30 this.url = options.url
31 this.height = options.height
32 this.width = options.width
33 this.interval = options.interval
34
35 this.boundedHijackMouseTooltip = this.hijackMouseTooltip.bind(this)
36
37 this.player.ready(() => {
38 player.addClass('vjs-storyboard')
39
40 this.init()
41 })
42 }
43
44 init () {
45 const controls = this.player.controlBar as any
46
47 // default control bar component tree is expected
48 // https://docs.videojs.com/tutorial-components.html#default-component-tree
49 this.progress = controls?.progressControl
50 this.seekBar = this.progress?.seekBar
51
52 this.mouseTimeTooltip = this.seekBar?.mouseTimeDisplay?.timeTooltip
53
54 this.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement
55 this.seekBar?.el()?.appendChild(this.spritePlaceholder)
56
57 this.player.on([ 'ready', 'loadstart' ], evt => {
58 if (evt !== 'ready') {
59 const spriteSource = this.player.currentSources().find(source => {
60 return Object.prototype.hasOwnProperty.call(source, 'storyboard')
61 }) as any
62 const spriteOpts = spriteSource?.['storyboard'] as StoryboardOptions
63
64 if (spriteOpts) {
65 this.url = spriteOpts.url
66 this.height = spriteOpts.height
67 this.width = spriteOpts.width
68 this.interval = spriteOpts.interval
69 }
70 }
71
72 this.cached = !!this.sprites[this.url]
73
74 this.load()
75 })
76 }
77
78 private load () {
79 const spriteEvents = [ 'mousemove', 'touchmove' ]
80
81 if (this.isReady()) {
82 if (!this.cached) {
83 this.sprites[this.url] = videojs.dom.createEl('img', {
84 src: this.url
85 })
86 }
87 this.progress.on(spriteEvents, this.boundedHijackMouseTooltip)
88 } else {
89 this.progress.off(spriteEvents, this.boundedHijackMouseTooltip)
90
91 this.resetMouseTooltip()
92 }
93 }
94
95 private hijackMouseTooltip (evt: Event) {
96 const sprite = this.sprites[this.url]
97 const imgWidth = sprite.naturalWidth
98 const imgHeight = sprite.naturalHeight
99 const seekBarEl = this.seekBar.el()
100
101 if (!sprite.complete || !imgWidth || !imgHeight) {
102 this.resetMouseTooltip()
103 return
104 }
105
106 this.player.requestNamedAnimationFrame('StoryBoardPlugin#hijackMouseTooltip', () => {
107 const seekBarRect = videojs.dom.getBoundingClientRect(seekBarEl)
108 const playerRect = videojs.dom.getBoundingClientRect(this.player.el())
109
110 if (!seekBarRect || !playerRect) return
111
112 const seekBarX = videojs.dom.getPointerPosition(seekBarEl, evt).x
113 let position = seekBarX * this.player.duration()
114
115 const maxPosition = Math.round((imgHeight / this.height) * (imgWidth / this.width)) - 1
116 position = Math.min(position / this.interval, maxPosition)
117
118 const responsive = 600
119 const playerWidth = this.player.currentWidth()
120 const scaleFactor = responsive && playerWidth < responsive
121 ? playerWidth / responsive
122 : 1
123 const columns = imgWidth / this.width
124
125 const scaledWidth = this.width * scaleFactor
126 const scaledHeight = this.height * scaleFactor
127 const cleft = Math.floor(position % columns) * -scaledWidth
128 const ctop = Math.floor(position / columns) * -scaledHeight
129
130 const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px`
131 const topOffset = -scaledHeight - 60
132
133 const previewHalfSize = Math.round(scaledWidth / 2)
134 let left = seekBarRect.width * seekBarX - previewHalfSize
135
136 // Seek bar doesn't take all the player width, so we can add/minus a few more pixels
137 const minLeft = playerRect.left - seekBarRect.left
138 const maxLeft = seekBarRect.width - scaledWidth + (playerRect.right - seekBarRect.right)
139
140 if (left < minLeft) left = minLeft
141 if (left > maxLeft) left = maxLeft
142
143 const tooltipStyle: { [id: string]: string } = {
144 'background-image': `url("${this.url}")`,
145 'background-repeat': 'no-repeat',
146 'background-position': `${cleft}px ${ctop}px`,
147 'background-size': bgSize,
148
149 'color': '#fff',
150 'text-shadow': '1px 1px #000',
151
152 'position': 'relative',
153
154 'top': `${topOffset}px`,
155
156 'border': '1px solid #000',
157
158 // border should not overlay thumbnail area
159 'width': `${scaledWidth + 2}px`,
160 'height': `${scaledHeight + 2}px`
161 }
162
163 tooltipStyle.left = `${left}px`
164
165 for (const [ key, value ] of Object.entries(tooltipStyle)) {
166 this.spritePlaceholder.style.setProperty(key, value)
167 }
168 })
169 }
170
171 private resetMouseTooltip () {
172 if (this.spritePlaceholder) {
173 this.spritePlaceholder.style.cssText = ''
174 }
175 }
176
177 private isReady () {
178 return this.mouseTimeTooltip && this.width && this.height && this.url
179 }
180}
181
182videojs.registerPlugin('storyboard', StoryboardPlugin)
183
184export { StoryboardPlugin }
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts
index 1f3a0aa2e..a73341b4c 100644
--- a/client/src/assets/player/types/manager-options.ts
+++ b/client/src/assets/player/types/manager-options.ts
@@ -1,6 +1,6 @@
1import { PluginsManager } from '@root-helpers/plugins-manager' 1import { PluginsManager } from '@root-helpers/plugins-manager'
2import { LiveVideoLatencyMode, VideoFile } from '@shared/models' 2import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
3import { PlaylistPluginOptions, VideoJSCaption } from './peertube-videojs-typings' 3import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
4 4
5export type PlayerMode = 'webtorrent' | 'p2p-media-loader' 5export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
6 6
@@ -78,6 +78,7 @@ export interface CommonOptions extends CustomizationOptions {
78 language?: string 78 language?: string
79 79
80 videoCaptions: VideoJSCaption[] 80 videoCaptions: VideoJSCaption[]
81 storyboard: VideoJSStoryboard
81 82
82 videoUUID: string 83 videoUUID: string
83 videoShortUUID: string 84 videoShortUUID: string
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts
index 723c42c5d..30d2b287f 100644
--- a/client/src/assets/player/types/peertube-videojs-typings.ts
+++ b/client/src/assets/player/types/peertube-videojs-typings.ts
@@ -49,6 +49,8 @@ declare module 'video.js' {
49 49
50 stats (options?: StatsCardOptions): StatsForNerdsPlugin 50 stats (options?: StatsCardOptions): StatsForNerdsPlugin
51 51
52 storyboard (options: StoryboardOptions): void
53
52 textTracks (): TextTrackList & { 54 textTracks (): TextTrackList & {
53 tracks_: (TextTrack & { id: string, label: string, src: string })[] 55 tracks_: (TextTrack & { id: string, label: string, src: string })[]
54 } 56 }
@@ -89,6 +91,13 @@ type VideoJSCaption = {
89 src: string 91 src: string
90} 92}
91 93
94type VideoJSStoryboard = {
95 url: string
96 width: number
97 height: number
98 interval: number
99}
100
92type PeerTubePluginOptions = { 101type PeerTubePluginOptions = {
93 mode: PlayerMode 102 mode: PlayerMode
94 103
@@ -118,6 +127,13 @@ type MetricsPluginOptions = {
118 videoUUID: string 127 videoUUID: string
119} 128}
120 129
130type StoryboardOptions = {
131 url: string
132 width: number
133 height: number
134 interval: number
135}
136
121type PlaylistPluginOptions = { 137type PlaylistPluginOptions = {
122 elements: VideoPlaylistElement[] 138 elements: VideoPlaylistElement[]
123 139
@@ -238,6 +254,7 @@ type PlaylistItemOptions = {
238 254
239export { 255export {
240 PlayerNetworkInfo, 256 PlayerNetworkInfo,
257 VideoJSStoryboard,
241 PlaylistItemOptions, 258 PlaylistItemOptions,
242 NextPreviousVideoButtonOptions, 259 NextPreviousVideoButtonOptions,
243 ResolutionUpdateData, 260 ResolutionUpdateData,
@@ -251,6 +268,7 @@ export {
251 PeerTubeResolution, 268 PeerTubeResolution,
252 VideoJSPluginOptions, 269 VideoJSPluginOptions,
253 LoadedQualityData, 270 LoadedQualityData,
271 StoryboardOptions,
254 PeerTubeLinkButtonOptions, 272 PeerTubeLinkButtonOptions,
255 PeerTubeP2PInfoButtonOptions 273 PeerTubeP2PInfoButtonOptions
256} 274}
diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss
index 96b3adf66..02d5fa169 100644
--- a/client/src/sass/player/control-bar.scss
+++ b/client/src/sass/player/control-bar.scss
@@ -3,6 +3,20 @@
3@use '_mixins' as *; 3@use '_mixins' as *;
4@use './_player-variables' as *; 4@use './_player-variables' as *;
5 5
6// Like the time tooltip
7.video-js .vjs-progress-holder .vjs-storyboard-sprite-placeholder {
8 display: none;
9}
10
11.video-js .vjs-progress-control:hover .vjs-storyboard-sprite-placeholder,
12.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-storyboard-sprite-placeholder {
13 display: block;
14
15 // Ensure that we maintain a font-size of ~10px.
16 font-size: 0.6em;
17 visibility: visible;
18}
19
6.video-js.vjs-peertube-skin .vjs-control-bar { 20.video-js.vjs-peertube-skin .vjs-control-bar {
7 z-index: 100; 21 z-index: 100;
8 22
@@ -79,6 +93,7 @@
79 top: -0.3em; 93 top: -0.3em;
80 } 94 }
81 95
96 // Only used on mobile
82 .vjs-time-tooltip { 97 .vjs-time-tooltip {
83 display: none; 98 display: none;
84 } 99 }
diff --git a/client/src/sass/player/mobile.scss b/client/src/sass/player/mobile.scss
index 84d7a00f1..d150c54ee 100644
--- a/client/src/sass/player/mobile.scss
+++ b/client/src/sass/player/mobile.scss
@@ -6,6 +6,31 @@
6/* Special mobile style */ 6/* Special mobile style */
7 7
8.video-js.vjs-peertube-skin.vjs-is-mobile { 8.video-js.vjs-peertube-skin.vjs-is-mobile {
9 // No hover means we can't display the storyboard/time tooltip on mouse hover
10 // Use the time tooltip in progress control instead
11 .vjs-mouse-display {
12 display: none !important;
13 }
14
15 .vjs-storyboard-sprite-placeholder {
16 display: none;
17 }
18
19 .vjs-progress-control .vjs-sliding {
20
21 .vjs-time-tooltip,
22 .vjs-storyboard-sprite-placeholder {
23 display: block !important;
24
25 visibility: visible !important;
26 }
27
28 .vjs-time-tooltip {
29 color: #fff;
30 background-color: rgba(0, 0, 0, 0.8);
31 }
32 }
33
9 .vjs-control-bar { 34 .vjs-control-bar {
10 .vjs-progress-control .vjs-slider .vjs-play-progress { 35 .vjs-progress-control .vjs-slider .vjs-play-progress {
11 // Always display the circle on mobile 36 // Always display the circle on mobile