diff options
Diffstat (limited to 'client/src')
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' | |||
9 | import { ServerService } from '@app/core/server/server.service' | 9 | import { ServerService } from '@app/core/server/server.service' |
10 | import { | 10 | import { |
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 | ||
25 | export const CACHE_PREVIEWS_SIZE_VALIDATOR: BuildFormValidator = { | 25 | export 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 | |||
34 | export 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' | |||
6 | import './shared/bezels/bezels-plugin' | 6 | import './shared/bezels/bezels-plugin' |
7 | import './shared/peertube/peertube-plugin' | 7 | import './shared/peertube/peertube-plugin' |
8 | import './shared/resolutions/peertube-resolutions-plugin' | 8 | import './shared/resolutions/peertube-resolutions-plugin' |
9 | import './shared/control-bar/storyboard-plugin' | ||
9 | import './shared/control-bar/next-previous-video-button' | 10 | import './shared/control-bar/next-previous-video-button' |
10 | import './shared/control-bar/p2p-info-button' | 11 | import './shared/control-bar/p2p-info-button' |
11 | import './shared/control-bar/peertube-link-button' | 12 | import './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) |
43 | CaptionsButton.prototype.label_ = ' ' | 44 | CaptionsButton.prototype.label_ = ' ' |
44 | 45 | ||
46 | // TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged | ||
47 | const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any | ||
48 | if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) { | ||
49 | PlayProgressBar.prototype.options_.children.push('timeTooltip') | ||
50 | } | ||
51 | |||
45 | export class PeertubePlayerManager { | 52 | export 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' | |||
3 | export * from './peertube-link-button' | 3 | export * from './peertube-link-button' |
4 | export * from './peertube-live-display' | 4 | export * from './peertube-live-display' |
5 | export * from './peertube-load-progress-bar' | 5 | export * from './peertube-load-progress-bar' |
6 | export * from './storyboard-plugin' | ||
6 | export * from './theater-button' | 7 | export * 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { 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 | |||
7 | const Plugin = videojs.getPlugin('plugin') | ||
8 | |||
9 | class 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 | |||
182 | videojs.registerPlugin('storyboard', StoryboardPlugin) | ||
183 | |||
184 | export { 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 @@ | |||
1 | import { PluginsManager } from '@root-helpers/plugins-manager' | 1 | import { PluginsManager } from '@root-helpers/plugins-manager' |
2 | import { LiveVideoLatencyMode, VideoFile } from '@shared/models' | 2 | import { LiveVideoLatencyMode, VideoFile } from '@shared/models' |
3 | import { PlaylistPluginOptions, VideoJSCaption } from './peertube-videojs-typings' | 3 | import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' |
4 | 4 | ||
5 | export type PlayerMode = 'webtorrent' | 'p2p-media-loader' | 5 | export 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 | ||
94 | type VideoJSStoryboard = { | ||
95 | url: string | ||
96 | width: number | ||
97 | height: number | ||
98 | interval: number | ||
99 | } | ||
100 | |||
92 | type PeerTubePluginOptions = { | 101 | type 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 | ||
130 | type StoryboardOptions = { | ||
131 | url: string | ||
132 | width: number | ||
133 | height: number | ||
134 | interval: number | ||
135 | } | ||
136 | |||
121 | type PlaylistPluginOptions = { | 137 | type PlaylistPluginOptions = { |
122 | elements: VideoPlaylistElement[] | 138 | elements: VideoPlaylistElement[] |
123 | 139 | ||
@@ -238,6 +254,7 @@ type PlaylistItemOptions = { | |||
238 | 254 | ||
239 | export { | 255 | export { |
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 |