Remove webtorrent support from client

pull/5897/head
Chocobozzz 2023-06-29 15:55:00 +02:00
parent 8ef866071f
commit a1bd2b77d9
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
80 changed files with 2590 additions and 3896 deletions

View File

@ -71,7 +71,6 @@
"@types/sanitize-html": "2.6.2", "@types/sanitize-html": "2.6.2",
"@types/sha.js": "^2.4.0", "@types/sha.js": "^2.4.0",
"@types/video.js": "^7.3.40", "@types/video.js": "^7.3.40",
"@types/webtorrent": "^0.109.0",
"@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0", "@typescript-eslint/parser": "^5.43.0",
"@wdio/browserstack-service": "^8.10.5", "@wdio/browserstack-service": "^8.10.5",
@ -85,14 +84,12 @@
"babel-loader": "^9.1.0", "babel-loader": "^9.1.0",
"bootstrap": "^5.1.3", "bootstrap": "^5.1.3",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"cache-chunk-store": "^3.0.0",
"chart.js": "^4.3.0", "chart.js": "^4.3.0",
"chartjs-plugin-zoom": "~2.0.1", "chartjs-plugin-zoom": "~2.0.1",
"chromedriver": "^113.0.0", "chromedriver": "^113.0.0",
"core-js": "^3.22.8", "core-js": "^3.22.8",
"css-loader": "^6.2.0", "css-loader": "^6.2.0",
"debug": "^4.3.1", "debug": "^4.3.1",
"dexie": "^3.2.2",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",
"eslint-plugin-jsdoc": "^44.2.4", "eslint-plugin-jsdoc": "^44.2.4",
@ -103,7 +100,6 @@
"hls.js": "~1.3", "hls.js": "~1.3",
"html-loader": "^4.1.0", "html-loader": "^4.1.0",
"html-webpack-plugin": "^5.3.1", "html-webpack-plugin": "^5.3.1",
"https-browserify": "^1.0.0",
"intl-messageformat": "^10.1.0", "intl-messageformat": "^10.1.0",
"jschannel": "^1.0.2", "jschannel": "^1.0.2",
"linkify-html": "^4.0.2", "linkify-html": "^4.0.2",
@ -115,9 +111,7 @@
"path-browserify": "^1.0.0", "path-browserify": "^1.0.0",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"primeng": "^16.0.0-rc.2", "primeng": "^16.0.0-rc.2",
"process": "^0.11.10",
"purify-css": "^1.2.5", "purify-css": "^1.2.5",
"querystring": "^0.2.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"rxjs": "^7.3.0", "rxjs": "^7.3.0",
"sanitize-html": "^2.1.2", "sanitize-html": "^2.1.2",
@ -125,23 +119,17 @@
"sass-loader": "^13.2.0", "sass-loader": "^13.2.0",
"sha.js": "^2.4.11", "sha.js": "^2.4.11",
"socket.io-client": "^4.5.4", "socket.io-client": "^4.5.4",
"stream-browserify": "^3.0.0",
"stream-http": "^3.0.0",
"stylelint": "^15.1.0", "stylelint": "^15.1.0",
"stylelint-config-sass-guidelines": "^10.0.0", "stylelint-config-sass-guidelines": "^10.0.0",
"ts-loader": "^9.3.0", "ts-loader": "^9.3.0",
"tslib": "^2.4.0", "tslib": "^2.4.0",
"typescript": "~4.9.5", "typescript": "~4.9.5",
"url": "^0.11.0",
"video.js": "^7.19.2", "video.js": "^7.19.2",
"videostream": "~3.2.1",
"wdio-chromedriver-service": "^8.1.1", "wdio-chromedriver-service": "^8.1.1",
"wdio-geckodriver-service": "^5.0.1", "wdio-geckodriver-service": "^5.0.1",
"webpack": "^5.73.0", "webpack": "^5.73.0",
"webpack-bundle-analyzer": "^4.4.2", "webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^5.0.1", "webpack-cli": "^5.0.1",
"webtorrent": "1.8.26",
"whatwg-fetch": "^3.0.0",
"zone.js": "~0.13.0" "zone.js": "~0.13.0"
}, },
"dependencies": {} "dependencies": {}

View File

@ -152,12 +152,24 @@ export class VideoWatchPlaylistComponent {
this.onPlaylistVideosNearOfBottom(position) this.onPlaylistVideosNearOfBottom(position)
} }
// ---------------------------------------------------------------------------
hasPreviousVideo () { hasPreviousVideo () {
return !!this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous') return !!this.getPreviousVideo()
} }
getPreviousVideo () {
return this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous')
}
// ---------------------------------------------------------------------------
hasNextVideo () { hasNextVideo () {
return !!this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next') return !!this.getNextVideo()
}
getNextVideo () {
return this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next')
} }
navigateToPreviousPlaylistVideo () { navigateToPreviousPlaylistVideo () {

View File

@ -8,7 +8,7 @@
</div> </div>
<div id="videojs-wrapper"> <div id="videojs-wrapper">
<img class="placeholder-image" *ngIf="playerPlaceholderImgSrc" [src]="playerPlaceholderImgSrc" alt="Placeholder image" i18n-alt> <video #playerElement class="video-js vjs-peertube-skin" playsinline="true"></video>
</div> </div>
<my-video-watch-playlist <my-video-watch-playlist

View File

@ -1,6 +1,5 @@
import { Hotkey, HotkeysService } from 'angular2-hotkeys' import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs' import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
import { VideoJsPlayer } from 'video.js'
import { PlatformLocation } from '@angular/common' import { PlatformLocation } from '@angular/common'
import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
@ -19,13 +18,13 @@ import {
UserService UserService
} from '@app/core' } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service' 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 { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
import { LiveVideoService } from '@app/shared/shared-video-live' import { LiveVideoService } from '@app/shared/shared-video-live'
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { logger } from '@root-helpers/logger' 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 { timeToInt } from '@shared/core-utils'
import { import {
HTMLServerConfig, HTMLServerConfig,
@ -39,10 +38,10 @@ import {
VideoState VideoState
} from '@shared/models' } from '@shared/models'
import { import {
CustomizationOptions, HLSOptions,
P2PMediaLoaderOptions, PeerTubePlayer,
PeertubePlayerManager, PeerTubePlayerContructorOptions,
PeertubePlayerManagerOptions, PeerTubePlayerLoadOptions,
PlayerMode, PlayerMode,
videojs videojs
} from '../../../assets/player' } from '../../../assets/player'
@ -50,7 +49,24 @@ import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { VideoWatchPlaylistComponent } from './shared' 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({ @Component({
selector: 'my-video-watch', selector: 'my-video-watch',
@ -60,10 +76,9 @@ type URLOptions = CustomizationOptions & { playerMode: PlayerMode }
export class VideoWatchComponent implements OnInit, OnDestroy { export class VideoWatchComponent implements OnInit, OnDestroy {
@ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent
@ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
@ViewChild('playerElement') playerElement: ElementRef<HTMLVideoElement>
player: VideoJsPlayer peertubePlayer: PeerTubePlayer
playerElement: HTMLVideoElement
playerPlaceholderImgSrc: string
theaterEnabled = false theaterEnabled = false
video: VideoDetails = null video: VideoDetails = null
@ -78,8 +93,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
remoteServerDown = false remoteServerDown = false
noPlaylistVideoFound = false noPlaylistVideoFound = false
private nextVideoUUID = '' private nextRecommendedVideoUUID = ''
private nextVideoTitle = '' private nextRecommendedVideoTitle = ''
private videoFileToken: string private videoFileToken: string
@ -130,11 +145,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return this.userService.getAnonymousUser() return this.userService.getAnonymousUser()
} }
ngOnInit () { async ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig() this.serverConfig = this.serverService.getHTMLConfig()
PeertubePlayerManager.initState()
this.loadRouteParams() this.loadRouteParams()
this.loadRouteQuery() this.loadRouteQuery()
@ -143,10 +156,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.hooks.runAction('action:video-watch.init', 'video-watch') this.hooks.runAction('action:video-watch.init', 'video-watch')
setTimeout(cleanupVideoWatch, 1500) // Run in timeout to ensure we're not blocking the UI 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 () { ngOnDestroy () {
this.flushPlayer() if (this.peertubePlayer) this.peertubePlayer.destroy()
// Unsubscribe subscriptions // Unsubscribe subscriptions
if (this.paramsSub) this.paramsSub.unsubscribe() 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 // The recommended videos's first element should be the next video
const video = videos[0] const video = videos[0]
this.nextVideoUUID = video.uuid this.nextRecommendedVideoUUID = video.uuid
this.nextVideoTitle = video.name this.nextRecommendedVideoTitle = video.name
} }
handleTimestampClicked (timestamp: number) { 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() scrollToTop()
} }
@ -243,7 +266,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition) this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition)
const start = queryParams['start'] 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.isSameElement(this.video, videoId)) return
if (this.player) this.player.pause()
this.video = undefined this.video = undefined
const videoObs = this.hooks.wrapObsFun( const videoObs = this.hooks.wrapObsFun(
@ -291,23 +312,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.userService.getAnonymousOrLoggedUser() this.userService.getAnonymousOrLoggedUser()
]).subscribe({ ]).subscribe({
next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => { 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({ this.onVideoFetched({
video, video,
live, live,
@ -316,7 +320,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoFileToken, videoFileToken,
videoPassword, videoPassword,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
urlOptions,
forceAutoplay forceAutoplay
}).catch(err => { }).catch(err => {
this.handleGlobalError(err) this.handleGlobalError(err)
@ -386,14 +389,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
const errorMessage: string = typeof err === 'string' ? err : err.message const errorMessage: string = typeof err === 'string' ? err : err.message
if (!errorMessage) return 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) this.notifier.error(errorMessage)
} }
@ -422,7 +417,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoFileToken: string videoFileToken: string
videoPassword: string videoPassword: string
urlOptions: URLOptions
loggedInOrAnonymousUser: User loggedInOrAnonymousUser: User
forceAutoplay: boolean forceAutoplay: boolean
}) { }) {
@ -431,7 +425,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
live, live,
videoCaptions, videoCaptions,
storyboards, storyboards,
urlOptions,
videoFileToken, videoFileToken,
videoPassword, videoPassword,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
@ -448,7 +441,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.storyboards = storyboards this.storyboards = storyboards
// Re init attributes // Re init attributes
this.playerPlaceholderImgSrc = undefined
this.remoteServerDown = false this.remoteServerDown = false
this.currentTime = undefined this.currentTime = undefined
@ -462,7 +454,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.buildHotkeysHelp(video) this.buildHotkeysHelp(video)
this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay }) this.loadPlayer({ loggedInOrAnonymousUser, forceAutoplay })
.catch(err => logger.error('Cannot build the player', err)) .catch(err => logger.error('Cannot build the player', err))
this.setOpenGraphTags() this.setOpenGraphTags()
@ -475,28 +467,19 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions) this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions)
} }
private async buildPlayer (options: { private async loadPlayer (options: {
urlOptions: URLOptions
loggedInOrAnonymousUser: User loggedInOrAnonymousUser: User
forceAutoplay: boolean forceAutoplay: boolean
}) { }) {
const { urlOptions, loggedInOrAnonymousUser, forceAutoplay } = options const { loggedInOrAnonymousUser, forceAutoplay } = options
// Flush old player if needed
this.flushPlayer()
const videoState = this.video.state.id const videoState = this.video.state.id
if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) { if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) {
this.playerPlaceholderImgSrc = this.video.previewPath this.updatePlayerOnNoLive()
return return
} }
// Build video element, because videojs removes it on dispose this.peertubePlayer?.enable()
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)
const params = { const params = {
video: this.video, video: this.video,
@ -505,86 +488,49 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
liveVideo: this.liveVideo, liveVideo: this.liveVideo,
videoFileToken: this.videoFileToken, videoFileToken: this.videoFileToken,
videoPassword: this.videoPassword, videoPassword: this.videoPassword,
urlOptions, urlOptions: this.getUrlOptions(),
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay, forceAutoplay,
user: this.user 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, params,
'video-watch', 'video-watch',
'filter:internal.video-watch.player.build-options.params', 'filter:internal.video-watch.player.load-options.params',
'filter:internal.video-watch.player.build-options.result' 'filter:internal.video-watch.player.load-options.result'
) )
this.zone.runOutsideAngular(async () => { 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) => { const player = this.peertubePlayer.getPlayer()
this.zone.run(() => this.handleGlobalError(data.err))
})
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 // 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
headText: $localize`Up Next`,
cancelText: $localize`Cancel`,
suspendedText: $localize`Autoplay is suspended`,
getTitle: () => this.nextVideoTitle,
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) { if (this.video.isLive) {
this.zone.run(() => this.video.state.id = VideoState.LIVE_ENDED) player.one('ended', () => {
} this.zone.run(() => {
}) // We changed the video, it's not a live anymore
if (!this.video.isLive) return
this.player.on('theaterChange', (_: any, enabled: boolean) => { this.video.state.id = VideoState.LIVE_ENDED
this.updatePlayerOnNoLive()
})
})
}
player.on('theater-change', (_: any, enabled: boolean) => {
this.zone.run(() => this.theaterEnabled = enabled) this.zone.run(() => this.theaterEnabled = enabled)
}) })
this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', {
player: this.player, player,
playlist: this.playlist, playlist: this.playlist,
playlistPosition: this.playlistPosition, playlistPosition: this.playlistPosition,
videojs, videojs,
@ -601,15 +547,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return true return true
} }
private playNextVideoInAngularZone () { private getNextVideoTitle () {
if (this.playlist) { if (this.playlist) {
this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) return this.videoWatchPlaylist.getNextVideo()?.video?.name || ''
}
return this.nextRecommendedVideoTitle
}
private playNextVideoInAngularZone () {
this.zone.run(() => {
if (this.playlist) {
this.videoWatchPlaylist.navigateToNextPlaylistVideo()
return return
} }
if (this.nextVideoUUID) { if (this.nextRecommendedVideoUUID) {
this.router.navigate([ '/w', this.nextVideoUUID ]) this.router.navigate([ '/w', this.nextRecommendedVideoUUID ])
} }
})
} }
private isAutoplay () { private isAutoplay () {
@ -637,19 +593,45 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
) )
} }
private flushPlayer () { private buildPeerTubePlayerConstructorOptions (options: {
// Remove player if it exists urlOptions: URLOptions
if (!this.player) return }): PeerTubePlayerContructorOptions {
const { urlOptions } = options
try { return {
this.player.dispose() playerElement: () => this.playerElement.nativeElement,
this.player = undefined
} catch (err) { enableHotkeys: true,
logger.error('Cannot dispose player.', err) 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 video: VideoDetails
liveVideo: LiveVideo liveVideo: LiveVideo
videoCaptions: VideoCaption[] videoCaptions: VideoCaption[]
@ -658,12 +640,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoFileToken: string videoFileToken: string
videoPassword: string videoPassword: string
urlOptions: CustomizationOptions & { playerMode: PlayerMode } urlOptions: URLOptions
loggedInOrAnonymousUser: User loggedInOrAnonymousUser: User
forceAutoplay: boolean forceAutoplay: boolean
user?: AuthUser // Keep for plugins user?: AuthUser // Keep for plugins
}) { }): PeerTubePlayerLoadOptions {
const { const {
video, video,
liveVideo, liveVideo,
@ -674,7 +656,30 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
urlOptions, urlOptions,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay 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 getStartTime = () => {
const byUrl = urlOptions.startTime !== undefined const byUrl = urlOptions.startTime !== undefined
@ -714,55 +719,28 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
? { latencyMode: liveVideo.latencyMode } ? { latencyMode: liveVideo.latencyMode }
: undefined : undefined
const options: PeertubePlayerManagerOptions = { return {
common: { mode,
autoplay: this.isAutoplay(), autoplay: this.isAutoplay(),
forceAutoplay, forceAutoplay,
p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
hasNextVideo: () => this.hasNextVideo(), duration: this.video.duration,
nextVideo: () => this.playNextVideoInAngularZone(),
playerElement: this.playerElement,
onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
videoDuration: video.duration,
enableHotkeys: true,
inactivityTimeout: 2500,
poster: video.previewUrl, poster: video.previewUrl,
p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
startTime, startTime,
stopTime: urlOptions.stopTime, stopTime: urlOptions.stopTime,
controlBar: urlOptions.controlBar,
controls: urlOptions.controls,
muted: urlOptions.muted,
loop: urlOptions.loop,
subtitle: urlOptions.subtitle,
playbackRate: urlOptions.playbackRate,
peertubeLink: urlOptions.peertubeLink,
theaterButton: true,
captions: videoCaptions.length !== 0,
embedUrl: video.embedUrl, embedUrl: video.embedUrl,
embedTitle: video.name, embedTitle: video.name,
instanceName: this.serverConfig.instance.name,
isLive: video.isLive, isLive: video.isLive,
liveOptions, liveOptions,
language: this.localeId,
metricsUrl: environment.apiUrl + '/api/v1/metrics/playback',
videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
? this.videoService.getVideoViewUrl(video.uuid) ? this.videoService.getVideoViewUrl(video.uuid)
: null, : null,
videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS,
authorizationHeader: () => this.authService.getRequestHeaderValue(),
serverUrl: environment.originServerUrl || window.location.origin,
videoFileToken: () => videoFileToken, videoFileToken: () => videoFileToken,
requiresUserAuth: videoRequiresUserAuth(video, videoPassword), requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
@ -776,56 +754,45 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoShortUUID: video.shortUUID, videoShortUUID: video.shortUUID,
videoUUID: video.uuid, videoUUID: video.uuid,
errorNotifier: (message: string) => this.notifier.error(message) previousVideo: {
enabled: this.playlist && this.videoWatchPlaylist.hasPreviousVideo(),
handler: this.playlist
? () => this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo())
: undefined,
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 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) { private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) {
@ -873,6 +840,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.video.viewers = newViewers this.video.viewers = newViewers
} }
private updatePlayerOnNoLive () {
this.peertubePlayer.unload()
this.peertubePlayer.disable()
this.peertubePlayer.setPoster(this.video.previewPath)
}
private buildHotkeysHelp (video: Video) { private buildHotkeysHelp (video: Video) {
if (this.hotkeys.length !== 0) { if (this.hotkeys.length !== 0) {
this.hotkeysService.remove(this.hotkeys) 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('og:url', window.location.href)
this.metaService.setTag('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
}
}
} }

View File

@ -34,6 +34,8 @@ function toBoolean (value: any) {
if (value === 'true') return true if (value === 'true') return true
if (value === 'false') return false if (value === 'false') return false
if (value === '1') return true
if (value === '0') return false
return undefined return undefined
} }

View File

@ -241,7 +241,6 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
} }
reloadVideos () { reloadVideos () {
console.log('reload')
this.pagination.currentPage = 1 this.pagination.currentPage = 1
this.loadMoreVideos(true) this.loadMoreVideos(true)
} }
@ -420,8 +419,9 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
this.lastQueryLength = data.length this.lastQueryLength = data.length
if (reset) this.videos = [] if (reset) this.videos = []
this.videos = this.videos.concat(data) this.videos = this.videos.concat(data)
console.log('subscribe')
if (this.groupByDate) this.buildGroupedDateLabels() if (this.groupByDate) this.buildGroupedDateLabels()
this.onDataSubject.next(data) this.onDataSubject.next(data)

View File

@ -1,2 +1,2 @@
export * from './peertube-player-manager' export * from './peertube-player'
export * from './types' export * from './types'

View File

@ -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<videojs.Player> {
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
}

View File

@ -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 ? '<span class="vjs-icon-tick-white"></span>' : ''),
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: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label
}))
}
return { content }
}
}
// ############################################################################
export {
videojs
}

