From c4207f978e23c77f09c4646b940dfd532281300f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 2 Feb 2022 11:16:23 +0100 Subject: [PATCH 1/1] Fast forward on HLS decode error --- .../+video-watch/video-watch.component.ts | 8 +- client/src/assets/player/index.ts | 2 + .../player/p2p-media-loader/hls-plugin.ts | 6 + .../assets/player/peertube-player-manager.ts | 602 +++--------------- .../player/peertube-player-options-builder.ts | 489 ++++++++++++++ client/src/assets/player/peertube-plugin.ts | 8 + .../assets/player/peertube-videojs-typings.ts | 2 +- .../player/webtorrent/webtorrent-plugin.ts | 12 +- client/src/standalone/videos/embed.ts | 9 +- scripts/i18n/create-custom-files.ts | 3 +- 10 files changed, 601 insertions(+), 540 deletions(-) create mode 100644 client/src/assets/player/index.ts create mode 100644 client/src/assets/player/peertube-player-options-builder.ts 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 1ee18f52f..1f45c4d26 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -33,7 +33,6 @@ import { VideoPrivacy, VideoState } from '@shared/models' -import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from '../../../assets/player/peertube-player-local-storage' import { CustomizationOptions, P2PMediaLoaderOptions, @@ -41,7 +40,8 @@ import { PeertubePlayerManagerOptions, PlayerMode, videojs -} from '../../../assets/player/peertube-player-manager' +} from '../../../assets/player' +import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from '../../../assets/player/peertube-player-local-storage' import { environment } from '../../../environments/environment' import { VideoWatchPlaylistComponent } from './shared' @@ -612,7 +612,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoCaptions: playerCaptions, videoShortUUID: video.shortUUID, - videoUUID: video.uuid + videoUUID: video.uuid, + + errorNotifier: (message: string) => this.notifier.error(message) }, webtorrent: { diff --git a/client/src/assets/player/index.ts b/client/src/assets/player/index.ts new file mode 100644 index 000000000..e2a6ccf24 --- /dev/null +++ b/client/src/assets/player/index.ts @@ -0,0 +1,2 @@ +export * from './peertube-player-manager' +export * from './peertube-player-options-builder' diff --git a/client/src/assets/player/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/p2p-media-loader/hls-plugin.ts index 640858025..ae31bcfe1 100644 --- a/client/src/assets/player/p2p-media-loader/hls-plugin.ts +++ b/client/src/assets/player/p2p-media-loader/hls-plugin.ts @@ -174,6 +174,12 @@ class Html5Hlsjs { dispose () { this.videoElement.removeEventListener('play', this.handlers.play) + // FIXME: https://github.com/video-dev/hls.js/issues/4092 + const untypedHLS = this.hls as any + untypedHLS.log = untypedHLS.warn = () => { + // empty + } + this.hls.destroy() } diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index b9a289aa0..2ef42a961 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -24,28 +24,12 @@ import './mobile/peertube-mobile-plugin' import './mobile/peertube-mobile-buttons' import './hotkeys/peertube-hotkeys-plugin' import videojs from 'video.js' -import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' import { PluginsManager } from '@root-helpers/plugins-manager' -import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' -import { isDefaultLocale } from '@shared/core-utils/i18n' -import { VideoFile } from '@shared/models' -import { copyToClipboard } from '../../root-helpers/utils' -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 { getAverageBandwidthInStore, saveAverageBandwidth } from './peertube-player-local-storage' -import { - NextPreviousVideoButtonOptions, - P2PMediaLoaderPluginOptions, - PeerTubeLinkButtonOptions, - PlayerNetworkInfo, - PlaylistPluginOptions, - UserWatching, - VideoJSCaption, - VideoJSPluginOptions -} from './peertube-videojs-typings' +import { saveAverageBandwidth } from './peertube-player-local-storage' +import { CommonOptions, PeertubePlayerManagerOptions, PeertubePlayerOptionsBuilder, PlayerMode } from './peertube-player-options-builder' +import { PlayerNetworkInfo } from './peertube-videojs-typings' import { TranslationsManager } from './translations-manager' -import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isMobile, isSafari } from './utils' +import { isMobile } from './utils' // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' @@ -56,112 +40,49 @@ 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_ = ' ' -export type PlayerMode = 'webtorrent' | 'p2p-media-loader' - -export type WebtorrentOptions = { - videoFiles: VideoFile[] -} - -export type P2PMediaLoaderOptions = { - playlistUrl: string - segmentsSha256Url: string - trackerAnnounce: string[] - redundancyBaseUrls: string[] - videoFiles: VideoFile[] -} - -export interface CustomizationOptions { - startTime: number | string - stopTime: number | string - - controls?: boolean - muted?: boolean - loop?: boolean - subtitle?: string - resume?: string - - peertubeLink: boolean -} - -export interface CommonOptions extends CustomizationOptions { - playerElement: HTMLVideoElement - onPlayerElementChange: (element: HTMLVideoElement) => void - - autoplay: boolean - p2pEnabled: boolean - - nextVideo?: () => void - hasNextVideo?: () => boolean - - previousVideo?: () => void - hasPreviousVideo?: () => boolean - - playlist?: PlaylistPluginOptions - - videoDuration: number - enableHotkeys: boolean - inactivityTimeout: number - poster: string - - theaterButton: boolean - captions: boolean - - videoViewUrl: string - embedUrl: string - embedTitle: string - - isLive: boolean - - language?: string - - videoCaptions: VideoJSCaption[] - - videoUUID: string - videoShortUUID: string - - userWatching?: UserWatching - - serverUrl: string -} - -export type PeertubePlayerManagerOptions = { - common: CommonOptions - webtorrent: WebtorrentOptions - p2pMediaLoader?: P2PMediaLoaderOptions - - pluginsManager: PluginsManager -} - export class PeertubePlayerManager { private static playerElementClassName: 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 () { - PeertubePlayerManager.alreadyPlayed = false + this.alreadyPlayed = false } static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) { this.pluginsManager = options.pluginsManager - let p2pMediaLoader: any - this.onPlayerChange = onPlayerChange this.playerElementClassName = options.common.playerElement.className if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin') if (mode === 'p2p-media-loader') { - [ p2pMediaLoader ] = await Promise.all([ + const [ p2pMediaLoaderModule ] = await Promise.all([ import('@peertube/p2p-media-loader-hlsjs'), import('./p2p-media-loader/p2p-media-loader-plugin') ]) - } - const videojsOptions = await this.getVideojsOptions(mode, options, p2pMediaLoader) + 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 PeertubePlayerOptionsBuilder(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) { @@ -169,27 +90,24 @@ export class PeertubePlayerManager { let alreadyFallback = false - player.tech(true).one('error', () => { - if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options) + const handleError = () => { + if (alreadyFallback) return alreadyFallback = true - }) - player.one('error', () => { - if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options) - 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', () => { - PeertubePlayerManager.alreadyPlayed = true + self.alreadyPlayed = true }) - self.addContextMenu({ - mode, - player, - videoShortUUID: options.common.videoShortUUID, - videoEmbedUrl: options.common.embedUrl, - videoEmbedTitle: options.common.embedTitle - }) + self.addContextMenu(videojsOptionsBuilder, player, options.common) if (isMobile()) player.peertubeMobile() if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin() @@ -214,437 +132,77 @@ export class PeertubePlayerManager { }) } - private static async maybeFallbackToWebTorrent (currentMode: PlayerMode, player: any, options: PeertubePlayerManagerOptions) { - if (currentMode === 'webtorrent') return - - console.log('Fallback to webtorrent.') - - const newVideoElement = document.createElement('video') - newVideoElement.className = this.playerElementClassName - - // VideoJS wraps our video element inside a div - let currentParentPlayerElement = options.common.playerElement.parentNode - // Fix on IOS, don't ask me why - if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(options.common.playerElement.id).parentNode - - currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement) - - options.common.playerElement = newVideoElement - options.common.onPlayerElementChange(newVideoElement) - - player.dispose() - - await import('./webtorrent/webtorrent-plugin') - - const mode = 'webtorrent' - const videojsOptions = await this.getVideojsOptions(mode, options) + private static async tryToRecoverHLSError (err: any, currentPlayer: videojs.Player, options: PeertubePlayerManagerOptions) { + if (err.code === 3) { // Decode error - const self = this - videojs(newVideoElement, videojsOptions, function (this: videojs.Player) { - const player = this - - self.addContextMenu({ - mode, - player, - videoShortUUID: options.common.videoShortUUID, - videoEmbedUrl: options.common.embedUrl, - videoEmbedTitle: options.common.embedTitle - }) - - PeertubePlayerManager.onPlayerChange(player) - }) - } - - private static async getVideojsOptions ( - mode: PlayerMode, - options: PeertubePlayerManagerOptions, - p2pMediaLoaderModule?: any - ): Promise { - const commonOptions = options.common - const isHLS = mode === 'p2p-media-loader' - - let autoplay = this.getAutoPlayValue(commonOptions.autoplay) - const html5 = { - preloadTextTracks: false - } - - const plugins: VideoJSPluginOptions = { - peertube: { - mode, - autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent - videoViewUrl: commonOptions.videoViewUrl, - videoDuration: commonOptions.videoDuration, - userWatching: commonOptions.userWatching, - subtitle: commonOptions.subtitle, - videoCaptions: commonOptions.videoCaptions, - stopTime: commonOptions.stopTime, - isLive: commonOptions.isLive, - videoUUID: commonOptions.videoUUID + // 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 (commonOptions.playlist) { - plugins.playlist = commonOptions.playlist - } - - if (isHLS) { - const { hlsjs } = PeertubePlayerManager.addP2PMediaLoaderOptions(plugins, options, p2pMediaLoaderModule) - - Object.assign(html5, hlsjs.html5) - } - - if (mode === 'webtorrent') { - PeertubePlayerManager.addWebTorrentOptions(plugins, options) - - // WebTorrent plugin handles autoplay, because we do some hackish stuff in there - autoplay = false - } - - 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), - - poster: commonOptions.poster, - inactivityTimeout: commonOptions.inactivityTimeout, - playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], - - plugins, - - controlBar: { - children: this.getControlBarChildren(mode, { - videoShortUUID: commonOptions.videoShortUUID, - p2pEnabled: commonOptions.p2pEnabled, - - captions: commonOptions.captions, - peertubeLink: commonOptions.peertubeLink, - theaterButton: commonOptions.theaterButton, - - nextVideo: commonOptions.nextVideo, - hasNextVideo: commonOptions.hasNextVideo, - previousVideo: commonOptions.previousVideo, - hasPreviousVideo: commonOptions.hasPreviousVideo - }) as any // FIXME: typings + if (this.videojsDecodeErrors === 20) { + this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) + return } - } - - if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { - Object.assign(videojsOptions, { language: commonOptions.language }) - } - - return this.pluginsManager.runHook('filter:internal.player.videojs.options.result', videojsOptions) - } - - private static addP2PMediaLoaderOptions ( - plugins: VideoJSPluginOptions, - options: PeertubePlayerManagerOptions, - p2pMediaLoaderModule: any - ) { - const p2pMediaLoaderOptions = options.p2pMediaLoader - const commonOptions = options.common - - const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce - .filter(t => t.startsWith('ws')) - - const redundancyUrlManager = new RedundancyUrlManager(options.p2pMediaLoader.redundancyBaseUrls) - - const p2pMediaLoader: P2PMediaLoaderPluginOptions = { - redundancyUrlManager, - type: 'application/x-mpegURL', - startTime: commonOptions.startTime, - src: p2pMediaLoaderOptions.playlistUrl - } - let consumeOnly = false - if ((navigator as any)?.connection?.type === 'cellular') { - console.log('We are on a cellular connection: disabling seeding.') - consumeOnly = true - } - - const p2pMediaLoaderConfig: HlsJsEngineSettings = { - loader: { - trackerAnnounce, - segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url, options.common.isLive), - rtcConfig: getRtcConfig(), - requiredSegmentsPriority: 1, - simultaneousHttpDownloads: 1, - segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager, 1), - useP2P: commonOptions.p2pEnabled, - consumeOnly - }, - segments: { - swarmId: p2pMediaLoaderOptions.playlistUrl - } - } - - const hlsjs = { - levelLabelHandler: (level: { height: number, width: number }) => { - const resolution = Math.min(level.height || 0, level.width || 0) + console.log('Fast forwarding HLS to recover from an error.') - const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === resolution) - // We don't have files for live videos - if (!file) return level.height + this.videojsDecodeErrors++ - let label = file.resolution.label - if (file.fps >= 50) label += file.fps + options.common.startTime = currentPlayer.currentTime() + 2 + options.common.autoplay = true + this.rebuildAndUpdateVideoElement(currentPlayer, options.common) - return label - }, - html5: { - hlsjsConfig: this.getHLSOptions(p2pMediaLoaderModule, p2pMediaLoaderConfig) - } + const newPlayer = await this.buildPlayer('p2p-media-loader', options) + this.onPlayerChange(newPlayer) + } else { + this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) } - - const toAssign = { p2pMediaLoader, hlsjs } - Object.assign(plugins, toAssign) - - return toAssign } - private static getHLSOptions (p2pMediaLoaderModule: any, p2pMediaLoaderConfig: HlsJsEngineSettings) { - const base = { - capLevelToPlayerSize: true, - autoStartLoad: false, - liveSyncDurationCount: 5, - - loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() - } - - const averageBandwidth = getAverageBandwidthInStore() - if (!averageBandwidth) return base - - return { - ...base, - - abrEwmaDefaultEstimate: averageBandwidth * 8, // We want bit/s - startLevel: -1, - testBandwidth: false, - debug: false - } - } - - private static addWebTorrentOptions (plugins: VideoJSPluginOptions, options: PeertubePlayerManagerOptions) { - const commonOptions = options.common - const webtorrentOptions = options.webtorrent - const p2pMediaLoaderOptions = options.p2pMediaLoader - - const autoplay = this.getAutoPlayValue(commonOptions.autoplay) === 'play' - - const webtorrent = { - autoplay, - playerRefusedP2P: commonOptions.p2pEnabled === false, - videoDuration: commonOptions.videoDuration, - playerElement: commonOptions.playerElement, - 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 - } - - Object.assign(plugins, { webtorrent }) - } - - private static getControlBarChildren (mode: PlayerMode, options: { - p2pEnabled: boolean - videoShortUUID: string - - peertubeLink: boolean - theaterButton: boolean - captions: boolean - - nextVideo?: () => void - hasNextVideo?: () => boolean - - previousVideo?: () => void - hasPreviousVideo?: () => boolean - }) { - const settingEntries = [] - const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar' - - // Keep an order - settingEntries.push('playbackRateMenuButton') - if (options.captions === true) settingEntries.push('captionsButton') - settingEntries.push('resolutionMenuButton') - - const children = {} - - if (options.previousVideo) { - const buttonOptions: NextPreviousVideoButtonOptions = { - type: 'previous', - handler: options.previousVideo, - isDisabled: () => { - if (!options.hasPreviousVideo) return false - - return !options.hasPreviousVideo() - } - } - - Object.assign(children, { - previousVideoButton: buttonOptions - }) - } - - Object.assign(children, { playToggle: {} }) - - if (options.nextVideo) { - const buttonOptions: NextPreviousVideoButtonOptions = { - type: 'next', - handler: options.nextVideo, - isDisabled: () => { - if (!options.hasNextVideo) return false - - return !options.hasNextVideo() - } - } - - Object.assign(children, { - nextVideoButton: buttonOptions - }) + private static async maybeFallbackToWebTorrent ( + currentMode: PlayerMode, + currentPlayer: videojs.Player, + options: PeertubePlayerManagerOptions + ) { + if (options.webtorrent.videoFiles.length === 0 || currentMode === 'webtorrent') { + currentPlayer.peertube().displayFatalError() + return } - Object.assign(children, { - currentTimeDisplay: {}, - timeDivider: {}, - durationDisplay: {}, - liveDisplay: {}, - - flexibleWidthSpacer: {}, - progressControl: { - children: { - seekBar: { - children: { - [loadProgressBar]: {}, - mouseTimeDisplay: {}, - playProgressBar: {} - } - } - } - }, - - p2PInfoButton: { - p2pEnabled: options.p2pEnabled - }, - - muteToggle: {}, - volumeControl: {}, - - settingsButton: { - setup: { - maxHeightOffset: 40 - }, - entries: settingEntries - } - }) - - if (options.peertubeLink === true) { - Object.assign(children, { - peerTubeLinkButton: { shortUUID: options.videoShortUUID } as PeerTubeLinkButtonOptions - }) - } + console.log('Fallback to webtorrent.') - if (options.theaterButton === true) { - Object.assign(children, { - theaterButton: {} - }) - } + this.rebuildAndUpdateVideoElement(currentPlayer, options.common) - Object.assign(children, { - fullscreenToggle: {} - }) + await import('./webtorrent/webtorrent-plugin') - return children + const newPlayer = await this.buildPlayer('webtorrent', options) + this.onPlayerChange(newPlayer) } - private static addContextMenu (options: { - mode: PlayerMode - player: videojs.Player - videoShortUUID: string - videoEmbedUrl: string - videoEmbedTitle: string - }) { - const { mode, player, videoEmbedTitle, videoEmbedUrl, videoShortUUID } = options - - 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: videoShortUUID })) - } - }, - { - label: player.localize('Copy the video URL at the current time'), - listener: function (this: videojs.Player) { - const url = buildVideoLink({ shortUUID: videoShortUUID }) + private static rebuildAndUpdateVideoElement (player: videojs.Player, commonOptions: CommonOptions) { + const newVideoElement = document.createElement('video') + newVideoElement.className = this.playerElementClassName - copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() })) - } - }, - { - icon: 'code', - label: player.localize('Copy embed code'), - listener: () => { - copyToClipboard(buildVideoOrPlaylistEmbed(videoEmbedUrl, videoEmbedTitle)) - } - } - ] + // 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 - if (mode === 'webtorrent') { - items.push({ - label: player.localize('Copy magnet URI'), - listener: function (this: videojs.Player) { - copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri) - } - }) - } + currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement) - items.push({ - icon: 'info', - label: player.localize('Stats for nerds'), - listener: () => { - player.stats().show() - } - }) + commonOptions.playerElement = newVideoElement + commonOptions.onPlayerElementChange(newVideoElement) - return items.map(i => ({ - ...i, - label: `` + i.label - })) - } + player.dispose() - // adding the menu - player.contextmenuUI({ content }) + return newVideoElement } - private static getAutoPlayValue (autoplay: any) { - 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 PeertubePlayerManager.alreadyPlayed ? 'play' : false - } + private static addContextMenu (optionsBuilder: PeertubePlayerOptionsBuilder, player: videojs.Player, commonOptions: CommonOptions) { + const options = optionsBuilder.getContextMenuOptions(player, commonOptions) - return 'play' + player.contextmenuUI(options) } } diff --git a/client/src/assets/player/peertube-player-options-builder.ts b/client/src/assets/player/peertube-player-options-builder.ts new file mode 100644 index 000000000..901f6cd3b --- /dev/null +++ b/client/src/assets/player/peertube-player-options-builder.ts @@ -0,0 +1,489 @@ +import videojs from 'video.js' +import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' +import { PluginsManager } from '@root-helpers/plugins-manager' +import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' +import { isDefaultLocale } from '@shared/core-utils/i18n' +import { VideoFile } from '@shared/models' +import { copyToClipboard } from '../../root-helpers/utils' +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 { getAverageBandwidthInStore } from './peertube-player-local-storage' +import { + NextPreviousVideoButtonOptions, + P2PMediaLoaderPluginOptions, + PeerTubeLinkButtonOptions, + PlaylistPluginOptions, + UserWatching, + VideoJSCaption, + VideoJSPluginOptions +} from './peertube-videojs-typings' +import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils' + +export type PlayerMode = 'webtorrent' | 'p2p-media-loader' + +export type WebtorrentOptions = { + videoFiles: VideoFile[] +} + +export type P2PMediaLoaderOptions = { + playlistUrl: string + segmentsSha256Url: string + trackerAnnounce: string[] + redundancyBaseUrls: string[] + videoFiles: VideoFile[] +} + +export interface CustomizationOptions { + startTime: number | string + stopTime: number | string + + controls?: boolean + muted?: boolean + loop?: boolean + subtitle?: string + resume?: string + + peertubeLink: boolean +} + +export interface CommonOptions extends CustomizationOptions { + playerElement: HTMLVideoElement + onPlayerElementChange: (element: HTMLVideoElement) => void + + autoplay: boolean + p2pEnabled: boolean + + nextVideo?: () => void + hasNextVideo?: () => boolean + + previousVideo?: () => void + hasPreviousVideo?: () => boolean + + playlist?: PlaylistPluginOptions + + videoDuration: number + enableHotkeys: boolean + inactivityTimeout: number + poster: string + + theaterButton: boolean + captions: boolean + + videoViewUrl: string + embedUrl: string + embedTitle: string + + isLive: boolean + + language?: string + + videoCaptions: VideoJSCaption[] + + videoUUID: string + videoShortUUID: string + + userWatching?: UserWatching + + serverUrl: string + + errorNotifier: (message: string) => void +} + +export type PeertubePlayerManagerOptions = { + common: CommonOptions + webtorrent: WebtorrentOptions + p2pMediaLoader?: P2PMediaLoaderOptions + + pluginsManager: PluginsManager +} + +export class PeertubePlayerOptionsBuilder { + + constructor ( + private mode: PlayerMode, + private options: PeertubePlayerManagerOptions, + private p2pMediaLoaderModule?: any + ) { + + } + + getVideojsOptions (alreadyPlayed: boolean): videojs.PlayerOptions { + const commonOptions = this.options.common + const isHLS = this.mode === 'p2p-media-loader' + + 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 + videoViewUrl: commonOptions.videoViewUrl, + videoDuration: commonOptions.videoDuration, + userWatching: commonOptions.userWatching, + subtitle: commonOptions.subtitle, + videoCaptions: commonOptions.videoCaptions, + stopTime: commonOptions.stopTime, + isLive: commonOptions.isLive, + videoUUID: commonOptions.videoUUID + } + } + + if (commonOptions.playlist) { + plugins.playlist = commonOptions.playlist + } + + if (isHLS) { + const { hlsjs } = this.addP2PMediaLoaderOptions(plugins) + + Object.assign(html5, hlsjs.html5) + } + + if (this.mode === 'webtorrent') { + this.addWebTorrentOptions(plugins, alreadyPlayed) + + // WebTorrent plugin handles autoplay, because we do some hackish stuff in there + autoplay = false + } + + 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: this.getControlBarChildren(this.mode, { + videoShortUUID: commonOptions.videoShortUUID, + p2pEnabled: commonOptions.p2pEnabled, + + captions: commonOptions.captions, + peertubeLink: commonOptions.peertubeLink, + theaterButton: commonOptions.theaterButton, + + nextVideo: commonOptions.nextVideo, + hasNextVideo: commonOptions.hasNextVideo, + + previousVideo: commonOptions.previousVideo, + hasPreviousVideo: commonOptions.hasPreviousVideo + }) as any // FIXME: typings + } + } + + if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { + Object.assign(videojsOptions, { language: commonOptions.language }) + } + + return videojsOptions + } + + private addP2PMediaLoaderOptions (plugins: VideoJSPluginOptions) { + const p2pMediaLoaderOptions = this.options.p2pMediaLoader + const commonOptions = this.options.common + + const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce + .filter(t => t.startsWith('ws')) + + const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls) + + const p2pMediaLoader: P2PMediaLoaderPluginOptions = { + redundancyUrlManager, + type: 'application/x-mpegURL', + startTime: commonOptions.startTime, + src: p2pMediaLoaderOptions.playlistUrl + } + + let consumeOnly = false + if ((navigator as any)?.connection?.type === 'cellular') { + console.log('We are on a cellular connection: disabling seeding.') + consumeOnly = true + } + + const p2pMediaLoaderConfig: HlsJsEngineSettings = { + loader: { + trackerAnnounce, + segmentValidator: segmentValidatorFactory(this.options.p2pMediaLoader.segmentsSha256Url, this.options.common.isLive), + rtcConfig: getRtcConfig(), + requiredSegmentsPriority: 1, + simultaneousHttpDownloads: 1, + segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager, 1), + useP2P: commonOptions.p2pEnabled, + consumeOnly + }, + segments: { + swarmId: p2pMediaLoaderOptions.playlistUrl + } + } + + const hlsjs = { + levelLabelHandler: (level: { height: number, width: number }) => { + const resolution = Math.min(level.height || 0, level.width || 0) + + const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === resolution) + // We don't have files for live videos + if (!file) return level.height + + let label = file.resolution.label + if (file.fps >= 50) label += file.fps + + return label + }, + html5: { + hlsjsConfig: this.getHLSOptions(p2pMediaLoaderConfig) + } + } + + const toAssign = { p2pMediaLoader, hlsjs } + Object.assign(plugins, toAssign) + + return toAssign + } + + private getHLSOptions (p2pMediaLoaderConfig: HlsJsEngineSettings) { + const base = { + capLevelToPlayerSize: true, + autoStartLoad: false, + liveSyncDurationCount: 5, + + loader: new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() + } + + const averageBandwidth = getAverageBandwidthInStore() + if (!averageBandwidth) return base + + return { + ...base, + + abrEwmaDefaultEstimate: averageBandwidth * 8, // We want bit/s + startLevel: -1, + testBandwidth: false, + debug: false + } + } + + private addWebTorrentOptions (plugins: VideoJSPluginOptions, alreadyPlayed: boolean) { + const commonOptions = this.options.common + const webtorrentOptions = this.options.webtorrent + const p2pMediaLoaderOptions = this.options.p2pMediaLoader + + const autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed) === 'play' + + const webtorrent = { + autoplay, + + playerRefusedP2P: commonOptions.p2pEnabled === false, + videoDuration: commonOptions.videoDuration, + playerElement: commonOptions.playerElement, + + 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 + } + + Object.assign(plugins, { webtorrent }) + } + + private getControlBarChildren (mode: PlayerMode, options: { + p2pEnabled: boolean + videoShortUUID: string + + peertubeLink: boolean + theaterButton: boolean + captions: boolean + + nextVideo?: () => void + hasNextVideo?: () => boolean + + previousVideo?: () => void + hasPreviousVideo?: () => boolean + }) { + const settingEntries = [] + const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar' + + // Keep an order + settingEntries.push('playbackRateMenuButton') + if (options.captions === true) settingEntries.push('captionsButton') + settingEntries.push('resolutionMenuButton') + + const children = {} + + if (options.previousVideo) { + const buttonOptions: NextPreviousVideoButtonOptions = { + type: 'previous', + handler: options.previousVideo, + isDisabled: () => { + if (!options.hasPreviousVideo) return false + + return !options.hasPreviousVideo() + } + } + + Object.assign(children, { + previousVideoButton: buttonOptions + }) + } + + Object.assign(children, { playToggle: {} }) + + if (options.nextVideo) { + const buttonOptions: NextPreviousVideoButtonOptions = { + type: 'next', + handler: options.nextVideo, + isDisabled: () => { + if (!options.hasNextVideo) return false + + return !options.hasNextVideo() + } + } + + Object.assign(children, { + nextVideoButton: buttonOptions + }) + } + + Object.assign(children, { + currentTimeDisplay: {}, + timeDivider: {}, + durationDisplay: {}, + liveDisplay: {}, + + flexibleWidthSpacer: {}, + progressControl: { + children: { + seekBar: { + children: { + [loadProgressBar]: {}, + mouseTimeDisplay: {}, + playProgressBar: {} + } + } + } + }, + + p2PInfoButton: { + p2pEnabled: options.p2pEnabled + }, + + muteToggle: {}, + volumeControl: {}, + + settingsButton: { + setup: { + maxHeightOffset: 40 + }, + entries: settingEntries + } + }) + + if (options.peertubeLink === true) { + Object.assign(children, { + peerTubeLinkButton: { shortUUID: options.videoShortUUID } as PeerTubeLinkButtonOptions + }) + } + + if (options.theaterButton === true) { + Object.assign(children, { + theaterButton: {} + }) + } + + Object.assign(children, { + fullscreenToggle: {} + }) + + return children + } + + private getAutoPlayValue (autoplay: any, 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 '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(commonOptions.embedUrl, 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/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts index fd612dd4f..b5c42d1c5 100644 --- a/client/src/assets/player/peertube-plugin.ts +++ b/client/src/assets/player/peertube-plugin.ts @@ -122,6 +122,14 @@ class PeerTubePlugin extends Plugin { this.alterInactivity() } + displayFatalError () { + this.player.addClass('vjs-error-display-enabled') + } + + hideFatalError () { + this.player.removeClass('vjs-error-display-enabled') + } + private initializePlayer () { if (isMobile()) this.player.addClass('vjs-is-mobile') diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index b20ef7a3b..246f0d390 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -4,7 +4,7 @@ import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' import { Html5Hlsjs } from './p2p-media-loader/hls-plugin' import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin' import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager' -import { PlayerMode } from './peertube-player-manager' +import { PlayerMode } from './peertube-player-options-builder' import { PeerTubePlugin } from './peertube-plugin' import { PeerTubeResolutionsPlugin } from './peertube-resolutions-plugin' import { PlaylistPlugin } from './playlist/playlist-plugin' diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts index 16dc7a244..4bcb2766a 100644 --- a/client/src/assets/player/webtorrent/webtorrent-plugin.ts +++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts @@ -145,7 +145,7 @@ class WebTorrentPlugin extends Plugin { } // Do not display error to user because we will have multiple fallback - this.disableErrorDisplay(); + this.player.peertube().hideFatalError(); // Hack to "simulate" src link in video.js >= 6 // Without this, we can't play the video after pausing it @@ -524,7 +524,7 @@ class WebTorrentPlugin extends Plugin { this.torrent = null // Enable error display now this is our last fallback - this.player.one('error', () => this.enableErrorDisplay()) + this.player.one('error', () => this.player.peertube().displayFatalError()) const httpUrl = this.currentVideoFile.fileUrl this.player.src = this.savePlayerSrcFunction @@ -549,14 +549,6 @@ class WebTorrentPlugin extends Plugin { return this.player.trigger('customError', { err }) } - private enableErrorDisplay () { - this.player.addClass('vjs-error-display-enabled') - } - - private disableErrorDisplay () { - this.player.removeClass('vjs-error-display-enabled') - } - private pickAverageVideoFile () { if (this.videoFiles.length === 1) return this.videoFiles[0] diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index eb8076b98..054f771ab 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -14,7 +14,7 @@ import { VideoPlaylistElement, VideoStreamingPlaylistType } from '../../../../shared/models' -import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../assets/player/peertube-player-manager' +import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../assets/player' import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings' import { TranslationsManager } from '../../assets/player/translations-manager' import { isP2PEnabled } from '../../assets/player/utils' @@ -558,7 +558,11 @@ export class PeerTubeEmbed { serverUrl: window.location.origin, language: navigator.language, embedUrl: window.location.origin + videoInfo.embedPath, - embedTitle: videoInfo.name + embedTitle: videoInfo.name, + + errorNotifier: () => { + // Empty, we don't have a notifier in the embed + } }, webtorrent: { @@ -664,7 +668,6 @@ export class PeerTubeEmbed { this.player.dispose() this.playerElement = null this.displayError('This video is not available because the remote instance is not responding.', translations) - } } diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts index 248a5b038..bf3dfa1c9 100755 --- a/scripts/i18n/create-custom-files.ts +++ b/scripts/i18n/create-custom-files.ts @@ -55,7 +55,8 @@ const playerKeys = { 'Playlist: {1}': 'Playlist: {1}', 'disabled': 'disabled', ' off': ' off', - 'Player mode': 'Player mode' + 'Player mode': 'Player mode', + 'The video failed to play, will try to fast forward.': 'The video failed to play, will try to fast forward.' } Object.assign(playerKeys, videojs) -- 2.41.0