diff --git a/client/package.json b/client/package.json index 564e56ae7..149322192 100644 --- a/client/package.json +++ b/client/package.json @@ -71,7 +71,6 @@ "@types/sanitize-html": "2.6.2", "@types/sha.js": "^2.4.0", "@types/video.js": "^7.3.40", - "@types/webtorrent": "^0.109.0", "@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/parser": "^5.43.0", "@wdio/browserstack-service": "^8.10.5", @@ -85,14 +84,12 @@ "babel-loader": "^9.1.0", "bootstrap": "^5.1.3", "buffer": "^6.0.3", - "cache-chunk-store": "^3.0.0", "chart.js": "^4.3.0", "chartjs-plugin-zoom": "~2.0.1", "chromedriver": "^113.0.0", "core-js": "^3.22.8", "css-loader": "^6.2.0", "debug": "^4.3.1", - "dexie": "^3.2.2", "eslint": "^8.28.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-jsdoc": "^44.2.4", @@ -103,7 +100,6 @@ "hls.js": "~1.3", "html-loader": "^4.1.0", "html-webpack-plugin": "^5.3.1", - "https-browserify": "^1.0.0", "intl-messageformat": "^10.1.0", "jschannel": "^1.0.2", "linkify-html": "^4.0.2", @@ -115,9 +111,7 @@ "path-browserify": "^1.0.0", "postcss": "^8.4.14", "primeng": "^16.0.0-rc.2", - "process": "^0.11.10", "purify-css": "^1.2.5", - "querystring": "^0.2.1", "raw-loader": "^4.0.2", "rxjs": "^7.3.0", "sanitize-html": "^2.1.2", @@ -125,23 +119,17 @@ "sass-loader": "^13.2.0", "sha.js": "^2.4.11", "socket.io-client": "^4.5.4", - "stream-browserify": "^3.0.0", - "stream-http": "^3.0.0", "stylelint": "^15.1.0", "stylelint-config-sass-guidelines": "^10.0.0", "ts-loader": "^9.3.0", "tslib": "^2.4.0", "typescript": "~4.9.5", - "url": "^0.11.0", "video.js": "^7.19.2", - "videostream": "~3.2.1", "wdio-chromedriver-service": "^8.1.1", "wdio-geckodriver-service": "^5.0.1", "webpack": "^5.73.0", "webpack-bundle-analyzer": "^4.4.2", "webpack-cli": "^5.0.1", - "webtorrent": "1.8.26", - "whatwg-fetch": "^3.0.0", "zone.js": "~0.13.0" }, "dependencies": {} diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts index ec85db0ff..97d71a510 100644 --- a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts +++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts @@ -152,12 +152,24 @@ export class VideoWatchPlaylistComponent { this.onPlaylistVideosNearOfBottom(position) } + // --------------------------------------------------------------------------- + hasPreviousVideo () { - return !!this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous') + return !!this.getPreviousVideo() } + getPreviousVideo () { + return this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous') + } + + // --------------------------------------------------------------------------- + hasNextVideo () { - return !!this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next') + return !!this.getNextVideo() + } + + getNextVideo () { + return this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next') } navigateToPreviousPlaylistVideo () { diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html index 80fd6e40f..294ff4b3a 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.html +++ b/client/src/app/+videos/+video-watch/video-watch.component.html @@ -8,7 +8,7 @@
- Placeholder image +
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 54e0649ba..aebec52fb 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -1,6 +1,5 @@ import { Hotkey, HotkeysService } from 'angular2-hotkeys' import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs' -import { VideoJsPlayer } from 'video.js' import { PlatformLocation } from '@angular/common' import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' @@ -19,13 +18,13 @@ import { UserService } from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' -import { isXPercentInViewport, scrollToTop } from '@app/helpers' +import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' import { LiveVideoService } from '@app/shared/shared-video-live' import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' import { logger } from '@root-helpers/logger' -import { isP2PEnabled, videoRequiresUserAuth, videoRequiresFileToken } from '@root-helpers/video' +import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video' import { timeToInt } from '@shared/core-utils' import { HTMLServerConfig, @@ -39,10 +38,10 @@ import { VideoState } from '@shared/models' import { - CustomizationOptions, - P2PMediaLoaderOptions, - PeertubePlayerManager, - PeertubePlayerManagerOptions, + HLSOptions, + PeerTubePlayer, + PeerTubePlayerContructorOptions, + PeerTubePlayerLoadOptions, PlayerMode, videojs } from '../../../assets/player' @@ -50,7 +49,24 @@ import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from import { environment } from '../../../environments/environment' import { VideoWatchPlaylistComponent } from './shared' -type URLOptions = CustomizationOptions & { playerMode: PlayerMode } +type URLOptions = { + playerMode: PlayerMode + + startTime: number | string + stopTime: number | string + + controls?: boolean + controlBar?: boolean + + muted?: boolean + loop?: boolean + subtitle?: string + resume?: string + + peertubeLink: boolean + + playbackRate?: number | string +} @Component({ selector: 'my-video-watch', @@ -60,10 +76,9 @@ type URLOptions = CustomizationOptions & { playerMode: PlayerMode } export class VideoWatchComponent implements OnInit, OnDestroy { @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent + @ViewChild('playerElement') playerElement: ElementRef - player: VideoJsPlayer - playerElement: HTMLVideoElement - playerPlaceholderImgSrc: string + peertubePlayer: PeerTubePlayer theaterEnabled = false video: VideoDetails = null @@ -78,8 +93,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { remoteServerDown = false noPlaylistVideoFound = false - private nextVideoUUID = '' - private nextVideoTitle = '' + private nextRecommendedVideoUUID = '' + private nextRecommendedVideoTitle = '' private videoFileToken: string @@ -130,11 +145,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy { return this.userService.getAnonymousUser() } - ngOnInit () { + async ngOnInit () { this.serverConfig = this.serverService.getHTMLConfig() - PeertubePlayerManager.initState() - this.loadRouteParams() this.loadRouteQuery() @@ -143,10 +156,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.hooks.runAction('action:video-watch.init', 'video-watch') setTimeout(cleanupVideoWatch, 1500) // Run in timeout to ensure we're not blocking the UI + + const constructorOptions = await this.hooks.wrapFun( + this.buildPeerTubePlayerConstructorOptions.bind(this), + { urlOptions: this.getUrlOptions() }, + 'video-watch', + 'filter:internal.video-watch.player.build-options.params', + 'filter:internal.video-watch.player.build-options.result' + ) + + this.peertubePlayer = new PeerTubePlayer(constructorOptions) } ngOnDestroy () { - this.flushPlayer() + if (this.peertubePlayer) this.peertubePlayer.destroy() // Unsubscribe subscriptions if (this.paramsSub) this.paramsSub.unsubscribe() @@ -171,14 +194,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy { // The recommended videos's first element should be the next video const video = videos[0] - this.nextVideoUUID = video.uuid - this.nextVideoTitle = video.name + this.nextRecommendedVideoUUID = video.uuid + this.nextRecommendedVideoTitle = video.name } handleTimestampClicked (timestamp: number) { - if (!this.player || this.video.isLive) return + if (!this.peertubePlayer || this.video.isLive) return - this.player.currentTime(timestamp) + this.peertubePlayer.getPlayer().currentTime(timestamp) scrollToTop() } @@ -243,7 +266,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition) const start = queryParams['start'] - if (this.player && start) this.player.currentTime(parseInt(start, 10)) + if (this.peertubePlayer && start) this.peertubePlayer.getPlayer().currentTime(parseInt(start, 10)) }) } @@ -256,8 +279,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { if (this.isSameElement(this.video, videoId)) return - if (this.player) this.player.pause() - this.video = undefined const videoObs = this.hooks.wrapObsFun( @@ -291,23 +312,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.userService.getAnonymousOrLoggedUser() ]).subscribe({ next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => { - const queryParams = this.route.snapshot.queryParams - - const urlOptions = { - resume: queryParams.resume, - - startTime: queryParams.start, - stopTime: queryParams.stop, - - muted: queryParams.muted, - loop: queryParams.loop, - subtitle: queryParams.subtitle, - - playerMode: queryParams.mode, - playbackRate: queryParams.playbackRate, - peertubeLink: false - } - this.onVideoFetched({ video, live, @@ -316,7 +320,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoFileToken, videoPassword, loggedInOrAnonymousUser, - urlOptions, forceAutoplay }).catch(err => { this.handleGlobalError(err) @@ -386,14 +389,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { const errorMessage: string = typeof err === 'string' ? err : err.message if (!errorMessage) return - // Display a message in the video player instead of a notification - if (errorMessage.includes('from xs param')) { - this.flushPlayer() - this.remoteServerDown = true - - return - } - this.notifier.error(errorMessage) } @@ -422,7 +417,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoFileToken: string videoPassword: string - urlOptions: URLOptions loggedInOrAnonymousUser: User forceAutoplay: boolean }) { @@ -431,7 +425,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { live, videoCaptions, storyboards, - urlOptions, videoFileToken, videoPassword, loggedInOrAnonymousUser, @@ -448,7 +441,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.storyboards = storyboards // Re init attributes - this.playerPlaceholderImgSrc = undefined this.remoteServerDown = false this.currentTime = undefined @@ -462,7 +454,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.buildHotkeysHelp(video) - this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay }) + this.loadPlayer({ loggedInOrAnonymousUser, forceAutoplay }) .catch(err => logger.error('Cannot build the player', err)) this.setOpenGraphTags() @@ -475,28 +467,19 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions) } - private async buildPlayer (options: { - urlOptions: URLOptions + private async loadPlayer (options: { loggedInOrAnonymousUser: User forceAutoplay: boolean }) { - const { urlOptions, loggedInOrAnonymousUser, forceAutoplay } = options - - // Flush old player if needed - this.flushPlayer() + const { loggedInOrAnonymousUser, forceAutoplay } = options const videoState = this.video.state.id if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) { - this.playerPlaceholderImgSrc = this.video.previewPath + this.updatePlayerOnNoLive() return } - // Build video element, because videojs removes it on dispose - const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper') - this.playerElement = document.createElement('video') - this.playerElement.className = 'video-js vjs-peertube-skin' - this.playerElement.setAttribute('playsinline', 'true') - playerElementWrapper.appendChild(this.playerElement) + this.peertubePlayer?.enable() const params = { video: this.video, @@ -505,86 +488,49 @@ export class VideoWatchComponent implements OnInit, OnDestroy { liveVideo: this.liveVideo, videoFileToken: this.videoFileToken, videoPassword: this.videoPassword, - urlOptions, + urlOptions: this.getUrlOptions(), loggedInOrAnonymousUser, forceAutoplay, user: this.user } - const { playerMode, playerOptions } = await this.hooks.wrapFun( - this.buildPlayerManagerOptions.bind(this), + + const loadOptions = await this.hooks.wrapFun( + this.buildPeerTubePlayerLoadOptions.bind(this), params, 'video-watch', - 'filter:internal.video-watch.player.build-options.params', - 'filter:internal.video-watch.player.build-options.result' + 'filter:internal.video-watch.player.load-options.params', + 'filter:internal.video-watch.player.load-options.result' ) this.zone.runOutsideAngular(async () => { - this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player) + await this.peertubePlayer.load(loadOptions) - this.player.on('customError', (_e, data: any) => { - this.zone.run(() => this.handleGlobalError(data.err)) - }) + const player = this.peertubePlayer.getPlayer() - this.player.on('timeupdate', () => { + player.on('timeupdate', () => { // Don't need to trigger angular change for this variable, that is sent to children components on click - this.currentTime = Math.floor(this.player.currentTime()) + this.currentTime = Math.floor(player.currentTime()) }) - /** - * condition: true to make the upnext functionality trigger, false to disable the upnext functionality - * go to the next video in 'condition()' if you don't want of the timer. - * next: function triggered at the end of the timer. - * suspended: function used at each click of the timer checking if we need to reset progress - * and wait until suspended becomes truthy again. - */ - this.player.upnext({ - timeout: 5000, // 5s + if (this.video.isLive) { + player.one('ended', () => { + this.zone.run(() => { + // We changed the video, it's not a live anymore + if (!this.video.isLive) return - headText: $localize`Up Next`, - cancelText: $localize`Cancel`, - suspendedText: $localize`Autoplay is suspended`, + this.video.state.id = VideoState.LIVE_ENDED - getTitle: () => this.nextVideoTitle, + this.updatePlayerOnNoLive() + }) + }) + } - next: () => this.zone.run(() => this.playNextVideoInAngularZone()), - condition: () => { - if (!this.playlist) return this.isAutoPlayNext() - - // Don't wait timeout to play the next playlist video - if (this.isPlaylistAutoPlayNext()) { - this.playNextVideoInAngularZone() - return undefined - } - - return false - }, - - suspended: () => { - return ( - !isXPercentInViewport(this.player.el() as HTMLElement, 80) || - !document.getElementById('content').contains(document.activeElement) - ) - } - }) - - this.player.one('stopped', () => { - if (this.playlist && this.isPlaylistAutoPlayNext()) { - this.playNextVideoInAngularZone() - } - }) - - this.player.one('ended', () => { - if (this.video.isLive) { - this.zone.run(() => this.video.state.id = VideoState.LIVE_ENDED) - } - }) - - this.player.on('theaterChange', (_: any, enabled: boolean) => { + player.on('theater-change', (_: any, enabled: boolean) => { this.zone.run(() => this.theaterEnabled = enabled) }) this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { - player: this.player, + player, playlist: this.playlist, playlistPosition: this.playlistPosition, videojs, @@ -601,15 +547,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy { return true } - private playNextVideoInAngularZone () { + private getNextVideoTitle () { if (this.playlist) { - this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) - return + return this.videoWatchPlaylist.getNextVideo()?.video?.name || '' } - if (this.nextVideoUUID) { - this.router.navigate([ '/w', this.nextVideoUUID ]) - } + return this.nextRecommendedVideoTitle + } + + private playNextVideoInAngularZone () { + this.zone.run(() => { + if (this.playlist) { + this.videoWatchPlaylist.navigateToNextPlaylistVideo() + return + } + + if (this.nextRecommendedVideoUUID) { + this.router.navigate([ '/w', this.nextRecommendedVideoUUID ]) + } + }) } private isAutoplay () { @@ -637,19 +593,45 @@ export class VideoWatchComponent implements OnInit, OnDestroy { ) } - private flushPlayer () { - // Remove player if it exists - if (!this.player) return + private buildPeerTubePlayerConstructorOptions (options: { + urlOptions: URLOptions + }): PeerTubePlayerContructorOptions { + const { urlOptions } = options - try { - this.player.dispose() - this.player = undefined - } catch (err) { - logger.error('Cannot dispose player.', err) + return { + playerElement: () => this.playerElement.nativeElement, + + enableHotkeys: true, + inactivityTimeout: 2500, + + theaterButton: true, + + controls: urlOptions.controls, + controlBar: urlOptions.controlBar, + + muted: urlOptions.muted, + loop: urlOptions.loop, + + playbackRate: urlOptions.playbackRate, + + instanceName: this.serverConfig.instance.name, + language: this.localeId, + metricsUrl: environment.apiUrl + '/api/v1/metrics/playback', + + videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS, + authorizationHeader: () => this.authService.getRequestHeaderValue(), + + serverUrl: environment.originServerUrl || window.location.origin, + + errorNotifier: (message: string) => this.notifier.error(message), + + peertubeLink: () => false, + + pluginsManager: this.pluginService.getPluginsManager() } } - private buildPlayerManagerOptions (params: { + private buildPeerTubePlayerLoadOptions (options: { video: VideoDetails liveVideo: LiveVideo videoCaptions: VideoCaption[] @@ -658,12 +640,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoFileToken: string videoPassword: string - urlOptions: CustomizationOptions & { playerMode: PlayerMode } + urlOptions: URLOptions loggedInOrAnonymousUser: User forceAutoplay: boolean user?: AuthUser // Keep for plugins - }) { + }): PeerTubePlayerLoadOptions { const { video, liveVideo, @@ -674,7 +656,30 @@ export class VideoWatchComponent implements OnInit, OnDestroy { urlOptions, loggedInOrAnonymousUser, forceAutoplay - } = params + } = options + + let mode: PlayerMode + + if (urlOptions.playerMode) { + if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader' + else mode = 'web-video' + } else { + if (video.hasHlsPlaylist()) mode = 'p2p-media-loader' + else mode = 'web-video' + } + + let hlsOptions: HLSOptions + if (video.hasHlsPlaylist()) { + const hlsPlaylist = video.getHlsPlaylist() + + hlsOptions = { + playlistUrl: hlsPlaylist.playlistUrl, + segmentsSha256Url: hlsPlaylist.segmentsSha256Url, + redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), + trackerAnnounce: video.trackerUrls, + videoFiles: hlsPlaylist.files + } + } const getStartTime = () => { const byUrl = urlOptions.startTime !== undefined @@ -714,118 +719,80 @@ export class VideoWatchComponent implements OnInit, OnDestroy { ? { latencyMode: liveVideo.latencyMode } : undefined - const options: PeertubePlayerManagerOptions = { - common: { - autoplay: this.isAutoplay(), - forceAutoplay, - p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled), + return { + mode, - hasNextVideo: () => this.hasNextVideo(), - nextVideo: () => this.playNextVideoInAngularZone(), + autoplay: this.isAutoplay(), + forceAutoplay, - playerElement: this.playerElement, - onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element, + duration: this.video.duration, + poster: video.previewUrl, + p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled), - videoDuration: video.duration, - enableHotkeys: true, - inactivityTimeout: 2500, - poster: video.previewUrl, + startTime, + stopTime: urlOptions.stopTime, - startTime, - stopTime: urlOptions.stopTime, - controlBar: urlOptions.controlBar, - controls: urlOptions.controls, - muted: urlOptions.muted, - loop: urlOptions.loop, - subtitle: urlOptions.subtitle, - playbackRate: urlOptions.playbackRate, + embedUrl: video.embedUrl, + embedTitle: video.name, - peertubeLink: urlOptions.peertubeLink, + isLive: video.isLive, + liveOptions, - theaterButton: true, - captions: videoCaptions.length !== 0, + videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE + ? this.videoService.getVideoViewUrl(video.uuid) + : null, - embedUrl: video.embedUrl, - embedTitle: video.name, - instanceName: this.serverConfig.instance.name, + videoFileToken: () => videoFileToken, + requiresUserAuth: videoRequiresUserAuth(video, videoPassword), + requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && + !video.canAccessPasswordProtectedVideoWithoutPassword(this.user), + videoPassword: () => videoPassword, - isLive: video.isLive, - liveOptions, + videoCaptions: playerCaptions, + storyboard, - language: this.localeId, + videoShortUUID: video.shortUUID, + videoUUID: video.uuid, - metricsUrl: environment.apiUrl + '/api/v1/metrics/playback', + previousVideo: { + enabled: this.playlist && this.videoWatchPlaylist.hasPreviousVideo(), - videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE - ? this.videoService.getVideoViewUrl(video.uuid) - : null, - videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS, - authorizationHeader: () => this.authService.getRequestHeaderValue(), + handler: this.playlist + ? () => this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo()) + : undefined, - serverUrl: environment.originServerUrl || window.location.origin, - - videoFileToken: () => videoFileToken, - requiresUserAuth: videoRequiresUserAuth(video, videoPassword), - requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && - !video.canAccessPasswordProtectedVideoWithoutPassword(this.user), - videoPassword: () => videoPassword, - - videoCaptions: playerCaptions, - storyboard, - - videoShortUUID: video.shortUUID, - videoUUID: video.uuid, - - errorNotifier: (message: string) => this.notifier.error(message) + displayControlBarButton: !!this.playlist }, - webtorrent: { + nextVideo: { + enabled: this.hasNextVideo(), + handler: () => this.playNextVideoInAngularZone(), + getVideoTitle: () => this.getNextVideoTitle(), + displayControlBarButton: this.hasNextVideo() + }, + + upnext: { + isEnabled: () => { + if (this.playlist) return this.isPlaylistAutoPlayNext() + + return this.isAutoPlayNext() + }, + + isSuspended: (player: videojs.Player) => { + return !isXPercentInViewport(player.el() as HTMLElement, 80) + }, + + timeout: this.playlist + ? 0 // Don't wait to play next video in playlist + : 5000 // 5 seconds for a recommended video + }, + + hls: hlsOptions, + + webVideo: { videoFiles: video.files - }, - - pluginsManager: this.pluginService.getPluginsManager() - } - - // Only set this if we're in a playlist - if (this.playlist) { - options.common.hasPreviousVideo = () => this.videoWatchPlaylist.hasPreviousVideo() - - options.common.previousVideo = () => { - this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo()) } } - - let mode: PlayerMode - - if (urlOptions.playerMode) { - if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader' - else mode = 'webtorrent' - } else { - if (video.hasHlsPlaylist()) mode = 'p2p-media-loader' - else mode = 'webtorrent' - } - - // FIXME: remove, we don't support these old web browsers anymore - // p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available - if (typeof TextEncoder === 'undefined') { - mode = 'webtorrent' - } - - if (mode === 'p2p-media-loader') { - const hlsPlaylist = video.getHlsPlaylist() - - const p2pMediaLoader = { - playlistUrl: hlsPlaylist.playlistUrl, - segmentsSha256Url: hlsPlaylist.segmentsSha256Url, - redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), - trackerAnnounce: video.trackerUrls, - videoFiles: hlsPlaylist.files - } as P2PMediaLoaderOptions - - Object.assign(options, { p2pMediaLoader }) - } - - return { playerMode: mode, playerOptions: options } } private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) { @@ -873,6 +840,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.video.viewers = newViewers } + private updatePlayerOnNoLive () { + this.peertubePlayer.unload() + this.peertubePlayer.disable() + this.peertubePlayer.setPoster(this.video.previewPath) + } + private buildHotkeysHelp (video: Video) { if (this.hotkeys.length !== 0) { this.hotkeysService.remove(this.hotkeys) @@ -944,4 +917,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.metaService.setTag('og:url', window.location.href) this.metaService.setTag('url', window.location.href) } + + private getUrlOptions (): URLOptions { + const queryParams = this.route.snapshot.queryParams + + return { + resume: queryParams.resume, + + startTime: queryParams.start, + stopTime: queryParams.stop, + + muted: toBoolean(queryParams.muted), + loop: toBoolean(queryParams.loop), + subtitle: queryParams.subtitle, + + playerMode: queryParams.mode, + playbackRate: queryParams.playbackRate, + + controlBar: toBoolean(queryParams.controlBar), + + peertubeLink: false + } + } } diff --git a/client/src/app/helpers/utils/object.ts b/client/src/app/helpers/utils/object.ts index 69b2b18c0..b69e31edf 100644 --- a/client/src/app/helpers/utils/object.ts +++ b/client/src/app/helpers/utils/object.ts @@ -34,6 +34,8 @@ function toBoolean (value: any) { if (value === 'true') return true if (value === 'false') return false + if (value === '1') return true + if (value === '0') return false return undefined } diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts index 45df0be38..14a5abd7a 100644 --- a/client/src/app/shared/shared-video-miniature/videos-list.component.ts +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts @@ -241,7 +241,6 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy { } reloadVideos () { - console.log('reload') this.pagination.currentPage = 1 this.loadMoreVideos(true) } @@ -420,8 +419,9 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy { this.lastQueryLength = data.length if (reset) this.videos = [] + this.videos = this.videos.concat(data) - console.log('subscribe') + if (this.groupByDate) this.buildGroupedDateLabels() this.onDataSubject.next(data) diff --git a/client/src/assets/player/index.ts b/client/src/assets/player/index.ts index 9b87afc4a..d34188ea7 100644 --- a/client/src/assets/player/index.ts +++ b/client/src/assets/player/index.ts @@ -1,2 +1,2 @@ -export * from './peertube-player-manager' +export * from './peertube-player' export * from './types' diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts deleted file mode 100644 index 66d9c7298..000000000 --- a/client/src/assets/player/peertube-player-manager.ts +++ /dev/null @@ -1,277 +0,0 @@ -import '@peertube/videojs-contextmenu' -import './shared/upnext/end-card' -import './shared/upnext/upnext-plugin' -import './shared/stats/stats-card' -import './shared/stats/stats-plugin' -import './shared/bezels/bezels-plugin' -import './shared/peertube/peertube-plugin' -import './shared/resolutions/peertube-resolutions-plugin' -import './shared/control-bar/storyboard-plugin' -import './shared/control-bar/next-previous-video-button' -import './shared/control-bar/p2p-info-button' -import './shared/control-bar/peertube-link-button' -import './shared/control-bar/peertube-load-progress-bar' -import './shared/control-bar/theater-button' -import './shared/control-bar/peertube-live-display' -import './shared/settings/resolution-menu-button' -import './shared/settings/resolution-menu-item' -import './shared/settings/settings-dialog' -import './shared/settings/settings-menu-button' -import './shared/settings/settings-menu-item' -import './shared/settings/settings-panel' -import './shared/settings/settings-panel-child' -import './shared/playlist/playlist-plugin' -import './shared/mobile/peertube-mobile-plugin' -import './shared/mobile/peertube-mobile-buttons' -import './shared/hotkeys/peertube-hotkeys-plugin' -import './shared/metrics/metrics-plugin' -import videojs from 'video.js' -import { logger } from '@root-helpers/logger' -import { PluginsManager } from '@root-helpers/plugins-manager' -import { isMobile } from '@root-helpers/web-browser' -import { saveAverageBandwidth } from './peertube-player-local-storage' -import { ManagerOptionsBuilder } from './shared/manager-options' -import { TranslationsManager } from './translations-manager' -import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode, PlayerNetworkInfo } from './types' - -// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) -(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' - -const CaptionsButton = videojs.getComponent('CaptionsButton') as any -// Change Captions to Subtitles/CC -CaptionsButton.prototype.controlText_ = 'Subtitles/CC' -// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) -CaptionsButton.prototype.label_ = ' ' - -// TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged -const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any -if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) { - PlayProgressBar.prototype.options_.children.push('timeTooltip') -} - -export class PeertubePlayerManager { - private static playerElementClassName: string - private static playerElementAttributes: { name: string, value: string }[] = [] - - private static onPlayerChange: (player: videojs.Player) => void - private static alreadyPlayed = false - private static pluginsManager: PluginsManager - - private static videojsDecodeErrors = 0 - - private static p2pMediaLoaderModule: any - - static initState () { - this.alreadyPlayed = false - } - - static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) { - this.pluginsManager = options.pluginsManager - - this.onPlayerChange = onPlayerChange - - this.playerElementClassName = options.common.playerElement.className - - for (const name of options.common.playerElement.getAttributeNames()) { - this.playerElementAttributes.push({ name, value: options.common.playerElement.getAttribute(name) }) - } - - if (mode === 'webtorrent') await import('./shared/webtorrent/webtorrent-plugin') - if (mode === 'p2p-media-loader') { - const [ p2pMediaLoaderModule ] = await Promise.all([ - import('@peertube/p2p-media-loader-hlsjs'), - import('./shared/p2p-media-loader/p2p-media-loader-plugin') - ]) - - this.p2pMediaLoaderModule = p2pMediaLoaderModule - } - - await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs) - - return this.buildPlayer(mode, options) - } - - private static async buildPlayer (mode: PlayerMode, options: PeertubePlayerManagerOptions): Promise { - const videojsOptionsBuilder = new ManagerOptionsBuilder(mode, options, this.p2pMediaLoaderModule) - - const videojsOptions = await this.pluginsManager.runHook( - 'filter:internal.player.videojs.options.result', - videojsOptionsBuilder.getVideojsOptions(this.alreadyPlayed) - ) - - const self = this - return new Promise(res => { - videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) { - const player = this - - if (!isNaN(+options.common.playbackRate)) { - player.playbackRate(+options.common.playbackRate) - } - - let alreadyFallback = false - - const handleError = () => { - if (alreadyFallback) return - alreadyFallback = true - - if (mode === 'p2p-media-loader') { - self.tryToRecoverHLSError(player.error(), player, options) - } else { - self.maybeFallbackToWebTorrent(mode, player, options) - } - } - - player.one('error', () => handleError()) - - player.one('play', () => { - self.alreadyPlayed = true - }) - - self.addContextMenu(videojsOptionsBuilder, player, options.common) - - if (isMobile()) player.peertubeMobile() - if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive }) - if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden') - - player.bezels() - - player.stats({ - videoUUID: options.common.videoUUID, - videoIsLive: options.common.isLive, - mode, - p2pEnabled: options.common.p2pEnabled - }) - - if (options.common.storyboard) { - player.storyboard(options.common.storyboard) - } - - player.on('p2pInfo', (_, data: PlayerNetworkInfo) => { - if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return - - saveAverageBandwidth(data.bandwidthEstimate) - }) - - const offlineNotificationElem = document.createElement('div') - offlineNotificationElem.classList.add('vjs-peertube-offline-notification') - offlineNotificationElem.innerText = player.localize('You seem to be offline and the video may not work') - - let offlineNotificationElemAdded = false - - const handleOnline = () => { - if (!offlineNotificationElemAdded) return - - player.el().removeChild(offlineNotificationElem) - offlineNotificationElemAdded = false - - logger.info('The browser is online') - } - - const handleOffline = () => { - if (offlineNotificationElemAdded) return - - player.el().appendChild(offlineNotificationElem) - offlineNotificationElemAdded = true - - logger.info('The browser is offline') - } - - window.addEventListener('online', handleOnline) - window.addEventListener('offline', handleOffline) - - player.on('dispose', () => { - window.removeEventListener('online', handleOnline) - window.removeEventListener('offline', handleOffline) - }) - - return res(player) - }) - }) - } - - private static async tryToRecoverHLSError (err: any, currentPlayer: videojs.Player, options: PeertubePlayerManagerOptions) { - if (err.code === MediaError.MEDIA_ERR_DECODE) { - - // Display a notification to user - if (this.videojsDecodeErrors === 0) { - options.common.errorNotifier(currentPlayer.localize('The video failed to play, will try to fast forward.')) - } - - if (this.videojsDecodeErrors === 20) { - this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) - return - } - - logger.info('Fast forwarding HLS to recover from an error.') - - this.videojsDecodeErrors++ - - options.common.startTime = currentPlayer.currentTime() + 2 - options.common.autoplay = true - this.rebuildAndUpdateVideoElement(currentPlayer, options.common) - - const newPlayer = await this.buildPlayer('p2p-media-loader', options) - this.onPlayerChange(newPlayer) - } else { - this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) - } - } - - private static async maybeFallbackToWebTorrent ( - currentMode: PlayerMode, - currentPlayer: videojs.Player, - options: PeertubePlayerManagerOptions - ) { - if (options.webtorrent.videoFiles.length === 0 || currentMode === 'webtorrent') { - currentPlayer.peertube().displayFatalError() - return - } - - logger.info('Fallback to webtorrent.') - - this.rebuildAndUpdateVideoElement(currentPlayer, options.common) - - await import('./shared/webtorrent/webtorrent-plugin') - - const newPlayer = await this.buildPlayer('webtorrent', options) - this.onPlayerChange(newPlayer) - } - - private static rebuildAndUpdateVideoElement (player: videojs.Player, commonOptions: CommonOptions) { - const newVideoElement = document.createElement('video') - - // Reset class - newVideoElement.className = this.playerElementClassName - - // Reapply attributes - for (const { name, value } of this.playerElementAttributes) { - newVideoElement.setAttribute(name, value) - } - - // VideoJS wraps our video element inside a div - let currentParentPlayerElement = commonOptions.playerElement.parentNode - // Fix on IOS, don't ask me why - if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(commonOptions.playerElement.id).parentNode - - currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement) - - commonOptions.playerElement = newVideoElement - commonOptions.onPlayerElementChange(newVideoElement) - - player.dispose() - - return newVideoElement - } - - private static addContextMenu (optionsBuilder: ManagerOptionsBuilder, player: videojs.Player, commonOptions: CommonOptions) { - const options = optionsBuilder.getContextMenuOptions(player, commonOptions) - - player.contextmenuUI(options) - } -} - -// ############################################################################ - -export { - videojs -} diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts new file mode 100644 index 000000000..a7a2b4065 --- /dev/null +++ b/client/src/assets/player/peertube-player.ts @@ -0,0 +1,522 @@ +import '@peertube/videojs-contextmenu' +import './shared/upnext/end-card' +import './shared/upnext/upnext-plugin' +import './shared/stats/stats-card' +import './shared/stats/stats-plugin' +import './shared/bezels/bezels-plugin' +import './shared/peertube/peertube-plugin' +import './shared/resolutions/peertube-resolutions-plugin' +import './shared/control-bar/storyboard-plugin' +import './shared/control-bar/next-previous-video-button' +import './shared/control-bar/p2p-info-button' +import './shared/control-bar/peertube-link-button' +import './shared/control-bar/theater-button' +import './shared/control-bar/peertube-live-display' +import './shared/settings/resolution-menu-button' +import './shared/settings/resolution-menu-item' +import './shared/settings/settings-dialog' +import './shared/settings/settings-menu-button' +import './shared/settings/settings-menu-item' +import './shared/settings/settings-panel' +import './shared/settings/settings-panel-child' +import './shared/playlist/playlist-plugin' +import './shared/mobile/peertube-mobile-plugin' +import './shared/mobile/peertube-mobile-buttons' +import './shared/hotkeys/peertube-hotkeys-plugin' +import './shared/metrics/metrics-plugin' +import videojs, { VideoJsPlayer } from 'video.js' +import { logger } from '@root-helpers/logger' +import { PluginsManager } from '@root-helpers/plugins-manager' +import { copyToClipboard } from '@root-helpers/utils' +import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' +import { isMobile } from '@root-helpers/web-browser' +import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@shared/core-utils' +import { saveAverageBandwidth } from './peertube-player-local-storage' +import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder' +import { TranslationsManager } from './translations-manager' +import { PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions, PlayerNetworkInfo, VideoJSPluginOptions } from './types' + +// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) +(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' + +const CaptionsButton = videojs.getComponent('CaptionsButton') as any +// Change Captions to Subtitles/CC +CaptionsButton.prototype.controlText_ = 'Subtitles/CC' +// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) +CaptionsButton.prototype.label_ = ' ' + +// TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged +const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any +if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) { + PlayProgressBar.prototype.options_.children.push('timeTooltip') +} + +export class PeerTubePlayer { + private pluginsManager: PluginsManager + + private videojsDecodeErrors = 0 + + private p2pMediaLoaderModule: any + + private player: VideoJsPlayer + + private currentLoadOptions: PeerTubePlayerLoadOptions + + private moduleLoaded = { + webVideo: false, + p2pMediaLoader: false + } + + constructor (private options: PeerTubePlayerContructorOptions) { + this.pluginsManager = options.pluginsManager + } + + unload () { + if (!this.player) return + + this.disposeDynamicPluginsIfNeeded() + + this.player.reset() + } + + async load (loadOptions: PeerTubePlayerLoadOptions) { + this.currentLoadOptions = loadOptions + + this.setPoster('') + + this.disposeDynamicPluginsIfNeeded() + + await this.lazyLoadModulesIfNeeded() + await this.buildPlayerIfNeeded() + + if (this.currentLoadOptions.mode === 'p2p-media-loader') { + await this.loadP2PMediaLoader() + } else { + this.loadWebVideo() + } + + this.loadDynamicPlugins() + + if (this.options.controlBar === false) this.player.controlBar.hide() + else this.player.controlBar.show() + + this.player.autoplay(this.getAutoPlayValue(this.currentLoadOptions.autoplay)) + + this.player.trigger('video-change') + } + + getPlayer () { + return this.player + } + + destroy () { + if (this.player) this.player.dispose() + } + + setPoster (url: string) { + this.player?.poster(url) + this.options.playerElement().poster = url + } + + enable () { + if (!this.player) return + + (this.player.el() as HTMLElement).style.pointerEvents = 'auto' + } + + disable () { + if (!this.player) return + + if (this.player.isFullscreen()) { + this.player.exitFullscreen() + } + + // Disable player + this.player.hasStarted(false) + this.player.removeClass('vjs-has-autoplay') + this.player.bigPlayButton.hide(); + + (this.player.el() as HTMLElement).style.pointerEvents = 'none' + } + + private async loadP2PMediaLoader () { + const hlsOptionsBuilder = new HLSOptionsBuilder({ + ...pick(this.options, [ 'pluginsManager', 'serverUrl', 'authorizationHeader' ]), + ...pick(this.currentLoadOptions, [ + 'videoPassword', + 'requiresUserAuth', + 'videoFileToken', + 'requiresPassword', + 'isLive', + 'p2pEnabled', + 'liveOptions', + 'hls' + ]) + }, this.p2pMediaLoaderModule) + + const { hlsjs, p2pMediaLoader } = await hlsOptionsBuilder.getPluginOptions() + + this.player.hlsjs(hlsjs) + this.player.p2pMediaLoader(p2pMediaLoader) + } + + private loadWebVideo () { + const webVideoOptionsBuilder = new WebVideoOptionsBuilder(pick(this.currentLoadOptions, [ + 'videoFileToken', + 'webVideo', + 'hls', + 'startTime' + ])) + + this.player.webVideo(webVideoOptionsBuilder.getPluginOptions()) + } + + private async buildPlayerIfNeeded () { + if (this.player) return + + await TranslationsManager.loadLocaleInVideoJS(this.options.serverUrl, this.options.language, videojs) + + const videojsOptions = await this.pluginsManager.runHook( + 'filter:internal.player.videojs.options.result', + this.getVideojsOptions() + ) + + this.player = videojs(this.options.playerElement(), videojsOptions) + + this.player.ready(() => { + if (!isNaN(+this.options.playbackRate)) { + this.player.playbackRate(+this.options.playbackRate) + } + + let alreadyFallback = false + + const handleError = () => { + if (alreadyFallback) return + alreadyFallback = true + + if (this.currentLoadOptions.mode === 'p2p-media-loader') { + this.tryToRecoverHLSError(this.player.error()) + } else { + this.maybeFallbackToWebVideo() + } + } + + this.player.one('error', () => handleError()) + + this.player.on('p2p-info', (_, data: PlayerNetworkInfo) => { + if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return + + saveAverageBandwidth(data.bandwidthEstimate) + }) + + this.player.contextmenuUI(this.getContextMenuOptions()) + + this.displayNotificationWhenOffline() + }) + } + + private disposeDynamicPluginsIfNeeded () { + if (!this.player) return + + if (this.player.usingPlugin('peertubeMobile')) this.player.peertubeMobile().dispose() + if (this.player.usingPlugin('peerTubeHotkeysPlugin')) this.player.peerTubeHotkeysPlugin().dispose() + if (this.player.usingPlugin('playlist')) this.player.playlist().dispose() + if (this.player.usingPlugin('bezels')) this.player.bezels().dispose() + if (this.player.usingPlugin('upnext')) this.player.upnext().dispose() + if (this.player.usingPlugin('stats')) this.player.stats().dispose() + if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose() + + if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose() + + if (this.player.usingPlugin('p2pMediaLoader')) this.player.p2pMediaLoader().dispose() + if (this.player.usingPlugin('hlsjs')) this.player.hlsjs().dispose() + + if (this.player.usingPlugin('webVideo')) this.player.webVideo().dispose() + } + + private loadDynamicPlugins () { + if (isMobile()) this.player.peertubeMobile() + + this.player.bezels() + + this.player.stats({ + videoUUID: this.currentLoadOptions.videoUUID, + videoIsLive: this.currentLoadOptions.isLive, + mode: this.currentLoadOptions.mode, + p2pEnabled: this.currentLoadOptions.p2pEnabled + }) + + if (this.options.enableHotkeys === true) { + this.player.peerTubeHotkeysPlugin({ isLive: this.currentLoadOptions.isLive }) + } + + if (this.currentLoadOptions.playlist) { + this.player.playlist(this.currentLoadOptions.playlist) + } + + if (this.currentLoadOptions.upnext) { + this.player.upnext({ + timeout: this.currentLoadOptions.upnext.timeout, + + getTitle: () => this.currentLoadOptions.nextVideo.getVideoTitle(), + + next: () => this.currentLoadOptions.nextVideo.handler(), + isDisplayed: () => this.currentLoadOptions.nextVideo.enabled && this.currentLoadOptions.upnext.isEnabled(), + + isSuspended: () => this.currentLoadOptions.upnext.isSuspended(this.player) + }) + } + + if (this.currentLoadOptions.storyboard) { + this.player.storyboard(this.currentLoadOptions.storyboard) + } + + if (this.currentLoadOptions.dock) { + this.player.peertubeDock(this.currentLoadOptions.dock) + } + } + + private async lazyLoadModulesIfNeeded () { + if (this.currentLoadOptions.mode === 'web-video' && this.moduleLoaded.webVideo !== true) { + await import('./shared/web-video/web-video-plugin') + } + + if (this.currentLoadOptions.mode === 'p2p-media-loader' && this.moduleLoaded.p2pMediaLoader !== true) { + const [ p2pMediaLoaderModule ] = await Promise.all([ + import('@peertube/p2p-media-loader-hlsjs'), + import('./shared/p2p-media-loader/hls-plugin'), + import('./shared/p2p-media-loader/p2p-media-loader-plugin') + ]) + + this.p2pMediaLoaderModule = p2pMediaLoaderModule + } + } + + private async tryToRecoverHLSError (err: any) { + if (err.code === MediaError.MEDIA_ERR_DECODE) { + + // Display a notification to user + if (this.videojsDecodeErrors === 0) { + this.options.errorNotifier(this.player.localize('The video failed to play, will try to fast forward.')) + } + + if (this.videojsDecodeErrors === 20) { + this.maybeFallbackToWebVideo() + return + } + + logger.info('Fast forwarding HLS to recover from an error.') + + this.videojsDecodeErrors++ + + await this.load({ + ...this.currentLoadOptions, + + mode: 'p2p-media-loader', + startTime: this.player.currentTime() + 2, + autoplay: true + }) + } else { + this.maybeFallbackToWebVideo() + } + } + + private async maybeFallbackToWebVideo () { + if (this.currentLoadOptions.webVideo.videoFiles.length === 0 || this.currentLoadOptions.mode === 'web-video') { + this.player.peertube().displayFatalError() + return + } + + logger.info('Fallback to web-video.') + + await this.load({ + ...this.currentLoadOptions, + + mode: 'web-video', + startTime: this.player.currentTime(), + autoplay: true + }) + } + + getVideojsOptions (): videojs.PlayerOptions { + const html5 = { + preloadTextTracks: false + } + + const plugins: VideoJSPluginOptions = { + peertube: { + hasAutoplay: () => this.getAutoPlayValue(this.currentLoadOptions.autoplay), + + videoViewUrl: () => this.currentLoadOptions.videoViewUrl, + videoViewIntervalMs: this.options.videoViewIntervalMs, + + authorizationHeader: this.options.authorizationHeader, + + videoDuration: () => this.currentLoadOptions.duration, + + startTime: () => this.currentLoadOptions.startTime, + stopTime: () => this.currentLoadOptions.stopTime, + + videoCaptions: () => this.currentLoadOptions.videoCaptions, + isLive: () => this.currentLoadOptions.isLive, + videoUUID: () => this.currentLoadOptions.videoUUID, + subtitle: () => this.currentLoadOptions.subtitle + }, + metrics: { + mode: () => this.currentLoadOptions.mode, + + metricsUrl: () => this.options.metricsUrl, + videoUUID: () => this.currentLoadOptions.videoUUID + } + } + + const controlBarOptionsBuilder = new ControlBarOptionsBuilder({ + ...this.options, + + videoShortUUID: () => this.currentLoadOptions.videoShortUUID, + p2pEnabled: () => this.currentLoadOptions.p2pEnabled, + + nextVideo: () => this.currentLoadOptions.nextVideo, + previousVideo: () => this.currentLoadOptions.previousVideo + }) + + const videojsOptions = { + html5, + + // We don't use text track settings for now + textTrackSettings: false as any, // FIXME: typings + controls: this.options.controls !== undefined ? this.options.controls : true, + loop: this.options.loop !== undefined ? this.options.loop : false, + + muted: this.options.muted !== undefined + ? this.options.muted + : undefined, // Undefined so the player knows it has to check the local storage + + autoplay: this.getAutoPlayValue(this.currentLoadOptions.autoplay), + + poster: this.currentLoadOptions.poster, + inactivityTimeout: this.options.inactivityTimeout, + playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], + + plugins, + + controlBar: { + children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings + }, + + language: this.options.language && !isDefaultLocale(this.options.language) + ? this.options.language + : undefined + } + + return videojsOptions + } + + private getAutoPlayValue (autoplay: boolean): videojs.Autoplay { + if (autoplay !== true) return false + + return this.currentLoadOptions.forceAutoplay + ? 'any' + : 'play' + } + + private displayNotificationWhenOffline () { + const offlineNotificationElem = document.createElement('div') + offlineNotificationElem.classList.add('vjs-peertube-offline-notification') + offlineNotificationElem.innerText = this.player.localize('You seem to be offline and the video may not work') + + let offlineNotificationElemAdded = false + + const handleOnline = () => { + if (!offlineNotificationElemAdded) return + + this.player.el().removeChild(offlineNotificationElem) + offlineNotificationElemAdded = false + + logger.info('The browser is online') + } + + const handleOffline = () => { + if (offlineNotificationElemAdded) return + + this.player.el().appendChild(offlineNotificationElem) + offlineNotificationElemAdded = true + + logger.info('The browser is offline') + } + + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + this.player.on('dispose', () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + }) + } + + private getContextMenuOptions () { + + const content = () => { + const self = this + const player = this.player + + const shortUUID = self.currentLoadOptions.videoShortUUID + const isLoopEnabled = player.options_['loop'] + + const items = [ + { + icon: 'repeat', + label: player.localize('Play in loop') + (isLoopEnabled ? '' : ''), + listener: function () { + player.options_['loop'] = !isLoopEnabled + } + }, + { + label: player.localize('Copy the video URL'), + listener: function () { + copyToClipboard(buildVideoLink({ shortUUID })) + } + }, + { + label: player.localize('Copy the video URL at the current time'), + listener: function () { + const url = buildVideoLink({ shortUUID }) + + copyToClipboard(decorateVideoLink({ url, startTime: player.currentTime() })) + } + }, + { + icon: 'code', + label: player.localize('Copy embed code'), + listener: () => { + copyToClipboard(buildVideoOrPlaylistEmbed({ + embedUrl: self.currentLoadOptions.embedUrl, + embedTitle: self.currentLoadOptions.embedTitle + })) + } + } + ] + + items.push({ + icon: 'info', + label: player.localize('Stats for nerds'), + listener: () => { + player.stats().show() + } + }) + + return items.map(i => ({ + ...i, + label: `` + i.label + })) + } + + return { content } + } +} + +// ############################################################################ + +export { + videojs +} diff --git a/client/src/assets/player/shared/bezels/bezels-plugin.ts b/client/src/assets/player/shared/bezels/bezels-plugin.ts index ca88bc1f9..6afb2c6a3 100644 --- a/client/src/assets/player/shared/bezels/bezels-plugin.ts +++ b/client/src/assets/player/shared/bezels/bezels-plugin.ts @@ -1,5 +1,5 @@ import videojs from 'video.js' -import './pause-bezel' +import { PauseBezel } from './pause-bezel' const Plugin = videojs.getPlugin('plugin') @@ -12,7 +12,7 @@ class BezelsPlugin extends Plugin { player.addClass('vjs-bezels') }) - player.addChild('PauseBezel', options) + player.addChild(new PauseBezel(player, options)) } } diff --git a/client/src/assets/player/shared/bezels/pause-bezel.ts b/client/src/assets/player/shared/bezels/pause-bezel.ts index e35c39a5f..d364ad0dd 100644 --- a/client/src/assets/player/shared/bezels/pause-bezel.ts +++ b/client/src/assets/player/shared/bezels/pause-bezel.ts @@ -32,26 +32,61 @@ function getPlayBezel () { } const Component = videojs.getComponent('Component') -class PauseBezel extends Component { +export class PauseBezel extends Component { container: HTMLDivElement + private firstPlayDone = false + private paused = false + + private playerPauseHandler: () => void + private playerPlayHandler: () => void + private videoChangeHandler: () => void + constructor (player: videojs.Player, options?: videojs.ComponentOptions) { super(player, options) // Hide bezels on mobile since we already have our mobile overlay if (isMobile()) return - player.on('pause', (_: any) => { - if (player.seeking() || player.ended()) return + this.playerPauseHandler = () => { + if (player.seeking()) return + + this.paused = true + + if (player.ended()) return + this.container.innerHTML = getPauseBezel() this.showBezel() - }) + } + + this.playerPlayHandler = () => { + if (player.seeking() || !this.firstPlayDone || !this.paused) { + this.firstPlayDone = true + return + } + + this.paused = false + this.firstPlayDone = true - player.on('play', (_: any) => { - if (player.seeking()) return this.container.innerHTML = getPlayBezel() this.showBezel() - }) + } + + this.videoChangeHandler = () => { + this.firstPlayDone = false + } + + player.on('video-change', () => this.videoChangeHandler) + player.on('pause', this.playerPauseHandler) + player.on('play', this.playerPlayHandler) + } + + dispose () { + if (this.playerPauseHandler) this.player().off('pause', this.playerPauseHandler) + if (this.playerPlayHandler) this.player().off('play', this.playerPlayHandler) + if (this.videoChangeHandler) this.player().off('video-change', this.videoChangeHandler) + + super.dispose() } createEl () { diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts index 24877c267..9307027f6 100644 --- a/client/src/assets/player/shared/control-bar/index.ts +++ b/client/src/assets/player/shared/control-bar/index.ts @@ -2,6 +2,5 @@ export * from './next-previous-video-button' export * from './p2p-info-button' export * from './peertube-link-button' export * from './peertube-live-display' -export * from './peertube-load-progress-bar' export * from './storyboard-plugin' export * from './theater-button' diff --git a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts index b7b986806..18a107f52 100644 --- a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts +++ b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts @@ -4,14 +4,18 @@ import { NextPreviousVideoButtonOptions } from '../../types' const Button = videojs.getComponent('Button') class NextPreviousVideoButton extends Button { - private readonly nextPreviousVideoButtonOptions: NextPreviousVideoButtonOptions + options_: NextPreviousVideoButtonOptions & videojs.ComponentOptions - constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions) { - super(player, options as any) + constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions & videojs.ComponentOptions) { + super(player, options) - this.nextPreviousVideoButtonOptions = options + this.player().on('video-change', () => { + this.updateDisabled() + this.updateShowing() + }) - this.update() + this.updateDisabled() + this.updateShowing() } createEl () { @@ -35,15 +39,20 @@ class NextPreviousVideoButton extends Button { } handleClick () { - this.nextPreviousVideoButtonOptions.handler() + this.options_.handler() } - update () { - const disabled = this.nextPreviousVideoButtonOptions.isDisabled() + updateDisabled () { + const disabled = this.options_.isDisabled() if (disabled) this.addClass('vjs-disabled') else this.removeClass('vjs-disabled') } + + updateShowing () { + if (this.options_.isDisplayed()) this.show() + else this.hide() + } } videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) diff --git a/client/src/assets/player/shared/control-bar/p2p-info-button.ts b/client/src/assets/player/shared/control-bar/p2p-info-button.ts index 1979654ad..4177b3280 100644 --- a/client/src/assets/player/shared/control-bar/p2p-info-button.ts +++ b/client/src/assets/player/shared/control-bar/p2p-info-button.ts @@ -1,71 +1,44 @@ import videojs from 'video.js' -import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../../types' +import { PlayerNetworkInfo } from '../../types' import { bytes } from '../common' const Button = videojs.getComponent('Button') -class P2pInfoButton extends Button { - - constructor (player: videojs.Player, options?: PeerTubeP2PInfoButtonOptions) { - super(player, options as any) - } +class P2PInfoButton extends Button { + el_: HTMLElement createEl () { - const div = videojs.dom.createEl('div', { - className: 'vjs-peertube' - }) - const subDivWebtorrent = videojs.dom.createEl('div', { + const div = videojs.dom.createEl('div', { className: 'vjs-peertube' }) + const subDivP2P = videojs.dom.createEl('div', { className: 'vjs-peertube-hidden' // Hide the stats before we get the info }) as HTMLDivElement - div.appendChild(subDivWebtorrent) + div.appendChild(subDivP2P) - // Stop here if P2P is not enabled - const p2pEnabled = (this.options_ as PeerTubeP2PInfoButtonOptions).p2pEnabled - if (!p2pEnabled) return div as HTMLButtonElement + const downloadIcon = videojs.dom.createEl('span', { className: 'icon icon-download' }) + subDivP2P.appendChild(downloadIcon) - const downloadIcon = videojs.dom.createEl('span', { - className: 'icon icon-download' - }) - subDivWebtorrent.appendChild(downloadIcon) - - const downloadSpeedText = videojs.dom.createEl('span', { - className: 'download-speed-text' - }) - const downloadSpeedNumber = videojs.dom.createEl('span', { - className: 'download-speed-number' - }) + const downloadSpeedText = videojs.dom.createEl('span', { className: 'download-speed-text' }) + const downloadSpeedNumber = videojs.dom.createEl('span', { className: 'download-speed-number' }) const downloadSpeedUnit = videojs.dom.createEl('span') downloadSpeedText.appendChild(downloadSpeedNumber) downloadSpeedText.appendChild(downloadSpeedUnit) - subDivWebtorrent.appendChild(downloadSpeedText) + subDivP2P.appendChild(downloadSpeedText) - const uploadIcon = videojs.dom.createEl('span', { - className: 'icon icon-upload' - }) - subDivWebtorrent.appendChild(uploadIcon) + const uploadIcon = videojs.dom.createEl('span', { className: 'icon icon-upload' }) + subDivP2P.appendChild(uploadIcon) - const uploadSpeedText = videojs.dom.createEl('span', { - className: 'upload-speed-text' - }) - const uploadSpeedNumber = videojs.dom.createEl('span', { - className: 'upload-speed-number' - }) + const uploadSpeedText = videojs.dom.createEl('span', { className: 'upload-speed-text' }) + const uploadSpeedNumber = videojs.dom.createEl('span', { className: 'upload-speed-number' }) const uploadSpeedUnit = videojs.dom.createEl('span') uploadSpeedText.appendChild(uploadSpeedNumber) uploadSpeedText.appendChild(uploadSpeedUnit) - subDivWebtorrent.appendChild(uploadSpeedText) + subDivP2P.appendChild(uploadSpeedText) - const peersText = videojs.dom.createEl('span', { - className: 'peers-text' - }) - const peersNumber = videojs.dom.createEl('span', { - className: 'peers-number' - }) - subDivWebtorrent.appendChild(peersNumber) - subDivWebtorrent.appendChild(peersText) + const peersText = videojs.dom.createEl('span', { className: 'peers-text' }) + const peersNumber = videojs.dom.createEl('span', { className: 'peers-number' }) + subDivP2P.appendChild(peersNumber) + subDivP2P.appendChild(peersText) - const subDivHttp = videojs.dom.createEl('div', { - className: 'vjs-peertube-hidden' - }) + const subDivHttp = videojs.dom.createEl('div', { className: 'vjs-peertube-hidden' }) as HTMLElement const subDivHttpText = videojs.dom.createEl('span', { className: 'http-fallback', textContent: 'HTTP' @@ -74,14 +47,9 @@ class P2pInfoButton extends Button { subDivHttp.appendChild(subDivHttpText) div.appendChild(subDivHttp) - this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { - // We are in HTTP fallback - if (!data) { - subDivHttp.className = 'vjs-peertube-displayed' - subDivWebtorrent.className = 'vjs-peertube-hidden' - - return - } + this.player_.on('p2p-info', (_event: any, data: PlayerNetworkInfo) => { + subDivP2P.className = 'vjs-peertube-displayed' + subDivHttp.className = 'vjs-peertube-hidden' const p2pStats = data.p2p const httpStats = data.http @@ -92,17 +60,17 @@ class P2pInfoButton extends Button { const totalUploaded = bytes(p2pStats.uploaded) const numPeers = p2pStats.numPeers - subDivWebtorrent.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + subDivP2P.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' if (data.source === 'p2p-media-loader') { const downloadedFromServer = bytes(httpStats.downloaded).join(' ') const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') - subDivWebtorrent.title += + subDivP2P.title += ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' + ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n' } - subDivWebtorrent.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ') + subDivP2P.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ') downloadSpeedNumber.textContent = downloadSpeed[0] downloadSpeedUnit.textContent = ' ' + downloadSpeed[1] @@ -114,11 +82,24 @@ class P2pInfoButton extends Button { peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer')) subDivHttp.className = 'vjs-peertube-hidden' - subDivWebtorrent.className = 'vjs-peertube-displayed' + subDivP2P.className = 'vjs-peertube-displayed' + }) + + this.player_.on('http-info', (_event, data: PlayerNetworkInfo) => { + // We are in HTTP fallback + subDivHttp.className = 'vjs-peertube-displayed' + subDivP2P.className = 'vjs-peertube-hidden' + + subDivHttp.title = this.player().localize('Total downloaded: ') + bytes(data.http.downloaded).join(' ') + }) + + this.player_.on('video-change', () => { + subDivP2P.className = 'vjs-peertube-hidden' + subDivHttp.className = 'vjs-peertube-hidden' }) return div as HTMLButtonElement } } -videojs.registerComponent('P2PInfoButton', P2pInfoButton) +videojs.registerComponent('P2PInfoButton', P2PInfoButton) diff --git a/client/src/assets/player/shared/control-bar/peertube-link-button.ts b/client/src/assets/player/shared/control-bar/peertube-link-button.ts index 45d7ac42f..8242b9cea 100644 --- a/client/src/assets/player/shared/control-bar/peertube-link-button.ts +++ b/client/src/assets/player/shared/control-bar/peertube-link-button.ts @@ -3,37 +3,58 @@ import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' import { PeerTubeLinkButtonOptions } from '../../types' const Component = videojs.getComponent('Component') -class PeerTubeLinkButton extends Component { - constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) { - super(player, options as any) +class PeerTubeLinkButton extends Component { + private mouseEnterHandler: () => void + private clickHandler: () => void + + options_: PeerTubeLinkButtonOptions & videojs.ComponentOptions + + constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions & videojs.ComponentOptions) { + super(player, options) + + this.updateShowing() + this.player().on('video-change', () => this.updateShowing()) + } + + dispose () { + if (this.el()) return + + this.el().removeEventListener('mouseenter', this.mouseEnterHandler) + this.el().removeEventListener('click', this.clickHandler) + + super.dispose() } createEl () { - return this.buildElement() + const el = videojs.dom.createEl('a', { + href: this.buildLink(), + innerHTML: this.options_.instanceName, + title: this.player().localize('Video page (new window)'), + className: 'vjs-peertube-link', + target: '_blank' + }) + + this.mouseEnterHandler = () => this.updateHref() + this.clickHandler = () => this.player().pause() + + el.addEventListener('mouseenter', this.mouseEnterHandler) + el.addEventListener('click', this.clickHandler) + + return el + } + + updateShowing () { + if (this.options_.isDisplayed()) this.show() + else this.hide() } updateHref () { this.el().setAttribute('href', this.buildLink()) } - private buildElement () { - const el = videojs.dom.createEl('a', { - href: this.buildLink(), - innerHTML: (this.options_ as PeerTubeLinkButtonOptions).instanceName, - title: this.player().localize('Video page (new window)'), - className: 'vjs-peertube-link', - target: '_blank' - }) - - el.addEventListener('mouseenter', () => this.updateHref()) - el.addEventListener('click', () => this.player().pause()) - - return el as HTMLButtonElement - } - private buildLink () { - const url = buildVideoLink({ shortUUID: (this.options_ as PeerTubeLinkButtonOptions).shortUUID }) + const url = buildVideoLink({ shortUUID: this.options_.shortUUID() }) return decorateVideoLink({ url, startTime: this.player().currentTime() }) } diff --git a/client/src/assets/player/shared/control-bar/peertube-live-display.ts b/client/src/assets/player/shared/control-bar/peertube-live-display.ts index 649eb0b00..f9f6bf12f 100644 --- a/client/src/assets/player/shared/control-bar/peertube-live-display.ts +++ b/client/src/assets/player/shared/control-bar/peertube-live-display.ts @@ -13,7 +13,6 @@ class PeerTubeLiveDisplay extends ClickableComponent { this.interval = this.setInterval(() => this.updateClass(), 1000) - this.show() this.updateSync(true) } @@ -30,7 +29,7 @@ class PeerTubeLiveDisplay extends ClickableComponent { createEl () { const el = super.createEl('div', { - className: 'vjs-live-control vjs-control' + className: 'vjs-pt-live-control vjs-control' }) this.contentEl_ = videojs.dom.createEl('div', { @@ -83,10 +82,9 @@ class PeerTubeLiveDisplay extends ClickableComponent { } private getHLSJS () { - const p2pMediaLoader = this.player()?.p2pMediaLoader - if (!p2pMediaLoader) return undefined + if (!this.player()?.usingPlugin('p2pMediaLoader')) return - return p2pMediaLoader().getHLSJS() + return this.player().p2pMediaLoader().getHLSJS() } } diff --git a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts b/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts deleted file mode 100644 index 623e70eb2..000000000 --- a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts +++ /dev/null @@ -1,33 +0,0 @@ -import videojs from 'video.js' - -const Component = videojs.getComponent('Component') - -class PeerTubeLoadProgressBar extends Component { - - constructor (player: videojs.Player, options?: videojs.ComponentOptions) { - super(player, options) - - this.on(player, 'progress', this.update) - } - - createEl () { - return super.createEl('div', { - className: 'vjs-load-progress', - innerHTML: `${this.localize('Loaded')}: 0%` - }) - } - - dispose () { - super.dispose() - } - - update () { - const torrent = this.player().webtorrent().getTorrent() - if (!torrent) return - - (this.el() as HTMLElement).style.width = (torrent.progress * 100) + '%' - } - -} - -Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar) diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts index 81ab60842..80c69b5f2 100644 --- a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts +++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts @@ -24,6 +24,8 @@ class StoryboardPlugin extends Plugin { private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip + private onReadyOrLoadstartHandler: (event: { type: 'ready' }) => void + constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) { super(player, options) @@ -54,7 +56,7 @@ class StoryboardPlugin extends Plugin { this.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement this.seekBar?.el()?.appendChild(this.spritePlaceholder) - this.player.on([ 'ready', 'loadstart' ], event => { + this.onReadyOrLoadstartHandler = event => { if (event.type !== 'ready') { const spriteSource = this.player.currentSources().find(source => { return Object.prototype.hasOwnProperty.call(source, 'storyboard') @@ -72,7 +74,18 @@ class StoryboardPlugin extends Plugin { this.cached = !!this.sprites[this.url] this.load() - }) + } + + this.player.on([ 'ready', 'loadstart' ], this.onReadyOrLoadstartHandler) + } + + dispose () { + if (this.onReadyOrLoadstartHandler) this.player.off([ 'ready', 'loadstart' ], this.onReadyOrLoadstartHandler) + if (this.progress) this.progress.off([ 'mousemove', 'touchmove' ], this.boundedHijackMouseTooltip) + + this.seekBar?.el()?.removeChild(this.spritePlaceholder) + + super.dispose() } private load () { diff --git a/client/src/assets/player/shared/control-bar/theater-button.ts b/client/src/assets/player/shared/control-bar/theater-button.ts index 56c349d6b..a5feb56ee 100644 --- a/client/src/assets/player/shared/control-bar/theater-button.ts +++ b/client/src/assets/player/shared/control-bar/theater-button.ts @@ -1,14 +1,19 @@ import videojs from 'video.js' import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' +import { TheaterButtonOptions } from '../../types' const Button = videojs.getComponent('Button') class TheaterButton extends Button { private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' - constructor (player: videojs.Player, options: videojs.ComponentOptions) { + private theaterButtonOptions: TheaterButtonOptions + + constructor (player: videojs.Player, options: TheaterButtonOptions & videojs.ComponentOptions) { super(player, options) + this.theaterButtonOptions = options + const enabled = getStoredTheater() if (enabled === true) { this.player().addClass(TheaterButton.THEATER_MODE_CLASS) @@ -19,6 +24,9 @@ class TheaterButton extends Button { this.controlText('Theater mode') this.player().theaterEnabled = enabled + + this.updateShowing() + this.player().on('video-change', () => this.updateShowing()) } buildCSSClass () { @@ -36,7 +44,7 @@ class TheaterButton extends Button { saveTheaterInStore(theaterEnabled) - this.player_.trigger('theaterChange', theaterEnabled) + this.player_.trigger('theater-change', theaterEnabled) } handleClick () { @@ -48,6 +56,11 @@ class TheaterButton extends Button { private isTheaterEnabled () { return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) } + + private updateShowing () { + if (this.theaterButtonOptions.isDisplayed()) this.show() + else this.hide() + } } videojs.registerComponent('TheaterButton', TheaterButton) diff --git a/client/src/assets/player/shared/dock/peertube-dock-component.ts b/client/src/assets/player/shared/dock/peertube-dock-component.ts index 183c7a00f..c13ca647b 100644 --- a/client/src/assets/player/shared/dock/peertube-dock-component.ts +++ b/client/src/assets/player/shared/dock/peertube-dock-component.ts @@ -10,17 +10,20 @@ export type PeerTubeDockComponentOptions = { class PeerTubeDockComponent extends Component { + options_: videojs.ComponentOptions & PeerTubeDockComponentOptions + + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor (player: videojs.Player, options: videojs.ComponentOptions & PeerTubeDockComponentOptions) { + super(player, options) + } + createEl () { - const options = this.options_ as PeerTubeDockComponentOptions + const el = super.createEl('div', { className: 'peertube-dock' }) - const el = super.createEl('div', { - className: 'peertube-dock' - }) - - if (options.avatarUrl) { + if (this.options_.avatarUrl) { const avatar = videojs.dom.createEl('img', { className: 'peertube-dock-avatar', - src: options.avatarUrl + src: this.options_.avatarUrl }) el.appendChild(avatar) @@ -30,27 +33,27 @@ class PeerTubeDockComponent extends Component { className: 'peertube-dock-title-description' }) - if (options.title) { + if (this.options_.title) { const title = videojs.dom.createEl('div', { className: 'peertube-dock-title', - title: options.title, - innerHTML: options.title + title: this.options_.title, + innerHTML: this.options_.title }) elWrapperTitleDescription.appendChild(title) } - if (options.description) { + if (this.options_.description) { const description = videojs.dom.createEl('div', { className: 'peertube-dock-description', - title: options.description, - innerHTML: options.description + title: this.options_.description, + innerHTML: this.options_.description }) elWrapperTitleDescription.appendChild(description) } - if (options.title || options.description) { + if (this.options_.title || this.options_.description) { el.appendChild(elWrapperTitleDescription) } diff --git a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts index 245981692..fc71a8c4b 100644 --- a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts +++ b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts @@ -10,14 +10,25 @@ export type PeerTubeDockPluginOptions = { } class PeerTubeDockPlugin extends Plugin { + private dockComponent: PeerTubeDockComponent + constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) { super(player, options) - this.player.addClass('peertube-dock') - - this.player.ready(() => { - this.player.addChild('PeerTubeDockComponent', options) as PeerTubeDockComponent + player.ready(() => { + player.addClass('peertube-dock') }) + + this.dockComponent = new PeerTubeDockComponent(player, options) + player.addChild(this.dockComponent) + } + + dispose () { + this.dockComponent?.dispose() + this.player.removeChild(this.dockComponent) + this.player.removeClass('peertube-dock') + + super.dispose() } } diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts index 2742b21a1..e77b7dc6d 100644 --- a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts +++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts @@ -31,6 +31,8 @@ class PeerTubeHotkeysPlugin extends Plugin { dispose () { document.removeEventListener('keydown', this.handleKeyFunction) + + super.dispose() } private onKeyDown (event: KeyboardEvent) { diff --git a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts deleted file mode 100644 index 26f923e92..000000000 --- a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - CommonOptions, - NextPreviousVideoButtonOptions, - PeerTubeLinkButtonOptions, - PeertubePlayerManagerOptions, - PlayerMode -} from '../../types' - -export class ControlBarOptionsBuilder { - private options: CommonOptions - - constructor ( - globalOptions: PeertubePlayerManagerOptions, - private mode: PlayerMode - ) { - this.options = globalOptions.common - } - - getChildrenOptions () { - const children = {} - - if (this.options.previousVideo) { - Object.assign(children, this.getPreviousVideo()) - } - - Object.assign(children, { playToggle: {} }) - - if (this.options.nextVideo) { - Object.assign(children, this.getNextVideo()) - } - - Object.assign(children, { - ...this.getTimeControls(), - - flexibleWidthSpacer: {}, - - ...this.getProgressControl(), - - p2PInfoButton: { - p2pEnabled: this.options.p2pEnabled - }, - - muteToggle: {}, - volumeControl: {}, - - ...this.getSettingsButton() - }) - - if (this.options.peertubeLink === true) { - Object.assign(children, { - peerTubeLinkButton: { - shortUUID: this.options.videoShortUUID, - instanceName: this.options.instanceName - } as PeerTubeLinkButtonOptions - }) - } - - if (this.options.theaterButton === true) { - Object.assign(children, { - theaterButton: {} - }) - } - - Object.assign(children, { - fullscreenToggle: {} - }) - - return children - } - - private getSettingsButton () { - const settingEntries: string[] = [] - - if (!this.options.isLive) { - settingEntries.push('playbackRateMenuButton') - } - - if (this.options.captions === true) settingEntries.push('captionsButton') - - settingEntries.push('resolutionMenuButton') - - return { - settingsButton: { - setup: { - maxHeightOffset: 40 - }, - entries: settingEntries - } - } - } - - private getTimeControls () { - if (this.options.isLive) { - return { - peerTubeLiveDisplay: {} - } - } - - return { - currentTimeDisplay: {}, - timeDivider: {}, - durationDisplay: {} - } - } - - private getProgressControl () { - if (this.options.isLive) return {} - - const loadProgressBar = this.mode === 'webtorrent' - ? 'peerTubeLoadProgressBar' - : 'loadProgressBar' - - return { - progressControl: { - children: { - seekBar: { - children: { - [loadProgressBar]: {}, - mouseTimeDisplay: {}, - playProgressBar: {} - } - } - } - } - } - } - - private getPreviousVideo () { - const buttonOptions: NextPreviousVideoButtonOptions = { - type: 'previous', - handler: this.options.previousVideo, - isDisabled: () => { - if (!this.options.hasPreviousVideo) return false - - return !this.options.hasPreviousVideo() - } - } - - return { previousVideoButton: buttonOptions } - } - - private getNextVideo () { - const buttonOptions: NextPreviousVideoButtonOptions = { - type: 'next', - handler: this.options.nextVideo, - isDisabled: () => { - if (!this.options.hasNextVideo) return false - - return !this.options.hasNextVideo() - } - } - - return { nextVideoButton: buttonOptions } - } -} diff --git a/client/src/assets/player/shared/manager-options/index.ts b/client/src/assets/player/shared/manager-options/index.ts deleted file mode 100644 index 4934d8302..000000000 --- a/client/src/assets/player/shared/manager-options/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './manager-options-builder' diff --git a/client/src/assets/player/shared/manager-options/manager-options-builder.ts b/client/src/assets/player/shared/manager-options/manager-options-builder.ts deleted file mode 100644 index 5d3ee4c4a..000000000 --- a/client/src/assets/player/shared/manager-options/manager-options-builder.ts +++ /dev/null @@ -1,186 +0,0 @@ -import videojs from 'video.js' -import { copyToClipboard } from '@root-helpers/utils' -import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' -import { isIOS, isSafari } from '@root-helpers/web-browser' -import { buildVideoLink, decorateVideoLink, pick } from '@shared/core-utils' -import { isDefaultLocale } from '@shared/core-utils/i18n' -import { VideoJSPluginOptions } from '../../types' -import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../types/manager-options' -import { ControlBarOptionsBuilder } from './control-bar-options-builder' -import { HLSOptionsBuilder } from './hls-options-builder' -import { WebTorrentOptionsBuilder } from './webtorrent-options-builder' - -export class ManagerOptionsBuilder { - - constructor ( - private mode: PlayerMode, - private options: PeertubePlayerManagerOptions, - private p2pMediaLoaderModule?: any - ) { - - } - - async getVideojsOptions (alreadyPlayed: boolean): Promise { - const commonOptions = this.options.common - - let autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed) - const html5 = { - preloadTextTracks: false - } - - const plugins: VideoJSPluginOptions = { - peertube: { - mode: this.mode, - autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent - - ...pick(commonOptions, [ - 'videoViewUrl', - 'videoViewIntervalMs', - 'authorizationHeader', - 'startTime', - 'videoDuration', - 'subtitle', - 'videoCaptions', - 'stopTime', - 'isLive', - 'videoUUID' - ]) - }, - metrics: { - mode: this.mode, - - ...pick(commonOptions, [ - 'metricsUrl', - 'videoUUID' - ]) - } - } - - if (commonOptions.playlist) { - plugins.playlist = commonOptions.playlist - } - - if (this.mode === 'p2p-media-loader') { - const hlsOptionsBuilder = new HLSOptionsBuilder(this.options, this.p2pMediaLoaderModule) - const options = await hlsOptionsBuilder.getPluginOptions() - - Object.assign(plugins, pick(options, [ 'hlsjs', 'p2pMediaLoader' ])) - Object.assign(html5, options.html5) - } else if (this.mode === 'webtorrent') { - const webtorrentOptionsBuilder = new WebTorrentOptionsBuilder(this.options, this.getAutoPlayValue(autoplay, alreadyPlayed)) - - Object.assign(plugins, webtorrentOptionsBuilder.getPluginOptions()) - - // WebTorrent plugin handles autoplay, because we do some hackish stuff in there - autoplay = false - } - - const controlBarOptionsBuilder = new ControlBarOptionsBuilder(this.options, this.mode) - - const videojsOptions = { - html5, - - // We don't use text track settings for now - textTrackSettings: false as any, // FIXME: typings - controls: commonOptions.controls !== undefined ? commonOptions.controls : true, - loop: commonOptions.loop !== undefined ? commonOptions.loop : false, - - muted: commonOptions.muted !== undefined - ? commonOptions.muted - : undefined, // Undefined so the player knows it has to check the local storage - - autoplay: this.getAutoPlayValue(autoplay, alreadyPlayed), - - poster: commonOptions.poster, - inactivityTimeout: commonOptions.inactivityTimeout, - playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], - - plugins, - - controlBar: { - children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings - } - } - - if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { - Object.assign(videojsOptions, { language: commonOptions.language }) - } - - return videojsOptions - } - - private getAutoPlayValue (autoplay: videojs.Autoplay, alreadyPlayed: boolean) { - if (autoplay !== true) return autoplay - - // On first play, disable autoplay to avoid issues - // But if the player already played videos, we can safely autoplay next ones - if (isIOS() || isSafari()) { - return alreadyPlayed ? 'play' : false - } - - return this.options.common.forceAutoplay - ? 'any' - : 'play' - } - - getContextMenuOptions (player: videojs.Player, commonOptions: CommonOptions) { - const content = () => { - const isLoopEnabled = player.options_['loop'] - - const items = [ - { - icon: 'repeat', - label: player.localize('Play in loop') + (isLoopEnabled ? '' : ''), - listener: function () { - player.options_['loop'] = !isLoopEnabled - } - }, - { - label: player.localize('Copy the video URL'), - listener: function () { - copyToClipboard(buildVideoLink({ shortUUID: commonOptions.videoShortUUID })) - } - }, - { - label: player.localize('Copy the video URL at the current time'), - listener: function (this: videojs.Player) { - const url = buildVideoLink({ shortUUID: commonOptions.videoShortUUID }) - - copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() })) - } - }, - { - icon: 'code', - label: player.localize('Copy embed code'), - listener: () => { - copyToClipboard(buildVideoOrPlaylistEmbed({ embedUrl: commonOptions.embedUrl, embedTitle: commonOptions.embedTitle })) - } - } - ] - - if (this.mode === 'webtorrent') { - items.push({ - label: player.localize('Copy magnet URI'), - listener: function (this: videojs.Player) { - copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri) - } - }) - } - - items.push({ - icon: 'info', - label: player.localize('Stats for nerds'), - listener: () => { - player.stats().show() - } - }) - - return items.map(i => ({ - ...i, - label: `` + i.label - })) - } - - return { content } - } -} diff --git a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts deleted file mode 100644 index 80eec02cf..000000000 --- a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { addQueryParams } from '../../../../../../shared/core-utils' -import { PeertubePlayerManagerOptions, WebtorrentPluginOptions } from '../../types' - -export class WebTorrentOptionsBuilder { - - constructor ( - private options: PeertubePlayerManagerOptions, - private autoPlayValue: any - ) { - - } - - getPluginOptions () { - const commonOptions = this.options.common - const webtorrentOptions = this.options.webtorrent - const p2pMediaLoaderOptions = this.options.p2pMediaLoader - - const autoplay = this.autoPlayValue === 'play' - - const webtorrent: WebtorrentPluginOptions = { - autoplay, - - playerRefusedP2P: commonOptions.p2pEnabled === false, - videoDuration: commonOptions.videoDuration, - playerElement: commonOptions.playerElement, - - videoFileToken: commonOptions.videoFileToken, - - requiresUserAuth: commonOptions.requiresUserAuth, - - buildWebSeedUrls: file => { - if (!commonOptions.requiresUserAuth && !commonOptions.requiresPassword) return [] - - return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ] - }, - - videoFiles: webtorrentOptions.videoFiles.length !== 0 - ? webtorrentOptions.videoFiles - // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode - : p2pMediaLoaderOptions?.videoFiles || [], - - startTime: commonOptions.startTime - } - - return { webtorrent } - } -} diff --git a/client/src/assets/player/shared/metrics/metrics-plugin.ts b/client/src/assets/player/shared/metrics/metrics-plugin.ts index 2aae3e90a..48363a724 100644 --- a/client/src/assets/player/shared/metrics/metrics-plugin.ts +++ b/client/src/assets/player/shared/metrics/metrics-plugin.ts @@ -1,14 +1,15 @@ +import debug from 'debug' import videojs from 'video.js' -import { PlaybackMetricCreate } from '../../../../../../shared/models' -import { MetricsPluginOptions, PlayerMode, PlayerNetworkInfo } from '../../types' import { logger } from '@root-helpers/logger' +import { PlaybackMetricCreate } from '../../../../../../shared/models' +import { MetricsPluginOptions, PlayerNetworkInfo } from '../../types' + +const debugLogger = debug('peertube:player:metrics') const Plugin = videojs.getPlugin('plugin') class MetricsPlugin extends Plugin { - private readonly metricsUrl: string - private readonly videoUUID: string - private readonly mode: PlayerMode + options_: MetricsPluginOptions private downloadedBytesP2P = 0 private downloadedBytesHTTP = 0 @@ -28,29 +29,54 @@ class MetricsPlugin extends Plugin { constructor (player: videojs.Player, options: MetricsPluginOptions) { super(player) - this.metricsUrl = options.metricsUrl - this.videoUUID = options.videoUUID - this.mode = options.mode + this.options_ = options - this.player.one('play', () => { - this.runMetricsInterval() + this.trackBytes() + this.trackResolutionChange() + this.trackErrors() - this.trackBytes() - this.trackResolutionChange() - this.trackErrors() + this.one('play', () => { + this.player.on('video-change', () => { + this.runMetricsIntervalOnPlay() + }) }) + + this.runMetricsIntervalOnPlay() } dispose () { if (this.metricsInterval) clearInterval(this.metricsInterval) + + super.dispose() + } + + private runMetricsIntervalOnPlay () { + this.downloadedBytesP2P = 0 + this.downloadedBytesHTTP = 0 + this.uploadedBytesP2P = 0 + + this.resolutionChanges = 0 + this.errors = 0 + + this.lastPlayerNetworkInfo = undefined + + debugLogger('Will track metrics on next play') + + this.player.one('play', () => { + debugLogger('Tracking metrics') + + this.runMetricsInterval() + }) } private runMetricsInterval () { + if (this.metricsInterval) clearInterval(this.metricsInterval) + this.metricsInterval = setInterval(() => { let resolution: number let fps: number - if (this.mode === 'p2p-media-loader') { + if (this.player.usingPlugin('p2pMediaLoader')) { const level = this.player.p2pMediaLoader().getCurrentLevel() if (!level) return @@ -60,21 +86,23 @@ class MetricsPlugin extends Plugin { fps = framerate ? parseInt(framerate, 10) : undefined - } else { // webtorrent - const videoFile = this.player.webtorrent().getCurrentVideoFile() + } else if (this.player.usingPlugin('webVideo')) { + const videoFile = this.player.webVideo().getCurrentVideoFile() if (!videoFile) return resolution = videoFile.resolution.id fps = videoFile.fps && videoFile.fps !== -1 ? videoFile.fps : undefined + } else { + return } const body: PlaybackMetricCreate = { resolution, fps, - playerMode: this.mode, + playerMode: this.options_.mode(), resolutionChanges: this.resolutionChanges, @@ -85,7 +113,7 @@ class MetricsPlugin extends Plugin { uploadedBytesP2P: this.uploadedBytesP2P, - videoId: this.videoUUID + videoId: this.options_.videoUUID() } this.resolutionChanges = 0 @@ -99,15 +127,13 @@ class MetricsPlugin extends Plugin { const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) - return fetch(this.metricsUrl, { method: 'POST', body: JSON.stringify(body), headers }) + return fetch(this.options_.metricsUrl(), { method: 'POST', body: JSON.stringify(body), headers }) .catch(err => logger.error('Cannot send metrics to the server.', err)) }, this.CONSTANTS.METRICS_INTERVAL) } private trackBytes () { - this.player.on('p2pInfo', (_event, data: PlayerNetworkInfo) => { - if (!data) return - + this.player.on('p2p-info', (_event, data: PlayerNetworkInfo) => { this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0) @@ -115,10 +141,18 @@ class MetricsPlugin extends Plugin { this.lastPlayerNetworkInfo = data }) + + this.player.on('http-info', (_event, data: PlayerNetworkInfo) => { + this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) + }) } private trackResolutionChange () { - this.player.on('engineResolutionChange', () => { + this.player.on('engine-resolution-change', () => { + this.resolutionChanges++ + }) + + this.player.on('user-resolution-change', () => { this.resolutionChanges++ }) } diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts index 09cb98f2e..1bc3ca38d 100644 --- a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts +++ b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts @@ -2,22 +2,20 @@ import videojs from 'video.js' const Component = videojs.getComponent('Component') class PeerTubeMobileButtons extends Component { + private mainButton: HTMLDivElement private rewind: Element private forward: Element private rewindText: Element private forwardText: Element + private touchStartHandler: (e: TouchEvent) => void + createEl () { - const container = super.createEl('div', { - className: 'vjs-mobile-buttons-overlay' - }) as HTMLDivElement + const container = super.createEl('div', { className: 'vjs-mobile-buttons-overlay' }) as HTMLDivElement + this.mainButton = super.createEl('div', { className: 'main-button' }) as HTMLDivElement - const mainButton = super.createEl('div', { - className: 'main-button' - }) as HTMLDivElement - - mainButton.addEventListener('touchstart', e => { + this.touchStartHandler = e => { e.stopPropagation() if (this.player_.paused() || this.player_.ended()) { @@ -26,7 +24,9 @@ class PeerTubeMobileButtons extends Component { } this.player_.pause() - }) + } + + this.mainButton.addEventListener('touchstart', this.touchStartHandler, { passive: true }) this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' }) this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' }) @@ -40,12 +40,18 @@ class PeerTubeMobileButtons extends Component { this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' })) container.appendChild(this.rewind) - container.appendChild(mainButton) + container.appendChild(this.mainButton) container.appendChild(this.forward) return container } + dispose () { + if (this.touchStartHandler) this.mainButton.removeEventListener('touchstart', this.touchStartHandler) + + super.dispose() + } + displayFastSeek (amount: number) { if (amount === 0) { this.hideRewind() diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts index 646e9f8c6..f31fa7ddb 100644 --- a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts +++ b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts @@ -21,6 +21,15 @@ class PeerTubeMobilePlugin extends Plugin { private setCurrentTimeTimeout: ReturnType + private onPlayHandler: () => void + private onFullScreenChangeHandler: () => void + private onTouchStartHandler: (event: TouchEvent) => void + private onMobileButtonTouchStartHandler: (event: TouchEvent) => void + private sliderActiveHandler: () => void + private sliderInactiveHandler: () => void + + private seekBar: videojs.Component + constructor (player: videojs.Player, options: videojs.PlayerOptions) { super(player, options) @@ -36,18 +45,38 @@ class PeerTubeMobilePlugin extends Plugin { (this.player.options_.userActions as any).click = false this.player.options_.userActions.doubleClick = false - this.player.one('play', () => { - this.initTouchStartEvents() - }) + this.onPlayHandler = () => this.initTouchStartEvents() + this.player.one('play', this.onPlayHandler) + + this.seekBar = this.player.getDescendant([ 'controlBar', 'progressControl', 'seekBar' ]) + + this.sliderActiveHandler = () => this.player.addClass('vjs-mobile-sliding') + this.sliderInactiveHandler = () => this.player.removeClass('vjs-mobile-sliding') + + this.seekBar.on('slideractive', this.sliderActiveHandler) + this.seekBar.on('sliderinactive', this.sliderInactiveHandler) + } + + dispose () { + if (this.onPlayHandler) this.player.off('play', this.onPlayHandler) + if (this.onFullScreenChangeHandler) this.player.off('fullscreenchange', this.onFullScreenChangeHandler) + if (this.onTouchStartHandler) this.player.off('touchstart', this.onFullScreenChangeHandler) + if (this.onMobileButtonTouchStartHandler) { + this.peerTubeMobileButtons?.el().removeEventListener('touchstart', this.onMobileButtonTouchStartHandler) + } + + super.dispose() } private handleFullscreenRotation () { - this.player.on('fullscreenchange', () => { + this.onFullScreenChangeHandler = () => { if (!this.player.isFullscreen() || this.isPortraitVideo()) return screen.orientation.lock('landscape') .catch(err => logger.error('Cannot lock screen to landscape.', err)) - }) + } + + this.player.on('fullscreenchange', this.onFullScreenChangeHandler) } private isPortraitVideo () { @@ -80,19 +109,22 @@ class PeerTubeMobilePlugin extends Plugin { this.lastTapEvent = event } - this.player.on('touchstart', (event: TouchEvent) => { + this.onTouchStartHandler = event => { // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it if (this.player.userActive()) return handleTouchStart(event) - }) + } + this.player.on('touchstart', this.onTouchStartHandler) - this.peerTubeMobileButtons.el().addEventListener('touchstart', (event: TouchEvent) => { + this.onMobileButtonTouchStartHandler = event => { // Prevent mousemove/click events firing on the player, that conflict with our user active logic event.preventDefault() handleTouchStart(event) - }, { passive: false }) + } + + this.peerTubeMobileButtons.el().addEventListener('touchstart', this.onMobileButtonTouchStartHandler, { passive: false }) } private onDoubleTap (event: TouchEvent) { diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts index d05d6193c..d83ec625a 100644 --- a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts +++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts @@ -14,6 +14,10 @@ type Metadata = { levels: Level[] } +// --------------------------------------------------------------------------- +// Source handler registration +// --------------------------------------------------------------------------- + type HookFn = (player: videojs.Player, hljs: Hlsjs) => void const registerSourceHandler = function (vjs: typeof videojs) { @@ -25,10 +29,13 @@ const registerSourceHandler = function (vjs: typeof videojs) { const html5 = vjs.getTech('Html5') if (!html5) { - logger.error('No Hml5 tech found in videojs') + logger.error('No "Html5" tech found in videojs') return } + // Already registered + if ((html5 as any).canPlaySource({ type: 'application/x-mpegURL' })) return + // FIXME: typings (html5 as any).registerSourceHandler({ canHandleSource: function (source: videojs.Tech.SourceObject) { @@ -56,32 +63,55 @@ const registerSourceHandler = function (vjs: typeof videojs) { (vjs as any).Html5Hlsjs = Html5Hlsjs } -function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) { - const player = this +// --------------------------------------------------------------------------- +// HLS options plugin +// --------------------------------------------------------------------------- - if (!options) return +const Plugin = videojs.getPlugin('plugin') - if (!player.srOptions_) { - player.srOptions_ = {} +class HLSJSConfigHandler extends Plugin { + + constructor (player: videojs.Player, options: HlsjsConfigHandlerOptions) { + super(player, options) + + if (!options) return + + if (!player.srOptions_) { + player.srOptions_ = {} + } + + if (!player.srOptions_.hlsjsConfig) { + player.srOptions_.hlsjsConfig = options.hlsjsConfig + } + + if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { + player.srOptions_.levelLabelHandler = options.levelLabelHandler + } + + registerSourceHandler(videojs) } - if (!player.srOptions_.hlsjsConfig) { - player.srOptions_.hlsjsConfig = options.hlsjsConfig - } + dispose () { + this.player.srOptions_ = undefined - if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { - player.srOptions_.levelLabelHandler = options.levelLabelHandler + const tech = this.player.tech(true) as any + if (tech.hlsProvider) { + tech.hlsProvider.dispose() + tech.hlsProvider = undefined + } + + super.dispose() } } -const registerConfigPlugin = function (vjs: typeof videojs) { - // Used in Brightcove since we don't pass options directly there - const registerVjsPlugin = vjs.registerPlugin || vjs.plugin - registerVjsPlugin('hlsjs', hlsjsConfigHandler) -} +videojs.registerPlugin('hlsjs', HLSJSConfigHandler) -class Html5Hlsjs { - private static readonly hooks: { [id: string]: HookFn[] } = {} +// --------------------------------------------------------------------------- +// HLS JS source handler +// --------------------------------------------------------------------------- + +export class Html5Hlsjs { + private static hooks: { [id: string]: HookFn[] } = {} private readonly videoElement: HTMLVideoElement private readonly errorCounts: ErrorCounts = {} @@ -101,8 +131,9 @@ class Html5Hlsjs { private dvrDuration: number = null private edgeMargin: number = null - private handlers: { [ id in 'play' ]: EventListener } = { - play: null + private handlers: { [ id in 'play' | 'error' ]: EventListener } = { + play: null, + error: null } constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { @@ -115,7 +146,7 @@ class Html5Hlsjs { this.videoElement = tech.el() as HTMLVideoElement this.player = vjs((tech.options_ as any).playerId) - this.videoElement.addEventListener('error', event => { + this.handlers.error = event => { let errorTxt: string const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error @@ -143,7 +174,8 @@ class Html5Hlsjs { } logger.error(`MEDIA_ERROR: ${errorTxt}`) - }) + } + this.videoElement.addEventListener('error', this.handlers.error) this.initialize() } @@ -174,6 +206,7 @@ class Html5Hlsjs { // See comment for `initialize` method. dispose () { this.videoElement.removeEventListener('play', this.handlers.play) + this.videoElement.removeEventListener('error', this.handlers.error) // FIXME: https://github.com/video-dev/hls.js/issues/4092 const untypedHLS = this.hls as any @@ -200,6 +233,10 @@ class Html5Hlsjs { return true } + static removeAllHooks () { + Html5Hlsjs.hooks = {} + } + private _executeHooksFor (type: string) { if (Html5Hlsjs.hooks[type] === undefined) { return @@ -421,7 +458,7 @@ class Html5Hlsjs { ? data.level : -1 - this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true }) + this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false }) }) this.hls.attachMedia(this.videoElement) @@ -433,9 +470,3 @@ class Html5Hlsjs { this._initHlsjs() } } - -export { - Html5Hlsjs, - registerSourceHandler, - registerConfigPlugin -} diff --git a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts index e6f525fea..fe967a730 100644 --- a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts +++ b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts @@ -3,19 +3,12 @@ import videojs from 'video.js' import { Events, Segment } from '@peertube/p2p-media-loader-core' import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' import { logger } from '@root-helpers/logger' -import { addQueryParams, timeToInt } from '@shared/core-utils' +import { addQueryParams } from '@shared/core-utils' import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' -import { registerConfigPlugin, registerSourceHandler } from './hls-plugin' - -registerConfigPlugin(videojs) -registerSourceHandler(videojs) +import { SettingsButton } from '../settings/settings-menu-button' const Plugin = videojs.getPlugin('plugin') class P2pMediaLoaderPlugin extends Plugin { - - private readonly CONSTANTS = { - INFO_SCHEDULER: 1000 // Don't change this - } private readonly options: P2PMediaLoaderPluginOptions private hlsjs: Hlsjs @@ -31,7 +24,6 @@ class P2pMediaLoaderPlugin extends Plugin { pendingDownload: [] as number[], totalDownload: 0 } - private startTime: number private networkInfoInterval: any @@ -39,7 +31,6 @@ class P2pMediaLoaderPlugin extends Plugin { super(player) this.options = options - this.startTime = timeToInt(options.startTime) // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 if (!(videojs as any).Html5Hlsjs) { @@ -77,17 +68,22 @@ class P2pMediaLoaderPlugin extends Plugin { }) player.ready(() => { - this.initializeCore() - this.initializePlugin() }) } dispose () { - if (this.hlsjs) this.hlsjs.destroy() - if (this.p2pEngine) this.p2pEngine.destroy() + this.p2pEngine?.removeAllListeners() + this.p2pEngine?.destroy() + + this.hlsjs?.destroy() + this.options.segmentValidator?.destroy(); + + (videojs as any).Html5Hlsjs?.removeAllHooks() clearInterval(this.networkInfoInterval) + + super.dispose() } getCurrentLevel () { @@ -104,18 +100,6 @@ class P2pMediaLoaderPlugin extends Plugin { return this.hlsjs } - private initializeCore () { - this.player.one('play', () => { - this.player.addClass('vjs-has-big-play-button-clicked') - }) - - this.player.one('canplay', () => { - if (this.startTime) { - this.player.currentTime(this.startTime) - } - }) - } - private initializePlugin () { initHlsJsPlayer(this.hlsjs) @@ -133,7 +117,7 @@ class P2pMediaLoaderPlugin extends Plugin { this.runStats() - this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engineResolutionChange')) + this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engine-resolution-change')) } private runStats () { @@ -167,7 +151,7 @@ class P2pMediaLoaderPlugin extends Plugin { this.statsP2PBytes.pendingUpload = [] this.statsHTTPBytes.pendingDownload = [] - return this.player.trigger('p2pInfo', { + return this.player.trigger('p2p-info', { source: 'p2p-media-loader', http: { downloadSpeed: httpDownloadSpeed, @@ -182,7 +166,7 @@ class P2pMediaLoaderPlugin extends Plugin { }, bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8 } as PlayerNetworkInfo) - }, this.CONSTANTS.INFO_SCHEDULER) + }, 1000) } private arraySum (data: number[]) { @@ -190,10 +174,7 @@ class P2pMediaLoaderPlugin extends Plugin { } private fallbackToBuiltInIOS () { - logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.'); - - // Workaround to force video.js to not re create a video element - (this.player as any).playerElIngest_ = this.player.el().parentNode + logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.') this.player.src({ type: this.options.type, @@ -203,9 +184,14 @@ class P2pMediaLoaderPlugin extends Plugin { }) }) - this.player.ready(() => { - this.initializeCore() - }) + // Resolution button is not supported in built-in HLS player + this.getResolutionButton().hide() + } + + private getResolutionButton () { + const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton + + return settingsButton.menu.getChild('resolutionMenuButton') } } diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts index e86d3d159..a2f7e676d 100644 --- a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts +++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts @@ -9,30 +9,29 @@ type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string const maxRetries = 10 -function segmentValidatorFactory (options: { - serverUrl: string - segmentsSha256Url: string - authorizationHeader: () => string - requiresUserAuth: boolean - requiresPassword: boolean - videoPassword: () => string -}) { - const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options +export class SegmentValidator { - let segmentsJSON = fetchSha256Segments({ - serverUrl, - segmentsSha256Url, - authorizationHeader, - requiresUserAuth, - requiresPassword, - videoPassword - }) - const regex = /bytes=(\d+)-(\d+)/ + private readonly bytesRangeRegex = /bytes=(\d+)-(\d+)/ + + private destroyed = false + + constructor (private readonly options: { + serverUrl: string + segmentsSha256Url: string + authorizationHeader: () => string + requiresUserAuth: boolean + requiresPassword: boolean + videoPassword: () => string + }) { + + } + + async validate (segment: Segment, _method: string, _peerId: string, retry = 1) { + if (this.destroyed) return - return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { const filename = basename(removeQueryParams(segment.url)) - const segmentValue = (await segmentsJSON)[filename] + const segmentValue = (await this.fetchSha256Segments())[filename] if (!segmentValue && retry > maxRetries) { throw new Error(`Unknown segment name ${filename} in segment validator`) @@ -43,15 +42,7 @@ function segmentValidatorFactory (options: { await wait(500) - segmentsJSON = fetchSha256Segments({ - serverUrl, - segmentsSha256Url, - authorizationHeader, - requiresUserAuth, - requiresPassword, - videoPassword - }) - await segmentValidator(segment, _method, _peerId, retry + 1) + await this.validate(segment, _method, _peerId, retry + 1) return } @@ -62,7 +53,7 @@ function segmentValidatorFactory (options: { if (typeof segmentValue === 'string') { hashShouldBe = segmentValue } else { - const captured = regex.exec(segment.range) + const captured = this.bytesRangeRegex.exec(segment.range) range = captured[1] + '-' + captured[2] hashShouldBe = segmentValue[range] @@ -72,7 +63,7 @@ function segmentValidatorFactory (options: { throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) } - const calculatedSha = await sha256Hex(segment.data) + const calculatedSha = await this.sha256Hex(segment.data) if (calculatedSha !== hashShouldBe) { throw new Error( `Hashes does not correspond for segment ${filename}/${range}` + @@ -80,65 +71,53 @@ function segmentValidatorFactory (options: { ) } } -} -// --------------------------------------------------------------------------- - -export { - segmentValidatorFactory -} - -// --------------------------------------------------------------------------- - -function fetchSha256Segments (options: { - serverUrl: string - segmentsSha256Url: string - authorizationHeader: () => string - requiresUserAuth: boolean - requiresPassword: boolean - videoPassword: () => string -}): Promise { - const { serverUrl, segmentsSha256Url, requiresUserAuth, authorizationHeader, requiresPassword, videoPassword } = options - - let headers: { [ id: string ]: string } = {} - if (isSameOrigin(serverUrl, segmentsSha256Url)) { - if (requiresPassword) headers = { 'x-peertube-video-password': videoPassword() } - else if (requiresUserAuth) headers = { Authorization: authorizationHeader() } + destroy () { + this.destroyed = true } - return fetch(segmentsSha256Url, { headers }) - .then(res => res.json() as Promise) - .catch(err => { - logger.error('Cannot get sha256 segments', err) - return {} + private fetchSha256Segments (): Promise { + let headers: { [ id: string ]: string } = {} + + if (isSameOrigin(this.options.serverUrl, this.options.segmentsSha256Url)) { + if (this.options.requiresPassword) headers = { 'x-peertube-video-password': this.options.videoPassword() } + else if (this.options.requiresUserAuth) headers = { Authorization: this.options.authorizationHeader() } + } + + return fetch(this.options.segmentsSha256Url, { headers }) + .then(res => res.json() as Promise) + .catch(err => { + logger.error('Cannot get sha256 segments', err) + return {} + }) + } + + private async sha256Hex (data?: ArrayBuffer) { + if (!data) return undefined + + if (window.crypto.subtle) { + return window.crypto.subtle.digest('SHA-256', data) + .then(data => this.bufferToHex(data)) + } + + // Fallback for non HTTPS context + const shaModule = (await import('sha.js') as any).default + // eslint-disable-next-line new-cap + return new shaModule.sha256().update(Buffer.from(data)).digest('hex') + } + + // Thanks: https://stackoverflow.com/a/53307879 + private bufferToHex (buffer?: ArrayBuffer) { + if (!buffer) return '' + + let s = '' + const h = '0123456789abcdef' + const o = new Uint8Array(buffer) + + o.forEach((v: any) => { + s += h[v >> 4] + h[v & 15] }) -} -async function sha256Hex (data?: ArrayBuffer) { - if (!data) return undefined - - if (window.crypto.subtle) { - return window.crypto.subtle.digest('SHA-256', data) - .then(data => bufferToHex(data)) + return s } - - // Fallback for non HTTPS context - const shaModule = (await import('sha.js') as any).default - // eslint-disable-next-line new-cap - return new shaModule.sha256().update(Buffer.from(data)).digest('hex') -} - -// Thanks: https://stackoverflow.com/a/53307879 -function bufferToHex (buffer?: ArrayBuffer) { - if (!buffer) return '' - - let s = '' - const h = '0123456789abcdef' - const o = new Uint8Array(buffer) - - o.forEach((v: any) => { - s += h[v >> 4] + h[v & 15] - }) - - return s } diff --git a/client/src/assets/player/shared/peertube/peertube-plugin.ts b/client/src/assets/player/shared/peertube/peertube-plugin.ts index af2147749..f52ec75f4 100644 --- a/client/src/assets/player/shared/peertube/peertube-plugin.ts +++ b/client/src/assets/player/shared/peertube/peertube-plugin.ts @@ -1,7 +1,7 @@ import debug from 'debug' import videojs from 'video.js' import { logger } from '@root-helpers/logger' -import { isMobile } from '@root-helpers/web-browser' +import { isIOS, isMobile } from '@root-helpers/web-browser' import { timeToInt } from '@shared/core-utils' import { VideoView, VideoViewEvent } from '@shared/models/videos' import { @@ -13,7 +13,7 @@ import { saveVideoWatchHistory, saveVolumeInStore } from '../../peertube-player-local-storage' -import { PeerTubePluginOptions, VideoJSCaption } from '../../types' +import { PeerTubePluginOptions } from '../../types' import { SettingsButton } from '../settings/settings-menu-button' const debugLogger = debug('peertube:player:peertube') @@ -21,43 +21,59 @@ const debugLogger = debug('peertube:player:peertube') const Plugin = videojs.getPlugin('plugin') class PeerTubePlugin extends Plugin { - private readonly videoViewUrl: string + private readonly videoViewUrl: () => string private readonly authorizationHeader: () => string + private readonly initialInactivityTimeout: number - private readonly videoUUID: string - private readonly startTime: number + private readonly hasAutoplay: () => videojs.Autoplay - private readonly videoViewIntervalMs: number - - private videoCaptions: VideoJSCaption[] - private defaultSubtitle: string + private currentSubtitle: string + private currentPlaybackRate: number private videoViewInterval: any private menuOpened = false private mouseInControlBar = false private mouseInSettings = false - private readonly initialInactivityTimeout: number - constructor (player: videojs.Player, options?: PeerTubePluginOptions) { + private videoViewOnPlayHandler: (...args: any[]) => void + private videoViewOnSeekedHandler: (...args: any[]) => void + private videoViewOnEndedHandler: (...args: any[]) => void + + private stopTimeHandler: (...args: any[]) => void + + constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) { super(player) this.videoViewUrl = options.videoViewUrl this.authorizationHeader = options.authorizationHeader - this.videoUUID = options.videoUUID - this.startTime = timeToInt(options.startTime) - this.videoViewIntervalMs = options.videoViewIntervalMs + this.hasAutoplay = options.hasAutoplay - this.videoCaptions = options.videoCaptions this.initialInactivityTimeout = this.player.options_.inactivityTimeout - if (options.autoplay !== false) this.player.addClass('vjs-has-autoplay') + this.currentSubtitle = this.options.subtitle() || getStoredLastSubtitle() + + this.initializePlayer() + this.initOnVideoChange() + + this.deleteLegacyIndexedDB() this.player.on('autoplay-failure', () => { + debugLogger('Autoplay failed') + this.player.removeClass('vjs-has-autoplay') + + // Fix a bug on iOS where the big play button is not displayed when autoplay fails + if (isIOS()) this.player.hasStarted(false) }) - this.player.ready(() => { + this.player.on('ratechange', () => { + this.currentPlaybackRate = this.player.playbackRate() + + this.player.defaultPlaybackRate(this.currentPlaybackRate) + }) + + this.player.one('canplay', () => { const playerOptions = this.player.options_ const volume = getStoredVolume() @@ -65,28 +81,15 @@ class PeerTubePlugin extends Plugin { const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() if (muted !== undefined) this.player.muted(muted) + }) - this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() + this.player.ready(() => { this.player.on('volumechange', () => { saveVolumeInStore(this.player.volume()) saveMuteInStore(this.player.muted()) }) - if (options.stopTime) { - const stopTime = timeToInt(options.stopTime) - const self = this - - this.player.on('timeupdate', function onTimeUpdate () { - if (self.player.currentTime() > stopTime) { - self.player.pause() - self.player.trigger('stopped') - - self.player.off('timeupdate', onTimeUpdate) - } - }) - } - this.player.textTracks().addEventListener('change', () => { const showing = this.player.textTracks().tracks_.find(t => { return t.kind === 'captions' && t.mode === 'showing' @@ -94,23 +97,24 @@ class PeerTubePlugin extends Plugin { if (!showing) { saveLastSubtitle('off') + this.currentSubtitle = undefined return } + this.currentSubtitle = showing.language saveLastSubtitle(showing.language) }) - this.player.on('sourcechange', () => this.initCaptions()) - - this.player.duration(options.videoDuration) - - this.initializePlayer() - this.runUserViewing() + this.player.on('video-change', () => { + this.initOnVideoChange() + }) }) } dispose () { if (this.videoViewInterval) clearInterval(this.videoViewInterval) + + super.dispose() } onMenuOpened () { @@ -162,40 +166,70 @@ class PeerTubePlugin extends Plugin { this.initSmoothProgressBar() - this.initCaptions() - - this.listenControlBarMouse() + this.player.ready(() => { + this.listenControlBarMouse() + }) this.listenFullScreenChange() } + private initOnVideoChange () { + if (this.hasAutoplay() !== false) this.player.addClass('vjs-has-autoplay') + else this.player.removeClass('vjs-has-autoplay') + + if (this.currentPlaybackRate && this.currentPlaybackRate !== 1) { + debugLogger('Setting playback rate to ' + this.currentPlaybackRate) + + this.player.playbackRate(this.currentPlaybackRate) + } + + this.player.ready(() => { + this.initCaptions() + this.updateControlBar() + }) + + this.handleStartStopTime() + this.runUserViewing() + } + // --------------------------------------------------------------------------- private runUserViewing () { - let lastCurrentTime = this.startTime + const startTime = timeToInt(this.options.startTime()) + + let lastCurrentTime = startTime let lastViewEvent: VideoViewEvent - this.player.one('play', () => { - this.notifyUserIsWatching(this.startTime, lastViewEvent) - }) + if (this.videoViewInterval) clearInterval(this.videoViewInterval) + if (this.videoViewOnPlayHandler) this.player.off('play', this.videoViewOnPlayHandler) + if (this.videoViewOnSeekedHandler) this.player.off('seeked', this.videoViewOnSeekedHandler) + if (this.videoViewOnEndedHandler) this.player.off('ended', this.videoViewOnEndedHandler) - this.player.on('seeked', () => { + this.videoViewOnPlayHandler = () => { + this.notifyUserIsWatching(startTime, lastViewEvent) + } + + this.videoViewOnSeekedHandler = () => { const diff = Math.floor(this.player.currentTime()) - lastCurrentTime // Don't take into account small forwards if (diff > 0 && diff < 3) return lastViewEvent = 'seek' - }) + } - this.player.one('ended', () => { + this.videoViewOnEndedHandler = () => { const currentTime = Math.floor(this.player.duration()) lastCurrentTime = currentTime this.notifyUserIsWatching(currentTime, lastViewEvent) lastViewEvent = undefined - }) + } + + this.player.one('play', this.videoViewOnPlayHandler) + this.player.on('seeked', this.videoViewOnSeekedHandler) + this.player.one('ended', this.videoViewOnEndedHandler) this.videoViewInterval = setInterval(() => { const currentTime = Math.floor(this.player.currentTime()) @@ -209,13 +243,13 @@ class PeerTubePlugin extends Plugin { .catch(err => logger.error('Cannot notify user is watching.', err)) lastViewEvent = undefined - }, this.videoViewIntervalMs) + }, this.options.videoViewIntervalMs) } private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { // Server won't save history, so save the video position in local storage if (!this.authorizationHeader()) { - saveVideoWatchHistory(this.videoUUID, currentTime) + saveVideoWatchHistory(this.options.videoUUID(), currentTime) } if (!this.videoViewUrl) return Promise.resolve(true) @@ -225,7 +259,7 @@ class PeerTubePlugin extends Plugin { const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader()) - return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers }) + return fetch(this.videoViewUrl(), { method: 'POST', body: JSON.stringify(body), headers }) } // --------------------------------------------------------------------------- @@ -279,18 +313,89 @@ class PeerTubePlugin extends Plugin { } private initCaptions () { - for (const caption of this.videoCaptions) { + debugLogger('Init captions with current subtitle ' + this.currentSubtitle) + + this.player.tech(true).clearTracks('text') + + for (const caption of this.options.videoCaptions()) { this.player.addRemoteTextTrack({ kind: 'captions', label: caption.label, language: caption.language, id: caption.language, src: caption.src, - default: this.defaultSubtitle === caption.language - }, false) + default: this.currentSubtitle === caption.language + }, true) } - this.player.trigger('captionsChanged') + this.player.trigger('captions-changed') + } + + private updateControlBar () { + debugLogger('Updating control bar') + + if (this.options.isLive()) { + this.getPlaybackRateButton().hide() + + this.player.controlBar.getChild('progressControl').hide() + this.player.controlBar.getChild('currentTimeDisplay').hide() + this.player.controlBar.getChild('timeDivider').hide() + this.player.controlBar.getChild('durationDisplay').hide() + + this.player.controlBar.getChild('peerTubeLiveDisplay').show() + } else { + this.getPlaybackRateButton().show() + + this.player.controlBar.getChild('progressControl').show() + this.player.controlBar.getChild('currentTimeDisplay').show() + this.player.controlBar.getChild('timeDivider').show() + this.player.controlBar.getChild('durationDisplay').show() + + this.player.controlBar.getChild('peerTubeLiveDisplay').hide() + } + + if (this.options.videoCaptions().length === 0) { + this.getCaptionsButton().hide() + } else { + this.getCaptionsButton().show() + } + } + + private handleStartStopTime () { + this.player.duration(this.options.videoDuration()) + + if (this.stopTimeHandler) { + this.player.off('timeupdate', this.stopTimeHandler) + this.stopTimeHandler = undefined + } + + // Prefer canplaythrough instead of canplay because Chrome has issues with the second one + this.player.one('canplaythrough', () => { + if (this.options.startTime()) { + debugLogger('Start the video at ' + this.options.startTime()) + + this.player.currentTime(timeToInt(this.options.startTime())) + } + + if (this.options.stopTime()) { + const stopTime = timeToInt(this.options.stopTime()) + + this.stopTimeHandler = () => { + if (this.player.currentTime() <= stopTime) return + + debugLogger('Stopping the video at ' + this.options.stopTime()) + + // Time top stop + this.player.pause() + this.player.trigger('auto-stopped') + + this.player.off('timeupdate', this.stopTimeHandler) + this.stopTimeHandler = undefined + } + + this.player.on('timeupdate', this.stopTimeHandler) + } + }) } // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 @@ -314,6 +419,37 @@ class PeerTubePlugin extends Plugin { this.update() } } + + private getCaptionsButton () { + const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton + + return settingsButton.menu.getChild('captionsButton') as videojs.CaptionsButton + } + + private getPlaybackRateButton () { + const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton + + return settingsButton.menu.getChild('playbackRateMenuButton') + } + + // We don't use webtorrent anymore, so we can safely remove old chunks from IndexedDB + private deleteLegacyIndexedDB () { + try { + if (typeof window.indexedDB === 'undefined') return + if (!window.indexedDB) return + if (typeof window.indexedDB.databases !== 'function') return + + window.indexedDB.databases() + .then(databases => { + for (const db of databases) { + window.indexedDB.deleteDatabase(db.name) + } + }) + } catch (err) { + debugLogger('Cannot delete legacy indexed DB', err) + // Nothing to do + } + } } videojs.registerPlugin('peertube', PeerTubePlugin) diff --git a/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts b/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts new file mode 100644 index 000000000..b467e3637 --- /dev/null +++ b/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts @@ -0,0 +1,136 @@ +import { + NextPreviousVideoButtonOptions, + PeerTubeLinkButtonOptions, + PeerTubePlayerContructorOptions, + PeerTubePlayerLoadOptions, + TheaterButtonOptions +} from '../../types' + +type ControlBarOptionsBuilderConstructorOptions = + Pick & + { + videoShortUUID: () => string + p2pEnabled: () => boolean + + previousVideo: () => PeerTubePlayerLoadOptions['previousVideo'] + nextVideo: () => PeerTubePlayerLoadOptions['nextVideo'] + } + +export class ControlBarOptionsBuilder { + + constructor (private options: ControlBarOptionsBuilderConstructorOptions) { + } + + getChildrenOptions () { + const children = { + ...this.getPreviousVideo(), + + playToggle: {}, + + ...this.getNextVideo(), + + ...this.getTimeControls(), + + ...this.getProgressControl(), + + p2PInfoButton: {}, + muteToggle: {}, + volumeControl: {}, + + ...this.getSettingsButton(), + + ...this.getPeerTubeLinkButton(), + + ...this.getTheaterButton(), + + fullscreenToggle: {} + } + + return children + } + + private getSettingsButton () { + const settingEntries: string[] = [] + + settingEntries.push('playbackRateMenuButton') + settingEntries.push('captionsButton') + settingEntries.push('resolutionMenuButton') + + return { + settingsButton: { + setup: { + maxHeightOffset: 40 + }, + entries: settingEntries + } + } + } + + private getTimeControls () { + return { + peerTubeLiveDisplay: {}, + + currentTimeDisplay: {}, + timeDivider: {}, + durationDisplay: {} + } + } + + private getProgressControl () { + return { + progressControl: { + children: { + seekBar: { + children: { + loadProgressBar: {}, + mouseTimeDisplay: {}, + playProgressBar: {} + } + } + } + } + } + } + + private getPreviousVideo () { + const buttonOptions: NextPreviousVideoButtonOptions = { + type: 'previous', + handler: () => this.options.previousVideo().handler(), + isDisabled: () => !this.options.previousVideo().enabled, + isDisplayed: () => this.options.previousVideo().displayControlBarButton + } + + return { previousVideoButton: buttonOptions } + } + + private getNextVideo () { + const buttonOptions: NextPreviousVideoButtonOptions = { + type: 'next', + handler: () => this.options.nextVideo().handler(), + isDisabled: () => !this.options.nextVideo().enabled, + isDisplayed: () => this.options.nextVideo().displayControlBarButton + } + + return { nextVideoButton: buttonOptions } + } + + private getPeerTubeLinkButton () { + const options: PeerTubeLinkButtonOptions = { + isDisplayed: this.options.peertubeLink, + shortUUID: this.options.videoShortUUID, + instanceName: this.options.instanceName + } + + return { peerTubeLinkButton: options } + } + + private getTheaterButton () { + const options: TheaterButtonOptions = { + isDisplayed: () => this.options.theaterButton + } + + return { + theaterButton: options + } + } +} diff --git a/client/src/assets/player/shared/manager-options/hls-options-builder.ts b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts similarity index 67% rename from client/src/assets/player/shared/manager-options/hls-options-builder.ts rename to client/src/assets/player/shared/player-options-builder/hls-options-builder.ts index 8091110bc..10df2db5d 100644 --- a/client/src/assets/player/shared/manager-options/hls-options-builder.ts +++ b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts @@ -3,49 +3,61 @@ import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' import { logger } from '@root-helpers/logger' import { LiveVideoLatencyMode } from '@shared/models' import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' -import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types' -import { PeertubePlayerManagerOptions } from '../../types/manager-options' +import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types' import { getRtcConfig, isSameOrigin } from '../common' import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' -import { segmentValidatorFactory } from '../p2p-media-loader/segment-validator' +import { SegmentValidator } from '../p2p-media-loader/segment-validator' + +type ConstructorOptions = + Pick & + Pick export class HLSOptionsBuilder { constructor ( - private options: PeertubePlayerManagerOptions, + private options: ConstructorOptions, private p2pMediaLoaderModule?: any ) { } async getPluginOptions () { - const commonOptions = this.options.common - - const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls) + const redundancyUrlManager = new RedundancyUrlManager(this.options.hls.redundancyBaseUrls) + const segmentValidator = new SegmentValidator({ + segmentsSha256Url: this.options.hls.segmentsSha256Url, + authorizationHeader: this.options.authorizationHeader, + requiresUserAuth: this.options.requiresUserAuth, + serverUrl: this.options.serverUrl, + requiresPassword: this.options.requiresPassword, + videoPassword: this.options.videoPassword + }) const p2pMediaLoaderConfig = await this.options.pluginsManager.runHook( 'filter:internal.player.p2p-media-loader.options.result', - this.getP2PMediaLoaderOptions(redundancyUrlManager) + this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator }) ) const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader const p2pMediaLoader: P2PMediaLoaderPluginOptions = { - requiresUserAuth: commonOptions.requiresUserAuth, - videoFileToken: commonOptions.videoFileToken, + requiresUserAuth: this.options.requiresUserAuth, + videoFileToken: this.options.videoFileToken, redundancyUrlManager, type: 'application/x-mpegURL', - startTime: commonOptions.startTime, - src: this.options.p2pMediaLoader.playlistUrl, + src: this.options.hls.playlistUrl, + segmentValidator, loader } const hlsjs = { + hlsjsConfig: this.getHLSJSOptions(loader), + levelLabelHandler: (level: { height: number, width: number }) => { const resolution = Math.min(level.height || 0, level.width || 0) - const file = this.options.p2pMediaLoader.videoFiles.find(f => f.resolution.id === resolution) + const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution) // We don't have files for live videos if (!file) return level.height @@ -56,26 +68,27 @@ export class HLSOptionsBuilder { } } - const html5 = { - hlsjsConfig: this.getHLSJSOptions(loader) - } - - return { p2pMediaLoader, hlsjs, html5 } + return { p2pMediaLoader, hlsjs } } // --------------------------------------------------------------------------- - private getP2PMediaLoaderOptions (redundancyUrlManager: RedundancyUrlManager): HlsJsEngineSettings { + private getP2PMediaLoaderOptions (options: { + redundancyUrlManager: RedundancyUrlManager + segmentValidator: SegmentValidator + }): HlsJsEngineSettings { + const { redundancyUrlManager, segmentValidator } = options + let consumeOnly = false if ((navigator as any)?.connection?.type === 'cellular') { logger.info('We are on a cellular connection: disabling seeding.') consumeOnly = true } - const trackerAnnounce = this.options.p2pMediaLoader.trackerAnnounce - .filter(t => t.startsWith('ws')) + const trackerAnnounce = this.options.hls.trackerAnnounce + .filter(t => t.startsWith('ws')) - const specificLiveOrVODOptions = this.options.common.isLive + const specificLiveOrVODOptions = this.options.isLive ? this.getP2PMediaLoaderLiveOptions() : this.getP2PMediaLoaderVODOptions() @@ -88,35 +101,28 @@ export class HLSOptionsBuilder { httpFailedSegmentTimeout: 1000, xhrSetup: (xhr, url) => { - const { requiresUserAuth, requiresPassword } = this.options.common + const { requiresUserAuth, requiresPassword } = this.options if (!(requiresUserAuth || requiresPassword)) return - if (!isSameOrigin(this.options.common.serverUrl, url)) return + if (!isSameOrigin(this.options.serverUrl, url)) return - if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.common.videoPassword()) + if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.videoPassword()) - else xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) + else xhr.setRequestHeader('Authorization', this.options.authorizationHeader()) }, - segmentValidator: segmentValidatorFactory({ - segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url, - authorizationHeader: this.options.common.authorizationHeader, - requiresUserAuth: this.options.common.requiresUserAuth, - serverUrl: this.options.common.serverUrl, - requiresPassword: this.options.common.requiresPassword, - videoPassword: this.options.common.videoPassword - }), + segmentValidator: segmentValidator.validate.bind(segmentValidator), segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), - useP2P: this.options.common.p2pEnabled, + useP2P: this.options.p2pEnabled, consumeOnly, ...specificLiveOrVODOptions }, segments: { - swarmId: this.options.p2pMediaLoader.playlistUrl, + swarmId: this.options.hls.playlistUrl, forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20 } } @@ -127,7 +133,7 @@ export class HLSOptionsBuilder { requiredSegmentsPriority: 1 } - const latencyMode = this.options.common.liveOptions.latencyMode + const latencyMode = this.options.liveOptions.latencyMode switch (latencyMode) { case LiveVideoLatencyMode.SMALL_LATENCY: @@ -165,7 +171,7 @@ export class HLSOptionsBuilder { // --------------------------------------------------------------------------- private getHLSJSOptions (loader: P2PMediaLoader) { - const specificLiveOrVODOptions = this.options.common.isLive + const specificLiveOrVODOptions = this.options.isLive ? this.getHLSLiveOptions() : this.getHLSVODOptions() @@ -193,7 +199,7 @@ export class HLSOptionsBuilder { } private getHLSLiveOptions () { - const latencyMode = this.options.common.liveOptions.latencyMode + const latencyMode = this.options.liveOptions.latencyMode switch (latencyMode) { case LiveVideoLatencyMode.SMALL_LATENCY: diff --git a/client/src/assets/player/shared/player-options-builder/index.ts b/client/src/assets/player/shared/player-options-builder/index.ts new file mode 100644 index 000000000..674754a94 --- /dev/null +++ b/client/src/assets/player/shared/player-options-builder/index.ts @@ -0,0 +1,3 @@ +export * from './control-bar-options-builder' +export * from './hls-options-builder' +export * from './web-video-options-builder' diff --git a/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts b/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts new file mode 100644 index 000000000..a3c3c3f27 --- /dev/null +++ b/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts @@ -0,0 +1,22 @@ +import { PeerTubePlayerLoadOptions, WebVideoPluginOptions } from '../../types' + +type ConstructorOptions = Pick + +export class WebVideoOptionsBuilder { + + constructor (private options: ConstructorOptions) { + + } + + getPluginOptions (): WebVideoPluginOptions { + return { + videoFileToken: this.options.videoFileToken, + + videoFiles: this.options.webVideo.videoFiles.length !== 0 + ? this.options.webVideo.videoFiles + : this.options?.hls.videoFiles || [], + + startTime: this.options.startTime + } + } +} diff --git a/client/src/assets/player/shared/playlist/playlist-button.ts b/client/src/assets/player/shared/playlist/playlist-button.ts index 6cfaf4158..45cbb4899 100644 --- a/client/src/assets/player/shared/playlist/playlist-button.ts +++ b/client/src/assets/player/shared/playlist/playlist-button.ts @@ -8,8 +8,15 @@ class PlaylistButton extends ClickableComponent { private playlistInfoElement: HTMLElement private wrapper: HTMLElement - constructor (player: videojs.Player, options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu }) { - super(player, options as any) + options_: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions + + // FIXME: eslint -> it's not a useless constructor, we need to extend constructor options typings + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor ( + player: videojs.Player, + options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions + ) { + super(player, options) } createEl () { @@ -40,20 +47,15 @@ class PlaylistButton extends ClickableComponent { } update () { - const options = this.options_ as PlaylistPluginOptions + this.playlistInfoElement.innerHTML = this.options_.getCurrentPosition() + '/' + this.options_.playlist.videosLength - this.playlistInfoElement.innerHTML = options.getCurrentPosition() + '/' + options.playlist.videosLength - this.wrapper.title = this.player().localize('Playlist: {1}', [ options.playlist.displayName ]) + this.wrapper.title = this.player().localize('Playlist: {1}', [ this.options_.playlist.displayName ]) } handleClick () { - const playlistMenu = this.getPlaylistMenu() + const playlistMenu = this.options_.playlistMenu playlistMenu.open() } - - private getPlaylistMenu () { - return (this.options_ as any).playlistMenu as PlaylistMenu - } } videojs.registerComponent('PlaylistButton', PlaylistButton) diff --git a/client/src/assets/player/shared/playlist/playlist-menu-item.ts b/client/src/assets/player/shared/playlist/playlist-menu-item.ts index 81b5acf30..f9366332d 100644 --- a/client/src/assets/player/shared/playlist/playlist-menu-item.ts +++ b/client/src/assets/player/shared/playlist/playlist-menu-item.ts @@ -8,6 +8,11 @@ const Component = videojs.getComponent('Component') class PlaylistMenuItem extends Component { private element: VideoPlaylistElement + private clickHandler: () => void + private keyDownHandler: (event: KeyboardEvent) => void + + options_: videojs.ComponentOptions & PlaylistItemOptions + constructor (player: videojs.Player, options?: PlaylistItemOptions) { super(player, options as any) @@ -15,19 +20,27 @@ class PlaylistMenuItem extends Component { this.element = options.element - this.on([ 'click', 'tap' ], () => this.switchPlaylistItem()) - this.on('keydown', event => this.handleKeyDown(event)) + this.clickHandler = () => this.switchPlaylistItem() + this.keyDownHandler = event => this.handleKeyDown(event) + + this.on([ 'click', 'tap' ], this.clickHandler) + this.on('keydown', this.keyDownHandler) + } + + dispose () { + this.off([ 'click', 'tap' ], this.clickHandler) + this.off('keydown', this.keyDownHandler) + + super.dispose() } createEl () { - const options = this.options_ as PlaylistItemOptions - const li = super.createEl('li', { className: 'vjs-playlist-menu-item', innerHTML: '' }) as HTMLElement - if (!options.element.video) { + if (!this.options_.element.video) { li.classList.add('vjs-disabled') } @@ -37,14 +50,14 @@ class PlaylistMenuItem extends Component { const position = super.createEl('div', { className: 'item-position', - innerHTML: options.element.position + innerHTML: this.options_.element.position }) positionBlock.appendChild(position) li.appendChild(positionBlock) - if (options.element.video) { - this.buildAvailableVideo(li, positionBlock, options) + if (this.options_.element.video) { + this.buildAvailableVideo(li, positionBlock, this.options_) } else { this.buildUnavailableVideo(li) } @@ -125,9 +138,7 @@ class PlaylistMenuItem extends Component { } private switchPlaylistItem () { - const options = this.options_ as PlaylistItemOptions - - options.onClicked() + this.options_.onClicked() } } diff --git a/client/src/assets/player/shared/playlist/playlist-menu.ts b/client/src/assets/player/shared/playlist/playlist-menu.ts index 1ec9ac804..53a5a7274 100644 --- a/client/src/assets/player/shared/playlist/playlist-menu.ts +++ b/client/src/assets/player/shared/playlist/playlist-menu.ts @@ -6,26 +6,32 @@ import { PlaylistMenuItem } from './playlist-menu-item' const Component = videojs.getComponent('Component') class PlaylistMenu extends Component { - private menuItems: PlaylistMenuItem[] + private menuItems: PlaylistMenuItem[] = [] - constructor (player: videojs.Player, options?: PlaylistPluginOptions) { - super(player, options as any) + private readonly userInactiveHandler: () => void + private readonly onMouseEnter: () => void + private readonly onMouseLeave: () => void - const self = this + private readonly onPlayerCick: (event: Event) => void - function userInactiveHandler () { - self.close() + options_: PlaylistPluginOptions & videojs.ComponentOptions + + constructor (player: videojs.Player, options?: PlaylistPluginOptions & videojs.ComponentOptions) { + super(player, options) + + this.userInactiveHandler = () => { + this.close() } - this.el().addEventListener('mouseenter', () => { - this.player().off('userinactive', userInactiveHandler) - }) + this.onMouseEnter = () => { + this.player().off('userinactive', this.userInactiveHandler) + } - this.el().addEventListener('mouseleave', () => { - this.player().one('userinactive', userInactiveHandler) - }) + this.onMouseLeave = () => { + this.player().one('userinactive', this.userInactiveHandler) + } - this.player().on('click', event => { + this.onPlayerCick = event => { let current = event.target as HTMLElement do { @@ -40,14 +46,31 @@ class PlaylistMenu extends Component { } while (current) this.close() - }) + } + + this.el().addEventListener('mouseenter', this.onMouseEnter) + this.el().addEventListener('mouseleave', this.onMouseLeave) + + this.player().on('click', this.onPlayerCick) + } + + dispose () { + this.el().removeEventListener('mouseenter', this.onMouseEnter) + this.el().removeEventListener('mouseleave', this.onMouseLeave) + + this.player().off('userinactive', this.userInactiveHandler) + this.player().off('click', this.onPlayerCick) + + for (const item of this.menuItems) { + item.dispose() + } + + super.dispose() } createEl () { this.menuItems = [] - const options = this.getOptions() - const menu = super.createEl('div', { className: 'vjs-playlist-menu', innerHTML: '', @@ -61,11 +84,11 @@ class PlaylistMenu extends Component { const headerLeft = super.createEl('div') const leftTitle = super.createEl('div', { - innerHTML: options.playlist.displayName, + innerHTML: this.options_.playlist.displayName, className: 'title' }) - const playlistChannel = options.playlist.videoChannel + const playlistChannel = this.options_.playlist.videoChannel const leftSubtitle = super.createEl('div', { innerHTML: playlistChannel ? this.player().localize('By {1}', [ playlistChannel.displayName ]) @@ -86,7 +109,7 @@ class PlaylistMenu extends Component { const list = super.createEl('ol') - for (const playlistElement of options.elements) { + for (const playlistElement of this.options_.elements) { const item = new PlaylistMenuItem(this.player(), { element: playlistElement, onClicked: () => this.onItemClicked(playlistElement) @@ -100,13 +123,13 @@ class PlaylistMenu extends Component { menu.appendChild(header) menu.appendChild(list) + this.update() + return menu } update () { - const options = this.getOptions() - - this.updateSelected(options.getCurrentPosition()) + this.updateSelected(this.options_.getCurrentPosition()) } open () { @@ -123,12 +146,8 @@ class PlaylistMenu extends Component { } } - private getOptions () { - return this.options_ as PlaylistPluginOptions - } - private onItemClicked (element: VideoPlaylistElement) { - this.getOptions().onItemClicked(element) + this.options_.onItemClicked(element) } } diff --git a/client/src/assets/player/shared/playlist/playlist-plugin.ts b/client/src/assets/player/shared/playlist/playlist-plugin.ts index 44de0da5a..c00e45843 100644 --- a/client/src/assets/player/shared/playlist/playlist-plugin.ts +++ b/client/src/assets/player/shared/playlist/playlist-plugin.ts @@ -8,17 +8,10 @@ const Plugin = videojs.getPlugin('plugin') class PlaylistPlugin extends Plugin { private playlistMenu: PlaylistMenu private playlistButton: PlaylistButton - private options: PlaylistPluginOptions constructor (player: videojs.Player, options?: PlaylistPluginOptions) { super(player, options) - this.options = options - - this.player.ready(() => { - player.addClass('vjs-playlist') - }) - this.playlistMenu = new PlaylistMenu(player, options) this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu }) @@ -26,8 +19,16 @@ class PlaylistPlugin extends Plugin { player.addChild(this.playlistButton, options) } - updateSelected () { - this.playlistMenu.updateSelected(this.options.getCurrentPosition()) + dispose () { + this.player.removeClass('vjs-playlist') + + this.playlistMenu.dispose() + this.playlistButton.dispose() + + this.player.removeChild(this.playlistMenu) + this.player.removeChild(this.playlistButton) + + super.dispose() } } diff --git a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts index 4fafd27b1..4d6701003 100644 --- a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts +++ b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts @@ -8,7 +8,16 @@ class PeerTubeResolutionsPlugin extends Plugin { private resolutions: PeerTubeResolution[] = [] private autoResolutionChosenId: number - private autoResolutionEnabled = true + + constructor (player: videojs.Player) { + super(player) + + player.on('video-change', () => { + this.resolutions = [] + + this.trigger('resolutions-removed') + }) + } add (resolutions: PeerTubeResolution[]) { for (const r of resolutions) { @@ -18,12 +27,12 @@ class PeerTubeResolutionsPlugin extends Plugin { this.currentSelection = this.getSelected() this.sort() - this.trigger('resolutionsAdded') + this.trigger('resolutions-added') } remove (resolutionIndex: number) { this.resolutions = this.resolutions.filter(r => r.id !== resolutionIndex) - this.trigger('resolutionRemoved') + this.trigger('resolutions-removed') } getResolutions () { @@ -40,10 +49,10 @@ class PeerTubeResolutionsPlugin extends Plugin { select (options: { id: number - byEngine: boolean + fireCallback: boolean autoResolutionChosenId?: number }) { - const { id, autoResolutionChosenId, byEngine } = options + const { id, autoResolutionChosenId, fireCallback } = options if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return @@ -55,25 +64,11 @@ class PeerTubeResolutionsPlugin extends Plugin { if (r.selected) { this.currentSelection = r - if (!byEngine) r.selectCallback() + if (fireCallback) r.selectCallback() } } - this.trigger('resolutionChanged') - } - - disableAutoResolution () { - this.autoResolutionEnabled = false - this.trigger('autoResolutionEnabledChanged') - } - - enabledAutoResolution () { - this.autoResolutionEnabled = true - this.trigger('autoResolutionEnabledChanged') - } - - isAutoResolutionEnabeld () { - return this.autoResolutionEnabled + this.trigger('resolutions-changed') } private sort () { diff --git a/client/src/assets/player/shared/settings/resolution-menu-button.ts b/client/src/assets/player/shared/settings/resolution-menu-button.ts index 672411c11..c39894284 100644 --- a/client/src/assets/player/shared/settings/resolution-menu-button.ts +++ b/client/src/assets/player/shared/settings/resolution-menu-button.ts @@ -11,12 +11,12 @@ class ResolutionMenuButton extends MenuButton { this.controlText('Quality') - player.peertubeResolutions().on('resolutionsAdded', () => this.buildQualities()) - player.peertubeResolutions().on('resolutionRemoved', () => this.cleanupQualities()) + player.peertubeResolutions().on('resolutions-added', () => this.update()) + player.peertubeResolutions().on('resolutions-removed', () => this.update()) // For parent - player.peertubeResolutions().on('resolutionChanged', () => { - setTimeout(() => this.trigger('labelUpdated')) + player.peertubeResolutions().on('resolutions-changed', () => { + setTimeout(() => this.trigger('label-updated')) }) } @@ -37,7 +37,34 @@ class ResolutionMenuButton extends MenuButton { } createMenu () { - return new Menu(this.player_) + const menu: videojs.Menu = new Menu(this.player_, { menuButton: this }) + const resolutions = this.player().peertubeResolutions().getResolutions() + + for (const r of resolutions) { + const label = r.label === '0p' + ? this.player().localize('Audio-only') + : r.label + + const component = new ResolutionMenuItem( + this.player_, + { + id: r.id + '', + resolutionId: r.id, + label, + selected: r.selected + } + ) + + menu.addItem(component) + } + + return menu + } + + update () { + super.update() + + this.trigger('menu-changed') } buildCSSClass () { @@ -47,60 +74,6 @@ class ResolutionMenuButton extends MenuButton { buildWrapperCSSClass () { return 'vjs-resolution-control ' + super.buildWrapperCSSClass() } - - private addClickListener (component: any) { - component.on('click', () => { - const children = this.menu.children() - - for (const child of children) { - if (component !== child) { - (child as videojs.MenuItem).selected(false) - } - } - }) - } - - private buildQualities () { - for (const d of this.player().peertubeResolutions().getResolutions()) { - const label = d.label === '0p' - ? this.player().localize('Audio-only') - : d.label - - this.menu.addChild(new ResolutionMenuItem( - this.player_, - { - id: d.id + '', - resolutionId: d.id, - label, - selected: d.selected - }) - ) - } - - for (const m of this.menu.children()) { - this.addClickListener(m) - } - - this.trigger('menuChanged') - } - - private cleanupQualities () { - const resolutions = this.player().peertubeResolutions().getResolutions() - - this.menu.children().forEach((children: ResolutionMenuItem) => { - if (children.resolutionId === undefined) { - return - } - - if (resolutions.find(r => r.id === children.resolutionId)) { - return - } - - this.menu.removeChild(children) - }) - - this.trigger('menuChanged') - } } videojs.registerComponent('ResolutionMenuButton', ResolutionMenuButton) diff --git a/client/src/assets/player/shared/settings/resolution-menu-item.ts b/client/src/assets/player/shared/settings/resolution-menu-item.ts index c59b8b891..86387f533 100644 --- a/client/src/assets/player/shared/settings/resolution-menu-item.ts +++ b/client/src/assets/player/shared/settings/resolution-menu-item.ts @@ -10,35 +10,32 @@ class ResolutionMenuItem extends MenuItem { readonly resolutionId: number private readonly label: string - private autoResolutionEnabled: boolean private autoResolutionChosen: string + private updateSelectionHandler: () => void + constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) { - options.selectable = true + super(player, { ...options, selectable: true }) - super(player, options) - - this.autoResolutionEnabled = true this.autoResolutionChosen = '' this.resolutionId = options.resolutionId this.label = options.label - player.peertubeResolutions().on('resolutionChanged', () => this.updateSelection()) + this.updateSelectionHandler = () => this.updateSelection() + player.peertubeResolutions().on('resolutions-changed', this.updateSelectionHandler) + } - // We only want to disable the "Auto" item - if (this.resolutionId === -1) { - player.peertubeResolutions().on('autoResolutionEnabledChanged', () => this.updateAutoResolution()) - } + dispose () { + this.player().peertubeResolutions().off('resolutions-changed', this.updateSelectionHandler) + + super.dispose() } handleClick (event: any) { - // Auto button disabled? - if (this.autoResolutionEnabled === false && this.resolutionId === -1) return - super.handleClick(event) - this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false }) + this.player().peertubeResolutions().select({ id: this.resolutionId, fireCallback: true }) } updateSelection () { @@ -51,19 +48,6 @@ class ResolutionMenuItem extends MenuItem { this.selected(this.resolutionId === selectedResolution.id) } - updateAutoResolution () { - const enabled = this.player().peertubeResolutions().isAutoResolutionEnabeld() - - // Check if the auto resolution is enabled or not - if (enabled === false) { - this.addClass('disabled') - } else { - this.removeClass('disabled') - } - - this.autoResolutionEnabled = enabled - } - getLabel () { if (this.resolutionId === -1) { return this.label + ' ' + this.autoResolutionChosen + '' diff --git a/client/src/assets/player/shared/settings/settings-dialog.ts b/client/src/assets/player/shared/settings/settings-dialog.ts index f5fbbe7ad..ba39d0f45 100644 --- a/client/src/assets/player/shared/settings/settings-dialog.ts +++ b/client/src/assets/player/shared/settings/settings-dialog.ts @@ -28,6 +28,18 @@ class SettingsDialog extends Component { 'aria-describedby': dialogDescriptionId }) } + + show () { + this.player().addClass('vjs-settings-dialog-opened') + + super.show() + } + + hide () { + this.player().removeClass('vjs-settings-dialog-opened') + + super.hide() + } } Component.registerComponent('SettingsDialog', SettingsDialog) diff --git a/client/src/assets/player/shared/settings/settings-menu-button.ts b/client/src/assets/player/shared/settings/settings-menu-button.ts index 4cf29866b..9499a43eb 100644 --- a/client/src/assets/player/shared/settings/settings-menu-button.ts +++ b/client/src/assets/player/shared/settings/settings-menu-button.ts @@ -71,7 +71,7 @@ class SettingsButton extends Button { } } - onDisposeSettingsItem (event: any, name: string) { + onDisposeSettingsItem (_event: any, name: string) { if (name === undefined) { const children = this.menu.children() @@ -103,6 +103,8 @@ class SettingsButton extends Button { if (this.isInIframe()) { window.removeEventListener('blur', this.documentClickHandler) } + + super.dispose() } onAddSettingsItem (event: any, data: any) { @@ -249,8 +251,8 @@ class SettingsButton extends Button { } resetChildren () { - for (const menuChild of this.menu.children()) { - (menuChild as SettingsMenuItem).reset() + for (const menuChild of this.menu.children() as SettingsMenuItem[]) { + menuChild.reset() } } @@ -258,8 +260,8 @@ class SettingsButton extends Button { * Hide all the sub menus */ hideChildren () { - for (const menuChild of this.menu.children()) { - (menuChild as SettingsMenuItem).hideSubMenu() + for (const menuChild of this.menu.children() as SettingsMenuItem[]) { + menuChild.hideSubMenu() } } diff --git a/client/src/assets/player/shared/settings/settings-menu-item.ts b/client/src/assets/player/shared/settings/settings-menu-item.ts index 288e3b233..9916ae27f 100644 --- a/client/src/assets/player/shared/settings/settings-menu-item.ts +++ b/client/src/assets/player/shared/settings/settings-menu-item.ts @@ -70,17 +70,22 @@ class SettingsMenuItem extends MenuItem { this.build() // Update on rate change - player.on('ratechange', this.submenuClickHandler) + if (subMenuName === 'PlaybackRateMenuButton') { + player.on('ratechange', this.submenuClickHandler) + } if (subMenuName === 'CaptionsButton') { - // Hack to regenerate captions on HTTP fallback - player.on('captionsChanged', () => { + player.on('captions-changed', () => { + // Wait menu component rebuild setTimeout(() => { - this.settingsSubMenuEl_.innerHTML = '' - this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) - this.update() - this.bindClickEvents() - }, 0) + this.rebuildAfterMenuChange() + }, 150) + }) + } + + if (subMenuName === 'ResolutionMenuButton') { + this.subMenu.on('menu-changed', () => { + this.rebuildAfterMenuChange() }) } @@ -89,6 +94,12 @@ class SettingsMenuItem extends MenuItem { }) } + dispose () { + this.settingsSubMenuEl_.removeEventListener('transitionend', this.transitionEndHandler) + + super.dispose() + } + eventHandlers () { this.submenuClickHandler = this.onSubmenuClick.bind(this) this.transitionEndHandler = this.onTransitionEnd.bind(this) @@ -190,27 +201,6 @@ class SettingsMenuItem extends MenuItem { (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText()) } - /** - * Add/remove prefixed event listener for CSS Transition - * - * @method PrefixedEvent - */ - PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { - const prefix = [ 'webkit', 'moz', 'MS', 'o', '' ] - - for (let p = 0; p < prefix.length; p++) { - if (!prefix[p]) { - type = type.toLowerCase() - } - - if (action === 'addEvent') { - element.addEventListener(prefix[p] + type, callback, false) - } else if (action === 'removeEvent') { - element.removeEventListener(prefix[p] + type, callback, false) - } - } - } - onTransitionEnd (event: any) { if (event.propertyName !== 'margin-right') { return @@ -254,12 +244,7 @@ class SettingsMenuItem extends MenuItem { } build () { - this.subMenu.on('labelUpdated', () => { - this.update() - }) - this.subMenu.on('menuChanged', () => { - this.bindClickEvents() - this.setSize() + this.subMenu.on('label-updated', () => { this.update() }) @@ -272,25 +257,12 @@ class SettingsMenuItem extends MenuItem { this.setSize() this.bindClickEvents() - // prefixed event listeners for CSS TransitionEnd - this.PrefixedEvent( - this.settingsSubMenuEl_, - 'TransitionEnd', - this.transitionEndHandler, - 'addEvent' - ) + this.settingsSubMenuEl_.addEventListener('transitionend', this.transitionEndHandler, false) } update (event?: any) { - let target: HTMLElement = null const subMenu = this.subMenu.name() - if (event && event.type === 'tap') { - target = event.target - } else if (event) { - target = event.currentTarget - } - // Playback rate menu button doesn't get a vjs-selected class // or sets options_['selected'] on the selected playback rate. // Thus we get the submenu value based on the labelEl of playbackRateMenuButton @@ -321,6 +293,13 @@ class SettingsMenuItem extends MenuItem { } } + let target: HTMLElement = null + if (event && event.type === 'tap') { + target = event.target + } else if (event) { + target = event.currentTarget + } + if (target && !target.classList.contains('vjs-back-button')) { this.settingsButton.hideDialog() } @@ -369,6 +348,15 @@ class SettingsMenuItem extends MenuItem { } } + private rebuildAfterMenuChange () { + this.settingsSubMenuEl_.innerHTML = '' + this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) + this.update() + this.createBackButton() + this.setSize() + this.bindClickEvents() + } + } (SettingsMenuItem as any).prototype.contentElType = 'button' diff --git a/client/src/assets/player/shared/stats/stats-card.ts b/client/src/assets/player/shared/stats/stats-card.ts index 471a5e46c..fad68cec9 100644 --- a/client/src/assets/player/shared/stats/stats-card.ts +++ b/client/src/assets/player/shared/stats/stats-card.ts @@ -7,7 +7,7 @@ import { bytes } from '../common' interface StatsCardOptions extends videojs.ComponentOptions { videoUUID: string videoIsLive: boolean - mode: 'webtorrent' | 'p2p-media-loader' + mode: 'web-video' | 'p2p-media-loader' p2pEnabled: boolean } @@ -34,7 +34,7 @@ class StatsCard extends Component { updateInterval: any - mode: 'webtorrent' | 'p2p-media-loader' + mode: 'web-video' | 'p2p-media-loader' metadataStore: any = {} @@ -63,6 +63,9 @@ class StatsCard extends Component { private liveLatency: InfoElement + private onP2PInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void + private onHTTPInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void + createEl () { this.containerEl = videojs.dom.createEl('div', { className: 'vjs-stats-content' @@ -86,9 +89,7 @@ class StatsCard extends Component { this.populateInfoBlocks() - this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => { - if (!data) return // HTTP fallback - + this.onP2PInfoHandler = (_event, data) => { this.mode = data.source const p2pStats = data.p2p @@ -105,11 +106,29 @@ class StatsCard extends Component { this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') } - }) + } + + this.onHTTPInfoHandler = (_event, data) => { + this.mode = data.source + + this.playerNetworkInfo.totalDownloaded = bytes(data.http.downloaded).join(' ') + } + + this.player().on('p2p-info', this.onP2PInfoHandler) + this.player().on('http-info', this.onHTTPInfoHandler) return this.containerEl } + dispose () { + if (this.updateInterval) clearInterval(this.updateInterval) + + this.player().off('p2p-info', this.onP2PInfoHandler) + this.player().off('http-info', this.onHTTPInfoHandler) + + super.dispose() + } + toggle () { if (this.updateInterval) this.hide() else this.show() @@ -122,7 +141,7 @@ class StatsCard extends Component { try { const options = this.mode === 'p2p-media-loader' ? this.buildHLSOptions() - : await this.buildWebTorrentOptions() // Default + : await this.buildWebVideoOptions() // Default this.populateInfoValues(options) } catch (err) { @@ -170,8 +189,8 @@ class StatsCard extends Component { } } - private async buildWebTorrentOptions () { - const videoFile = this.player_.webtorrent().getCurrentVideoFile() + private async buildWebVideoOptions () { + const videoFile = this.player_.webVideo().getCurrentVideoFile() if (!this.metadataStore[videoFile.fileUrl]) { this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) @@ -194,7 +213,7 @@ class StatsCard extends Component { const resolution = videoFile?.resolution.label + videoFile?.fps const buffer = this.timeRangesToString(this.player_.buffered()) - const progress = this.player_.webtorrent().getTorrent()?.progress + const progress = this.player_.bufferedPercent() return { playerNetworkInfo: this.playerNetworkInfo, @@ -284,8 +303,10 @@ class StatsCard extends Component { ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` : undefined - this.setInfoValue(this.playerMode, this.mode || 'HTTP') - this.setInfoValue(this.p2p, player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled')) + const p2pEnabled = this.options_.p2pEnabled && this.mode === 'p2p-media-loader' + + this.setInfoValue(this.playerMode, this.mode) + this.setInfoValue(this.p2p, player.localize(p2pEnabled ? 'enabled' : 'disabled')) this.setInfoValue(this.uuid, this.options_.videoUUID) this.setInfoValue(this.viewport, frames) diff --git a/client/src/assets/player/shared/stats/stats-plugin.ts b/client/src/assets/player/shared/stats/stats-plugin.ts index 8aad80e8a..86684a78c 100644 --- a/client/src/assets/player/shared/stats/stats-plugin.ts +++ b/client/src/assets/player/shared/stats/stats-plugin.ts @@ -7,10 +7,6 @@ class StatsForNerdsPlugin extends Plugin { private statsCard: StatsCard constructor (player: videojs.Player, options: StatsCardOptions) { - const settings = { - ...options - } - super(player) this.player.ready(() => { @@ -19,7 +15,17 @@ class StatsForNerdsPlugin extends Plugin { this.statsCard = new StatsCard(player, options) - player.addChild(this.statsCard, settings) + // Copy options + player.addChild(this.statsCard) + } + + dispose () { + if (this.statsCard) { + this.statsCard.dispose() + this.player.removeChild(this.statsCard) + } + + super.dispose() } show () { diff --git a/client/src/assets/player/shared/upnext/end-card.ts b/client/src/assets/player/shared/upnext/end-card.ts index 61668e407..3589e1fd8 100644 --- a/client/src/assets/player/shared/upnext/end-card.ts +++ b/client/src/assets/player/shared/upnext/end-card.ts @@ -1,6 +1,7 @@ import videojs from 'video.js' +import { UpNextPluginOptions } from '../../types' -function getMainTemplate (options: any) { +function getMainTemplate (options: EndCardOptions) { return `
${options.headText} @@ -23,15 +24,10 @@ function getMainTemplate (options: any) { ` } -export interface EndCardOptions extends videojs.ComponentOptions { - next: () => void - getTitle: () => string - timeout: number +export interface EndCardOptions extends videojs.ComponentOptions, UpNextPluginOptions { cancelText: string headText: string suspendedText: string - condition: () => boolean - suspended: () => boolean } const Component = videojs.getComponent('Component') @@ -52,27 +48,43 @@ class EndCard extends Component { suspendedMessage: HTMLElement nextButton: HTMLElement + private onEndedHandler: () => void + private onPlayingHandler: () => void + constructor (player: videojs.Player, options: EndCardOptions) { super(player, options) this.totalTicks = this.options_.timeout / this.interval - player.on('ended', (_: any) => { - if (!this.options_.condition()) return + this.onEndedHandler = () => { + if (!this.options_.isDisplayed()) return player.addClass('vjs-upnext--showing') - this.showCard((canceled: boolean) => { + + this.showCard(canceled => { player.removeClass('vjs-upnext--showing') + this.container.style.display = 'none' + if (!canceled) { this.options_.next() } }) - }) + } - player.on('playing', () => { + this.onPlayingHandler = () => { this.upNextEvents.trigger('playing') - }) + } + + player.on([ 'auto-stopped', 'ended' ], this.onEndedHandler) + player.on('playing', this.onPlayingHandler) + } + + dispose () { + if (this.onEndedHandler) this.player().off([ 'auto-stopped', 'ended' ], this.onEndedHandler) + if (this.onPlayingHandler) this.player().off('playing', this.onPlayingHandler) + + super.dispose() } createEl () { @@ -101,7 +113,7 @@ class EndCard extends Component { return container } - showCard (cb: (value: boolean) => void) { + showCard (cb: (canceled: boolean) => void) { let timeout: any this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) @@ -109,6 +121,10 @@ class EndCard extends Component { this.title.innerHTML = this.options_.getTitle() + if (this.totalTicks === 0) { + return cb(false) + } + this.upNextEvents.one('cancel', () => { clearTimeout(timeout) cb(true) @@ -134,7 +150,7 @@ class EndCard extends Component { } const update = () => { - if (this.options_.suspended()) { + if (this.options_.isSuspended()) { this.suspendedMessage.innerText = this.options_.suspendedText goToPercent(0) this.ticks = 0 diff --git a/client/src/assets/player/shared/upnext/upnext-plugin.ts b/client/src/assets/player/shared/upnext/upnext-plugin.ts index e12e8c503..0badcd68c 100644 --- a/client/src/assets/player/shared/upnext/upnext-plugin.ts +++ b/client/src/assets/player/shared/upnext/upnext-plugin.ts @@ -1,26 +1,24 @@ import videojs from 'video.js' +import { UpNextPluginOptions } from '../../types' import { EndCardOptions } from './end-card' const Plugin = videojs.getPlugin('plugin') class UpNextPlugin extends Plugin { - constructor (player: videojs.Player, options: Partial = {}) { - const settings = { - next: options.next, - getTitle: options.getTitle, - timeout: options.timeout || 5000, - cancelText: options.cancelText || 'Cancel', - headText: options.headText || 'Up Next', - suspendedText: options.suspendedText || 'Autoplay is suspended', - condition: options.condition, - suspended: options.suspended - } - + constructor (player: videojs.Player, options: UpNextPluginOptions) { super(player) - // UpNext plugin can be called later, so ensure the player is not disposed - if (this.player.isDisposed()) return + const settings: EndCardOptions = { + next: options.next, + getTitle: options.getTitle, + timeout: options.timeout, + cancelText: player.localize('Cancel'), + headText: player.localize('Up Next'), + suspendedText: player.localize('Autoplay is suspended'), + isDisplayed: options.isDisplayed, + isSuspended: options.isSuspended + } this.player.ready(() => { player.addClass('vjs-upnext') diff --git a/client/src/assets/player/shared/web-video/web-video-plugin.ts b/client/src/assets/player/shared/web-video/web-video-plugin.ts new file mode 100644 index 000000000..80e56795b --- /dev/null +++ b/client/src/assets/player/shared/web-video/web-video-plugin.ts @@ -0,0 +1,186 @@ +import debug from 'debug' +import videojs from 'video.js' +import { logger } from '@root-helpers/logger' +import { addQueryParams } from '@shared/core-utils' +import { VideoFile } from '@shared/models' +import { PeerTubeResolution, PlayerNetworkInfo, WebVideoPluginOptions } from '../../types' + +const debugLogger = debug('peertube:player:web-video-plugin') + +const Plugin = videojs.getPlugin('plugin') + +class WebVideoPlugin extends Plugin { + private readonly videoFiles: VideoFile[] + + private currentVideoFile: VideoFile + private videoFileToken: () => string + + private networkInfoInterval: any + + private onErrorHandler: () => void + private onPlayHandler: () => void + + constructor (player: videojs.Player, options?: WebVideoPluginOptions) { + super(player, options) + + this.videoFiles = options.videoFiles + this.videoFileToken = options.videoFileToken + + this.updateVideoFile({ videoFile: this.pickAverageVideoFile(), isUserResolutionChange: false }) + + player.ready(() => { + this.buildQualities() + + this.setupNetworkInfoInterval() + + if (this.videoFiles.length === 0) { + this.player.addClass('disabled') + return + } + }) + } + + dispose () { + clearInterval(this.networkInfoInterval) + + if (this.onErrorHandler) this.player.off('error', this.onErrorHandler) + if (this.onPlayHandler) this.player.off('canplay', this.onPlayHandler) + + super.dispose() + } + + getCurrentResolutionId () { + return this.currentVideoFile.resolution.id + } + + updateVideoFile (options: { + videoFile: VideoFile + isUserResolutionChange: boolean + }) { + this.currentVideoFile = options.videoFile + + debugLogger('Updating web video file to ' + this.currentVideoFile.fileUrl) + + const paused = this.player.paused() + const playbackRate = this.player.playbackRate() + const currentTime = this.player.currentTime() + + // Enable error display now this is our last fallback + this.onErrorHandler = () => this.player.peertube().displayFatalError() + this.player.one('error', this.onErrorHandler) + + let httpUrl = this.currentVideoFile.fileUrl + + if (this.videoFileToken()) { + httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) + } + + const oldAutoplayValue = this.player.autoplay() + if (options.isUserResolutionChange) { + this.player.autoplay(false) + this.player.addClass('vjs-updating-resolution') + } + + this.player.src(httpUrl) + + this.onPlayHandler = () => { + this.player.playbackRate(playbackRate) + this.player.currentTime(currentTime) + + this.adaptPosterForAudioOnly() + + if (options.isUserResolutionChange) { + this.player.trigger('user-resolution-change') + this.player.trigger('web-video-source-change') + + this.tryToPlay() + .then(() => { + if (paused) this.player.pause() + + this.player.autoplay(oldAutoplayValue) + }) + } + } + + this.player.one('canplay', this.onPlayHandler) + } + + getCurrentVideoFile () { + return this.currentVideoFile + } + + private adaptPosterForAudioOnly () { + // Audio-only (resolutionId === 0) gets special treatment + if (this.currentVideoFile.resolution.id === 0) { + this.player.audioPosterMode(true) + } else { + this.player.audioPosterMode(false) + } + } + + private tryToPlay () { + debugLogger('Try to play manually the video') + + const playPromise = this.player.play() + if (playPromise === undefined) return + + return playPromise + .catch((err: Error) => { + if (err.message.includes('The play() request was interrupted by a call to pause()')) { + return + } + + logger.warn(err) + this.player.pause() + this.player.posterImage.show() + this.player.removeClass('vjs-has-autoplay') + this.player.removeClass('vjs-playing-audio-only-content') + }) + .finally(() => { + this.player.removeClass('vjs-updating-resolution') + }) + } + + private pickAverageVideoFile () { + if (this.videoFiles.length === 1) return this.videoFiles[0] + + const files = this.videoFiles.filter(f => f.resolution.id !== 0) + return files[Math.floor(files.length / 2)] + } + + private buildQualities () { + const resolutions: PeerTubeResolution[] = this.videoFiles.map(videoFile => ({ + id: videoFile.resolution.id, + label: this.buildQualityLabel(videoFile), + height: videoFile.resolution.id, + selected: videoFile.id === this.currentVideoFile.id, + selectCallback: () => this.updateVideoFile({ videoFile, isUserResolutionChange: true }) + })) + + this.player.peertubeResolutions().add(resolutions) + } + + private buildQualityLabel (file: VideoFile) { + let label = file.resolution.label + + if (file.fps && file.fps >= 50) { + label += file.fps + } + + return label + } + + private setupNetworkInfoInterval () { + this.networkInfoInterval = setInterval(() => { + return this.player.trigger('http-info', { + source: 'web-video', + http: { + downloaded: this.player.bufferedPercent() * this.currentVideoFile.size + } + } as PlayerNetworkInfo) + }, 1000) + } +} + +videojs.registerPlugin('webVideo', WebVideoPlugin) +export { WebVideoPlugin } diff --git a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts b/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts deleted file mode 100644 index 74ae17704..000000000 --- a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts +++ /dev/null @@ -1,234 +0,0 @@ -// From https://github.com/MinEduTDF/idb-chunk-store -// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues -// Thanks @santiagogil and @Feross - -import Dexie from 'dexie' -import { EventEmitter } from 'events' -import { logger } from '@root-helpers/logger' - -class ChunkDatabase extends Dexie { - chunks: Dexie.Table<{ id: number, buf: Buffer }, number> - - constructor (dbname: string) { - super(dbname) - - this.version(1).stores({ - chunks: 'id' - }) - } -} - -class ExpirationDatabase extends Dexie { - databases: Dexie.Table<{ name: string, expiration: number }, number> - - constructor () { - super('webtorrent-expiration') - - this.version(1).stores({ - databases: 'name,expiration' - }) - } -} - -export class PeertubeChunkStore extends EventEmitter { - private static readonly BUFFERING_PUT_MS = 1000 - private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute - private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes - - chunkLength: number - - private pendingPut: { id: number, buf: Buffer, cb: (err?: Error) => void }[] = [] - // If the store is full - private memoryChunks: { [ id: number ]: Buffer | true } = {} - private databaseName: string - private putBulkTimeout: any - private cleanerInterval: any - private db: ChunkDatabase - private expirationDB: ExpirationDatabase - private readonly length: number - private readonly lastChunkLength: number - private readonly lastChunkIndex: number - - constructor (chunkLength: number, opts: any) { - super() - - this.databaseName = 'webtorrent-chunks-' - - if (!opts) opts = {} - if (opts.torrent?.infoHash) this.databaseName += opts.torrent.infoHash - else this.databaseName += '-default' - - this.setMaxListeners(100) - - this.chunkLength = Number(chunkLength) - if (!this.chunkLength) throw new Error('First argument must be a chunk length') - - this.length = Number(opts.length) || Infinity - - if (this.length !== Infinity) { - this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength - this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1 - } - - this.db = new ChunkDatabase(this.databaseName) - // Track databases that expired - this.expirationDB = new ExpirationDatabase() - - this.runCleaner() - } - - put (index: number, buf: Buffer, cb: (err?: Error) => void) { - const isLastChunk = (index === this.lastChunkIndex) - if (isLastChunk && buf.length !== this.lastChunkLength) { - return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength)) - } - if (!isLastChunk && buf.length !== this.chunkLength) { - return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength)) - } - - // Specify we have this chunk - this.memoryChunks[index] = true - - // Add it to the pending put - this.pendingPut.push({ id: index, buf, cb }) - // If it's already planned, return - if (this.putBulkTimeout) return - - // Plan a future bulk insert - this.putBulkTimeout = setTimeout(async () => { - const processing = this.pendingPut - this.pendingPut = [] - this.putBulkTimeout = undefined - - try { - await this.db.transaction('rw', this.db.chunks, () => { - return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf }))) - }) - } catch (err) { - logger.info('Cannot bulk insert chunks. Store them in memory.', err) - - processing.forEach(p => { - this.memoryChunks[p.id] = p.buf - }) - } finally { - processing.forEach(p => p.cb()) - } - }, PeertubeChunkStore.BUFFERING_PUT_MS) - } - - get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void { - if (typeof opts === 'function') return this.get(index, null, opts) - - // IndexDB could be slow, use our memory index first - const memoryChunk = this.memoryChunks[index] - if (memoryChunk === undefined) { - const err = new Error('Chunk not found') as any - err['notFound'] = true - - return process.nextTick(() => cb(err)) - } - - // Chunk in memory - if (memoryChunk !== true) return cb(null, memoryChunk) - - // Chunk in store - this.db.transaction('r', this.db.chunks, async () => { - const result = await this.db.chunks.get({ id: index }) - if (result === undefined) return cb(null, Buffer.alloc(0)) - - const buf = result.buf - if (!opts) return this.nextTick(cb, null, buf) - - const offset = opts.offset || 0 - const len = opts.length || (buf.length - offset) - return cb(null, buf.slice(offset, len + offset)) - }) - .catch(err => { - logger.error(err) - return cb(err) - }) - } - - close (cb: (err?: Error) => void) { - return this.destroy(cb) - } - - async destroy (cb: (err?: Error) => void) { - try { - if (this.pendingPut) { - clearTimeout(this.putBulkTimeout) - this.pendingPut = null - } - if (this.cleanerInterval) { - clearInterval(this.cleanerInterval) - this.cleanerInterval = null - } - - if (this.db) { - this.db.close() - - await this.dropDatabase(this.databaseName) - } - - if (this.expirationDB) { - this.expirationDB.close() - this.expirationDB = null - } - - return cb() - } catch (err) { - logger.error('Cannot destroy peertube chunk store.', err) - return cb(err) - } - } - - private runCleaner () { - this.checkExpiration() - - this.cleanerInterval = setInterval(() => { - this.checkExpiration() - }, PeertubeChunkStore.CLEANER_INTERVAL_MS) - } - - private async checkExpiration () { - let databasesToDeleteInfo: { name: string }[] = [] - - try { - await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => { - // Update our database expiration since we are alive - await this.expirationDB.databases.put({ - name: this.databaseName, - expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS - }) - - const now = new Date().getTime() - databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray() - }) - } catch (err) { - logger.error('Cannot update expiration of fetch expired databases.', err) - } - - for (const databaseToDeleteInfo of databasesToDeleteInfo) { - await this.dropDatabase(databaseToDeleteInfo.name) - } - } - - private async dropDatabase (databaseName: string) { - const dbToDelete = new ChunkDatabase(databaseName) - logger.info(`Destroying IndexDB database ${databaseName}`) - - try { - await dbToDelete.delete() - - await this.expirationDB.transaction('rw', this.expirationDB.databases, () => { - return this.expirationDB.databases.where({ name: databaseName }).delete() - }) - } catch (err) { - logger.error(`Cannot delete ${databaseName}.`, err) - } - } - - private nextTick (cb: (err?: Error, val?: T) => void, err: Error, val?: T) { - process.nextTick(() => cb(err, val), undefined) - } -} diff --git a/client/src/assets/player/shared/webtorrent/video-renderer.ts b/client/src/assets/player/shared/webtorrent/video-renderer.ts deleted file mode 100644 index a85d7a838..000000000 --- a/client/src/assets/player/shared/webtorrent/video-renderer.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Thanks: https://github.com/feross/render-media - -const MediaElementWrapper = require('mediasource') -import { logger } from '@root-helpers/logger' -import { extname } from 'path' -const Videostream = require('videostream') - -const VIDEOSTREAM_EXTS = [ - '.m4a', - '.m4v', - '.mp4' -] - -type RenderMediaOptions = { - controls: boolean - autoplay: boolean -} - -function renderVideo ( - file: any, - elem: HTMLVideoElement, - opts: RenderMediaOptions, - callback: (err: Error, renderer: any) => void -) { - validateFile(file) - - return renderMedia(file, elem, opts, callback) -} - -function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) { - const extension = extname(file.name).toLowerCase() - let preparedElem: any - let currentTime = 0 - let renderer: any - - try { - if (VIDEOSTREAM_EXTS.includes(extension)) { - renderer = useVideostream() - } else { - renderer = useMediaSource() - } - } catch (err) { - return callback(err) - } - - function useVideostream () { - prepareElem() - preparedElem.addEventListener('error', function onError (err: Error) { - preparedElem.removeEventListener('error', onError) - - return callback(err) - }) - preparedElem.addEventListener('loadstart', onLoadStart) - return new Videostream(file, preparedElem) - } - - function useMediaSource (useVP9 = false) { - const codecs = getCodec(file.name, useVP9) - - prepareElem() - preparedElem.addEventListener('error', function onError (err: Error) { - preparedElem.removeEventListener('error', onError) - - // Try with vp9 before returning an error - if (codecs.includes('vp8')) return fallbackToMediaSource(true) - - return callback(err) - }) - preparedElem.addEventListener('loadstart', onLoadStart) - - const wrapper = new MediaElementWrapper(preparedElem) - const writable = wrapper.createWriteStream(codecs) - file.createReadStream().pipe(writable) - - if (currentTime) preparedElem.currentTime = currentTime - - return wrapper - } - - function fallbackToMediaSource (useVP9 = false) { - if (useVP9 === true) logger.info('Falling back to media source with VP9 enabled.') - else logger.info('Falling back to media source..') - - useMediaSource(useVP9) - } - - function prepareElem () { - if (preparedElem === undefined) { - preparedElem = elem - - preparedElem.addEventListener('progress', function () { - currentTime = elem.currentTime - }) - } - } - - function onLoadStart () { - preparedElem.removeEventListener('loadstart', onLoadStart) - if (opts.autoplay) preparedElem.play() - - callback(null, renderer) - } -} - -function validateFile (file: any) { - if (file == null) { - throw new Error('file cannot be null or undefined') - } - if (typeof file.name !== 'string') { - throw new Error('missing or invalid file.name property') - } - if (typeof file.createReadStream !== 'function') { - throw new Error('missing or invalid file.createReadStream property') - } -} - -function getCodec (name: string, useVP9 = false) { - const ext = extname(name).toLowerCase() - if (ext === '.mp4') { - return 'video/mp4; codecs="avc1.640029, mp4a.40.5"' - } - - if (ext === '.webm') { - if (useVP9 === true) return 'video/webm; codecs="vp9, opus"' - - return 'video/webm; codecs="vp8, vorbis"' - } - - return undefined -} - -export { - renderVideo -} diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts deleted file mode 100644 index e2e220c03..000000000 --- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts +++ /dev/null @@ -1,663 +0,0 @@ -import videojs from 'video.js' -import * as WebTorrent from 'webtorrent' -import { logger } from '@root-helpers/logger' -import { isIOS } from '@root-helpers/web-browser' -import { addQueryParams, timeToInt } from '@shared/core-utils' -import { VideoFile } from '@shared/models' -import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../../peertube-player-local-storage' -import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../../types' -import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../common' -import { PeertubeChunkStore } from './peertube-chunk-store' -import { renderVideo } from './video-renderer' - -const CacheChunkStore = require('cache-chunk-store') - -type PlayOptions = { - forcePlay?: boolean - seek?: number - delay?: number -} - -const Plugin = videojs.getPlugin('plugin') - -class WebTorrentPlugin extends Plugin { - readonly videoFiles: VideoFile[] - - private readonly playerElement: HTMLVideoElement - - private readonly autoplay: boolean | string = false - private readonly startTime: number = 0 - private readonly savePlayerSrcFunction: videojs.Player['src'] - private readonly videoDuration: number - private readonly CONSTANTS = { - INFO_SCHEDULER: 1000, // Don't change this - AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds - AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it - AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check - AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds - BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth - } - - private readonly buildWebSeedUrls: (file: VideoFile) => string[] - - private readonly webtorrent = new WebTorrent({ - tracker: { - rtcConfig: getRtcConfig() - }, - dht: false - }) - - private currentVideoFile: VideoFile - private torrent: WebTorrent.Torrent - - private renderer: any - private fakeRenderer: any - private destroyingFakeRenderer = false - - private autoResolution = true - private autoResolutionPossible = true - private isAutoResolutionObservation = false - private playerRefusedP2P = false - - private requiresUserAuth: boolean - private videoFileToken: () => string - - private torrentInfoInterval: any - private autoQualityInterval: any - private addTorrentDelay: any - private qualityObservationTimer: any - private runAutoQualitySchedulerTimer: any - - private downloadSpeeds: number[] = [] - - constructor (player: videojs.Player, options?: WebtorrentPluginOptions) { - super(player) - - this.startTime = timeToInt(options.startTime) - - // Custom autoplay handled by webtorrent because we lazy play the video - this.autoplay = options.autoplay - - this.playerRefusedP2P = options.playerRefusedP2P - - this.videoFiles = options.videoFiles - this.videoDuration = options.videoDuration - - this.savePlayerSrcFunction = this.player.src - this.playerElement = options.playerElement - - this.requiresUserAuth = options.requiresUserAuth - this.videoFileToken = options.videoFileToken - - this.buildWebSeedUrls = options.buildWebSeedUrls - - this.player.ready(() => { - const playerOptions = this.player.options_ - - const volume = getStoredVolume() - if (volume !== undefined) this.player.volume(volume) - - const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() - if (muted !== undefined) this.player.muted(muted) - - this.player.duration(options.videoDuration) - - this.initializePlayer() - this.runTorrentInfoScheduler() - - this.player.one('play', () => { - // Don't run immediately scheduler, wait some seconds the TCP connections are made - this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) - }) - }) - } - - dispose () { - clearTimeout(this.addTorrentDelay) - clearTimeout(this.qualityObservationTimer) - clearTimeout(this.runAutoQualitySchedulerTimer) - - clearInterval(this.torrentInfoInterval) - clearInterval(this.autoQualityInterval) - - // Don't need to destroy renderer, video player will be destroyed - this.flushVideoFile(this.currentVideoFile, false) - - this.destroyFakeRenderer() - } - - getCurrentResolutionId () { - return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 - } - - updateVideoFile ( - videoFile?: VideoFile, - options: { - forcePlay?: boolean - seek?: number - delay?: number - } = {}, - done: () => void = () => { /* empty */ } - ) { - // Automatically choose the adapted video file - if (!videoFile) { - const savedAverageBandwidth = getAverageBandwidthInStore() - videoFile = savedAverageBandwidth - ? this.getAppropriateFile(savedAverageBandwidth) - : this.pickAverageVideoFile() - } - - if (!videoFile) { - throw Error(`Can't update video file since videoFile is undefined.`) - } - - // Don't add the same video file once again - if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { - return - } - - // Do not display error to user because we will have multiple fallback - this.player.peertube().hideFatalError(); - - // Hack to "simulate" src link in video.js >= 6 - // Without this, we can't play the video after pausing it - // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 - (this.player as any).src = () => true - const oldPlaybackRate = this.player.playbackRate() - - const previousVideoFile = this.currentVideoFile - this.currentVideoFile = videoFile - - // Don't try on iOS that does not support MediaSource - // Or don't use P2P if webtorrent is disabled - if (isIOS() || this.playerRefusedP2P) { - return this.fallbackToHttp(options, () => { - this.player.playbackRate(oldPlaybackRate) - return done() - }) - } - - this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { - this.player.playbackRate(oldPlaybackRate) - return done() - }) - - this.selectAppropriateResolution(true) - } - - updateEngineResolution (resolutionId: number, delay = 0) { - // Remember player state - const currentTime = this.player.currentTime() - const isPaused = this.player.paused() - - // Hide bigPlayButton - if (!isPaused) { - this.player.bigPlayButton.hide() - } - - // Audio-only (resolutionId === 0) gets special treatment - if (resolutionId === 0) { - // Audio-only: show poster, do not auto-hide controls - this.player.addClass('vjs-playing-audio-only-content') - this.player.posterImage.show() - } else { - // Hide poster to have black background - this.player.removeClass('vjs-playing-audio-only-content') - this.player.posterImage.hide() - } - - const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) - const options = { - forcePlay: false, - delay, - seek: currentTime + (delay / 1000) - } - - this.updateVideoFile(newVideoFile, options) - - this.player.trigger('engineResolutionChange') - } - - flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { - if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) { - if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() - - this.webtorrent.remove(videoFile.magnetUri) - logger.info(`Removed ${videoFile.magnetUri}`) - } - } - - disableAutoResolution () { - this.autoResolution = false - this.autoResolutionPossible = false - this.player.peertubeResolutions().disableAutoResolution() - } - - isAutoResolutionPossible () { - return this.autoResolutionPossible - } - - getTorrent () { - return this.torrent - } - - getCurrentVideoFile () { - return this.currentVideoFile - } - - changeQuality (id: number) { - if (id === -1) { - if (this.autoResolutionPossible === true) { - this.autoResolution = true - - this.selectAppropriateResolution(false) - } - - return - } - - this.autoResolution = false - this.updateEngineResolution(id) - this.selectAppropriateResolution(false) - } - - private addTorrent ( - magnetOrTorrentUrl: string, - previousVideoFile: VideoFile, - options: PlayOptions, - done: (err?: Error) => void - ) { - if (!magnetOrTorrentUrl) return this.fallbackToHttp(options, done) - - logger.info(`Adding ${magnetOrTorrentUrl}.`) - - const oldTorrent = this.torrent - const torrentOptions = { - // Don't use arrow function: it breaks webtorrent (that uses `new` keyword) - store: function (chunkLength: number, storeOpts: any) { - return new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { - max: 100 - }) - }, - urlList: this.buildWebSeedUrls(this.currentVideoFile) - } - - this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { - logger.info(`Added ${magnetOrTorrentUrl}.`) - - if (oldTorrent) { - // Pause the old torrent - this.stopTorrent(oldTorrent) - - // We use a fake renderer so we download correct pieces of the next file - if (options.delay) this.renderFileInFakeElement(torrent.files[0], options.delay) - } - - // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution) - this.addTorrentDelay = setTimeout(() => { - // We don't need the fake renderer anymore - this.destroyFakeRenderer() - - const paused = this.player.paused() - - this.flushVideoFile(previousVideoFile) - - // Update progress bar (just for the UI), do not wait rendering - if (options.seek) this.player.currentTime(options.seek) - - const renderVideoOptions = { autoplay: false, controls: true } - renderVideo(torrent.files[0], this.playerElement, renderVideoOptions, (err, renderer) => { - this.renderer = renderer - - if (err) return this.fallbackToHttp(options, done) - - return this.tryToPlay(err => { - if (err) return done(err) - - if (options.seek) this.seek(options.seek) - if (options.forcePlay === false && paused === true) this.player.pause() - - return done() - }) - }) - }, options.delay || 0) - }) - - this.torrent.on('error', (err: any) => logger.error(err)) - - this.torrent.on('warning', (err: any) => { - // We don't support HTTP tracker but we don't care -> we use the web socket tracker - if (err.message.indexOf('Unsupported tracker protocol') !== -1) return - - // Users don't care about issues with WebRTC, but developers do so log it in the console - if (err.message.indexOf('Ice connection failed') !== -1) { - logger.info(err) - return - } - - // Magnet hash is not up to date with the torrent file, add directly the torrent file - if (err.message.indexOf('incorrect info hash') !== -1) { - logger.error('Incorrect info hash detected, falling back to torrent file.') - const newOptions = { forcePlay: true, seek: options.seek } - return this.addTorrent((this.torrent as any)['xs'], previousVideoFile, newOptions, done) - } - - // Remote instance is down - if (err.message.indexOf('from xs param') !== -1) { - this.handleError(err) - } - - logger.warn(err) - }) - } - - private tryToPlay (done?: (err?: Error) => void) { - if (!done) done = function () { /* empty */ } - - const playPromise = this.player.play() - if (playPromise !== undefined) { - return playPromise.then(() => done()) - .catch((err: Error) => { - if (err.message.includes('The play() request was interrupted by a call to pause()')) { - return - } - - logger.warn(err) - this.player.pause() - this.player.posterImage.show() - this.player.removeClass('vjs-has-autoplay') - this.player.removeClass('vjs-has-big-play-button-clicked') - this.player.removeClass('vjs-playing-audio-only-content') - - return done() - }) - } - - return done() - } - - private seek (time: number) { - this.player.currentTime(time) - this.player.handleTechSeeked_() - } - - private getAppropriateFile (averageDownloadSpeed?: number): VideoFile { - if (this.videoFiles === undefined) return undefined - if (this.videoFiles.length === 1) return this.videoFiles[0] - - const files = this.videoFiles.filter(f => f.resolution.id !== 0) - if (files.length === 0) return undefined - - // Don't change the torrent if the player ended - if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile - - if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed() - - // Limit resolution according to player height - const playerHeight = this.playerElement.offsetHeight - - // We take the first resolution just above the player height - // Example: player height is 530px, we want the 720p file instead of 480p - let maxResolution = files[0].resolution.id - for (let i = files.length - 1; i >= 0; i--) { - const resolutionId = files[i].resolution.id - if (resolutionId !== 0 && resolutionId >= playerHeight) { - maxResolution = resolutionId - break - } - } - - // Filter videos we can play according to our screen resolution and bandwidth - const filteredFiles = files.filter(f => f.resolution.id <= maxResolution) - .filter(f => { - const fileBitrate = (f.size / this.videoDuration) - let threshold = fileBitrate - - // If this is for a higher resolution or an initial load: add a margin - if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) { - threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100) - } - - return averageDownloadSpeed > threshold - }) - - // If the download speed is too bad, return the lowest resolution we have - if (filteredFiles.length === 0) return videoFileMinByResolution(files) - - return videoFileMaxByResolution(filteredFiles) - } - - private getAndSaveActualDownloadSpeed () { - const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0) - const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length) - if (lastDownloadSpeeds.length === 0) return -1 - - const sum = lastDownloadSpeeds.reduce((a, b) => a + b) - const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length) - - // Save the average bandwidth for future use - saveAverageBandwidth(averageBandwidth) - - return averageBandwidth - } - - private initializePlayer () { - this.buildQualities() - - if (this.videoFiles.length === 0) { - this.player.addClass('disabled') - return - } - - if (this.autoplay !== false) { - this.player.posterImage.hide() - - return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) - } - - // Proxy first play - const oldPlay = this.player.play.bind(this.player); - (this.player as any).play = () => { - this.player.addClass('vjs-has-big-play-button-clicked') - this.player.play = oldPlay - - this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) - } - } - - private runAutoQualityScheduler () { - this.autoQualityInterval = setInterval(() => { - - // Not initialized or in HTTP fallback - if (this.torrent === undefined || this.torrent === null) return - if (this.autoResolution === false) return - if (this.isAutoResolutionObservation === true) return - - const file = this.getAppropriateFile() - let changeResolution = false - let changeResolutionDelay = 0 - - // Lower resolution - if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) { - logger.info(`Downgrading automatically the resolution to: ${file.resolution.label}`) - changeResolution = true - } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution - logger.info(`Upgrading automatically the resolution to: ${file.resolution.label}`) - changeResolution = true - changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY - } - - if (changeResolution === true) { - this.updateEngineResolution(file.resolution.id, changeResolutionDelay) - - // Wait some seconds in observation of our new resolution - this.isAutoResolutionObservation = true - - this.qualityObservationTimer = setTimeout(() => { - this.isAutoResolutionObservation = false - }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME) - } - }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER) - } - - private isPlayerWaiting () { - return this.player?.hasClass('vjs-waiting') - } - - private runTorrentInfoScheduler () { - this.torrentInfoInterval = setInterval(() => { - // Not initialized yet - if (this.torrent === undefined) return - - // Http fallback - if (this.torrent === null) return this.player.trigger('p2pInfo', false) - - // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too - if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) - - return this.player.trigger('p2pInfo', { - source: 'webtorrent', - http: { - downloadSpeed: 0, - downloaded: 0 - }, - p2p: { - downloadSpeed: this.torrent.downloadSpeed, - numPeers: this.torrent.numPeers, - uploadSpeed: this.torrent.uploadSpeed, - downloaded: this.torrent.downloaded, - uploaded: this.torrent.uploaded - }, - bandwidthEstimate: this.webtorrent.downloadSpeed - } as PlayerNetworkInfo) - }, this.CONSTANTS.INFO_SCHEDULER) - } - - private fallbackToHttp (options: PlayOptions, done?: (err?: Error) => void) { - const paused = this.player.paused() - - this.disableAutoResolution() - - this.flushVideoFile(this.currentVideoFile, true) - this.torrent = null - - // Enable error display now this is our last fallback - this.player.one('error', () => this.player.peertube().displayFatalError()) - - let httpUrl = this.currentVideoFile.fileUrl - - if (this.videoFileToken) { - httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) - } - - this.player.src = this.savePlayerSrcFunction - this.player.src(httpUrl) - - this.selectAppropriateResolution(true) - - // We changed the source, so reinit captions - this.player.trigger('sourcechange') - - return this.tryToPlay(err => { - if (err && done) return done(err) - - if (options.seek) this.seek(options.seek) - if (options.forcePlay === false && paused === true) this.player.pause() - - if (done) return done() - }) - } - - private handleError (err: Error | string) { - return this.player.trigger('customError', { err }) - } - - private pickAverageVideoFile () { - if (this.videoFiles.length === 1) return this.videoFiles[0] - - const files = this.videoFiles.filter(f => f.resolution.id !== 0) - return files[Math.floor(files.length / 2)] - } - - private stopTorrent (torrent: WebTorrent.Torrent) { - torrent.pause() - // Pause does not remove actual peers (in particular the webseed peer) - torrent.removePeer((torrent as any)['ws']) - } - - private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { - this.destroyingFakeRenderer = false - - const fakeVideoElem = document.createElement('video') - renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { - this.fakeRenderer = renderer - - // The renderer returns an error when we destroy it, so skip them - if (this.destroyingFakeRenderer === false && err) { - logger.error('Cannot render new torrent in fake video element.', err) - } - - // Load the future file at the correct time (in delay MS - 2 seconds) - fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000) - }) - } - - private destroyFakeRenderer () { - if (this.fakeRenderer) { - this.destroyingFakeRenderer = true - - if (this.fakeRenderer.destroy) { - try { - this.fakeRenderer.destroy() - } catch (err) { - logger.info('Cannot destroy correctly fake renderer.', err) - } - } - this.fakeRenderer = undefined - } - } - - private buildQualities () { - const resolutions: PeerTubeResolution[] = this.videoFiles.map(file => ({ - id: file.resolution.id, - label: this.buildQualityLabel(file), - height: file.resolution.id, - selected: false, - selectCallback: () => this.changeQuality(file.resolution.id) - })) - - resolutions.push({ - id: -1, - label: this.player.localize('Auto'), - selected: true, - selectCallback: () => this.changeQuality(-1) - }) - - this.player.peertubeResolutions().add(resolutions) - } - - private buildQualityLabel (file: VideoFile) { - let label = file.resolution.label - - if (file.fps && file.fps >= 50) { - label += file.fps - } - - return label - } - - private selectAppropriateResolution (byEngine: boolean) { - const resolution = this.autoResolution - ? -1 - : this.getCurrentResolutionId() - - const autoResolutionChosen = this.autoResolution - ? this.getCurrentResolutionId() - : undefined - - this.player.peertubeResolutions().select({ id: resolution, autoResolutionChosenId: autoResolutionChosen, byEngine }) - } -} - -videojs.registerPlugin('webtorrent', WebTorrentPlugin) -export { WebTorrentPlugin } diff --git a/client/src/assets/player/types/index.ts b/client/src/assets/player/types/index.ts index b73e0b3cb..4bf49f65c 100644 --- a/client/src/assets/player/types/index.ts +++ b/client/src/assets/player/types/index.ts @@ -1,2 +1,2 @@ -export * from './manager-options' +export * from './peertube-player-options' export * from './peertube-videojs-typings' diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/peertube-player-options.ts similarity index 53% rename from client/src/assets/player/types/manager-options.ts rename to client/src/assets/player/types/peertube-player-options.ts index a73341b4c..e1b8c7fab 100644 --- a/client/src/assets/player/types/manager-options.ts +++ b/client/src/assets/player/types/peertube-player-options.ts @@ -1,101 +1,117 @@ import { PluginsManager } from '@root-helpers/plugins-manager' import { LiveVideoLatencyMode, VideoFile } from '@shared/models' +import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' -export type PlayerMode = 'webtorrent' | 'p2p-media-loader' +export type PlayerMode = 'web-video' | 'p2p-media-loader' -export type WebtorrentOptions = { - videoFiles: VideoFile[] -} +export type PeerTubePlayerContructorOptions = { + playerElement: () => HTMLVideoElement -export type P2PMediaLoaderOptions = { - playlistUrl: string - segmentsSha256Url: string - trackerAnnounce: string[] - redundancyBaseUrls: string[] - videoFiles: VideoFile[] -} + controls: boolean + controlBar: boolean -export interface CustomizationOptions { - startTime: number | string - stopTime: number | string + muted: boolean + loop: boolean - controls?: boolean - controlBar?: boolean - - muted?: boolean - loop?: boolean - subtitle?: string - resume?: string - - peertubeLink: boolean + peertubeLink: () => boolean playbackRate?: number | string -} -export interface CommonOptions extends CustomizationOptions { - playerElement: HTMLVideoElement - onPlayerElementChange: (element: HTMLVideoElement) => void - - autoplay: boolean - forceAutoplay: boolean - - p2pEnabled: boolean - - nextVideo?: () => void - hasNextVideo?: () => boolean - - previousVideo?: () => void - hasPreviousVideo?: () => boolean - - playlist?: PlaylistPluginOptions - - videoDuration: number enableHotkeys: boolean inactivityTimeout: number - poster: string videoViewIntervalMs: number instanceName: string theaterButton: boolean - captions: boolean - videoViewUrl: string - authorizationHeader?: () => string + authorizationHeader: () => string metricsUrl: string + serverUrl: string + + errorNotifier: (message: string) => void + + // Current web browser language + language: string + + pluginsManager: PluginsManager +} + +export type PeerTubePlayerLoadOptions = { + mode: PlayerMode + + startTime?: number | string + stopTime?: number | string + + autoplay: boolean + forceAutoplay: boolean + + poster: string + subtitle?: string + videoViewUrl: string embedUrl: string embedTitle: string isLive: boolean + liveOptions?: { latencyMode: LiveVideoLatencyMode } - language?: string - videoCaptions: VideoJSCaption[] storyboard: VideoJSStoryboard videoUUID: string videoShortUUID: string - serverUrl: string + duration: number + requiresUserAuth: boolean videoFileToken: () => string requiresPassword: boolean videoPassword: () => string - errorNotifier: (message: string) => void + nextVideo: { + enabled: boolean + getVideoTitle: () => string + handler?: () => void + displayControlBarButton: boolean + } + + previousVideo: { + enabled: boolean + handler?: () => void + displayControlBarButton: boolean + } + + upnext?: { + isEnabled: () => boolean + isSuspended: (player: videojs.VideoJsPlayer) => boolean + timeout: number + } + + dock?: PeerTubeDockPluginOptions + + playlist?: PlaylistPluginOptions + + p2pEnabled: boolean + + hls?: HLSOptions + webVideo?: WebVideoOptions } -export type PeertubePlayerManagerOptions = { - common: CommonOptions - webtorrent: WebtorrentOptions - p2pMediaLoader?: P2PMediaLoaderOptions - - pluginsManager: PluginsManager +export type WebVideoOptions = { + videoFiles: VideoFile[] +} + +export type HLSOptions = { + playlistUrl: string + segmentsSha256Url: string + trackerAnnounce: string[] + redundancyBaseUrls: string[] + videoFiles: VideoFile[] } diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts index 30d2b287f..f10fc03a8 100644 --- a/client/src/assets/player/types/peertube-videojs-typings.ts +++ b/client/src/assets/player/types/peertube-videojs-typings.ts @@ -2,8 +2,11 @@ import { HlsConfig, Level } from 'hls.js' import videojs from 'video.js' import { Engine } from '@peertube/p2p-media-loader-hlsjs' import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' -import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' -import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin' +import { BezelsPlugin } from '../shared/bezels/bezels-plugin' +import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' +import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' +import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin' +import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin' import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' @@ -12,9 +15,10 @@ import { PlaylistPlugin } from '../shared/playlist/playlist-plugin' import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' import { StatsCardOptions } from '../shared/stats/stats-card' import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' -import { EndCardOptions } from '../shared/upnext/end-card' -import { WebTorrentPlugin } from '../shared/webtorrent/webtorrent-plugin' -import { PlayerMode } from './manager-options' +import { UpNextPlugin } from '../shared/upnext/upnext-plugin' +import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' +import { PlayerMode } from './peertube-player-options' +import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' declare module 'video.js' { @@ -31,35 +35,36 @@ declare module 'video.js' { handleTechSeeked_ (): void - // Plugins - - peertube (): PeerTubePlugin - - webtorrent (): WebTorrentPlugin - - p2pMediaLoader (): P2pMediaLoaderPlugin - - peertubeResolutions (): PeerTubeResolutionsPlugin - - contextmenuUI (options: any): any - - bezels (): void - peertubeMobile (): void - peerTubeHotkeysPlugin (options?: HotkeysOptions): void - - stats (options?: StatsCardOptions): StatsForNerdsPlugin - - storyboard (options: StoryboardOptions): void - textTracks (): TextTrackList & { tracks_: (TextTrack & { id: string, label: string, src: string })[] } - peertubeDock (options: PeerTubeDockPluginOptions): void + // Plugins - upnext (options: Partial): void + peertube (): PeerTubePlugin - playlist (): PlaylistPlugin + webVideo (options?: any): WebVideoPlugin + + p2pMediaLoader (options?: any): P2pMediaLoaderPlugin + hlsjs (options?: any): any + + peertubeResolutions (): PeerTubeResolutionsPlugin + + contextmenuUI (options?: any): any + + bezels (): BezelsPlugin + peertubeMobile (): PeerTubeMobilePlugin + peerTubeHotkeysPlugin (options?: HotkeysOptions): PeerTubeHotkeysPlugin + + stats (options?: StatsCardOptions): StatsForNerdsPlugin + + storyboard (options?: StoryboardOptions): StoryboardPlugin + + peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin + + upnext (options?: UpNextPluginOptions): UpNextPlugin + + playlist (options?: PlaylistPluginOptions): PlaylistPlugin } } @@ -99,32 +104,28 @@ type VideoJSStoryboard = { } type PeerTubePluginOptions = { - mode: PlayerMode + hasAutoplay: () => videojs.Autoplay - autoplay: videojs.Autoplay - videoDuration: number + videoViewUrl: () => string + videoViewIntervalMs: number - videoViewUrl: string authorizationHeader?: () => string - subtitle?: string + videoDuration: () => number - videoCaptions: VideoJSCaption[] + startTime: () => number | string + stopTime: () => number | string - startTime: number | string - stopTime: number | string - - isLive: boolean - - videoUUID: string - - videoViewIntervalMs: number + videoCaptions: () => VideoJSCaption[] + isLive: () => boolean + videoUUID: () => string + subtitle: () => string } type MetricsPluginOptions = { - mode: PlayerMode - metricsUrl: string - videoUUID: string + mode: () => PlayerMode + metricsUrl: () => string + videoUUID: () => string } type StoryboardOptions = { @@ -144,37 +145,36 @@ type PlaylistPluginOptions = { onItemClicked: (element: VideoPlaylistElement) => void } +type UpNextPluginOptions = { + timeout: number + + next: () => void + getTitle: () => string + isDisplayed: () => boolean + isSuspended: () => boolean +} + type NextPreviousVideoButtonOptions = { type: 'next' | 'previous' - handler: () => void + handler?: () => void + isDisplayed: () => boolean isDisabled: () => boolean } type PeerTubeLinkButtonOptions = { - shortUUID: string + isDisplayed: () => boolean + shortUUID: () => string instanceName: string } -type PeerTubeP2PInfoButtonOptions = { - p2pEnabled: boolean +type TheaterButtonOptions = { + isDisplayed: () => boolean } -type WebtorrentPluginOptions = { - playerElement: HTMLVideoElement - - autoplay: videojs.Autoplay - videoDuration: number - +type WebVideoPluginOptions = { videoFiles: VideoFile[] - startTime: number | string - - playerRefusedP2P: boolean - - requiresUserAuth: boolean videoFileToken: () => string - - buildWebSeedUrls: (file: VideoFile) => string[] } type P2PMediaLoaderPluginOptions = { @@ -182,9 +182,8 @@ type P2PMediaLoaderPluginOptions = { type: string src: string - startTime: number | string - loader: P2PMediaLoader + segmentValidator: SegmentValidator requiresUserAuth: boolean videoFileToken: () => string @@ -192,6 +191,8 @@ type P2PMediaLoaderPluginOptions = { export type P2PMediaLoader = { getEngine(): Engine + + destroy: () => void } type VideoJSPluginOptions = { @@ -200,7 +201,7 @@ type VideoJSPluginOptions = { peertube: PeerTubePluginOptions metrics: MetricsPluginOptions - webtorrent?: WebtorrentPluginOptions + webVideo?: WebVideoPluginOptions p2pMediaLoader?: P2PMediaLoaderPluginOptions } @@ -227,14 +228,14 @@ type AutoResolutionUpdateData = { } type PlayerNetworkInfo = { - source: 'webtorrent' | 'p2p-media-loader' + source: 'web-video' | 'p2p-media-loader' http: { - downloadSpeed: number + downloadSpeed?: number downloaded: number } - p2p: { + p2p?: { downloadSpeed: number uploadSpeed: number downloaded: number @@ -243,7 +244,7 @@ type PlayerNetworkInfo = { } // In bytes - bandwidthEstimate: number + bandwidthEstimate?: number } type PlaylistItemOptions = { @@ -254,6 +255,7 @@ type PlaylistItemOptions = { export { PlayerNetworkInfo, + TheaterButtonOptions, VideoJSStoryboard, PlaylistItemOptions, NextPreviousVideoButtonOptions, @@ -263,12 +265,12 @@ export { MetricsPluginOptions, VideoJSCaption, PeerTubePluginOptions, - WebtorrentPluginOptions, + WebVideoPluginOptions, P2PMediaLoaderPluginOptions, PeerTubeResolution, VideoJSPluginOptions, + UpNextPluginOptions, LoadedQualityData, StoryboardOptions, - PeerTubeLinkButtonOptions, - PeerTubeP2PInfoButtonOptions + PeerTubeLinkButtonOptions } diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss index 02d5fa169..09a75e2fd 100644 --- a/client/src/sass/player/control-bar.scss +++ b/client/src/sass/player/control-bar.scss @@ -3,20 +3,6 @@ @use '_mixins' as *; @use './_player-variables' as *; -// Like the time tooltip -.video-js .vjs-progress-holder .vjs-storyboard-sprite-placeholder { - display: none; -} - -.video-js .vjs-progress-control:hover .vjs-storyboard-sprite-placeholder, -.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-storyboard-sprite-placeholder { - display: block; - - // Ensure that we maintain a font-size of ~10px. - font-size: 0.6em; - visibility: visible; -} - .video-js.vjs-peertube-skin .vjs-control-bar { z-index: 100; @@ -26,11 +12,8 @@ text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); transition: visibility 0.3s, opacity 0.3s !important; - &.control-bar-hidden { - display: none !important; - } - - > button:first-child { + > button:not(.vjs-hidden):first-child, + > button.vjs-hidden + button:not(.vjs-hidden) { @include margin-left($first-control-bar-element-margin-left); } @@ -167,7 +150,7 @@ } } - .vjs-live-control { + .vjs-pt-live-control { padding: 5px 7px; border-radius: 3px; height: fit-content; @@ -245,6 +228,7 @@ .vjs-next-video, .vjs-previous-video { width: $control-bar-button-width - 4px; + cursor: pointer; &.vjs-disabled { cursor: default; diff --git a/client/src/sass/player/index.scss b/client/src/sass/player/index.scss index 5d0307d95..4bfd67a26 100644 --- a/client/src/sass/player/index.scss +++ b/client/src/sass/player/index.scss @@ -10,3 +10,4 @@ @use './playlist'; @use './stats'; @use './offline-notification'; +@use './storyboard.scss'; diff --git a/client/src/sass/player/mobile.scss b/client/src/sass/player/mobile.scss index d150c54ee..b0019d2c9 100644 --- a/client/src/sass/player/mobile.scss +++ b/client/src/sass/player/mobile.scss @@ -170,7 +170,8 @@ } } - &.vjs-scrubbing { + &.vjs-scrubbing, + &.vjs-mobile-sliding { .vjs-mobile-buttons-overlay { display: none; } diff --git a/client/src/sass/player/peertube-skin.scss b/client/src/sass/player/peertube-skin.scss index 4df8dbaf0..572ae7050 100644 --- a/client/src/sass/player/peertube-skin.scss +++ b/client/src/sass/player/peertube-skin.scss @@ -84,7 +84,9 @@ body { } // Do not display poster when video is starting - &.vjs-has-autoplay:not(.vjs-has-started) { + // Or if we change resolution manually + &.vjs-has-autoplay:not(.vjs-has-started), + &.vjs-updating-resolution { .vjs-poster { opacity: 0; visibility: hidden; diff --git a/client/src/sass/player/settings-menu.scss b/client/src/sass/player/settings-menu.scss index d2346c126..369c827f7 100644 --- a/client/src/sass/player/settings-menu.scss +++ b/client/src/sass/player/settings-menu.scss @@ -75,6 +75,7 @@ $setting-transition-easing: ease-out; > .vjs-menu { flex: 1; min-width: 200px; + padding: 5px 0; } > .vjs-menu, @@ -90,14 +91,6 @@ $setting-transition-easing: ease-out; background-color: rgba(255, 255, 255, 0.2); } - &:first-child { - margin-top: 5px; - } - - &:last-child { - margin-bottom: 5px; - } - &.disabled { opacity: 0.5; cursor: default !important; diff --git a/client/src/sass/player/storyboard.scss b/client/src/sass/player/storyboard.scss new file mode 100644 index 000000000..c80d1b59d --- /dev/null +++ b/client/src/sass/player/storyboard.scss @@ -0,0 +1,26 @@ +@use 'sass:math'; +@use '_variables' as *; +@use '_mixins' as *; +@use './_player-variables' as *; + +// Like the time tooltip +.video-js .vjs-progress-holder .vjs-storyboard-sprite-placeholder { + display: none; +} + +.video-js .vjs-progress-control:hover .vjs-storyboard-sprite-placeholder, +.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-storyboard-sprite-placeholder { + display: block; + + // Ensure that we maintain a font-size of ~10px. + font-size: 0.6em; + visibility: visible; +} + +.video-js.vjs-settings-dialog-opened { + .vjs-storyboard-sprite-placeholder, + .vjs-time-tooltip, + .vjs-mouse-display { + display: none !important; + } +} diff --git a/client/src/shims/http.ts b/client/src/shims/http.ts deleted file mode 100644 index 1b1767aab..000000000 --- a/client/src/shims/http.ts +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('stream-http') diff --git a/client/src/shims/https.ts b/client/src/shims/https.ts deleted file mode 100644 index f5ef70430..000000000 --- a/client/src/shims/https.ts +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('https-browserify') diff --git a/client/src/shims/stream.ts b/client/src/shims/stream.ts deleted file mode 100644 index 977fd05a0..000000000 --- a/client/src/shims/stream.ts +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('stream-browserify') diff --git a/client/src/standalone/videos/embed-api.ts b/client/src/standalone/videos/embed-api.ts index a99f1edae..cdda122b2 100644 --- a/client/src/standalone/videos/embed-api.ts +++ b/client/src/standalone/videos/embed-api.ts @@ -72,15 +72,12 @@ export class PeerTubeEmbedApi { private setResolution (resolutionId: number) { logger.info(`Set resolution ${resolutionId}`) - if (this.isWebtorrent()) { - if (resolutionId === -1 && this.embed.player.webtorrent().isAutoResolutionPossible() === false) return - - this.embed.player.webtorrent().changeQuality(resolutionId) - + if (this.isWebVideo() && resolutionId === -1) { + logger.error('Auto resolution cannot be set in web video player mode') return } - this.embed.player.p2pMediaLoader().getHLSJS().currentLevel = resolutionId + this.embed.player.peertubeResolutions().select({ id: resolutionId, fireCallback: true }) } private getCaptions (): PeerTubeTextTrack[] { @@ -152,8 +149,8 @@ export class PeerTubeEmbedApi { // --------------------------------------------------------------------------- // PeerTube specific capabilities - this.embed.player.peertubeResolutions().on('resolutionsAdded', () => this.loadResolutions()) - this.embed.player.peertubeResolutions().on('resolutionChanged', () => this.loadResolutions()) + this.embed.player.peertubeResolutions().on('resolutions-added', () => this.loadResolutions()) + this.embed.player.peertubeResolutions().on('resolutions-changed', () => this.loadResolutions()) this.loadResolutions() @@ -193,7 +190,7 @@ export class PeerTubeEmbedApi { }) } - private isWebtorrent () { - return !!this.embed.player.webtorrent + private isWebVideo () { + return !!this.embed.player.webVideo } } diff --git a/client/src/standalone/videos/embed.html b/client/src/standalone/videos/embed.html index a74bb4cee..e2dc02b60 100644 --- a/client/src/standalone/videos/embed.html +++ b/client/src/standalone/videos/embed.html @@ -44,11 +44,11 @@

- +
- +
- +
@@ -60,8 +60,6 @@
-
-