View File

@ -1,5 +1,5 @@
import videojs from 'video.js' import videojs from 'video.js'
import './pause-bezel' import { PauseBezel } from './pause-bezel'
const Plugin = videojs.getPlugin('plugin') const Plugin = videojs.getPlugin('plugin')
@ -12,7 +12,7 @@ class BezelsPlugin extends Plugin {
player.addClass('vjs-bezels') player.addClass('vjs-bezels')
}) })
player.addChild('PauseBezel', options) player.addChild(new PauseBezel(player, options))
} }
} }

View File

@ -32,26 +32,61 @@ function getPlayBezel () {
} }
const Component = videojs.getComponent('Component') const Component = videojs.getComponent('Component')
class PauseBezel extends Component { export class PauseBezel extends Component {
container: HTMLDivElement container: HTMLDivElement
private firstPlayDone = false
private paused = false
private playerPauseHandler: () => void
private playerPlayHandler: () => void
private videoChangeHandler: () => void
constructor (player: videojs.Player, options?: videojs.ComponentOptions) { constructor (player: videojs.Player, options?: videojs.ComponentOptions) {
super(player, options) super(player, options)
// Hide bezels on mobile since we already have our mobile overlay // Hide bezels on mobile since we already have our mobile overlay
if (isMobile()) return if (isMobile()) return
player.on('pause', (_: any) => { this.playerPauseHandler = () => {
if (player.seeking() || player.ended()) return if (player.seeking()) return
this.paused = true
if (player.ended()) return
this.container.innerHTML = getPauseBezel() this.container.innerHTML = getPauseBezel()
this.showBezel() 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.container.innerHTML = getPlayBezel()
this.showBezel() 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 () { createEl () {

View File

@ -2,6 +2,5 @@ export * from './next-previous-video-button'
export * from './p2p-info-button' export * from './p2p-info-button'
export * from './peertube-link-button' export * from './peertube-link-button'
export * from './peertube-live-display' export * from './peertube-live-display'
export * from './peertube-load-progress-bar'
export * from './storyboard-plugin' export * from './storyboard-plugin'
export * from './theater-button' export * from './theater-button'

View File

@ -4,14 +4,18 @@ import { NextPreviousVideoButtonOptions } from '../../types'
const Button = videojs.getComponent('Button') const Button = videojs.getComponent('Button')
class NextPreviousVideoButton extends Button { class NextPreviousVideoButton extends Button {
private readonly nextPreviousVideoButtonOptions: NextPreviousVideoButtonOptions options_: NextPreviousVideoButtonOptions & videojs.ComponentOptions
constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions) { constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions & videojs.ComponentOptions) {
super(player, options as any) super(player, options)
this.nextPreviousVideoButtonOptions = options this.player().on('video-change', () => {
this.updateDisabled()
this.updateShowing()
})
this.update() this.updateDisabled()
this.updateShowing()
} }
createEl () { createEl () {
@ -35,15 +39,20 @@ class NextPreviousVideoButton extends Button {
} }
handleClick () { handleClick () {
this.nextPreviousVideoButtonOptions.handler() this.options_.handler()
} }
update () { updateDisabled () {
const disabled = this.nextPreviousVideoButtonOptions.isDisabled() const disabled = this.options_.isDisabled()
if (disabled) this.addClass('vjs-disabled') if (disabled) this.addClass('vjs-disabled')
else this.removeClass('vjs-disabled') else this.removeClass('vjs-disabled')
} }
updateShowing () {
if (this.options_.isDisplayed()) this.show()
else this.hide()
}
} }
videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) videojs.registerComponent('NextVideoButton', NextPreviousVideoButton)

View File

@ -1,71 +1,44 @@
import videojs from 'video.js' import videojs from 'video.js'
import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../../types' import { PlayerNetworkInfo } from '../../types'
import { bytes } from '../common' import { bytes } from '../common'
const Button = videojs.getComponent('Button') const Button = videojs.getComponent('Button')
class P2pInfoButton extends Button { class P2PInfoButton extends Button {
el_: HTMLElement
constructor (player: videojs.Player, options?: PeerTubeP2PInfoButtonOptions) {
super(player, options as any)
}
createEl () { createEl () {
const div = videojs.dom.createEl('div', { const div = videojs.dom.createEl('div', { className: 'vjs-peertube' })
className: 'vjs-peertube' const subDivP2P = videojs.dom.createEl('div', {
})
const subDivWebtorrent = videojs.dom.createEl('div', {
className: 'vjs-peertube-hidden' // Hide the stats before we get the info className: 'vjs-peertube-hidden' // Hide the stats before we get the info
}) as HTMLDivElement }) as HTMLDivElement
div.appendChild(subDivWebtorrent) div.appendChild(subDivP2P)
// Stop here if P2P is not enabled const downloadIcon = videojs.dom.createEl('span', { className: 'icon icon-download' })
const p2pEnabled = (this.options_ as PeerTubeP2PInfoButtonOptions).p2pEnabled subDivP2P.appendChild(downloadIcon)
if (!p2pEnabled) return div as HTMLButtonElement
const downloadIcon = videojs.dom.createEl('span', { const downloadSpeedText = videojs.dom.createEl('span', { className: 'download-speed-text' })
className: 'icon icon-download' const downloadSpeedNumber = videojs.dom.createEl('span', { className: 'download-speed-number' })
})
subDivWebtorrent.appendChild(downloadIcon)
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') const downloadSpeedUnit = videojs.dom.createEl('span')
downloadSpeedText.appendChild(downloadSpeedNumber) downloadSpeedText.appendChild(downloadSpeedNumber)
downloadSpeedText.appendChild(downloadSpeedUnit) downloadSpeedText.appendChild(downloadSpeedUnit)
subDivWebtorrent.appendChild(downloadSpeedText) subDivP2P.appendChild(downloadSpeedText)
const uploadIcon = videojs.dom.createEl('span', { const uploadIcon = videojs.dom.createEl('span', { className: 'icon icon-upload' })
className: 'icon icon-upload' subDivP2P.appendChild(uploadIcon)
})
subDivWebtorrent.appendChild(uploadIcon)
const uploadSpeedText = videojs.dom.createEl('span', { const uploadSpeedText = videojs.dom.createEl('span', { className: 'upload-speed-text' })
className: 'upload-speed-text' const uploadSpeedNumber = videojs.dom.createEl('span', { className: 'upload-speed-number' })
})
const uploadSpeedNumber = videojs.dom.createEl('span', {
className: 'upload-speed-number'
})
const uploadSpeedUnit = videojs.dom.createEl('span') const uploadSpeedUnit = videojs.dom.createEl('span')
uploadSpeedText.appendChild(uploadSpeedNumber) uploadSpeedText.appendChild(uploadSpeedNumber)
uploadSpeedText.appendChild(uploadSpeedUnit) uploadSpeedText.appendChild(uploadSpeedUnit)
subDivWebtorrent.appendChild(uploadSpeedText) subDivP2P.appendChild(uploadSpeedText)
const peersText = videojs.dom.createEl('span', { const peersText = videojs.dom.createEl('span', { className: 'peers-text' })
className: 'peers-text' const peersNumber = videojs.dom.createEl('span', { className: 'peers-number' })
}) subDivP2P.appendChild(peersNumber)
const peersNumber = videojs.dom.createEl('span', { subDivP2P.appendChild(peersText)
className: 'peers-number'
})
subDivWebtorrent.appendChild(peersNumber)
subDivWebtorrent.appendChild(peersText)
const subDivHttp = videojs.dom.createEl('div', { const subDivHttp = videojs.dom.createEl('div', { className: 'vjs-peertube-hidden' }) as HTMLElement
className: 'vjs-peertube-hidden'
})
const subDivHttpText = videojs.dom.createEl('span', { const subDivHttpText = videojs.dom.createEl('span', {
className: 'http-fallback', className: 'http-fallback',
textContent: 'HTTP' textContent: 'HTTP'
@ -74,14 +47,9 @@ class P2pInfoButton extends Button {
subDivHttp.appendChild(subDivHttpText) subDivHttp.appendChild(subDivHttpText)
div.appendChild(subDivHttp) div.appendChild(subDivHttp)
this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { this.player_.on('p2p-info', (_event: any, data: PlayerNetworkInfo) => {
// We are in HTTP fallback subDivP2P.className = 'vjs-peertube-displayed'
if (!data) { subDivHttp.className = 'vjs-peertube-hidden'
subDivHttp.className = 'vjs-peertube-displayed'
subDivWebtorrent.className = 'vjs-peertube-hidden'
return
}
const p2pStats = data.p2p const p2pStats = data.p2p
const httpStats = data.http const httpStats = data.http
@ -92,17 +60,17 @@ class P2pInfoButton extends Button {
const totalUploaded = bytes(p2pStats.uploaded) const totalUploaded = bytes(p2pStats.uploaded)
const numPeers = p2pStats.numPeers 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') { if (data.source === 'p2p-media-loader') {
const downloadedFromServer = bytes(httpStats.downloaded).join(' ') const downloadedFromServer = bytes(httpStats.downloaded).join(' ')
const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ')
subDivWebtorrent.title += subDivP2P.title +=
' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' + ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' +
' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\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] downloadSpeedNumber.textContent = downloadSpeed[0]
downloadSpeedUnit.textContent = ' ' + downloadSpeed[1] downloadSpeedUnit.textContent = ' ' + downloadSpeed[1]
@ -114,11 +82,24 @@ class P2pInfoButton extends Button {
peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer')) peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer'))
subDivHttp.className = 'vjs-peertube-hidden' 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 return div as HTMLButtonElement
} }
} }
videojs.registerComponent('P2PInfoButton', P2pInfoButton) videojs.registerComponent('P2PInfoButton', P2PInfoButton)

View File

@ -3,37 +3,58 @@ import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
import { PeerTubeLinkButtonOptions } from '../../types' import { PeerTubeLinkButtonOptions } from '../../types'
const Component = videojs.getComponent('Component') const Component = videojs.getComponent('Component')
class PeerTubeLinkButton extends Component {
constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) { class PeerTubeLinkButton extends Component {
super(player, options as any) 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 () { 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 () { updateHref () {
this.el().setAttribute('href', this.buildLink()) 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 () { 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() }) return decorateVideoLink({ url, startTime: this.player().currentTime() })
} }

View File

@ -13,7 +13,6 @@ class PeerTubeLiveDisplay extends ClickableComponent {
this.interval = this.setInterval(() => this.updateClass(), 1000) this.interval = this.setInterval(() => this.updateClass(), 1000)
this.show()
this.updateSync(true) this.updateSync(true)
} }
@ -30,7 +29,7 @@ class PeerTubeLiveDisplay extends ClickableComponent {
createEl () { createEl () {
const el = super.createEl('div', { const el = super.createEl('div', {
className: 'vjs-live-control vjs-control' className: 'vjs-pt-live-control vjs-control'
}) })
this.contentEl_ = videojs.dom.createEl('div', { this.contentEl_ = videojs.dom.createEl('div', {
@ -83,10 +82,9 @@ class PeerTubeLiveDisplay extends ClickableComponent {
} }
private getHLSJS () { private getHLSJS () {
const p2pMediaLoader = this.player()?.p2pMediaLoader if (!this.player()?.usingPlugin('p2pMediaLoader')) return
if (!p2pMediaLoader) return undefined
return p2pMediaLoader().getHLSJS() return this.player().p2pMediaLoader().getHLSJS()
} }
} }

View File

@ -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: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>`
})
}
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)

View File

@ -24,6 +24,8 @@ class StoryboardPlugin extends Plugin {
private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip
private onReadyOrLoadstartHandler: (event: { type: 'ready' }) => void
constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) { constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) {
super(player, options) 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.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement
this.seekBar?.el()?.appendChild(this.spritePlaceholder) this.seekBar?.el()?.appendChild(this.spritePlaceholder)
this.player.on([ 'ready', 'loadstart' ], event => { this.onReadyOrLoadstartHandler = event => {
if (event.type !== 'ready') { if (event.type !== 'ready') {
const spriteSource = this.player.currentSources().find(source => { const spriteSource = this.player.currentSources().find(source => {
return Object.prototype.hasOwnProperty.call(source, 'storyboard') return Object.prototype.hasOwnProperty.call(source, 'storyboard')
@ -72,7 +74,18 @@ class StoryboardPlugin extends Plugin {
this.cached = !!this.sprites[this.url] this.cached = !!this.sprites[this.url]
this.load() 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 () { private load () {

View File

@ -1,14 +1,19 @@
import videojs from 'video.js' import videojs from 'video.js'
import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage'
import { TheaterButtonOptions } from '../../types'
const Button = videojs.getComponent('Button') const Button = videojs.getComponent('Button')
class TheaterButton extends Button { class TheaterButton extends Button {
private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' 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) super(player, options)
this.theaterButtonOptions = options
const enabled = getStoredTheater() const enabled = getStoredTheater()
if (enabled === true) { if (enabled === true) {
this.player().addClass(TheaterButton.THEATER_MODE_CLASS) this.player().addClass(TheaterButton.THEATER_MODE_CLASS)
@ -19,6 +24,9 @@ class TheaterButton extends Button {
this.controlText('Theater mode') this.controlText('Theater mode')
this.player().theaterEnabled = enabled this.player().theaterEnabled = enabled
this.updateShowing()
this.player().on('video-change', () => this.updateShowing())
} }
buildCSSClass () { buildCSSClass () {
@ -36,7 +44,7 @@ class TheaterButton extends Button {
saveTheaterInStore(theaterEnabled) saveTheaterInStore(theaterEnabled)
this.player_.trigger('theaterChange', theaterEnabled) this.player_.trigger('theater-change', theaterEnabled)
} }
handleClick () { handleClick () {
@ -48,6 +56,11 @@ class TheaterButton extends Button {
private isTheaterEnabled () { private isTheaterEnabled () {
return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS)
} }
private updateShowing () {
if (this.theaterButtonOptions.isDisplayed()) this.show()
else this.hide()
}
} }
videojs.registerComponent('TheaterButton', TheaterButton) videojs.registerComponent('TheaterButton', TheaterButton)

View File

@ -10,17 +10,20 @@ export type PeerTubeDockComponentOptions = {
class PeerTubeDockComponent extends Component { 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 () { createEl () {
const options = this.options_ as PeerTubeDockComponentOptions const el = super.createEl('div', { className: 'peertube-dock' })
const el = super.createEl('div', { if (this.options_.avatarUrl) {
className: 'peertube-dock'
})
if (options.avatarUrl) {
const avatar = videojs.dom.createEl('img', { const avatar = videojs.dom.createEl('img', {
className: 'peertube-dock-avatar', className: 'peertube-dock-avatar',
src: options.avatarUrl src: this.options_.avatarUrl
}) })
el.appendChild(avatar) el.appendChild(avatar)
@ -30,27 +33,27 @@ class PeerTubeDockComponent extends Component {
className: 'peertube-dock-title-description' className: 'peertube-dock-title-description'
}) })
if (options.title) { if (this.options_.title) {
const title = videojs.dom.createEl('div', { const title = videojs.dom.createEl('div', {
className: 'peertube-dock-title', className: 'peertube-dock-title',
title: options.title, title: this.options_.title,
innerHTML: options.title innerHTML: this.options_.title
}) })
elWrapperTitleDescription.appendChild(title) elWrapperTitleDescription.appendChild(title)
} }
if (options.description) { if (this.options_.description) {
const description = videojs.dom.createEl('div', { const description = videojs.dom.createEl('div', {
className: 'peertube-dock-description', className: 'peertube-dock-description',
title: options.description, title: this.options_.description,
innerHTML: options.description innerHTML: this.options_.description
}) })
elWrapperTitleDescription.appendChild(description) elWrapperTitleDescription.appendChild(description)
} }
if (options.title || options.description) { if (this.options_.title || this.options_.description) {
el.appendChild(elWrapperTitleDescription) el.appendChild(elWrapperTitleDescription)
} }

View File

@ -10,14 +10,25 @@ export type PeerTubeDockPluginOptions = {
} }
class PeerTubeDockPlugin extends Plugin { class PeerTubeDockPlugin extends Plugin {
private dockComponent: PeerTubeDockComponent
constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) { constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) {
super(player, options) super(player, options)
this.player.addClass('peertube-dock') player.ready(() => {
player.addClass('peertube-dock')
this.player.ready(() => {
this.player.addChild('PeerTubeDockComponent', options) as PeerTubeDockComponent
}) })
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()
} }
} }

View File

@ -31,6 +31,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
dispose () { dispose () {
document.removeEventListener('keydown', this.handleKeyFunction) document.removeEventListener('keydown', this.handleKeyFunction)
super.dispose()
} }
private onKeyDown (event: KeyboardEvent) { private onKeyDown (event: KeyboardEvent) {

View File

@ -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 }
}
}

View File

@ -1 +0,0 @@
export * from './manager-options-builder'

View File

@ -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<videojs.PlayerOptions> {
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 ? '<span class="vjs-icon-tick-white"></span>' : ''),
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: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label
}))
}
return { content }
}
}

View File

@ -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 }
}
}

View File

@ -1,14 +1,15 @@
import debug from 'debug'
import videojs from 'video.js' import videojs from 'video.js'
import { PlaybackMetricCreate } from '../../../../../../shared/models'
import { MetricsPluginOptions, PlayerMode, PlayerNetworkInfo } from '../../types'
import { logger } from '@root-helpers/logger' 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') const Plugin = videojs.getPlugin('plugin')
class MetricsPlugin extends Plugin { class MetricsPlugin extends Plugin {
private readonly metricsUrl: string options_: MetricsPluginOptions
private readonly videoUUID: string
private readonly mode: PlayerMode
private downloadedBytesP2P = 0 private downloadedBytesP2P = 0
private downloadedBytesHTTP = 0 private downloadedBytesHTTP = 0
@ -28,29 +29,54 @@ class MetricsPlugin extends Plugin {
constructor (player: videojs.Player, options: MetricsPluginOptions) { constructor (player: videojs.Player, options: MetricsPluginOptions) {
super(player) super(player)
this.metricsUrl = options.metricsUrl this.options_ = options
this.videoUUID = options.videoUUID
this.mode = options.mode
this.player.one('play', () => {
this.runMetricsInterval()
this.trackBytes() this.trackBytes()
this.trackResolutionChange() this.trackResolutionChange()
this.trackErrors() this.trackErrors()
this.one('play', () => {
this.player.on('video-change', () => {
this.runMetricsIntervalOnPlay()
}) })
})
this.runMetricsIntervalOnPlay()
} }
dispose () { dispose () {
if (this.metricsInterval) clearInterval(this.metricsInterval) 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 () { private runMetricsInterval () {
if (this.metricsInterval) clearInterval(this.metricsInterval)
this.metricsInterval = setInterval(() => { this.metricsInterval = setInterval(() => {
let resolution: number let resolution: number
let fps: number let fps: number
if (this.mode === 'p2p-media-loader') { if (this.player.usingPlugin('p2pMediaLoader')) {
const level = this.player.p2pMediaLoader().getCurrentLevel() const level = this.player.p2pMediaLoader().getCurrentLevel()
if (!level) return if (!level) return
@ -60,21 +86,23 @@ class MetricsPlugin extends Plugin {
fps = framerate fps = framerate
? parseInt(framerate, 10) ? parseInt(framerate, 10)
: undefined : undefined
} else { // webtorrent } else if (this.player.usingPlugin('webVideo')) {
const videoFile = this.player.webtorrent().getCurrentVideoFile() const videoFile = this.player.webVideo().getCurrentVideoFile()
if (!videoFile) return if (!videoFile) return
resolution = videoFile.resolution.id resolution = videoFile.resolution.id
fps = videoFile.fps && videoFile.fps !== -1 fps = videoFile.fps && videoFile.fps !== -1
? videoFile.fps ? videoFile.fps
: undefined : undefined
} else {
return
} }
const body: PlaybackMetricCreate = { const body: PlaybackMetricCreate = {
resolution, resolution,
fps, fps,
playerMode: this.mode, playerMode: this.options_.mode(),
resolutionChanges: this.resolutionChanges, resolutionChanges: this.resolutionChanges,
@ -85,7 +113,7 @@ class MetricsPlugin extends Plugin {
uploadedBytesP2P: this.uploadedBytesP2P, uploadedBytesP2P: this.uploadedBytesP2P,
videoId: this.videoUUID videoId: this.options_.videoUUID()
} }
this.resolutionChanges = 0 this.resolutionChanges = 0
@ -99,15 +127,13 @@ class MetricsPlugin extends Plugin {
const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) 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)) .catch(err => logger.error('Cannot send metrics to the server.', err))
}, this.CONSTANTS.METRICS_INTERVAL) }, this.CONSTANTS.METRICS_INTERVAL)
} }
private trackBytes () { private trackBytes () {
this.player.on('p2pInfo', (_event, data: PlayerNetworkInfo) => { this.player.on('p2p-info', (_event, data: PlayerNetworkInfo) => {
if (!data) return
this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0)
this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0) this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0)
@ -115,10 +141,18 @@ class MetricsPlugin extends Plugin {
this.lastPlayerNetworkInfo = data this.lastPlayerNetworkInfo = data
}) })
this.player.on('http-info', (_event, data: PlayerNetworkInfo) => {
this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0)
})
} }
private trackResolutionChange () { private trackResolutionChange () {
this.player.on('engineResolutionChange', () => { this.player.on('engine-resolution-change', () => {
this.resolutionChanges++
})
this.player.on('user-resolution-change', () => {
this.resolutionChanges++ this.resolutionChanges++
}) })
} }

View File

@ -2,22 +2,20 @@ import videojs from 'video.js'
const Component = videojs.getComponent('Component') const Component = videojs.getComponent('Component')
class PeerTubeMobileButtons extends Component { class PeerTubeMobileButtons extends Component {
private mainButton: HTMLDivElement
private rewind: Element private rewind: Element
private forward: Element private forward: Element
private rewindText: Element private rewindText: Element
private forwardText: Element private forwardText: Element
private touchStartHandler: (e: TouchEvent) => void
createEl () { createEl () {
const container = super.createEl('div', { const container = super.createEl('div', { className: 'vjs-mobile-buttons-overlay' }) as HTMLDivElement
className: 'vjs-mobile-buttons-overlay' this.mainButton = super.createEl('div', { className: 'main-button' }) as HTMLDivElement
}) as HTMLDivElement
const mainButton = super.createEl('div', { this.touchStartHandler = e => {
className: 'main-button'
}) as HTMLDivElement
mainButton.addEventListener('touchstart', e => {
e.stopPropagation() e.stopPropagation()
if (this.player_.paused() || this.player_.ended()) { if (this.player_.paused() || this.player_.ended()) {
@ -26,7 +24,9 @@ class PeerTubeMobileButtons extends Component {
} }
this.player_.pause() this.player_.pause()
}) }
this.mainButton.addEventListener('touchstart', this.touchStartHandler, { passive: true })
this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' }) this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' })
this.forward = super.createEl('div', { className: 'forward-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' })) this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' }))
container.appendChild(this.rewind) container.appendChild(this.rewind)
container.appendChild(mainButton) container.appendChild(this.mainButton)
container.appendChild(this.forward) container.appendChild(this.forward)
return container return container
} }
dispose () {
if (this.touchStartHandler) this.mainButton.removeEventListener('touchstart', this.touchStartHandler)
super.dispose()
}
displayFastSeek (amount: number) { displayFastSeek (amount: number) {
if (amount === 0) { if (amount === 0) {
this.hideRewind() this.hideRewind()

View File

@ -21,6 +21,15 @@ class PeerTubeMobilePlugin extends Plugin {
private setCurrentTimeTimeout: ReturnType<typeof setTimeout> private setCurrentTimeTimeout: ReturnType<typeof setTimeout>
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) { constructor (player: videojs.Player, options: videojs.PlayerOptions) {
super(player, options) super(player, options)
@ -36,18 +45,38 @@ class PeerTubeMobilePlugin extends Plugin {
(this.player.options_.userActions as any).click = false (this.player.options_.userActions as any).click = false
this.player.options_.userActions.doubleClick = false this.player.options_.userActions.doubleClick = false
this.player.one('play', () => { this.onPlayHandler = () => this.initTouchStartEvents()
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 () { private handleFullscreenRotation () {
this.player.on('fullscreenchange', () => { this.onFullScreenChangeHandler = () => {
if (!this.player.isFullscreen() || this.isPortraitVideo()) return if (!this.player.isFullscreen() || this.isPortraitVideo()) return
screen.orientation.lock('landscape') screen.orientation.lock('landscape')
.catch(err => logger.error('Cannot lock screen to landscape.', err)) .catch(err => logger.error('Cannot lock screen to landscape.', err))
}) }
this.player.on('fullscreenchange', this.onFullScreenChangeHandler)
} }
private isPortraitVideo () { private isPortraitVideo () {
@ -80,19 +109,22 @@ class PeerTubeMobilePlugin extends Plugin {
this.lastTapEvent = event 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 // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it
if (this.player.userActive()) return if (this.player.userActive()) return
handleTouchStart(event) 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 // Prevent mousemove/click events firing on the player, that conflict with our user active logic
event.preventDefault() event.preventDefault()
handleTouchStart(event) handleTouchStart(event)
}, { passive: false }) }
this.peerTubeMobileButtons.el().addEventListener('touchstart', this.onMobileButtonTouchStartHandler, { passive: false })
} }
private onDoubleTap (event: TouchEvent) { private onDoubleTap (event: TouchEvent) {

View File

@ -14,6 +14,10 @@ type Metadata = {
levels: Level[] levels: Level[]
} }
// ---------------------------------------------------------------------------
// Source handler registration
// ---------------------------------------------------------------------------
type HookFn = (player: videojs.Player, hljs: Hlsjs) => void type HookFn = (player: videojs.Player, hljs: Hlsjs) => void
const registerSourceHandler = function (vjs: typeof videojs) { const registerSourceHandler = function (vjs: typeof videojs) {
@ -25,10 +29,13 @@ const registerSourceHandler = function (vjs: typeof videojs) {
const html5 = vjs.getTech('Html5') const html5 = vjs.getTech('Html5')
if (!html5) { if (!html5) {
logger.error('No Hml5 tech found in videojs') logger.error('No "Html5" tech found in videojs')
return return
} }
// Already registered
if ((html5 as any).canPlaySource({ type: 'application/x-mpegURL' })) return
// FIXME: typings // FIXME: typings
(html5 as any).registerSourceHandler({ (html5 as any).registerSourceHandler({
canHandleSource: function (source: videojs.Tech.SourceObject) { canHandleSource: function (source: videojs.Tech.SourceObject) {
@ -56,8 +63,16 @@ const registerSourceHandler = function (vjs: typeof videojs) {
(vjs as any).Html5Hlsjs = Html5Hlsjs (vjs as any).Html5Hlsjs = Html5Hlsjs
} }
function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) { // ---------------------------------------------------------------------------
const player = this // HLS options plugin
// ---------------------------------------------------------------------------
const Plugin = videojs.getPlugin('plugin')
class HLSJSConfigHandler extends Plugin {
constructor (player: videojs.Player, options: HlsjsConfigHandlerOptions) {
super(player, options)
if (!options) return if (!options) return
@ -72,16 +87,31 @@ function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOp
if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) {
player.srOptions_.levelLabelHandler = options.levelLabelHandler player.srOptions_.levelLabelHandler = options.levelLabelHandler
} }
registerSourceHandler(videojs)
}
dispose () {
this.player.srOptions_ = undefined
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) { videojs.registerPlugin('hlsjs', HLSJSConfigHandler)
// Used in Brightcove since we don't pass options directly there
const registerVjsPlugin = vjs.registerPlugin || vjs.plugin
registerVjsPlugin('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 videoElement: HTMLVideoElement
private readonly errorCounts: ErrorCounts = {} private readonly errorCounts: ErrorCounts = {}
@ -101,8 +131,9 @@ class Html5Hlsjs {
private dvrDuration: number = null private dvrDuration: number = null
private edgeMargin: number = null private edgeMargin: number = null
private handlers: { [ id in 'play' ]: EventListener } = { private handlers: { [ id in 'play' | 'error' ]: EventListener } = {
play: null play: null,
error: null
} }
constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
@ -115,7 +146,7 @@ class Html5Hlsjs {
this.videoElement = tech.el() as HTMLVideoElement this.videoElement = tech.el() as HTMLVideoElement
this.player = vjs((tech.options_ as any).playerId) this.player = vjs((tech.options_ as any).playerId)
this.videoElement.addEventListener('error', event => { this.handlers.error = event => {
let errorTxt: string let errorTxt: string
const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error
@ -143,7 +174,8 @@ class Html5Hlsjs {
} }
logger.error(`MEDIA_ERROR: ${errorTxt}`) logger.error(`MEDIA_ERROR: ${errorTxt}`)
}) }
this.videoElement.addEventListener('error', this.handlers.error)
this.initialize() this.initialize()
} }
@ -174,6 +206,7 @@ class Html5Hlsjs {
// See comment for `initialize` method. // See comment for `initialize` method.
dispose () { dispose () {
this.videoElement.removeEventListener('play', this.handlers.play) this.videoElement.removeEventListener('play', this.handlers.play)
this.videoElement.removeEventListener('error', this.handlers.error)
// FIXME: https://github.com/video-dev/hls.js/issues/4092 // FIXME: https://github.com/video-dev/hls.js/issues/4092
const untypedHLS = this.hls as any const untypedHLS = this.hls as any
@ -200,6 +233,10 @@ class Html5Hlsjs {
return true return true
} }
static removeAllHooks () {
Html5Hlsjs.hooks = {}
}
private _executeHooksFor (type: string) { private _executeHooksFor (type: string) {
if (Html5Hlsjs.hooks[type] === undefined) { if (Html5Hlsjs.hooks[type] === undefined) {
return return
@ -421,7 +458,7 @@ class Html5Hlsjs {
? data.level ? data.level
: -1 : -1
this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true }) this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false })
}) })
this.hls.attachMedia(this.videoElement) this.hls.attachMedia(this.videoElement)
@ -433,9 +470,3 @@ class Html5Hlsjs {
this._initHlsjs() this._initHlsjs()
} }
} }
export {
Html5Hlsjs,
registerSourceHandler,
registerConfigPlugin
}

View File

@ -3,19 +3,12 @@ import videojs from 'video.js'
import { Events, Segment } from '@peertube/p2p-media-loader-core' import { Events, Segment } from '@peertube/p2p-media-loader-core'
import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
import { logger } from '@root-helpers/logger' 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 { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
import { registerConfigPlugin, registerSourceHandler } from './hls-plugin' import { SettingsButton } from '../settings/settings-menu-button'
registerConfigPlugin(videojs)
registerSourceHandler(videojs)
const Plugin = videojs.getPlugin('plugin') const Plugin = videojs.getPlugin('plugin')
class P2pMediaLoaderPlugin extends Plugin { class P2pMediaLoaderPlugin extends Plugin {
private readonly CONSTANTS = {
INFO_SCHEDULER: 1000 // Don't change this
}
private readonly options: P2PMediaLoaderPluginOptions private readonly options: P2PMediaLoaderPluginOptions
private hlsjs: Hlsjs private hlsjs: Hlsjs
@ -31,7 +24,6 @@ class P2pMediaLoaderPlugin extends Plugin {
pendingDownload: [] as number[], pendingDownload: [] as number[],
totalDownload: 0 totalDownload: 0
} }
private startTime: number
private networkInfoInterval: any private networkInfoInterval: any
@ -39,7 +31,6 @@ class P2pMediaLoaderPlugin extends Plugin {
super(player) super(player)
this.options = options this.options = options
this.startTime = timeToInt(options.startTime)
// FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
if (!(videojs as any).Html5Hlsjs) { if (!(videojs as any).Html5Hlsjs) {
@ -77,17 +68,22 @@ class P2pMediaLoaderPlugin extends Plugin {
}) })
player.ready(() => { player.ready(() => {
this.initializeCore()
this.initializePlugin() this.initializePlugin()
}) })
} }
dispose () { dispose () {
if (this.hlsjs) this.hlsjs.destroy() this.p2pEngine?.removeAllListeners()
if (this.p2pEngine) this.p2pEngine.destroy() this.p2pEngine?.destroy()
this.hlsjs?.destroy()
this.options.segmentValidator?.destroy();
(videojs as any).Html5Hlsjs?.removeAllHooks()
clearInterval(this.networkInfoInterval) clearInterval(this.networkInfoInterval)
super.dispose()
} }
getCurrentLevel () { getCurrentLevel () {
@ -104,18 +100,6 @@ class P2pMediaLoaderPlugin extends Plugin {
return this.hlsjs 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 () { private initializePlugin () {
initHlsJsPlayer(this.hlsjs) initHlsJsPlayer(this.hlsjs)
@ -133,7 +117,7 @@ class P2pMediaLoaderPlugin extends Plugin {
this.runStats() 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 () { private runStats () {
@ -167,7 +151,7 @@ class P2pMediaLoaderPlugin extends Plugin {
this.statsP2PBytes.pendingUpload = [] this.statsP2PBytes.pendingUpload = []
this.statsHTTPBytes.pendingDownload = [] this.statsHTTPBytes.pendingDownload = []
return this.player.trigger('p2pInfo', { return this.player.trigger('p2p-info', {
source: 'p2p-media-loader', source: 'p2p-media-loader',
http: { http: {
downloadSpeed: httpDownloadSpeed, downloadSpeed: httpDownloadSpeed,
@ -182,7 +166,7 @@ class P2pMediaLoaderPlugin extends Plugin {
}, },
bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8 bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8
} as PlayerNetworkInfo) } as PlayerNetworkInfo)
}, this.CONSTANTS.INFO_SCHEDULER) }, 1000)
} }
private arraySum (data: number[]) { private arraySum (data: number[]) {
@ -190,10 +174,7 @@ class P2pMediaLoaderPlugin extends Plugin {
} }
private fallbackToBuiltInIOS () { private fallbackToBuiltInIOS () {
logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.'); 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
this.player.src({ this.player.src({
type: this.options.type, type: this.options.type,
@ -203,9 +184,14 @@ class P2pMediaLoaderPlugin extends Plugin {
}) })
}) })
this.player.ready(() => { // Resolution button is not supported in built-in HLS player
this.initializeCore() this.getResolutionButton().hide()
}) }
private getResolutionButton () {
const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
return settingsButton.menu.getChild('resolutionMenuButton')
} }
} }

View File

@ -9,30 +9,29 @@ type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string
const maxRetries = 10 const maxRetries = 10
function segmentValidatorFactory (options: { export class SegmentValidator {
private readonly bytesRangeRegex = /bytes=(\d+)-(\d+)/
private destroyed = false
constructor (private readonly options: {
serverUrl: string serverUrl: string
segmentsSha256Url: string segmentsSha256Url: string
authorizationHeader: () => string authorizationHeader: () => string
requiresUserAuth: boolean requiresUserAuth: boolean
requiresPassword: boolean requiresPassword: boolean
videoPassword: () => string videoPassword: () => string
}) { }) {
const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options
let segmentsJSON = fetchSha256Segments({ }
serverUrl,
segmentsSha256Url, async validate (segment: Segment, _method: string, _peerId: string, retry = 1) {
authorizationHeader, if (this.destroyed) return
requiresUserAuth,
requiresPassword,
videoPassword
})
const regex = /bytes=(\d+)-(\d+)/
return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) {
const filename = basename(removeQueryParams(segment.url)) const filename = basename(removeQueryParams(segment.url))
const segmentValue = (await segmentsJSON)[filename] const segmentValue = (await this.fetchSha256Segments())[filename]
if (!segmentValue && retry > maxRetries) { if (!segmentValue && retry > maxRetries) {
throw new Error(`Unknown segment name ${filename} in segment validator`) throw new Error(`Unknown segment name ${filename} in segment validator`)
@ -43,15 +42,7 @@ function segmentValidatorFactory (options: {
await wait(500) await wait(500)
segmentsJSON = fetchSha256Segments({ await this.validate(segment, _method, _peerId, retry + 1)
serverUrl,
segmentsSha256Url,
authorizationHeader,
requiresUserAuth,
requiresPassword,
videoPassword
})
await segmentValidator(segment, _method, _peerId, retry + 1)
return return
} }
@ -62,7 +53,7 @@ function segmentValidatorFactory (options: {
if (typeof segmentValue === 'string') { if (typeof segmentValue === 'string') {
hashShouldBe = segmentValue hashShouldBe = segmentValue
} else { } else {
const captured = regex.exec(segment.range) const captured = this.bytesRangeRegex.exec(segment.range)
range = captured[1] + '-' + captured[2] range = captured[1] + '-' + captured[2]
hashShouldBe = segmentValue[range] hashShouldBe = segmentValue[range]
@ -72,7 +63,7 @@ function segmentValidatorFactory (options: {
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) 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) { if (calculatedSha !== hashShouldBe) {
throw new Error( throw new Error(
`Hashes does not correspond for segment ${filename}/${range}` + `Hashes does not correspond for segment ${filename}/${range}` +
@ -80,56 +71,43 @@ function segmentValidatorFactory (options: {
) )
} }
} }
}
// --------------------------------------------------------------------------- destroy () {
this.destroyed = true
export {
segmentValidatorFactory
}
// ---------------------------------------------------------------------------
function fetchSha256Segments (options: {
serverUrl: string
segmentsSha256Url: string
authorizationHeader: () => string
requiresUserAuth: boolean
requiresPassword: boolean
videoPassword: () => string
}): Promise<SegmentsJSON> {
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() }
} }
return fetch(segmentsSha256Url, { headers }) private fetchSha256Segments (): Promise<SegmentsJSON> {
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<SegmentsJSON>) .then(res => res.json() as Promise<SegmentsJSON>)
.catch(err => { .catch(err => {
logger.error('Cannot get sha256 segments', err) logger.error('Cannot get sha256 segments', err)
return {} return {}
}) })
} }
async function sha256Hex (data?: ArrayBuffer) { private async sha256Hex (data?: ArrayBuffer) {
if (!data) return undefined if (!data) return undefined
if (window.crypto.subtle) { if (window.crypto.subtle) {
return window.crypto.subtle.digest('SHA-256', data) return window.crypto.subtle.digest('SHA-256', data)
.then(data => bufferToHex(data)) .then(data => this.bufferToHex(data))
} }
// Fallback for non HTTPS context // Fallback for non HTTPS context
const shaModule = (await import('sha.js') as any).default const shaModule = (await import('sha.js') as any).default
// eslint-disable-next-line new-cap // eslint-disable-next-line new-cap
return new shaModule.sha256().update(Buffer.from(data)).digest('hex') return new shaModule.sha256().update(Buffer.from(data)).digest('hex')
} }
// Thanks: https://stackoverflow.com/a/53307879 // Thanks: https://stackoverflow.com/a/53307879
function bufferToHex (buffer?: ArrayBuffer) { private bufferToHex (buffer?: ArrayBuffer) {
if (!buffer) return '' if (!buffer) return ''
let s = '' let s = ''
@ -141,4 +119,5 @@ function bufferToHex (buffer?: ArrayBuffer) {
}) })
return s return s
}
} }

View File

@ -1,7 +1,7 @@
import debug from 'debug' import debug from 'debug'
import videojs from 'video.js' import videojs from 'video.js'
import { logger } from '@root-helpers/logger' 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 { timeToInt } from '@shared/core-utils'
import { VideoView, VideoViewEvent } from '@shared/models/videos' import { VideoView, VideoViewEvent } from '@shared/models/videos'
import { import {
@ -13,7 +13,7 @@ import {
saveVideoWatchHistory, saveVideoWatchHistory,
saveVolumeInStore saveVolumeInStore
} from '../../peertube-player-local-storage' } from '../../peertube-player-local-storage'
import { PeerTubePluginOptions, VideoJSCaption } from '../../types' import { PeerTubePluginOptions } from '../../types'
import { SettingsButton } from '../settings/settings-menu-button' import { SettingsButton } from '../settings/settings-menu-button'
const debugLogger = debug('peertube:player:peertube') const debugLogger = debug('peertube:player:peertube')
@ -21,43 +21,59 @@ const debugLogger = debug('peertube:player:peertube')
const Plugin = videojs.getPlugin('plugin') const Plugin = videojs.getPlugin('plugin')
class PeerTubePlugin extends Plugin { class PeerTubePlugin extends Plugin {
private readonly videoViewUrl: string private readonly videoViewUrl: () => string
private readonly authorizationHeader: () => string private readonly authorizationHeader: () => string
private readonly initialInactivityTimeout: number
private readonly videoUUID: string private readonly hasAutoplay: () => videojs.Autoplay
private readonly startTime: number
private readonly videoViewIntervalMs: number private currentSubtitle: string
private currentPlaybackRate: number
private videoCaptions: VideoJSCaption[]
private defaultSubtitle: string
private videoViewInterval: any private videoViewInterval: any
private menuOpened = false private menuOpened = false
private mouseInControlBar = false private mouseInControlBar = false
private mouseInSettings = 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) super(player)
this.videoViewUrl = options.videoViewUrl this.videoViewUrl = options.videoViewUrl
this.authorizationHeader = options.authorizationHeader this.authorizationHeader = options.authorizationHeader
this.videoUUID = options.videoUUID this.hasAutoplay = options.hasAutoplay
this.startTime = timeToInt(options.startTime)
this.videoViewIntervalMs = options.videoViewIntervalMs
this.videoCaptions = options.videoCaptions
this.initialInactivityTimeout = this.player.options_.inactivityTimeout 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', () => { this.player.on('autoplay-failure', () => {
debugLogger('Autoplay failed')
this.player.removeClass('vjs-has-autoplay') 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 playerOptions = this.player.options_
const volume = getStoredVolume() const volume = getStoredVolume()
@ -65,28 +81,15 @@ class PeerTubePlugin extends Plugin {
const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
if (muted !== undefined) this.player.muted(muted) if (muted !== undefined) this.player.muted(muted)
})
this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() this.player.ready(() => {
this.player.on('volumechange', () => { this.player.on('volumechange', () => {
saveVolumeInStore(this.player.volume()) saveVolumeInStore(this.player.volume())
saveMuteInStore(this.player.muted()) 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', () => { this.player.textTracks().addEventListener('change', () => {
const showing = this.player.textTracks().tracks_.find(t => { const showing = this.player.textTracks().tracks_.find(t => {
return t.kind === 'captions' && t.mode === 'showing' return t.kind === 'captions' && t.mode === 'showing'
@ -94,23 +97,24 @@ class PeerTubePlugin extends Plugin {
if (!showing) { if (!showing) {
saveLastSubtitle('off') saveLastSubtitle('off')
this.currentSubtitle = undefined
return return
} }
this.currentSubtitle = showing.language
saveLastSubtitle(showing.language) saveLastSubtitle(showing.language)
}) })
this.player.on('sourcechange', () => this.initCaptions()) this.player.on('video-change', () => {
this.initOnVideoChange()
this.player.duration(options.videoDuration) })
this.initializePlayer()
this.runUserViewing()
}) })
} }
dispose () { dispose () {
if (this.videoViewInterval) clearInterval(this.videoViewInterval) if (this.videoViewInterval) clearInterval(this.videoViewInterval)
super.dispose()
} }
onMenuOpened () { onMenuOpened () {
@ -162,40 +166,70 @@ class PeerTubePlugin extends Plugin {
this.initSmoothProgressBar() this.initSmoothProgressBar()
this.initCaptions() this.player.ready(() => {
this.listenControlBarMouse() this.listenControlBarMouse()
})
this.listenFullScreenChange() 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 () { private runUserViewing () {
let lastCurrentTime = this.startTime const startTime = timeToInt(this.options.startTime())
let lastCurrentTime = startTime
let lastViewEvent: VideoViewEvent let lastViewEvent: VideoViewEvent
this.player.one('play', () => { if (this.videoViewInterval) clearInterval(this.videoViewInterval)
this.notifyUserIsWatching(this.startTime, lastViewEvent) 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 const diff = Math.floor(this.player.currentTime()) - lastCurrentTime
// Don't take into account small forwards // Don't take into account small forwards
if (diff > 0 && diff < 3) return if (diff > 0 && diff < 3) return
lastViewEvent = 'seek' lastViewEvent = 'seek'
}) }
this.player.one('ended', () => { this.videoViewOnEndedHandler = () => {
const currentTime = Math.floor(this.player.duration()) const currentTime = Math.floor(this.player.duration())
lastCurrentTime = currentTime lastCurrentTime = currentTime
this.notifyUserIsWatching(currentTime, lastViewEvent) this.notifyUserIsWatching(currentTime, lastViewEvent)
lastViewEvent = undefined lastViewEvent = undefined
}) }
this.player.one('play', this.videoViewOnPlayHandler)
this.player.on('seeked', this.videoViewOnSeekedHandler)
this.player.one('ended', this.videoViewOnEndedHandler)
this.videoViewInterval = setInterval(() => { this.videoViewInterval = setInterval(() => {
const currentTime = Math.floor(this.player.currentTime()) 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)) .catch(err => logger.error('Cannot notify user is watching.', err))
lastViewEvent = undefined lastViewEvent = undefined
}, this.videoViewIntervalMs) }, this.options.videoViewIntervalMs)
} }
private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) {
// Server won't save history, so save the video position in local storage // Server won't save history, so save the video position in local storage
if (!this.authorizationHeader()) { if (!this.authorizationHeader()) {
saveVideoWatchHistory(this.videoUUID, currentTime) saveVideoWatchHistory(this.options.videoUUID(), currentTime)
} }
if (!this.videoViewUrl) return Promise.resolve(true) 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' }) const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader()) 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 () { 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({ this.player.addRemoteTextTrack({
kind: 'captions', kind: 'captions',
label: caption.label, label: caption.label,
language: caption.language, language: caption.language,
id: caption.language, id: caption.language,
src: caption.src, src: caption.src,
default: this.defaultSubtitle === caption.language default: this.currentSubtitle === caption.language
}, false) }, 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 // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
@ -314,6 +419,37 @@ class PeerTubePlugin extends Plugin {
this.update() 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) videojs.registerPlugin('peertube', PeerTubePlugin)

View File

@ -0,0 +1,136 @@
import {
NextPreviousVideoButtonOptions,
PeerTubeLinkButtonOptions,
PeerTubePlayerContructorOptions,
PeerTubePlayerLoadOptions,
TheaterButtonOptions
} from '../../types'
type ControlBarOptionsBuilderConstructorOptions =
Pick<PeerTubePlayerContructorOptions, 'peertubeLink' | 'instanceName' | 'theaterButton'> &
{
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
}
}
}

View File

@ -3,49 +3,61 @@ import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
import { logger } from '@root-helpers/logger' import { logger } from '@root-helpers/logger'
import { LiveVideoLatencyMode } from '@shared/models' import { LiveVideoLatencyMode } from '@shared/models'
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types' import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types'
import { PeertubePlayerManagerOptions } from '../../types/manager-options'
import { getRtcConfig, isSameOrigin } from '../common' import { getRtcConfig, isSameOrigin } from '../common'
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' 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<PeerTubePlayerContructorOptions, 'pluginsManager' | 'serverUrl' | 'authorizationHeader'> &
Pick<PeerTubePlayerLoadOptions, 'videoPassword' | 'requiresUserAuth' | 'videoFileToken' | 'requiresPassword' |
'isLive' | 'liveOptions' | 'p2pEnabled' | 'hls'>
export class HLSOptionsBuilder { export class HLSOptionsBuilder {
constructor ( constructor (
private options: PeertubePlayerManagerOptions, private options: ConstructorOptions,
private p2pMediaLoaderModule?: any private p2pMediaLoaderModule?: any
) { ) {
} }
async getPluginOptions () { async getPluginOptions () {
const commonOptions = this.options.common const redundancyUrlManager = new RedundancyUrlManager(this.options.hls.redundancyBaseUrls)
const segmentValidator = new SegmentValidator({
const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls) 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( const p2pMediaLoaderConfig = await this.options.pluginsManager.runHook(
'filter:internal.player.p2p-media-loader.options.result', '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 loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader
const p2pMediaLoader: P2PMediaLoaderPluginOptions = { const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
requiresUserAuth: commonOptions.requiresUserAuth, requiresUserAuth: this.options.requiresUserAuth,
videoFileToken: commonOptions.videoFileToken, videoFileToken: this.options.videoFileToken,
redundancyUrlManager, redundancyUrlManager,
type: 'application/x-mpegURL', type: 'application/x-mpegURL',
startTime: commonOptions.startTime, src: this.options.hls.playlistUrl,
src: this.options.p2pMediaLoader.playlistUrl, segmentValidator,
loader loader
} }
const hlsjs = { const hlsjs = {
hlsjsConfig: this.getHLSJSOptions(loader),
levelLabelHandler: (level: { height: number, width: number }) => { levelLabelHandler: (level: { height: number, width: number }) => {
const resolution = Math.min(level.height || 0, level.width || 0) 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 // We don't have files for live videos
if (!file) return level.height if (!file) return level.height
@ -56,26 +68,27 @@ export class HLSOptionsBuilder {
} }
} }
const html5 = { return { p2pMediaLoader, hlsjs }
hlsjsConfig: this.getHLSJSOptions(loader)
}
return { p2pMediaLoader, hlsjs, html5 }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private getP2PMediaLoaderOptions (redundancyUrlManager: RedundancyUrlManager): HlsJsEngineSettings { private getP2PMediaLoaderOptions (options: {
redundancyUrlManager: RedundancyUrlManager
segmentValidator: SegmentValidator
}): HlsJsEngineSettings {
const { redundancyUrlManager, segmentValidator } = options
let consumeOnly = false let consumeOnly = false
if ((navigator as any)?.connection?.type === 'cellular') { if ((navigator as any)?.connection?.type === 'cellular') {
logger.info('We are on a cellular connection: disabling seeding.') logger.info('We are on a cellular connection: disabling seeding.')
consumeOnly = true consumeOnly = true
} }
const trackerAnnounce = this.options.p2pMediaLoader.trackerAnnounce const trackerAnnounce = this.options.hls.trackerAnnounce
.filter(t => t.startsWith('ws')) .filter(t => t.startsWith('ws'))
const specificLiveOrVODOptions = this.options.common.isLive const specificLiveOrVODOptions = this.options.isLive
? this.getP2PMediaLoaderLiveOptions() ? this.getP2PMediaLoaderLiveOptions()
: this.getP2PMediaLoaderVODOptions() : this.getP2PMediaLoaderVODOptions()
@ -88,35 +101,28 @@ export class HLSOptionsBuilder {
httpFailedSegmentTimeout: 1000, httpFailedSegmentTimeout: 1000,
xhrSetup: (xhr, url) => { xhrSetup: (xhr, url) => {
const { requiresUserAuth, requiresPassword } = this.options.common const { requiresUserAuth, requiresPassword } = this.options
if (!(requiresUserAuth || requiresPassword)) return 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({ segmentValidator: segmentValidator.validate.bind(segmentValidator),
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
}),
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
useP2P: this.options.common.p2pEnabled, useP2P: this.options.p2pEnabled,
consumeOnly, consumeOnly,
...specificLiveOrVODOptions ...specificLiveOrVODOptions
}, },
segments: { segments: {
swarmId: this.options.p2pMediaLoader.playlistUrl, swarmId: this.options.hls.playlistUrl,
forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20 forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20
} }
} }
@ -127,7 +133,7 @@ export class HLSOptionsBuilder {
requiredSegmentsPriority: 1 requiredSegmentsPriority: 1
} }
const latencyMode = this.options.common.liveOptions.latencyMode const latencyMode = this.options.liveOptions.latencyMode
switch (latencyMode) { switch (latencyMode) {
case LiveVideoLatencyMode.SMALL_LATENCY: case LiveVideoLatencyMode.SMALL_LATENCY:
@ -165,7 +171,7 @@ export class HLSOptionsBuilder {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private getHLSJSOptions (loader: P2PMediaLoader) { private getHLSJSOptions (loader: P2PMediaLoader) {
const specificLiveOrVODOptions = this.options.common.isLive const specificLiveOrVODOptions = this.options.isLive
? this.getHLSLiveOptions() ? this.getHLSLiveOptions()
: this.getHLSVODOptions() : this.getHLSVODOptions()
@ -193,7 +199,7 @@ export class HLSOptionsBuilder {
} }
private getHLSLiveOptions () { private getHLSLiveOptions () {
const latencyMode = this.options.common.liveOptions.latencyMode const latencyMode = this.options.liveOptions.latencyMode
switch (latencyMode) { switch (latencyMode) {
case LiveVideoLatencyMode.SMALL_LATENCY: case LiveVideoLatencyMode.SMALL_LATENCY:

View File

@ -0,0 +1,3 @@
export * from './control-bar-options-builder'
export * from './hls-options-builder'
export * from './web-video-options-builder'

View File

@ -0,0 +1,22 @@
import { PeerTubePlayerLoadOptions, WebVideoPluginOptions } from '../../types'
type ConstructorOptions = Pick<PeerTubePlayerLoadOptions, 'videoFileToken' | 'webVideo' | 'hls' | 'startTime'>
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
}
}
}

View File

@ -8,8 +8,15 @@ class PlaylistButton extends ClickableComponent {
private playlistInfoElement: HTMLElement private playlistInfoElement: HTMLElement
private wrapper: HTMLElement private wrapper: HTMLElement
constructor (player: videojs.Player, options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu }) { options_: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions
super(player, options as any)
// 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 () { createEl () {
@ -40,20 +47,15 @@ class PlaylistButton extends ClickableComponent {
} }
update () { 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}', [ this.options_.playlist.displayName ])
this.wrapper.title = this.player().localize('Playlist: {1}', [ options.playlist.displayName ])
} }
handleClick () { handleClick () {
const playlistMenu = this.getPlaylistMenu() const playlistMenu = this.options_.playlistMenu
playlistMenu.open() playlistMenu.open()
} }
private getPlaylistMenu () {
return (this.options_ as any).playlistMenu as PlaylistMenu
}
} }
videojs.registerComponent('PlaylistButton', PlaylistButton) videojs.registerComponent('PlaylistButton', PlaylistButton)

View File

@ -8,6 +8,11 @@ const Component = videojs.getComponent('Component')
class PlaylistMenuItem extends Component { class PlaylistMenuItem extends Component {
private element: VideoPlaylistElement private element: VideoPlaylistElement
private clickHandler: () => void
private keyDownHandler: (event: KeyboardEvent) => void
options_: videojs.ComponentOptions & PlaylistItemOptions
constructor (player: videojs.Player, options?: PlaylistItemOptions) { constructor (player: videojs.Player, options?: PlaylistItemOptions) {
super(player, options as any) super(player, options as any)
@ -15,19 +20,27 @@ class PlaylistMenuItem extends Component {
this.element = options.element this.element = options.element
this.on([ 'click', 'tap' ], () => this.switchPlaylistItem()) this.clickHandler = () => this.switchPlaylistItem()
this.on('keydown', event => this.handleKeyDown(event)) 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 () { createEl () {
const options = this.options_ as PlaylistItemOptions
const li = super.createEl('li', { const li = super.createEl('li', {
className: 'vjs-playlist-menu-item', className: 'vjs-playlist-menu-item',
innerHTML: '' innerHTML: ''
}) as HTMLElement }) as HTMLElement
if (!options.element.video) { if (!this.options_.element.video) {
li.classList.add('vjs-disabled') li.classList.add('vjs-disabled')
} }
@ -37,14 +50,14 @@ class PlaylistMenuItem extends Component {
const position = super.createEl('div', { const position = super.createEl('div', {
className: 'item-position', className: 'item-position',
innerHTML: options.element.position innerHTML: this.options_.element.position
}) })
positionBlock.appendChild(position) positionBlock.appendChild(position)
li.appendChild(positionBlock) li.appendChild(positionBlock)
if (options.element.video) { if (this.options_.element.video) {
this.buildAvailableVideo(li, positionBlock, options) this.buildAvailableVideo(li, positionBlock, this.options_)
} else { } else {
this.buildUnavailableVideo(li) this.buildUnavailableVideo(li)
} }
@ -125,9 +138,7 @@ class PlaylistMenuItem extends Component {
} }
private switchPlaylistItem () { private switchPlaylistItem () {
const options = this.options_ as PlaylistItemOptions this.options_.onClicked()
options.onClicked()
} }
} }

View File

@ -6,26 +6,32 @@ import { PlaylistMenuItem } from './playlist-menu-item'
const Component = videojs.getComponent('Component') const Component = videojs.getComponent('Component')
class PlaylistMenu extends Component { class PlaylistMenu extends Component {
private menuItems: PlaylistMenuItem[] private menuItems: PlaylistMenuItem[] = []
constructor (player: videojs.Player, options?: PlaylistPluginOptions) { private readonly userInactiveHandler: () => void
super(player, options as any) private readonly onMouseEnter: () => void
private readonly onMouseLeave: () => void
const self = this private readonly onPlayerCick: (event: Event) => void
function userInactiveHandler () { options_: PlaylistPluginOptions & videojs.ComponentOptions
self.close()
constructor (player: videojs.Player, options?: PlaylistPluginOptions & videojs.ComponentOptions) {
super(player, options)
this.userInactiveHandler = () => {
this.close()
} }
this.el().addEventListener('mouseenter', () => { this.onMouseEnter = () => {
this.player().off('userinactive', userInactiveHandler) this.player().off('userinactive', this.userInactiveHandler)
}) }
this.el().addEventListener('mouseleave', () => { this.onMouseLeave = () => {
this.player().one('userinactive', userInactiveHandler) this.player().one('userinactive', this.userInactiveHandler)
}) }
this.player().on('click', event => { this.onPlayerCick = event => {
let current = event.target as HTMLElement let current = event.target as HTMLElement
do { do {
@ -40,14 +46,31 @@ class PlaylistMenu extends Component {
} while (current) } while (current)
this.close() 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 () { createEl () {
this.menuItems = [] this.menuItems = []
const options = this.getOptions()
const menu = super.createEl('div', { const menu = super.createEl('div', {
className: 'vjs-playlist-menu', className: 'vjs-playlist-menu',
innerHTML: '', innerHTML: '',
@ -61,11 +84,11 @@ class PlaylistMenu extends Component {
const headerLeft = super.createEl('div') const headerLeft = super.createEl('div')
const leftTitle = super.createEl('div', { const leftTitle = super.createEl('div', {
innerHTML: options.playlist.displayName, innerHTML: this.options_.playlist.displayName,
className: 'title' className: 'title'
}) })
const playlistChannel = options.playlist.videoChannel const playlistChannel = this.options_.playlist.videoChannel
const leftSubtitle = super.createEl('div', { const leftSubtitle = super.createEl('div', {
innerHTML: playlistChannel innerHTML: playlistChannel
? this.player().localize('By {1}', [ playlistChannel.displayName ]) ? this.player().localize('By {1}', [ playlistChannel.displayName ])
@ -86,7 +109,7 @@ class PlaylistMenu extends Component {
const list = super.createEl('ol') const list = super.createEl('ol')
for (const playlistElement of options.elements) { for (const playlistElement of this.options_.elements) {
const item = new PlaylistMenuItem(this.player(), { const item = new PlaylistMenuItem(this.player(), {
element: playlistElement, element: playlistElement,
onClicked: () => this.onItemClicked(playlistElement) onClicked: () => this.onItemClicked(playlistElement)
@ -100,13 +123,13 @@ class PlaylistMenu extends Component {
menu.appendChild(header) menu.appendChild(header)
menu.appendChild(list) menu.appendChild(list)
this.update()
return menu return menu
} }
update () { update () {
const options = this.getOptions() this.updateSelected(this.options_.getCurrentPosition())
this.updateSelected(options.getCurrentPosition())
} }
open () { open () {
@ -123,12 +146,8 @@ class PlaylistMenu extends Component {
} }
} }
private getOptions () {
return this.options_ as PlaylistPluginOptions
}
private onItemClicked (element: VideoPlaylistElement) { private onItemClicked (element: VideoPlaylistElement) {
this.getOptions().onItemClicked(element) this.options_.onItemClicked(element)
} }
} }

View File

@ -8,17 +8,10 @@ const Plugin = videojs.getPlugin('plugin')
class PlaylistPlugin extends Plugin { class PlaylistPlugin extends Plugin {
private playlistMenu: PlaylistMenu private playlistMenu: PlaylistMenu
private playlistButton: PlaylistButton private playlistButton: PlaylistButton
private options: PlaylistPluginOptions
constructor (player: videojs.Player, options?: PlaylistPluginOptions) { constructor (player: videojs.Player, options?: PlaylistPluginOptions) {
super(player, options) super(player, options)
this.options = options
this.player.ready(() => {
player.addClass('vjs-playlist')
})
this.playlistMenu = new PlaylistMenu(player, options) this.playlistMenu = new PlaylistMenu(player, options)
this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu }) this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu })
@ -26,8 +19,16 @@ class PlaylistPlugin extends Plugin {
player.addChild(this.playlistButton, options) player.addChild(this.playlistButton, options)
} }
updateSelected () { dispose () {
this.playlistMenu.updateSelected(this.options.getCurrentPosition()) this.player.removeClass('vjs-playlist')
this.playlistMenu.dispose()
this.playlistButton.dispose()
this.player.removeChild(this.playlistMenu)
this.player.removeChild(this.playlistButton)
super.dispose()
} }
} }

View File

@ -8,7 +8,16 @@ class PeerTubeResolutionsPlugin extends Plugin {
private resolutions: PeerTubeResolution[] = [] private resolutions: PeerTubeResolution[] = []
private autoResolutionChosenId: number 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[]) { add (resolutions: PeerTubeResolution[]) {
for (const r of resolutions) { for (const r of resolutions) {
@ -18,12 +27,12 @@ class PeerTubeResolutionsPlugin extends Plugin {
this.currentSelection = this.getSelected() this.currentSelection = this.getSelected()
this.sort() this.sort()
this.trigger('resolutionsAdded') this.trigger('resolutions-added')
} }
remove (resolutionIndex: number) { remove (resolutionIndex: number) {
this.resolutions = this.resolutions.filter(r => r.id !== resolutionIndex) this.resolutions = this.resolutions.filter(r => r.id !== resolutionIndex)
this.trigger('resolutionRemoved') this.trigger('resolutions-removed')
} }
getResolutions () { getResolutions () {
@ -40,10 +49,10 @@ class PeerTubeResolutionsPlugin extends Plugin {
select (options: { select (options: {
id: number id: number
byEngine: boolean fireCallback: boolean
autoResolutionChosenId?: number autoResolutionChosenId?: number
}) { }) {
const { id, autoResolutionChosenId, byEngine } = options const { id, autoResolutionChosenId, fireCallback } = options
if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return
@ -55,25 +64,11 @@ class PeerTubeResolutionsPlugin extends Plugin {
if (r.selected) { if (r.selected) {
this.currentSelection = r this.currentSelection = r
if (!byEngine) r.selectCallback() if (fireCallback) r.selectCallback()
} }
} }
this.trigger('resolutionChanged') this.trigger('resolutions-changed')
}
disableAutoResolution () {
this.autoResolutionEnabled = false
this.trigger('autoResolutionEnabledChanged')
}
enabledAutoResolution () {
this.autoResolutionEnabled = true
this.trigger('autoResolutionEnabledChanged')
}
isAutoResolutionEnabeld () {
return this.autoResolutionEnabled
} }
private sort () { private sort () {

View File

@ -11,12 +11,12 @@ class ResolutionMenuButton extends MenuButton {
this.controlText('Quality') this.controlText('Quality')
player.peertubeResolutions().on('resolutionsAdded', () => this.buildQualities()) player.peertubeResolutions().on('resolutions-added', () => this.update())
player.peertubeResolutions().on('resolutionRemoved', () => this.cleanupQualities()) player.peertubeResolutions().on('resolutions-removed', () => this.update())
// For parent // For parent
player.peertubeResolutions().on('resolutionChanged', () => { player.peertubeResolutions().on('resolutions-changed', () => {
setTimeout(() => this.trigger('labelUpdated')) setTimeout(() => this.trigger('label-updated'))
}) })
} }
@ -37,7 +37,34 @@ class ResolutionMenuButton extends MenuButton {
} }
createMenu () { 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 () { buildCSSClass () {
@ -47,60 +74,6 @@ class ResolutionMenuButton extends MenuButton {
buildWrapperCSSClass () { buildWrapperCSSClass () {
return 'vjs-resolution-control ' + super.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) videojs.registerComponent('ResolutionMenuButton', ResolutionMenuButton)

View File

@ -10,35 +10,32 @@ class ResolutionMenuItem extends MenuItem {
readonly resolutionId: number readonly resolutionId: number
private readonly label: string private readonly label: string
private autoResolutionEnabled: boolean
private autoResolutionChosen: string private autoResolutionChosen: string
private updateSelectionHandler: () => void
constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) { constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) {
options.selectable = true super(player, { ...options, selectable: true })
super(player, options)
this.autoResolutionEnabled = true
this.autoResolutionChosen = '' this.autoResolutionChosen = ''
this.resolutionId = options.resolutionId this.resolutionId = options.resolutionId
this.label = options.label 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) { handleClick (event: any) {
// Auto button disabled?
if (this.autoResolutionEnabled === false && this.resolutionId === -1) return
super.handleClick(event) super.handleClick(event)
this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false }) this.player().peertubeResolutions().select({ id: this.resolutionId, fireCallback: true })
} }
updateSelection () { updateSelection () {
@ -51,19 +48,6 @@ class ResolutionMenuItem extends MenuItem {
this.selected(this.resolutionId === selectedResolution.id) 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 () { getLabel () {
if (this.resolutionId === -1) { if (this.resolutionId === -1) {
return this.label + ' <small>' + this.autoResolutionChosen + '</small>' return this.label + ' <small>' + this.autoResolutionChosen + '</small>'

View File

@ -28,6 +28,18 @@ class SettingsDialog extends Component {
'aria-describedby': dialogDescriptionId '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) Component.registerComponent('SettingsDialog', SettingsDialog)

View File

@ -71,7 +71,7 @@ class SettingsButton extends Button {
} }
} }
onDisposeSettingsItem (event: any, name: string) { onDisposeSettingsItem (_event: any, name: string) {
if (name === undefined) { if (name === undefined) {
const children = this.menu.children() const children = this.menu.children()
@ -103,6 +103,8 @@ class SettingsButton extends Button {
if (this.isInIframe()) { if (this.isInIframe()) {
window.removeEventListener('blur', this.documentClickHandler) window.removeEventListener('blur', this.documentClickHandler)
} }
super.dispose()
} }
onAddSettingsItem (event: any, data: any) { onAddSettingsItem (event: any, data: any) {
@ -249,8 +251,8 @@ class SettingsButton extends Button {
} }
resetChildren () { resetChildren () {
for (const menuChild of this.menu.children()) { for (const menuChild of this.menu.children() as SettingsMenuItem[]) {
(menuChild as SettingsMenuItem).reset() menuChild.reset()
} }
} }
@ -258,8 +260,8 @@ class SettingsButton extends Button {
* Hide all the sub menus * Hide all the sub menus
*/ */
hideChildren () { hideChildren () {
for (const menuChild of this.menu.children()) { for (const menuChild of this.menu.children() as SettingsMenuItem[]) {
(menuChild as SettingsMenuItem).hideSubMenu() menuChild.hideSubMenu()
} }
} }

View File

@ -70,17 +70,22 @@ class SettingsMenuItem extends MenuItem {
this.build() this.build()
// Update on rate change // Update on rate change
if (subMenuName === 'PlaybackRateMenuButton') {
player.on('ratechange', this.submenuClickHandler) player.on('ratechange', this.submenuClickHandler)
}
if (subMenuName === 'CaptionsButton') { if (subMenuName === 'CaptionsButton') {
// Hack to regenerate captions on HTTP fallback player.on('captions-changed', () => {
player.on('captionsChanged', () => { // Wait menu component rebuild
setTimeout(() => { setTimeout(() => {
this.settingsSubMenuEl_.innerHTML = '' this.rebuildAfterMenuChange()
this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) }, 150)
this.update() })
this.bindClickEvents() }
}, 0)
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 () { eventHandlers () {
this.submenuClickHandler = this.onSubmenuClick.bind(this) this.submenuClickHandler = this.onSubmenuClick.bind(this)
this.transitionEndHandler = this.onTransitionEnd.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()) (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) { onTransitionEnd (event: any) {
if (event.propertyName !== 'margin-right') { if (event.propertyName !== 'margin-right') {
return return
@ -254,12 +244,7 @@ class SettingsMenuItem extends MenuItem {
} }
build () { build () {
this.subMenu.on('labelUpdated', () => { this.subMenu.on('label-updated', () => {
this.update()
})
this.subMenu.on('menuChanged', () => {
this.bindClickEvents()
this.setSize()
this.update() this.update()
}) })
@ -272,25 +257,12 @@ class SettingsMenuItem extends MenuItem {
this.setSize() this.setSize()
this.bindClickEvents() this.bindClickEvents()
// prefixed event listeners for CSS TransitionEnd this.settingsSubMenuEl_.addEventListener('transitionend', this.transitionEndHandler, false)
this.PrefixedEvent(
this.settingsSubMenuEl_,
'TransitionEnd',
this.transitionEndHandler,
'addEvent'
)
} }
update (event?: any) { update (event?: any) {
let target: HTMLElement = null
const subMenu = this.subMenu.name() 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 // Playback rate menu button doesn't get a vjs-selected class
// or sets options_['selected'] on the selected playback rate. // or sets options_['selected'] on the selected playback rate.
// Thus we get the submenu value based on the labelEl of playbackRateMenuButton // 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')) { if (target && !target.classList.contains('vjs-back-button')) {
this.settingsButton.hideDialog() 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' (SettingsMenuItem as any).prototype.contentElType = 'button'

View File

@ -7,7 +7,7 @@ import { bytes } from '../common'
interface StatsCardOptions extends videojs.ComponentOptions { interface StatsCardOptions extends videojs.ComponentOptions {
videoUUID: string videoUUID: string
videoIsLive: boolean videoIsLive: boolean
mode: 'webtorrent' | 'p2p-media-loader' mode: 'web-video' | 'p2p-media-loader'
p2pEnabled: boolean p2pEnabled: boolean
} }
@ -34,7 +34,7 @@ class StatsCard extends Component {
updateInterval: any updateInterval: any
mode: 'webtorrent' | 'p2p-media-loader' mode: 'web-video' | 'p2p-media-loader'
metadataStore: any = {} metadataStore: any = {}
@ -63,6 +63,9 @@ class StatsCard extends Component {
private liveLatency: InfoElement private liveLatency: InfoElement
private onP2PInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void
private onHTTPInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void
createEl () { createEl () {
this.containerEl = videojs.dom.createEl('div', { this.containerEl = videojs.dom.createEl('div', {
className: 'vjs-stats-content' className: 'vjs-stats-content'
@ -86,9 +89,7 @@ class StatsCard extends Component {
this.populateInfoBlocks() this.populateInfoBlocks()
this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => { this.onP2PInfoHandler = (_event, data) => {
if (!data) return // HTTP fallback
this.mode = data.source this.mode = data.source
const p2pStats = data.p2p const p2pStats = data.p2p
@ -105,11 +106,29 @@ class StatsCard extends Component {
this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ')
this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.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 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 () { toggle () {
if (this.updateInterval) this.hide() if (this.updateInterval) this.hide()
else this.show() else this.show()
@ -122,7 +141,7 @@ class StatsCard extends Component {
try { try {
const options = this.mode === 'p2p-media-loader' const options = this.mode === 'p2p-media-loader'
? this.buildHLSOptions() ? this.buildHLSOptions()
: await this.buildWebTorrentOptions() // Default : await this.buildWebVideoOptions() // Default
this.populateInfoValues(options) this.populateInfoValues(options)
} catch (err) { } catch (err) {
@ -170,8 +189,8 @@ class StatsCard extends Component {
} }
} }
private async buildWebTorrentOptions () { private async buildWebVideoOptions () {
const videoFile = this.player_.webtorrent().getCurrentVideoFile() const videoFile = this.player_.webVideo().getCurrentVideoFile()
if (!this.metadataStore[videoFile.fileUrl]) { if (!this.metadataStore[videoFile.fileUrl]) {
this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) 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 resolution = videoFile?.resolution.label + videoFile?.fps
const buffer = this.timeRangesToString(this.player_.buffered()) const buffer = this.timeRangesToString(this.player_.buffered())
const progress = this.player_.webtorrent().getTorrent()?.progress const progress = this.player_.bufferedPercent()
return { return {
playerNetworkInfo: this.playerNetworkInfo, playerNetworkInfo: this.playerNetworkInfo,
@ -284,8 +303,10 @@ class StatsCard extends Component {
? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)`
: undefined : undefined
this.setInfoValue(this.playerMode, this.mode || 'HTTP') const p2pEnabled = this.options_.p2pEnabled && this.mode === 'p2p-media-loader'
this.setInfoValue(this.p2p, player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled'))
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.uuid, this.options_.videoUUID)
this.setInfoValue(this.viewport, frames) this.setInfoValue(this.viewport, frames)

View File

@ -7,10 +7,6 @@ class StatsForNerdsPlugin extends Plugin {
private statsCard: StatsCard private statsCard: StatsCard
constructor (player: videojs.Player, options: StatsCardOptions) { constructor (player: videojs.Player, options: StatsCardOptions) {
const settings = {
...options
}
super(player) super(player)
this.player.ready(() => { this.player.ready(() => {
@ -19,7 +15,17 @@ class StatsForNerdsPlugin extends Plugin {
this.statsCard = new StatsCard(player, options) 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 () { show () {

View File

@ -1,6 +1,7 @@
import videojs from 'video.js' import videojs from 'video.js'
import { UpNextPluginOptions } from '../../types'
function getMainTemplate (options: any) { function getMainTemplate (options: EndCardOptions) {
return ` return `
<div class="vjs-upnext-top"> <div class="vjs-upnext-top">
<span class="vjs-upnext-headtext">${options.headText}</span> <span class="vjs-upnext-headtext">${options.headText}</span>
@ -23,15 +24,10 @@ function getMainTemplate (options: any) {
` `
} }
export interface EndCardOptions extends videojs.ComponentOptions { export interface EndCardOptions extends videojs.ComponentOptions, UpNextPluginOptions {
next: () => void
getTitle: () => string
timeout: number
cancelText: string cancelText: string
headText: string headText: string
suspendedText: string suspendedText: string
condition: () => boolean
suspended: () => boolean
} }
const Component = videojs.getComponent('Component') const Component = videojs.getComponent('Component')
@ -52,27 +48,43 @@ class EndCard extends Component {
suspendedMessage: HTMLElement suspendedMessage: HTMLElement
nextButton: HTMLElement nextButton: HTMLElement
private onEndedHandler: () => void
private onPlayingHandler: () => void
constructor (player: videojs.Player, options: EndCardOptions) { constructor (player: videojs.Player, options: EndCardOptions) {
super(player, options) super(player, options)
this.totalTicks = this.options_.timeout / this.interval this.totalTicks = this.options_.timeout / this.interval
player.on('ended', (_: any) => { this.onEndedHandler = () => {
if (!this.options_.condition()) return if (!this.options_.isDisplayed()) return
player.addClass('vjs-upnext--showing') player.addClass('vjs-upnext--showing')
this.showCard((canceled: boolean) => {
this.showCard(canceled => {
player.removeClass('vjs-upnext--showing') player.removeClass('vjs-upnext--showing')
this.container.style.display = 'none' this.container.style.display = 'none'
if (!canceled) { if (!canceled) {
this.options_.next() this.options_.next()
} }
}) })
}) }
player.on('playing', () => { this.onPlayingHandler = () => {
this.upNextEvents.trigger('playing') 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 () { createEl () {
@ -101,7 +113,7 @@ class EndCard extends Component {
return container return container
} }
showCard (cb: (value: boolean) => void) { showCard (cb: (canceled: boolean) => void) {
let timeout: any let timeout: any
this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`)
@ -109,6 +121,10 @@ class EndCard extends Component {
this.title.innerHTML = this.options_.getTitle() this.title.innerHTML = this.options_.getTitle()
if (this.totalTicks === 0) {
return cb(false)
}
this.upNextEvents.one('cancel', () => { this.upNextEvents.one('cancel', () => {
clearTimeout(timeout) clearTimeout(timeout)
cb(true) cb(true)
@ -134,7 +150,7 @@ class EndCard extends Component {
} }
const update = () => { const update = () => {
if (this.options_.suspended()) { if (this.options_.isSuspended()) {
this.suspendedMessage.innerText = this.options_.suspendedText this.suspendedMessage.innerText = this.options_.suspendedText
goToPercent(0) goToPercent(0)
this.ticks = 0 this.ticks = 0

View File

@ -1,26 +1,24 @@
import videojs from 'video.js' import videojs from 'video.js'
import { UpNextPluginOptions } from '../../types'
import { EndCardOptions } from './end-card' import { EndCardOptions } from './end-card'
const Plugin = videojs.getPlugin('plugin') const Plugin = videojs.getPlugin('plugin')
class UpNextPlugin extends Plugin { class UpNextPlugin extends Plugin {
constructor (player: videojs.Player, options: Partial<EndCardOptions> = {}) { constructor (player: videojs.Player, options: UpNextPluginOptions) {
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
}
super(player) super(player)
// UpNext plugin can be called later, so ensure the player is not disposed const settings: EndCardOptions = {
if (this.player.isDisposed()) return 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(() => { this.player.ready(() => {
player.addClass('vjs-upnext') player.addClass('vjs-upnext')

View File

@ -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 }

View File

@ -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 <T> (cb: (err?: Error, val?: T) => void, err: Error, val?: T) {
process.nextTick(() => cb(err, val), undefined)
}
}

View File

@ -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
}

View File

@ -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 }

View File

@ -1,2 +1,2 @@
export * from './manager-options' export * from './peertube-player-options'
export * from './peertube-videojs-typings' export * from './peertube-videojs-typings'

View File

@ -1,101 +1,117 @@
import { PluginsManager } from '@root-helpers/plugins-manager' import { PluginsManager } from '@root-helpers/plugins-manager'
import { LiveVideoLatencyMode, VideoFile } from '@shared/models' import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' 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 = { export type PeerTubePlayerContructorOptions = {
videoFiles: VideoFile[] playerElement: () => HTMLVideoElement
}
export type P2PMediaLoaderOptions = { controls: boolean
playlistUrl: string controlBar: boolean
segmentsSha256Url: string
trackerAnnounce: string[]
redundancyBaseUrls: string[]
videoFiles: VideoFile[]
}
export interface CustomizationOptions { muted: boolean
startTime: number | string loop: boolean
stopTime: number | string
controls?: boolean peertubeLink: () => boolean
controlBar?: boolean
muted?: boolean
loop?: boolean
subtitle?: string
resume?: string
peertubeLink: boolean
playbackRate?: number | string 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 enableHotkeys: boolean
inactivityTimeout: number inactivityTimeout: number
poster: string
videoViewIntervalMs: number videoViewIntervalMs: number
instanceName: string instanceName: string
theaterButton: boolean theaterButton: boolean
captions: boolean
videoViewUrl: string authorizationHeader: () => string
authorizationHeader?: () => string
metricsUrl: 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 embedUrl: string
embedTitle: string embedTitle: string
isLive: boolean isLive: boolean
liveOptions?: { liveOptions?: {
latencyMode: LiveVideoLatencyMode latencyMode: LiveVideoLatencyMode
} }
language?: string
videoCaptions: VideoJSCaption[] videoCaptions: VideoJSCaption[]
storyboard: VideoJSStoryboard storyboard: VideoJSStoryboard
videoUUID: string videoUUID: string
videoShortUUID: string videoShortUUID: string
serverUrl: string duration: number
requiresUserAuth: boolean requiresUserAuth: boolean
videoFileToken: () => string videoFileToken: () => string
requiresPassword: boolean requiresPassword: boolean
videoPassword: () => string 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 = { export type WebVideoOptions = {
common: CommonOptions videoFiles: VideoFile[]
webtorrent: WebtorrentOptions }
p2pMediaLoader?: P2PMediaLoaderOptions
export type HLSOptions = {
pluginsManager: PluginsManager playlistUrl: string
segmentsSha256Url: string
trackerAnnounce: string[]
redundancyBaseUrls: string[]
videoFiles: VideoFile[]
} }

View File

@ -2,8 +2,11 @@ import { HlsConfig, Level } from 'hls.js'
import videojs from 'video.js' import videojs from 'video.js'
import { Engine } from '@peertube/p2p-media-loader-hlsjs' import { Engine } from '@peertube/p2p-media-loader-hlsjs'
import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-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 { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' 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 { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin'
import { StatsCardOptions } from '../shared/stats/stats-card' import { StatsCardOptions } from '../shared/stats/stats-card'
import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin'
import { EndCardOptions } from '../shared/upnext/end-card' import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
import { WebTorrentPlugin } from '../shared/webtorrent/webtorrent-plugin' import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
import { PlayerMode } from './manager-options' import { PlayerMode } from './peertube-player-options'
import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
declare module 'video.js' { declare module 'video.js' {
@ -31,35 +35,36 @@ declare module 'video.js' {
handleTechSeeked_ (): void 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 & { textTracks (): TextTrackList & {
tracks_: (TextTrack & { id: string, label: string, src: string })[] tracks_: (TextTrack & { id: string, label: string, src: string })[]
} }
peertubeDock (options: PeerTubeDockPluginOptions): void // Plugins
upnext (options: Partial<EndCardOptions>): 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 = { type PeerTubePluginOptions = {
mode: PlayerMode hasAutoplay: () => videojs.Autoplay
autoplay: videojs.Autoplay videoViewUrl: () => string
videoDuration: number videoViewIntervalMs: number
videoViewUrl: string
authorizationHeader?: () => string authorizationHeader?: () => string
subtitle?: string videoDuration: () => number
videoCaptions: VideoJSCaption[] startTime: () => number | string
stopTime: () => number | string
startTime: number | string videoCaptions: () => VideoJSCaption[]
stopTime: number | string isLive: () => boolean
videoUUID: () => string
isLive: boolean subtitle: () => string
videoUUID: string
videoViewIntervalMs: number
} }
type MetricsPluginOptions = { type MetricsPluginOptions = {
mode: PlayerMode mode: () => PlayerMode
metricsUrl: string metricsUrl: () => string
videoUUID: string videoUUID: () => string
} }
type StoryboardOptions = { type StoryboardOptions = {
@ -144,37 +145,36 @@ type PlaylistPluginOptions = {
onItemClicked: (element: VideoPlaylistElement) => void onItemClicked: (element: VideoPlaylistElement) => void
} }
type UpNextPluginOptions = {
timeout: number
next: () => void
getTitle: () => string
isDisplayed: () => boolean
isSuspended: () => boolean
}
type NextPreviousVideoButtonOptions = { type NextPreviousVideoButtonOptions = {
type: 'next' | 'previous' type: 'next' | 'previous'
handler: () => void handler?: () => void
isDisplayed: () => boolean
isDisabled: () => boolean isDisabled: () => boolean
} }
type PeerTubeLinkButtonOptions = { type PeerTubeLinkButtonOptions = {
shortUUID: string isDisplayed: () => boolean
shortUUID: () => string
instanceName: string instanceName: string
} }
type PeerTubeP2PInfoButtonOptions = { type TheaterButtonOptions = {
p2pEnabled: boolean isDisplayed: () => boolean
} }
type WebtorrentPluginOptions = { type WebVideoPluginOptions = {
playerElement: HTMLVideoElement
autoplay: videojs.Autoplay
videoDuration: number
videoFiles: VideoFile[] videoFiles: VideoFile[]
startTime: number | string startTime: number | string
playerRefusedP2P: boolean
requiresUserAuth: boolean
videoFileToken: () => string videoFileToken: () => string
buildWebSeedUrls: (file: VideoFile) => string[]
} }
type P2PMediaLoaderPluginOptions = { type P2PMediaLoaderPluginOptions = {
@ -182,9 +182,8 @@ type P2PMediaLoaderPluginOptions = {
type: string type: string
src: string src: string
startTime: number | string
loader: P2PMediaLoader loader: P2PMediaLoader
segmentValidator: SegmentValidator
requiresUserAuth: boolean requiresUserAuth: boolean
videoFileToken: () => string videoFileToken: () => string
@ -192,6 +191,8 @@ type P2PMediaLoaderPluginOptions = {
export type P2PMediaLoader = { export type P2PMediaLoader = {
getEngine(): Engine getEngine(): Engine
destroy: () => void
} }
type VideoJSPluginOptions = { type VideoJSPluginOptions = {
@ -200,7 +201,7 @@ type VideoJSPluginOptions = {
peertube: PeerTubePluginOptions peertube: PeerTubePluginOptions
metrics: MetricsPluginOptions metrics: MetricsPluginOptions
webtorrent?: WebtorrentPluginOptions webVideo?: WebVideoPluginOptions
p2pMediaLoader?: P2PMediaLoaderPluginOptions p2pMediaLoader?: P2PMediaLoaderPluginOptions
} }
@ -227,14 +228,14 @@ type AutoResolutionUpdateData = {
} }
type PlayerNetworkInfo = { type PlayerNetworkInfo = {
source: 'webtorrent' | 'p2p-media-loader' source: 'web-video' | 'p2p-media-loader'
http: { http: {
downloadSpeed: number downloadSpeed?: number
downloaded: number downloaded: number
} }
p2p: { p2p?: {
downloadSpeed: number downloadSpeed: number
uploadSpeed: number uploadSpeed: number
downloaded: number downloaded: number
@ -243,7 +244,7 @@ type PlayerNetworkInfo = {
} }
// In bytes // In bytes
bandwidthEstimate: number bandwidthEstimate?: number
} }
type PlaylistItemOptions = { type PlaylistItemOptions = {
@ -254,6 +255,7 @@ type PlaylistItemOptions = {
export { export {
PlayerNetworkInfo, PlayerNetworkInfo,
TheaterButtonOptions,
VideoJSStoryboard, VideoJSStoryboard,
PlaylistItemOptions, PlaylistItemOptions,
NextPreviousVideoButtonOptions, NextPreviousVideoButtonOptions,
@ -263,12 +265,12 @@ export {
MetricsPluginOptions, MetricsPluginOptions,
VideoJSCaption, VideoJSCaption,
PeerTubePluginOptions, PeerTubePluginOptions,
WebtorrentPluginOptions, WebVideoPluginOptions,
P2PMediaLoaderPluginOptions, P2PMediaLoaderPluginOptions,
PeerTubeResolution, PeerTubeResolution,
VideoJSPluginOptions, VideoJSPluginOptions,
UpNextPluginOptions,
LoadedQualityData, LoadedQualityData,
StoryboardOptions, StoryboardOptions,
PeerTubeLinkButtonOptions, PeerTubeLinkButtonOptions
PeerTubeP2PInfoButtonOptions
} }

View File

@ -3,20 +3,6 @@
@use '_mixins' as *; @use '_mixins' as *;
@use './_player-variables' 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 { .video-js.vjs-peertube-skin .vjs-control-bar {
z-index: 100; z-index: 100;
@ -26,11 +12,8 @@
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
transition: visibility 0.3s, opacity 0.3s !important; transition: visibility 0.3s, opacity 0.3s !important;
&.control-bar-hidden { > button:not(.vjs-hidden):first-child,
display: none !important; > button.vjs-hidden + button:not(.vjs-hidden) {
}
> button:first-child {
@include margin-left($first-control-bar-element-margin-left); @include margin-left($first-control-bar-element-margin-left);
} }
@ -167,7 +150,7 @@
} }
} }
.vjs-live-control { .vjs-pt-live-control {
padding: 5px 7px; padding: 5px 7px;
border-radius: 3px; border-radius: 3px;
height: fit-content; height: fit-content;
@ -245,6 +228,7 @@
.vjs-next-video, .vjs-next-video,
.vjs-previous-video { .vjs-previous-video {
width: $control-bar-button-width - 4px; width: $control-bar-button-width - 4px;
cursor: pointer;
&.vjs-disabled { &.vjs-disabled {
cursor: default; cursor: default;

View File

@ -10,3 +10,4 @@
@use './playlist'; @use './playlist';
@use './stats'; @use './stats';
@use './offline-notification'; @use './offline-notification';
@use './storyboard.scss';

View File

@ -170,7 +170,8 @@
} }
} }
&.vjs-scrubbing { &.vjs-scrubbing,
&.vjs-mobile-sliding {
.vjs-mobile-buttons-overlay { .vjs-mobile-buttons-overlay {
display: none; display: none;
} }

View File

@ -84,7 +84,9 @@ body {
} }
// Do not display poster when video is starting // 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 { .vjs-poster {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;

View File

@ -75,6 +75,7 @@ $setting-transition-easing: ease-out;
> .vjs-menu { > .vjs-menu {
flex: 1; flex: 1;
min-width: 200px; min-width: 200px;
padding: 5px 0;
} }
> .vjs-menu, > .vjs-menu,
@ -90,14 +91,6 @@ $setting-transition-easing: ease-out;
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
} }
&:first-child {
margin-top: 5px;
}
&:last-child {
margin-bottom: 5px;
}
&.disabled { &.disabled {
opacity: 0.5; opacity: 0.5;
cursor: default !important; cursor: default !important;

View File

@ -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;
}
}

View File

@ -1 +0,0 @@
module.exports = require('stream-http')

View File

@ -1 +0,0 @@
module.exports = require('https-browserify')

View File

@ -1 +0,0 @@
module.exports = require('stream-browserify')

View File

@ -72,15 +72,12 @@ export class PeerTubeEmbedApi {
private setResolution (resolutionId: number) { private setResolution (resolutionId: number) {
logger.info(`Set resolution ${resolutionId}`) logger.info(`Set resolution ${resolutionId}`)
if (this.isWebtorrent()) { if (this.isWebVideo() && resolutionId === -1) {
if (resolutionId === -1 && this.embed.player.webtorrent().isAutoResolutionPossible() === false) return logger.error('Auto resolution cannot be set in web video player mode')
this.embed.player.webtorrent().changeQuality(resolutionId)
return return
} }
this.embed.player.p2pMediaLoader().getHLSJS().currentLevel = resolutionId this.embed.player.peertubeResolutions().select({ id: resolutionId, fireCallback: true })
} }
private getCaptions (): PeerTubeTextTrack[] { private getCaptions (): PeerTubeTextTrack[] {
@ -152,8 +149,8 @@ export class PeerTubeEmbedApi {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PeerTube specific capabilities // PeerTube specific capabilities
this.embed.player.peertubeResolutions().on('resolutionsAdded', () => this.loadResolutions()) this.embed.player.peertubeResolutions().on('resolutions-added', () => this.loadResolutions())
this.embed.player.peertubeResolutions().on('resolutionChanged', () => this.loadResolutions()) this.embed.player.peertubeResolutions().on('resolutions-changed', () => this.loadResolutions())
this.loadResolutions() this.loadResolutions()
@ -193,7 +190,7 @@ export class PeerTubeEmbedApi {
}) })
} }
private isWebtorrent () { private isWebVideo () {
return !!this.embed.player.webtorrent return !!this.embed.player.webVideo
} }
} }

View File

@ -48,7 +48,7 @@
<div id="video-password-content"></div> <div id="video-password-content"></div>
<form id="video-password-form"> <form id="video-password-form">
<input type="password" id="video-password-input" name="video-password" required> <input type="password" id="video-password-input" name="video-password" autocomplete="user-password" required>
<button type="submit" id="video-password-submit"> </button> <button type="submit" id="video-password-submit"> </button>
</form> </form>
@ -60,8 +60,6 @@
<div id="video-wrapper"></div> <div id="video-wrapper"></div>
<div id="placeholder-preview"></div>
<script type="text/javascript"> <script type="text/javascript">
// Can be called in embed.ts // Can be called in embed.ts
window.displayIncompatibleBrowser = function () { window.displayIncompatibleBrowser = function () {

View File

@ -3,7 +3,6 @@ import '../../assets/player/shared/dock/peertube-dock-component'
import '../../assets/player/shared/dock/peertube-dock-plugin' import '../../assets/player/shared/dock/peertube-dock-plugin'
import { PeerTubeServerError } from 'src/types' import { PeerTubeServerError } from 'src/types'
import videojs from 'video.js' import videojs from 'video.js'
import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
import { import {
HTMLServerConfig, HTMLServerConfig,
ResultList, ResultList,
@ -13,7 +12,7 @@ import {
VideoPlaylistElement, VideoPlaylistElement,
VideoState VideoState
} from '../../../../shared/models' } from '../../../../shared/models'
import { PeertubePlayerManager } from '../../assets/player' import { PeerTubePlayer } from '../../assets/player/peertube-player'
import { TranslationsManager } from '../../assets/player/translations-manager' import { TranslationsManager } from '../../assets/player/translations-manager'
import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers' import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers'
import { PeerTubeEmbedApi } from './embed-api' import { PeerTubeEmbedApi } from './embed-api'
@ -21,7 +20,7 @@ import {
AuthHTTP, AuthHTTP,
LiveManager, LiveManager,
PeerTubePlugin, PeerTubePlugin,
PlayerManagerOptions, PlayerOptionsBuilder,
PlaylistFetcher, PlaylistFetcher,
PlaylistTracker, PlaylistTracker,
Translations, Translations,
@ -36,17 +35,23 @@ export class PeerTubeEmbed {
config: HTMLServerConfig config: HTMLServerConfig
private translationsPromise: Promise<{ [id: string]: string }> private translationsPromise: Promise<{ [id: string]: string }>
private PeertubePlayerManagerModulePromise: Promise<any> private PeerTubePlayerManagerModulePromise: Promise<any>
private readonly http: AuthHTTP private readonly http: AuthHTTP
private readonly videoFetcher: VideoFetcher private readonly videoFetcher: VideoFetcher
private readonly playlistFetcher: PlaylistFetcher private readonly playlistFetcher: PlaylistFetcher
private readonly peertubePlugin: PeerTubePlugin private readonly peertubePlugin: PeerTubePlugin
private readonly playerHTML: PlayerHTML private readonly playerHTML: PlayerHTML
private readonly playerManagerOptions: PlayerManagerOptions private readonly playerOptionsBuilder: PlayerOptionsBuilder
private readonly liveManager: LiveManager private readonly liveManager: LiveManager
private peertubePlayer: PeerTubePlayer
private playlistTracker: PlaylistTracker private playlistTracker: PlaylistTracker
private alreadyInitialized = false
private alreadyPlayed = false
private videoPassword: string private videoPassword: string
private requiresPassword: boolean private requiresPassword: boolean
@ -59,7 +64,7 @@ export class PeerTubeEmbed {
this.playlistFetcher = new PlaylistFetcher(this.http) this.playlistFetcher = new PlaylistFetcher(this.http)
this.peertubePlugin = new PeerTubePlugin(this.http) this.peertubePlugin = new PeerTubePlugin(this.http)
this.playerHTML = new PlayerHTML(videoWrapperId) this.playerHTML = new PlayerHTML(videoWrapperId)
this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) this.playerOptionsBuilder = new PlayerOptionsBuilder(this.playerHTML, this.videoFetcher, this.peertubePlugin)
this.liveManager = new LiveManager(this.playerHTML) this.liveManager = new LiveManager(this.playerHTML)
this.requiresPassword = false this.requiresPassword = false
@ -81,14 +86,14 @@ export class PeerTubeEmbed {
} }
getScope () { getScope () {
return this.playerManagerOptions.getScope() return this.playerOptionsBuilder.getScope()
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async init () { async init () {
this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language) this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager') this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player')
// Issue when we parsed config from HTML, fallback to API // Issue when we parsed config from HTML, fallback to API
if (!this.config) { if (!this.config) {
@ -102,7 +107,7 @@ export class PeerTubeEmbed {
if (!videoId) return if (!videoId) return
return this.loadVideoAndBuildPlayer({ uuid: videoId, autoplayFromPreviousVideo: false, forceAutoplay: false }) return this.loadVideoAndBuildPlayer({ uuid: videoId, forceAutoplay: false })
} }
private async initPlaylist () { private async initPlaylist () {
@ -137,7 +142,7 @@ export class PeerTubeEmbed {
} }
private initializeApi () { private initializeApi () {
if (this.playerManagerOptions.hasAPIEnabled()) { if (this.playerOptionsBuilder.hasAPIEnabled()) {
if (this.api) { if (this.api) {
this.api.reInit() this.api.reInit()
return return
@ -159,7 +164,7 @@ export class PeerTubeEmbed {
this.playlistTracker.setCurrentElement(next) this.playlistTracker.setCurrentElement(next)
return this.loadVideoAndBuildPlayer({ uuid: next.video.uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }) return this.loadVideoAndBuildPlayer({ uuid: next.video.uuid, forceAutoplay: false })
} }
async playPreviousPlaylistVideo () { async playPreviousPlaylistVideo () {
@ -171,7 +176,7 @@ export class PeerTubeEmbed {
this.playlistTracker.setCurrentElement(previous) this.playlistTracker.setCurrentElement(previous)
await this.loadVideoAndBuildPlayer({ uuid: previous.video.uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }) await this.loadVideoAndBuildPlayer({ uuid: previous.video.uuid, forceAutoplay: false })
} }
getCurrentPlaylistPosition () { getCurrentPlaylistPosition () {
@ -182,10 +187,9 @@ export class PeerTubeEmbed {
private async loadVideoAndBuildPlayer (options: { private async loadVideoAndBuildPlayer (options: {
uuid: string uuid: string
autoplayFromPreviousVideo: boolean
forceAutoplay: boolean forceAutoplay: boolean
}) { }) {
const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options const { uuid, forceAutoplay } = options
try { try {
const { const {
@ -194,7 +198,7 @@ export class PeerTubeEmbed {
storyboardsPromise storyboardsPromise
} = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, autoplayFromPreviousVideo, forceAutoplay }) return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay })
} catch (err) { } catch (err) {
if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
@ -206,20 +210,14 @@ export class PeerTubeEmbed {
videoResponse: Response videoResponse: Response
storyboardsPromise: Promise<Response> storyboardsPromise: Promise<Response>
captionsPromise: Promise<Response> captionsPromise: Promise<Response>
autoplayFromPreviousVideo: boolean
forceAutoplay: boolean forceAutoplay: boolean
}) { }) {
const { videoResponse, captionsPromise, storyboardsPromise, autoplayFromPreviousVideo, forceAutoplay } = options const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options
this.resetPlayerElement()
const videoInfoPromise = videoResponse.json() const videoInfoPromise = videoResponse.json()
.then(async (videoInfo: VideoDetails) => { .then(async (videoInfo: VideoDetails) => {
this.playerManagerOptions.loadParams(this.config, videoInfo) this.playerOptionsBuilder.loadParams(this.config, videoInfo)
if (!autoplayFromPreviousVideo && !this.playerManagerOptions.hasAutoplay()) {
this.playerHTML.buildPlaceholder(videoInfo)
}
const live = videoInfo.isLive const live = videoInfo.isLive
? await this.videoFetcher.loadLive(videoInfo) ? await this.videoFetcher.loadLive(videoInfo)
: undefined : undefined
@ -235,89 +233,75 @@ export class PeerTubeEmbed {
{ video, live, videoFileToken }, { video, live, videoFileToken },
translations, translations,
captionsResponse, captionsResponse,
storyboardsResponse, storyboardsResponse
PeertubePlayerManagerModule
] = await Promise.all([ ] = await Promise.all([
videoInfoPromise, videoInfoPromise,
this.translationsPromise, this.translationsPromise,
captionsPromise, captionsPromise,
storyboardsPromise, storyboardsPromise,
this.PeertubePlayerManagerModulePromise this.buildPlayerIfNeeded()
]) ])
await this.peertubePlugin.loadPlugins(this.config, translations) this.peertubePlayer.setPoster(window.location.origin + video.previewPath)
const PlayerManager: typeof PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager const playlist = this.playlistTracker
? {
onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, forceAutoplay: false }),
const playerOptions = await this.playerManagerOptions.getPlayerOptions({ playlistTracker: this.playlistTracker,
playNext: () => this.playNextPlaylistVideo(),
playPrevious: () => this.playPreviousPlaylistVideo()
}
: undefined
const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({
video, video,
captionsResponse, captionsResponse,
autoplayFromPreviousVideo,
translations, translations,
serverConfig: this.config,
storyboardsResponse, storyboardsResponse,
authorizationHeader: () => this.http.getHeaderTokenValue(),
videoFileToken: () => videoFileToken, videoFileToken: () => videoFileToken,
videoPassword: () => this.videoPassword, videoPassword: () => this.videoPassword,
requiresPassword: this.requiresPassword, requiresPassword: this.requiresPassword,
onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }), playlist,
playlistTracker: this.playlistTracker,
playNextPlaylistVideo: () => this.playNextPlaylistVideo(),
playPreviousPlaylistVideo: () => this.playPreviousPlaylistVideo(),
live, live,
forceAutoplay forceAutoplay,
alreadyPlayed: this.alreadyPlayed
}) })
await this.peertubePlayer.load(loadOptions)
this.player = await PlayerManager.initialize(this.playerManagerOptions.getMode(), playerOptions, (player: videojs.Player) => { if (!this.alreadyInitialized) {
this.player = player this.player = this.peertubePlayer.getPlayer();
})
this.player.on('customError', (event: any, data: any) => {
const message = data?.err?.message || ''
if (!message.includes('from xs param')) return
this.player.dispose()
this.playerHTML.removePlayerElement()
this.playerHTML.displayError('This video is not available because the remote instance is not responding.', translations)
});
(window as any)['videojsPlayer'] = this.player (window as any)['videojsPlayer'] = this.player
this.buildCSS() this.buildCSS()
this.buildPlayerDock(video)
this.initializeApi() this.initializeApi()
this.playerHTML.removePlaceholder()
if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock()
if (this.isPlaylistEmbed()) {
await this.buildPlayerPlaylistUpnext()
this.player.playlist().updateSelected()
this.player.on('stopped', () => {
this.playNextPlaylistVideo()
})
} }
this.alreadyInitialized = true
this.player.one('play', () => {
this.alreadyPlayed = true
})
if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock()
if (video.isLive) { if (video.isLive) {
this.liveManager.listenForChanges({ this.liveManager.listenForChanges({
video, video,
onPublishedVideo: () => { onPublishedVideo: () => {
this.liveManager.stopListeningForChanges(video) this.liveManager.stopListeningForChanges(video)
this.loadVideoAndBuildPlayer({ uuid: video.uuid, autoplayFromPreviousVideo: false, forceAutoplay: true }) this.loadVideoAndBuildPlayer({ uuid: video.uuid, forceAutoplay: true })
} }
}) })
if (video.state.id === VideoState.WAITING_FOR_LIVE || video.state.id === VideoState.LIVE_ENDED) { if (video.state.id === VideoState.WAITING_FOR_LIVE || video.state.id === VideoState.LIVE_ENDED) {
this.liveManager.displayInfo({ state: video.state.id, translations }) this.liveManager.displayInfo({ state: video.state.id, translations })
this.peertubePlayer.disable()
this.disablePlayer()
} else { } else {
this.correctlyHandleLiveEnding(translations) this.correctlyHandleLiveEnding(translations)
} }
@ -326,74 +310,15 @@ export class PeerTubeEmbed {
this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video }) this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video })
} }
private resetPlayerElement () {
if (this.player) {
this.player.dispose()
this.player = undefined
}
const playerElement = document.createElement('video')
playerElement.className = 'video-js vjs-peertube-skin'
playerElement.setAttribute('playsinline', 'true')
this.playerHTML.setPlayerElement(playerElement)
this.playerHTML.addPlayerElementToDOM()
}
private async buildPlayerPlaylistUpnext () {
const translations = await this.translationsPromise
this.player.upnext({
timeout: 10000, // 10s
headText: peertubeTranslate('Up Next', translations),
cancelText: peertubeTranslate('Cancel', translations),
suspendedText: peertubeTranslate('Autoplay is suspended', translations),
getTitle: () => this.playlistTracker.nextVideoTitle(),
next: () => this.playNextPlaylistVideo(),
condition: () => !!this.playlistTracker.getNextPlaylistElement(),
suspended: () => false
})
}
private buildPlayerDock (videoInfo: VideoDetails) {
if (!this.playerManagerOptions.hasControls()) return
// On webtorrent fallback, player may have been disposed
if (!this.player.player_) return
const title = this.playerManagerOptions.hasTitle()
? videoInfo.name
: undefined
const description = this.playerManagerOptions.hasWarningTitle() && this.playerManagerOptions.hasP2PEnabled()
? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>'
: undefined
if (!title && !description) return
const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50)
const avatar = availableAvatars.length !== 0
? availableAvatars[0]
: undefined
this.player.peertubeDock({
title,
description,
avatarUrl: title && avatar
? avatar.path
: undefined
})
}
private buildCSS () { private buildCSS () {
const body = document.getElementById('custom-css') const body = document.getElementById('custom-css')
if (this.playerManagerOptions.hasBigPlayBackgroundColor()) { if (this.playerOptionsBuilder.hasBigPlayBackgroundColor()) {
body.style.setProperty('--embedBigPlayBackgroundColor', this.playerManagerOptions.getBigPlayBackgroundColor()) body.style.setProperty('--embedBigPlayBackgroundColor', this.playerOptionsBuilder.getBigPlayBackgroundColor())
} }
if (this.playerManagerOptions.hasForegroundColor()) { if (this.playerOptionsBuilder.hasForegroundColor()) {
body.style.setProperty('--embedForegroundColor', this.playerManagerOptions.getForegroundColor()) body.style.setProperty('--embedForegroundColor', this.playerOptionsBuilder.getForegroundColor())
} }
} }
@ -415,23 +340,10 @@ export class PeerTubeEmbed {
// Display the live ended information // Display the live ended information
this.liveManager.displayInfo({ state: VideoState.LIVE_ENDED, translations }) this.liveManager.displayInfo({ state: VideoState.LIVE_ENDED, translations })
this.disablePlayer() this.peertubePlayer.disable()
}) })
} }
private disablePlayer () {
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 handlePasswordError (err: PeerTubeServerError) { private async handlePasswordError (err: PeerTubeServerError) {
let incorrectPassword: boolean = null let incorrectPassword: boolean = null
if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false
@ -447,6 +359,33 @@ export class PeerTubeEmbed {
return true return true
} }
private async buildPlayerIfNeeded () {
if (this.peertubePlayer) {
this.peertubePlayer.enable()
return
}
const playerElement = document.createElement('video')
playerElement.className = 'video-js vjs-peertube-skin'
playerElement.setAttribute('playsinline', 'true')
this.playerHTML.setPlayerElement(playerElement)
this.playerHTML.addPlayerElementToDOM()
const [ { PeerTubePlayer } ] = await Promise.all([
this.PeerTubePlayerManagerModulePromise,
this.peertubePlugin.loadPlugins(this.config, await this.translationsPromise)
])
const constructorOptions = this.playerOptionsBuilder.getPlayerConstructorOptions({
serverConfig: this.config,
authorizationHeader: () => this.http.getHeaderTokenValue()
})
this.peertubePlayer = new PeerTubePlayer(constructorOptions)
this.player = this.peertubePlayer.getPlayer()
}
} }
PeerTubeEmbed.main() PeerTubeEmbed.main()

View File

@ -2,7 +2,7 @@ export * from './auth-http'
export * from './peertube-plugin' export * from './peertube-plugin'
export * from './live-manager' export * from './live-manager'
export * from './player-html' export * from './player-html'
export * from './player-manager-options' export * from './player-options-builder'
export * from './playlist-fetcher' export * from './playlist-fetcher'
export * from './playlist-tracker' export * from './playlist-tracker'
export * from './translations' export * from './translations'

View File

@ -1,5 +1,4 @@
import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
import { VideoDetails } from '../../../../../shared/models'
import { logger } from '../../../root-helpers' import { logger } from '../../../root-helpers'
import { Translations } from './translations' import { Translations } from './translations'
@ -59,7 +58,6 @@ export class PlayerHTML {
const { incorrectPassword, translations } = options const { incorrectPassword, translations } = options
return new Promise((resolve) => { return new Promise((resolve) => {
this.removePlaceholder()
this.wrapperElement.style.display = 'none' this.wrapperElement.style.display = 'none'
const translatedTitle = peertubeTranslate('This video is password protected', translations) const translatedTitle = peertubeTranslate('This video is password protected', translations)
@ -107,19 +105,6 @@ export class PlayerHTML {
this.wrapperElement.style.display = 'block' this.wrapperElement.style.display = 'block'
} }
buildPlaceholder (video: VideoDetails) {
const placeholder = this.getPlaceholderElement()
const url = window.location.origin + video.previewPath
placeholder.style.backgroundImage = `url("${url}")`
placeholder.style.display = 'block'
}
removePlaceholder () {
const placeholder = this.getPlaceholderElement()
placeholder.style.display = 'none'
}
displayInformation (text: string, translations: Translations) { displayInformation (text: string, translations: Translations) {
if (this.informationElement) this.removeInformation() if (this.informationElement) this.removeInformation()
@ -137,10 +122,6 @@ export class PlayerHTML {
this.informationElement = undefined this.informationElement = undefined
} }
private getPlaceholderElement () {
return document.getElementById('placeholder-preview')
}
private removeElement (element: HTMLElement) { private removeElement (element: HTMLElement) {
element.parentElement.removeChild(element) element.parentElement.removeChild(element)
} }

View File

@ -10,7 +10,7 @@ import {
VideoState, VideoState,
VideoStreamingPlaylistType VideoStreamingPlaylistType
} from '../../../../../shared/models' } from '../../../../../shared/models'
import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player' import { HLSOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions, PlayerMode, VideoJSCaption } from '../../../assets/player'
import { import {
getBoolOrDefault, getBoolOrDefault,
getParamString, getParamString,
@ -27,7 +27,7 @@ import { PlaylistTracker } from './playlist-tracker'
import { Translations } from './translations' import { Translations } from './translations'
import { VideoFetcher } from './video-fetcher' import { VideoFetcher } from './video-fetcher'
export class PlayerManagerOptions { export class PlayerOptionsBuilder {
private autoplay: boolean private autoplay: boolean
private controls: boolean private controls: boolean
@ -141,10 +141,10 @@ export class PlayerManagerOptions {
if (modeParam) { if (modeParam) {
if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader' if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader'
else this.mode = 'webtorrent' else this.mode = 'web-video'
} else { } else {
if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader' if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader'
else this.mode = 'webtorrent' else this.mode = 'web-video'
} }
} catch (err) { } catch (err) {
logger.error('Cannot get params from URL.', err) logger.error('Cannot get params from URL.', err)
@ -153,7 +153,47 @@ export class PlayerManagerOptions {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async getPlayerOptions (options: { getPlayerConstructorOptions (options: {
serverConfig: HTMLServerConfig
authorizationHeader: () => string
}): PeerTubePlayerContructorOptions {
const { serverConfig, authorizationHeader } = options
return {
controls: this.controls,
controlBar: this.controlBar,
muted: this.muted,
loop: this.loop,
playbackRate: this.playbackRate,
inactivityTimeout: 2500,
videoViewIntervalMs: 5000,
metricsUrl: window.location.origin + '/api/v1/metrics/playback',
authorizationHeader,
playerElement: () => this.playerHTML.getPlayerElement(),
enableHotkeys: true,
peertubeLink: () => this.peertubeLink,
instanceName: serverConfig.instance.name,
theaterButton: false,
serverUrl: window.location.origin,
language: navigator.language,
pluginsManager: this.peertubePlugin.getPluginsManager(),
errorNotifier: () => {
// Empty, we don't have a notifier in the embed
}
}
}
async getPlayerLoadOptions (options: {
video: VideoDetails video: VideoDetails
captionsResponse: Response captionsResponse: Response
@ -161,39 +201,35 @@ export class PlayerManagerOptions {
live?: LiveVideo live?: LiveVideo
alreadyPlayed: boolean
forceAutoplay: boolean forceAutoplay: boolean
authorizationHeader: () => string
videoFileToken: () => string videoFileToken: () => string
videoPassword: () => string videoPassword: () => string
requiresPassword: boolean requiresPassword: boolean
serverConfig: HTMLServerConfig
autoplayFromPreviousVideo: boolean
translations: Translations translations: Translations
playlistTracker?: PlaylistTracker playlist?: {
playNextPlaylistVideo?: () => any playlistTracker: PlaylistTracker
playPreviousPlaylistVideo?: () => any playNext: () => any
onVideoUpdate?: (uuid: string) => any playPrevious: () => any
}) { onVideoUpdate: (uuid: string) => any
}
}): Promise<PeerTubePlayerLoadOptions> {
const { const {
video, video,
captionsResponse, captionsResponse,
autoplayFromPreviousVideo,
videoFileToken, videoFileToken,
videoPassword, videoPassword,
requiresPassword, requiresPassword,
translations, translations,
alreadyPlayed,
forceAutoplay, forceAutoplay,
playlistTracker, playlist,
live, live,
storyboardsResponse, storyboardsResponse
authorizationHeader,
serverConfig
} = options } = options
const [ videoCaptions, storyboard ] = await Promise.all([ const [ videoCaptions, storyboard ] = await Promise.all([
@ -201,88 +237,56 @@ export class PlayerManagerOptions {
this.buildStoryboard(storyboardsResponse) this.buildStoryboard(storyboardsResponse)
]) ])
const playerOptions: PeertubePlayerManagerOptions = { return {
common: { mode: this.mode,
// Autoplay in playlist mode
autoplay: autoplayFromPreviousVideo ? true : this.autoplay, autoplay: forceAutoplay || alreadyPlayed || this.autoplay,
forceAutoplay, forceAutoplay,
controls: this.controls,
controlBar: this.controlBar,
muted: this.muted,
loop: this.loop,
p2pEnabled: this.p2pEnabled, p2pEnabled: this.p2pEnabled,
captions: videoCaptions.length !== 0,
subtitle: this.subtitle, subtitle: this.subtitle,
storyboard, storyboard,
startTime: playlistTracker startTime: playlist
? playlistTracker.getCurrentElement().startTimestamp ? playlist.playlistTracker.getCurrentElement().startTimestamp
: this.startTime, : this.startTime,
stopTime: playlistTracker stopTime: playlist
? playlistTracker.getCurrentElement().stopTimestamp ? playlist.playlistTracker.getCurrentElement().stopTimestamp
: this.stopTime, : this.stopTime,
playbackRate: this.playbackRate,
videoCaptions, videoCaptions,
inactivityTimeout: 2500,
videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid), videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
videoViewIntervalMs: 5000,
metricsUrl: window.location.origin + '/api/v1/metrics/playback',
videoShortUUID: video.shortUUID, videoShortUUID: video.shortUUID,
videoUUID: video.uuid, videoUUID: video.uuid,
playerElement: this.playerHTML.getPlayerElement(), duration: video.duration,
onPlayerElementChange: (element: HTMLVideoElement) => {
this.playerHTML.setPlayerElement(element)
},
videoDuration: video.duration,
enableHotkeys: true,
peertubeLink: this.peertubeLink,
instanceName: serverConfig.instance.name,
poster: window.location.origin + video.previewPath, poster: window.location.origin + video.previewPath,
theaterButton: false,
serverUrl: window.location.origin,
language: navigator.language,
embedUrl: window.location.origin + video.embedPath, embedUrl: window.location.origin + video.embedPath,
embedTitle: video.name, embedTitle: video.name,
requiresUserAuth: videoRequiresUserAuth(video), requiresUserAuth: videoRequiresUserAuth(video),
authorizationHeader,
videoFileToken, videoFileToken,
requiresPassword, requiresPassword,
videoPassword, videoPassword,
errorNotifier: () => {
// Empty, we don't have a notifier in the embed
},
...this.buildLiveOptions(video, live), ...this.buildLiveOptions(video, live),
...this.buildPlaylistOptions(options) ...this.buildPlaylistOptions(playlist),
},
webtorrent: { dock: this.buildDockOptions(video),
webVideo: {
videoFiles: video.files videoFiles: video.files
}, },
...this.buildP2PMediaLoaderOptions(video), hls: this.buildHLSOptions(video)
pluginsManager: this.peertubePlugin.getPluginsManager()
} }
return playerOptions
} }
private buildLiveOptions (video: VideoDetails, live: LiveVideo) { private buildLiveOptions (video: VideoDetails, live: LiveVideo) {
@ -308,15 +312,27 @@ export class PlayerManagerOptions {
} }
} }
private buildPlaylistOptions (options: { private buildPlaylistOptions (options?: {
playlistTracker?: PlaylistTracker playlistTracker: PlaylistTracker
playNextPlaylistVideo?: () => any playNext: () => any
playPreviousPlaylistVideo?: () => any playPrevious: () => any
onVideoUpdate?: (uuid: string) => any onVideoUpdate: (uuid: string) => any
}) { }) {
const { playlistTracker, playNextPlaylistVideo, playPreviousPlaylistVideo, onVideoUpdate } = options if (!options) {
return {
nextVideo: {
enabled: false,
displayControlBarButton: false,
getVideoTitle: () => ''
},
previousVideo: {
enabled: false,
displayControlBarButton: false
}
}
}
if (!playlistTracker) return {} const { playlistTracker, playNext, playPrevious, onVideoUpdate } = options
return { return {
playlist: { playlist: {
@ -332,27 +348,37 @@ export class PlayerManagerOptions {
} }
}, },
nextVideo: () => playNextPlaylistVideo(), previousVideo: {
hasNextVideo: () => playlistTracker.hasNextPlaylistElement(), enabled: playlistTracker.hasPreviousPlaylistElement(),
handler: () => playPrevious(),
displayControlBarButton: true
},
previousVideo: () => playPreviousPlaylistVideo(), nextVideo: {
hasPreviousVideo: () => playlistTracker.hasPreviousPlaylistElement() enabled: playlistTracker.hasNextPlaylistElement(),
handler: () => playNext(),
getVideoTitle: () => playlistTracker.getNextPlaylistElement()?.video?.name,
displayControlBarButton: true
},
upnext: {
isEnabled: () => true,
isSuspended: () => false,
timeout: 0
}
} }
} }
private buildP2PMediaLoaderOptions (video: VideoDetails) { private buildHLSOptions (video: VideoDetails): HLSOptions {
if (this.mode !== 'p2p-media-loader') return {}
const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
if (!hlsPlaylist) return undefined
return { return {
p2pMediaLoader: {
playlistUrl: hlsPlaylist.playlistUrl, playlistUrl: hlsPlaylist.playlistUrl,
segmentsSha256Url: hlsPlaylist.segmentsSha256Url, segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
trackerAnnounce: video.trackerUrls, trackerAnnounce: video.trackerUrls,
videoFiles: hlsPlaylist.files videoFiles: hlsPlaylist.files
} as P2PMediaLoaderOptions
} }
} }
@ -374,6 +400,35 @@ export class PlayerManagerOptions {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private buildDockOptions (videoInfo: VideoDetails) {
if (!this.hasControls()) return undefined
const title = this.hasTitle()
? videoInfo.name
: undefined
const description = this.hasWarningTitle() && this.hasP2PEnabled()
? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>'
: undefined
if (!title && !description) return
const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50)
const avatar = availableAvatars.length !== 0
? availableAvatars[0]
: undefined
return {
title,
description,
avatarUrl: title && avatar
? avatar.path
: undefined
}
}
// ---------------------------------------------------------------------------
private isP2PEnabled (config: HTMLServerConfig, video: Video) { private isP2PEnabled (config: HTMLServerConfig, video: Video) {
const userP2PEnabled = getBoolOrDefault( const userP2PEnabled = getBoolOrDefault(
peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED), peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED),

View File

@ -61,18 +61,9 @@
"fs": [ "fs": [
"src/shims/noop.ts" "src/shims/noop.ts"
], ],
"http": [
"src/shims/http.ts"
],
"https": [
"src/shims/https.ts"
],
"path": [ "path": [
"src/shims/path.ts" "src/shims/path.ts"
], ],
"stream": [
"src/shims/stream.ts"
],
"crypto": [ "crypto": [
"src/shims/noop.ts" "src/shims/noop.ts"
] ]

View File

@ -36,10 +36,7 @@ module.exports = function () {
fallback: { fallback: {
fs: [ path.resolve('src/shims/noop.ts') ], fs: [ path.resolve('src/shims/noop.ts') ],
http: [ path.resolve('src/shims/http.ts') ],
https: [ path.resolve('src/shims/https.ts') ],
path: [ path.resolve('src/shims/path.ts') ], path: [ path.resolve('src/shims/path.ts') ],
stream: [ path.resolve('src/shims/stream.ts') ],
crypto: [ path.resolve('src/shims/noop.ts') ] crypto: [ path.resolve('src/shims/noop.ts') ]
} }
}, },

File diff suppressed because it is too large Load Diff

View File

@ -72,7 +72,10 @@ const playerKeys = {
'Next video': 'Next video', 'Next video': 'Next video',
'This video is password protected': 'This video is password protected', 'This video is password protected': 'This video is password protected',
'You need a password to watch this video.': 'You need a password to watch this video.', 'You need a password to watch this video.': 'You need a password to watch this video.',
'Incorrect password, please enter a correct password': 'Incorrect password, please enter a correct password' 'Incorrect password, please enter a correct password': 'Incorrect password, please enter a correct password',
'Cancel': 'Cancel',
'Up Next': 'Up Next',
'Autoplay is suspended': 'Autoplay is suspended'
} }
Object.assign(playerKeys, videojs) Object.assign(playerKeys, videojs)

View File

@ -12,7 +12,7 @@ describe('Test video storyboards API validator', function () {
// --------------------------------------------------------------- // ---------------------------------------------------------------
before(async function () { before(async function () {
this.timeout(30000) this.timeout(120000)
server = await createSingleServer(1) server = await createSingleServer(1)
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])

View File

@ -1,7 +1,7 @@
import { VideoResolution } from '../videos' import { VideoResolution } from '../videos'
export interface PlaybackMetricCreate { export interface PlaybackMetricCreate {
playerMode: 'p2p-media-loader' | 'webtorrent' playerMode: 'p2p-media-loader' | 'webtorrent' | 'web-video' // FIXME: remove webtorrent player mode not used anymore in PeerTube v6
resolution?: VideoResolution resolution?: VideoResolution
fps?: number fps?: number

View File

@ -59,6 +59,10 @@ export const clientFilterHookObject = {
'filter:internal.video-watch.player.build-options.params': true, 'filter:internal.video-watch.player.build-options.params': true,
'filter:internal.video-watch.player.build-options.result': true, 'filter:internal.video-watch.player.build-options.result': true,
// Filter the options to load a new video in our player
'filter:internal.video-watch.player.load-options.params': true,
'filter:internal.video-watch.player.load-options.result': true,
// Filter our SVG icons content // Filter our SVG icons content
'filter:internal.common.svg-icons.get-content.params': true, 'filter:internal.common.svg-icons.get-content.params': true,
'filter:internal.common.svg-icons.get-content.result': true, 'filter:internal.common.svg-icons.get-content.result': true,