Merge branch 'feature/remove-webtorrent' into develop

This commit is contained in:
Chocobozzz 2023-07-10 16:13:22 +02:00
commit 63e2f087c3
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
89 changed files with 2596 additions and 3899 deletions

View File

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

View File

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

View File

@ -8,7 +8,7 @@
</div>
<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>
<my-video-watch-playlist

View File

@ -1,6 +1,5 @@
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
import { VideoJsPlayer } from 'video.js'
import { PlatformLocation } from '@angular/common'
import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
@ -19,13 +18,13 @@ import {
UserService
} from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { isXPercentInViewport, scrollToTop } from '@app/helpers'
import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers'
import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
import { LiveVideoService } from '@app/shared/shared-video-live'
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { logger } from '@root-helpers/logger'
import { isP2PEnabled, videoRequiresUserAuth, videoRequiresFileToken } from '@root-helpers/video'
import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video'
import { timeToInt } from '@shared/core-utils'
import {
HTMLServerConfig,
@ -39,10 +38,10 @@ import {
VideoState
} from '@shared/models'
import {
CustomizationOptions,
P2PMediaLoaderOptions,
PeertubePlayerManager,
PeertubePlayerManagerOptions,
HLSOptions,
PeerTubePlayer,
PeerTubePlayerContructorOptions,
PeerTubePlayerLoadOptions,
PlayerMode,
videojs
} from '../../../assets/player'
@ -50,7 +49,24 @@ import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from
import { environment } from '../../../environments/environment'
import { VideoWatchPlaylistComponent } from './shared'
type URLOptions = CustomizationOptions & { playerMode: PlayerMode }
type URLOptions = {
playerMode: PlayerMode
startTime: number | string
stopTime: number | string
controls?: boolean
controlBar?: boolean
muted?: boolean
loop?: boolean
subtitle?: string
resume?: string
peertubeLink: boolean
playbackRate?: number | string
}
@Component({
selector: 'my-video-watch',
@ -60,10 +76,9 @@ type URLOptions = CustomizationOptions & { playerMode: PlayerMode }
export class VideoWatchComponent implements OnInit, OnDestroy {
@ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent
@ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
@ViewChild('playerElement') playerElement: ElementRef<HTMLVideoElement>
player: VideoJsPlayer
playerElement: HTMLVideoElement
playerPlaceholderImgSrc: string
peertubePlayer: PeerTubePlayer
theaterEnabled = false
video: VideoDetails = null
@ -78,8 +93,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
remoteServerDown = false
noPlaylistVideoFound = false
private nextVideoUUID = ''
private nextVideoTitle = ''
private nextRecommendedVideoUUID = ''
private nextRecommendedVideoTitle = ''
private videoFileToken: string
@ -130,11 +145,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return this.userService.getAnonymousUser()
}
ngOnInit () {
async ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
PeertubePlayerManager.initState()
this.loadRouteParams()
this.loadRouteQuery()
@ -143,10 +156,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.hooks.runAction('action:video-watch.init', 'video-watch')
setTimeout(cleanupVideoWatch, 1500) // Run in timeout to ensure we're not blocking the UI
const constructorOptions = await this.hooks.wrapFun(
this.buildPeerTubePlayerConstructorOptions.bind(this),
{ urlOptions: this.getUrlOptions() },
'video-watch',
'filter:internal.video-watch.player.build-options.params',
'filter:internal.video-watch.player.build-options.result'
)
this.peertubePlayer = new PeerTubePlayer(constructorOptions)
}
ngOnDestroy () {
this.flushPlayer()
if (this.peertubePlayer) this.peertubePlayer.destroy()
// Unsubscribe subscriptions
if (this.paramsSub) this.paramsSub.unsubscribe()
@ -171,14 +194,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
// The recommended videos's first element should be the next video
const video = videos[0]
this.nextVideoUUID = video.uuid
this.nextVideoTitle = video.name
this.nextRecommendedVideoUUID = video.uuid
this.nextRecommendedVideoTitle = video.name
}
handleTimestampClicked (timestamp: number) {
if (!this.player || this.video.isLive) return
if (!this.peertubePlayer || this.video.isLive) return
this.player.currentTime(timestamp)
this.peertubePlayer.getPlayer().currentTime(timestamp)
scrollToTop()
}
@ -243,7 +266,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition)
const start = queryParams['start']
if (this.player && start) this.player.currentTime(parseInt(start, 10))
if (this.peertubePlayer && start) this.peertubePlayer.getPlayer().currentTime(parseInt(start, 10))
})
}
@ -256,8 +279,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
if (this.isSameElement(this.video, videoId)) return
if (this.player) this.player.pause()
this.video = undefined
const videoObs = this.hooks.wrapObsFun(
@ -291,23 +312,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.userService.getAnonymousOrLoggedUser()
]).subscribe({
next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => {
const queryParams = this.route.snapshot.queryParams
const urlOptions = {
resume: queryParams.resume,
startTime: queryParams.start,
stopTime: queryParams.stop,
muted: queryParams.muted,
loop: queryParams.loop,
subtitle: queryParams.subtitle,
playerMode: queryParams.mode,
playbackRate: queryParams.playbackRate,
peertubeLink: false
}
this.onVideoFetched({
video,
live,
@ -316,7 +320,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoFileToken,
videoPassword,
loggedInOrAnonymousUser,
urlOptions,
forceAutoplay
}).catch(err => {
this.handleGlobalError(err)
@ -386,14 +389,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
const errorMessage: string = typeof err === 'string' ? err : err.message
if (!errorMessage) return
// Display a message in the video player instead of a notification
if (errorMessage.includes('from xs param')) {
this.flushPlayer()
this.remoteServerDown = true
return
}
this.notifier.error(errorMessage)
}
@ -422,7 +417,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoFileToken: string
videoPassword: string
urlOptions: URLOptions
loggedInOrAnonymousUser: User
forceAutoplay: boolean
}) {
@ -431,7 +425,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
live,
videoCaptions,
storyboards,
urlOptions,
videoFileToken,
videoPassword,
loggedInOrAnonymousUser,
@ -448,7 +441,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.storyboards = storyboards
// Re init attributes
this.playerPlaceholderImgSrc = undefined
this.remoteServerDown = false
this.currentTime = undefined
@ -462,7 +454,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.buildHotkeysHelp(video)
this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay })
this.loadPlayer({ loggedInOrAnonymousUser, forceAutoplay })
.catch(err => logger.error('Cannot build the player', err))
this.setOpenGraphTags()
@ -475,28 +467,19 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions)
}
private async buildPlayer (options: {
urlOptions: URLOptions
private async loadPlayer (options: {
loggedInOrAnonymousUser: User
forceAutoplay: boolean
}) {
const { urlOptions, loggedInOrAnonymousUser, forceAutoplay } = options
// Flush old player if needed
this.flushPlayer()
const { loggedInOrAnonymousUser, forceAutoplay } = options
const videoState = this.video.state.id
if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) {
this.playerPlaceholderImgSrc = this.video.previewPath
this.updatePlayerOnNoLive()
return
}
// Build video element, because videojs removes it on dispose
const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
this.playerElement = document.createElement('video')
this.playerElement.className = 'video-js vjs-peertube-skin'
this.playerElement.setAttribute('playsinline', 'true')
playerElementWrapper.appendChild(this.playerElement)
this.peertubePlayer?.enable()
const params = {
video: this.video,
@ -505,86 +488,49 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
liveVideo: this.liveVideo,
videoFileToken: this.videoFileToken,
videoPassword: this.videoPassword,
urlOptions,
urlOptions: this.getUrlOptions(),
loggedInOrAnonymousUser,
forceAutoplay,
user: this.user
}
const { playerMode, playerOptions } = await this.hooks.wrapFun(
this.buildPlayerManagerOptions.bind(this),
const loadOptions = await this.hooks.wrapFun(
this.buildPeerTubePlayerLoadOptions.bind(this),
params,
'video-watch',
'filter:internal.video-watch.player.build-options.params',
'filter:internal.video-watch.player.build-options.result'
'filter:internal.video-watch.player.load-options.params',
'filter:internal.video-watch.player.load-options.result'
)
this.zone.runOutsideAngular(async () => {
this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player)
await this.peertubePlayer.load(loadOptions)
this.player.on('customError', (_e, data: any) => {
this.zone.run(() => this.handleGlobalError(data.err))
})
const player = this.peertubePlayer.getPlayer()
this.player.on('timeupdate', () => {
player.on('timeupdate', () => {
// Don't need to trigger angular change for this variable, that is sent to children components on click
this.currentTime = Math.floor(this.player.currentTime())
this.currentTime = Math.floor(player.currentTime())
})
/**
* condition: true to make the upnext functionality trigger, false to disable the upnext functionality
* go to the next video in 'condition()' if you don't want of the timer.
* next: function triggered at the end of the timer.
* suspended: function used at each click of the timer checking if we need to reset progress
* and wait until suspended becomes truthy again.
*/
this.player.upnext({
timeout: 5000, // 5s
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) {
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.hooks.runAction('action:video-watch.player.loaded', 'video-watch', {
player: this.player,
player,
playlist: this.playlist,
playlistPosition: this.playlistPosition,
videojs,
@ -601,15 +547,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return true
}
private playNextVideoInAngularZone () {
private getNextVideoTitle () {
if (this.playlist) {
this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
return this.videoWatchPlaylist.getNextVideo()?.video?.name || ''
}
return this.nextRecommendedVideoTitle
}
private playNextVideoInAngularZone () {
this.zone.run(() => {
if (this.playlist) {
this.videoWatchPlaylist.navigateToNextPlaylistVideo()
return
}
if (this.nextVideoUUID) {
this.router.navigate([ '/w', this.nextVideoUUID ])
if (this.nextRecommendedVideoUUID) {
this.router.navigate([ '/w', this.nextRecommendedVideoUUID ])
}
})
}
private isAutoplay () {
@ -637,19 +593,45 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
)
}
private flushPlayer () {
// Remove player if it exists
if (!this.player) return
private buildPeerTubePlayerConstructorOptions (options: {
urlOptions: URLOptions
}): PeerTubePlayerContructorOptions {
const { urlOptions } = options
try {
this.player.dispose()
this.player = undefined
} catch (err) {
logger.error('Cannot dispose player.', err)
return {
playerElement: () => this.playerElement.nativeElement,
enableHotkeys: true,
inactivityTimeout: 2500,
theaterButton: true,
controls: urlOptions.controls,
controlBar: urlOptions.controlBar,
muted: urlOptions.muted,
loop: urlOptions.loop,
playbackRate: urlOptions.playbackRate,
instanceName: this.serverConfig.instance.name,
language: this.localeId,
metricsUrl: environment.apiUrl + '/api/v1/metrics/playback',
videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS,
authorizationHeader: () => this.authService.getRequestHeaderValue(),
serverUrl: environment.originServerUrl || window.location.origin,
errorNotifier: (message: string) => this.notifier.error(message),
peertubeLink: () => false,
pluginsManager: this.pluginService.getPluginsManager()
}
}
private buildPlayerManagerOptions (params: {
private buildPeerTubePlayerLoadOptions (options: {
video: VideoDetails
liveVideo: LiveVideo
videoCaptions: VideoCaption[]
@ -658,12 +640,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoFileToken: string
videoPassword: string
urlOptions: CustomizationOptions & { playerMode: PlayerMode }
urlOptions: URLOptions
loggedInOrAnonymousUser: User
forceAutoplay: boolean
user?: AuthUser // Keep for plugins
}) {
}): PeerTubePlayerLoadOptions {
const {
video,
liveVideo,
@ -674,7 +656,30 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
urlOptions,
loggedInOrAnonymousUser,
forceAutoplay
} = params
} = options
let mode: PlayerMode
if (urlOptions.playerMode) {
if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
else mode = 'web-video'
} else {
if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
else mode = 'web-video'
}
let hlsOptions: HLSOptions
if (video.hasHlsPlaylist()) {
const hlsPlaylist = video.getHlsPlaylist()
hlsOptions = {
playlistUrl: hlsPlaylist.playlistUrl,
segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
trackerAnnounce: video.trackerUrls,
videoFiles: hlsPlaylist.files
}
}
const getStartTime = () => {
const byUrl = urlOptions.startTime !== undefined
@ -714,55 +719,28 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
? { latencyMode: liveVideo.latencyMode }
: undefined
const options: PeertubePlayerManagerOptions = {
common: {
return {
mode,
autoplay: this.isAutoplay(),
forceAutoplay,
p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
hasNextVideo: () => this.hasNextVideo(),
nextVideo: () => this.playNextVideoInAngularZone(),
playerElement: this.playerElement,
onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
videoDuration: video.duration,
enableHotkeys: true,
inactivityTimeout: 2500,
duration: this.video.duration,
poster: video.previewUrl,
p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
startTime,
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,
embedTitle: video.name,
instanceName: this.serverConfig.instance.name,
isLive: video.isLive,
liveOptions,
language: this.localeId,
metricsUrl: environment.apiUrl + '/api/v1/metrics/playback',
videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
? this.videoService.getVideoViewUrl(video.uuid)
: null,
videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS,
authorizationHeader: () => this.authService.getRequestHeaderValue(),
serverUrl: environment.originServerUrl || window.location.origin,
videoFileToken: () => videoFileToken,
requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
@ -776,56 +754,45 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoShortUUID: video.shortUUID,
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
},
pluginsManager: this.pluginService.getPluginsManager()
}
// Only set this if we're in a playlist
if (this.playlist) {
options.common.hasPreviousVideo = () => this.videoWatchPlaylist.hasPreviousVideo()
options.common.previousVideo = () => {
this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo())
}
}
let mode: PlayerMode
if (urlOptions.playerMode) {
if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
else mode = 'webtorrent'
} else {
if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
else mode = 'webtorrent'
}
// FIXME: remove, we don't support these old web browsers anymore
// p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available
if (typeof TextEncoder === 'undefined') {
mode = 'webtorrent'
}
if (mode === 'p2p-media-loader') {
const hlsPlaylist = video.getHlsPlaylist()
const p2pMediaLoader = {
playlistUrl: hlsPlaylist.playlistUrl,
segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
trackerAnnounce: video.trackerUrls,
videoFiles: hlsPlaylist.files
} as P2PMediaLoaderOptions
Object.assign(options, { p2pMediaLoader })
}
return { playerMode: mode, playerOptions: options }
}
private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) {
@ -873,6 +840,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.video.viewers = newViewers
}
private updatePlayerOnNoLive () {
this.peertubePlayer.unload()
this.peertubePlayer.disable()
this.peertubePlayer.setPoster(this.video.previewPath)
}
private buildHotkeysHelp (video: Video) {
if (this.hotkeys.length !== 0) {
this.hotkeysService.remove(this.hotkeys)
@ -944,4 +917,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.metaService.setTag('og:url', window.location.href)
this.metaService.setTag('url', window.location.href)
}
private getUrlOptions (): URLOptions {
const queryParams = this.route.snapshot.queryParams
return {
resume: queryParams.resume,
startTime: queryParams.start,
stopTime: queryParams.stop,
muted: toBoolean(queryParams.muted),
loop: toBoolean(queryParams.loop),
subtitle: queryParams.subtitle,
playerMode: queryParams.mode,
playbackRate: queryParams.playbackRate,
controlBar: toBoolean(queryParams.controlBar),
peertubeLink: false
}
}
}

View File

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

View File

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

View File

@ -1,2 +1,2 @@
export * from './peertube-player-manager'
export * from './peertube-player'
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 './pause-bezel'
import { PauseBezel } from './pause-bezel'
const Plugin = videojs.getPlugin('plugin')
@ -12,7 +12,7 @@ class BezelsPlugin extends Plugin {
player.addClass('vjs-bezels')
})
player.addChild('PauseBezel', options)
player.addChild(new PauseBezel(player, options))
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,37 +3,58 @@ import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
import { PeerTubeLinkButtonOptions } from '../../types'
const Component = videojs.getComponent('Component')
class PeerTubeLinkButton extends Component {
constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) {
super(player, options as any)
class PeerTubeLinkButton extends Component {
private mouseEnterHandler: () => void
private clickHandler: () => void
options_: PeerTubeLinkButtonOptions & videojs.ComponentOptions
constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions & videojs.ComponentOptions) {
super(player, options)
this.updateShowing()
this.player().on('video-change', () => this.updateShowing())
}
dispose () {
if (this.el()) return
this.el().removeEventListener('mouseenter', this.mouseEnterHandler)
this.el().removeEventListener('click', this.clickHandler)
super.dispose()
}
createEl () {
return this.buildElement()
const el = videojs.dom.createEl('a', {
href: this.buildLink(),
innerHTML: this.options_.instanceName,
title: this.player().localize('Video page (new window)'),
className: 'vjs-peertube-link',
target: '_blank'
})
this.mouseEnterHandler = () => this.updateHref()
this.clickHandler = () => this.player().pause()
el.addEventListener('mouseenter', this.mouseEnterHandler)
el.addEventListener('click', this.clickHandler)
return el
}
updateShowing () {
if (this.options_.isDisplayed()) this.show()
else this.hide()
}
updateHref () {
this.el().setAttribute('href', this.buildLink())
}
private buildElement () {
const el = videojs.dom.createEl('a', {
href: this.buildLink(),
innerHTML: (this.options_ as PeerTubeLinkButtonOptions).instanceName,
title: this.player().localize('Video page (new window)'),
className: 'vjs-peertube-link',
target: '_blank'
})
el.addEventListener('mouseenter', () => this.updateHref())
el.addEventListener('click', () => this.player().pause())
return el as HTMLButtonElement
}
private buildLink () {
const url = buildVideoLink({ shortUUID: (this.options_ as PeerTubeLinkButtonOptions).shortUUID })
const url = buildVideoLink({ shortUUID: this.options_.shortUUID() })
return decorateVideoLink({ url, startTime: this.player().currentTime() })
}

View File

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

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

View File

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

View File

@ -10,17 +10,20 @@ export type PeerTubeDockComponentOptions = {
class PeerTubeDockComponent extends Component {
options_: videojs.ComponentOptions & PeerTubeDockComponentOptions
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor (player: videojs.Player, options: videojs.ComponentOptions & PeerTubeDockComponentOptions) {
super(player, options)
}
createEl () {
const options = this.options_ as PeerTubeDockComponentOptions
const el = super.createEl('div', { className: 'peertube-dock' })
const el = super.createEl('div', {
className: 'peertube-dock'
})
if (options.avatarUrl) {
if (this.options_.avatarUrl) {
const avatar = videojs.dom.createEl('img', {
className: 'peertube-dock-avatar',
src: options.avatarUrl
src: this.options_.avatarUrl
})
el.appendChild(avatar)
@ -30,27 +33,27 @@ class PeerTubeDockComponent extends Component {
className: 'peertube-dock-title-description'
})
if (options.title) {
if (this.options_.title) {
const title = videojs.dom.createEl('div', {
className: 'peertube-dock-title',
title: options.title,
innerHTML: options.title
title: this.options_.title,
innerHTML: this.options_.title
})
elWrapperTitleDescription.appendChild(title)
}
if (options.description) {
if (this.options_.description) {
const description = videojs.dom.createEl('div', {
className: 'peertube-dock-description',
title: options.description,
innerHTML: options.description
title: this.options_.description,
innerHTML: this.options_.description
})
elWrapperTitleDescription.appendChild(description)
}
if (options.title || options.description) {
if (this.options_.title || this.options_.description) {
el.appendChild(elWrapperTitleDescription)
}

View File

@ -10,14 +10,25 @@ export type PeerTubeDockPluginOptions = {
}
class PeerTubeDockPlugin extends Plugin {
private dockComponent: PeerTubeDockComponent
constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) {
super(player, options)
this.player.addClass('peertube-dock')
this.player.ready(() => {
this.player.addChild('PeerTubeDockComponent', options) as PeerTubeDockComponent
player.ready(() => {
player.addClass('peertube-dock')
})
this.dockComponent = new PeerTubeDockComponent(player, options)
player.addChild(this.dockComponent)
}
dispose () {
this.dockComponent?.dispose()
this.player.removeChild(this.dockComponent)
this.player.removeClass('peertube-dock')
super.dispose()
}
}

View File

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

View File

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

View File

@ -21,6 +21,15 @@ class PeerTubeMobilePlugin extends Plugin {
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) {
super(player, options)
@ -36,18 +45,38 @@ class PeerTubeMobilePlugin extends Plugin {
(this.player.options_.userActions as any).click = false
this.player.options_.userActions.doubleClick = false
this.player.one('play', () => {
this.initTouchStartEvents()
})
this.onPlayHandler = () => this.initTouchStartEvents()
this.player.one('play', this.onPlayHandler)
this.seekBar = this.player.getDescendant([ 'controlBar', 'progressControl', 'seekBar' ])
this.sliderActiveHandler = () => this.player.addClass('vjs-mobile-sliding')
this.sliderInactiveHandler = () => this.player.removeClass('vjs-mobile-sliding')
this.seekBar.on('slideractive', this.sliderActiveHandler)
this.seekBar.on('sliderinactive', this.sliderInactiveHandler)
}
dispose () {
if (this.onPlayHandler) this.player.off('play', this.onPlayHandler)
if (this.onFullScreenChangeHandler) this.player.off('fullscreenchange', this.onFullScreenChangeHandler)
if (this.onTouchStartHandler) this.player.off('touchstart', this.onFullScreenChangeHandler)
if (this.onMobileButtonTouchStartHandler) {
this.peerTubeMobileButtons?.el().removeEventListener('touchstart', this.onMobileButtonTouchStartHandler)
}
super.dispose()
}
private handleFullscreenRotation () {
this.player.on('fullscreenchange', () => {
this.onFullScreenChangeHandler = () => {
if (!this.player.isFullscreen() || this.isPortraitVideo()) return
screen.orientation.lock('landscape')
.catch(err => logger.error('Cannot lock screen to landscape.', err))
})
}
this.player.on('fullscreenchange', this.onFullScreenChangeHandler)
}
private isPortraitVideo () {
@ -80,19 +109,22 @@ class PeerTubeMobilePlugin extends Plugin {
this.lastTapEvent = event
}
this.player.on('touchstart', (event: TouchEvent) => {
this.onTouchStartHandler = event => {
// Only enable user active on player touch, we listen event on peertube mobile buttons to disable it
if (this.player.userActive()) return
handleTouchStart(event)
})
}
this.player.on('touchstart', this.onTouchStartHandler)
this.peerTubeMobileButtons.el().addEventListener('touchstart', (event: TouchEvent) => {
this.onMobileButtonTouchStartHandler = event => {
// Prevent mousemove/click events firing on the player, that conflict with our user active logic
event.preventDefault()
handleTouchStart(event)
}, { passive: false })
}
this.peerTubeMobileButtons.el().addEventListener('touchstart', this.onMobileButtonTouchStartHandler, { passive: false })
}
private onDoubleTap (event: TouchEvent) {

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import debug from 'debug'
import videojs from 'video.js'
import { logger } from '@root-helpers/logger'
import { isMobile } from '@root-helpers/web-browser'
import { isIOS, isMobile } from '@root-helpers/web-browser'
import { timeToInt } from '@shared/core-utils'
import { VideoView, VideoViewEvent } from '@shared/models/videos'
import {
@ -13,7 +13,7 @@ import {
saveVideoWatchHistory,
saveVolumeInStore
} from '../../peertube-player-local-storage'
import { PeerTubePluginOptions, VideoJSCaption } from '../../types'
import { PeerTubePluginOptions } from '../../types'
import { SettingsButton } from '../settings/settings-menu-button'
const debugLogger = debug('peertube:player:peertube')
@ -21,43 +21,59 @@ const debugLogger = debug('peertube:player:peertube')
const Plugin = videojs.getPlugin('plugin')
class PeerTubePlugin extends Plugin {
private readonly videoViewUrl: string
private readonly videoViewUrl: () => string
private readonly authorizationHeader: () => string
private readonly initialInactivityTimeout: number
private readonly videoUUID: string
private readonly startTime: number
private readonly hasAutoplay: () => videojs.Autoplay
private readonly videoViewIntervalMs: number
private videoCaptions: VideoJSCaption[]
private defaultSubtitle: string
private currentSubtitle: string
private currentPlaybackRate: number
private videoViewInterval: any
private menuOpened = false
private mouseInControlBar = false
private mouseInSettings = false
private readonly initialInactivityTimeout: number
constructor (player: videojs.Player, options?: PeerTubePluginOptions) {
private videoViewOnPlayHandler: (...args: any[]) => void
private videoViewOnSeekedHandler: (...args: any[]) => void
private videoViewOnEndedHandler: (...args: any[]) => void
private stopTimeHandler: (...args: any[]) => void
constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) {
super(player)
this.videoViewUrl = options.videoViewUrl
this.authorizationHeader = options.authorizationHeader
this.videoUUID = options.videoUUID
this.startTime = timeToInt(options.startTime)
this.videoViewIntervalMs = options.videoViewIntervalMs
this.hasAutoplay = options.hasAutoplay
this.videoCaptions = options.videoCaptions
this.initialInactivityTimeout = this.player.options_.inactivityTimeout
if (options.autoplay !== false) this.player.addClass('vjs-has-autoplay')
this.currentSubtitle = this.options.subtitle() || getStoredLastSubtitle()
this.initializePlayer()
this.initOnVideoChange()
this.deleteLegacyIndexedDB()
this.player.on('autoplay-failure', () => {
debugLogger('Autoplay failed')
this.player.removeClass('vjs-has-autoplay')
// Fix a bug on iOS where the big play button is not displayed when autoplay fails
if (isIOS()) this.player.hasStarted(false)
})
this.player.ready(() => {
this.player.on('ratechange', () => {
this.currentPlaybackRate = this.player.playbackRate()
this.player.defaultPlaybackRate(this.currentPlaybackRate)
})
this.player.one('canplay', () => {
const playerOptions = this.player.options_
const volume = getStoredVolume()
@ -65,28 +81,15 @@ class PeerTubePlugin extends Plugin {
const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
if (muted !== undefined) this.player.muted(muted)
})
this.defaultSubtitle = options.subtitle || getStoredLastSubtitle()
this.player.ready(() => {
this.player.on('volumechange', () => {
saveVolumeInStore(this.player.volume())
saveMuteInStore(this.player.muted())
})
if (options.stopTime) {
const stopTime = timeToInt(options.stopTime)
const self = this
this.player.on('timeupdate', function onTimeUpdate () {
if (self.player.currentTime() > stopTime) {
self.player.pause()
self.player.trigger('stopped')
self.player.off('timeupdate', onTimeUpdate)
}
})
}
this.player.textTracks().addEventListener('change', () => {
const showing = this.player.textTracks().tracks_.find(t => {
return t.kind === 'captions' && t.mode === 'showing'
@ -94,23 +97,24 @@ class PeerTubePlugin extends Plugin {
if (!showing) {
saveLastSubtitle('off')
this.currentSubtitle = undefined
return
}
this.currentSubtitle = showing.language
saveLastSubtitle(showing.language)
})
this.player.on('sourcechange', () => this.initCaptions())
this.player.duration(options.videoDuration)
this.initializePlayer()
this.runUserViewing()
this.player.on('video-change', () => {
this.initOnVideoChange()
})
})
}
dispose () {
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
super.dispose()
}
onMenuOpened () {
@ -162,40 +166,70 @@ class PeerTubePlugin extends Plugin {
this.initSmoothProgressBar()
this.initCaptions()
this.player.ready(() => {
this.listenControlBarMouse()
})
this.listenFullScreenChange()
}
private initOnVideoChange () {
if (this.hasAutoplay() !== false) this.player.addClass('vjs-has-autoplay')
else this.player.removeClass('vjs-has-autoplay')
if (this.currentPlaybackRate && this.currentPlaybackRate !== 1) {
debugLogger('Setting playback rate to ' + this.currentPlaybackRate)
this.player.playbackRate(this.currentPlaybackRate)
}
this.player.ready(() => {
this.initCaptions()
this.updateControlBar()
})
this.handleStartStopTime()
this.runUserViewing()
}
// ---------------------------------------------------------------------------
private runUserViewing () {
let lastCurrentTime = this.startTime
const startTime = timeToInt(this.options.startTime())
let lastCurrentTime = startTime
let lastViewEvent: VideoViewEvent
this.player.one('play', () => {
this.notifyUserIsWatching(this.startTime, lastViewEvent)
})
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
if (this.videoViewOnPlayHandler) this.player.off('play', this.videoViewOnPlayHandler)
if (this.videoViewOnSeekedHandler) this.player.off('seeked', this.videoViewOnSeekedHandler)
if (this.videoViewOnEndedHandler) this.player.off('ended', this.videoViewOnEndedHandler)
this.player.on('seeked', () => {
this.videoViewOnPlayHandler = () => {
this.notifyUserIsWatching(startTime, lastViewEvent)
}
this.videoViewOnSeekedHandler = () => {
const diff = Math.floor(this.player.currentTime()) - lastCurrentTime
// Don't take into account small forwards
if (diff > 0 && diff < 3) return
lastViewEvent = 'seek'
})
}
this.player.one('ended', () => {
this.videoViewOnEndedHandler = () => {
const currentTime = Math.floor(this.player.duration())
lastCurrentTime = currentTime
this.notifyUserIsWatching(currentTime, lastViewEvent)
lastViewEvent = undefined
})
}
this.player.one('play', this.videoViewOnPlayHandler)
this.player.on('seeked', this.videoViewOnSeekedHandler)
this.player.one('ended', this.videoViewOnEndedHandler)
this.videoViewInterval = setInterval(() => {
const currentTime = Math.floor(this.player.currentTime())
@ -209,13 +243,13 @@ class PeerTubePlugin extends Plugin {
.catch(err => logger.error('Cannot notify user is watching.', err))
lastViewEvent = undefined
}, this.videoViewIntervalMs)
}, this.options.videoViewIntervalMs)
}
private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) {
// Server won't save history, so save the video position in local storage
if (!this.authorizationHeader()) {
saveVideoWatchHistory(this.videoUUID, currentTime)
saveVideoWatchHistory(this.options.videoUUID(), currentTime)
}
if (!this.videoViewUrl) return Promise.resolve(true)
@ -225,7 +259,7 @@ class PeerTubePlugin extends Plugin {
const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())
return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers })
return fetch(this.videoViewUrl(), { method: 'POST', body: JSON.stringify(body), headers })
}
// ---------------------------------------------------------------------------
@ -279,18 +313,89 @@ class PeerTubePlugin extends Plugin {
}
private initCaptions () {
for (const caption of this.videoCaptions) {
debugLogger('Init captions with current subtitle ' + this.currentSubtitle)
this.player.tech(true).clearTracks('text')
for (const caption of this.options.videoCaptions()) {
this.player.addRemoteTextTrack({
kind: 'captions',
label: caption.label,
language: caption.language,
id: caption.language,
src: caption.src,
default: this.defaultSubtitle === caption.language
}, false)
default: this.currentSubtitle === caption.language
}, true)
}
this.player.trigger('captionsChanged')
this.player.trigger('captions-changed')
}
private updateControlBar () {
debugLogger('Updating control bar')
if (this.options.isLive()) {
this.getPlaybackRateButton().hide()
this.player.controlBar.getChild('progressControl').hide()
this.player.controlBar.getChild('currentTimeDisplay').hide()
this.player.controlBar.getChild('timeDivider').hide()
this.player.controlBar.getChild('durationDisplay').hide()
this.player.controlBar.getChild('peerTubeLiveDisplay').show()
} else {
this.getPlaybackRateButton().show()
this.player.controlBar.getChild('progressControl').show()
this.player.controlBar.getChild('currentTimeDisplay').show()
this.player.controlBar.getChild('timeDivider').show()
this.player.controlBar.getChild('durationDisplay').show()
this.player.controlBar.getChild('peerTubeLiveDisplay').hide()
}
if (this.options.videoCaptions().length === 0) {
this.getCaptionsButton().hide()
} else {
this.getCaptionsButton().show()
}
}
private handleStartStopTime () {
this.player.duration(this.options.videoDuration())
if (this.stopTimeHandler) {
this.player.off('timeupdate', this.stopTimeHandler)
this.stopTimeHandler = undefined
}
// Prefer canplaythrough instead of canplay because Chrome has issues with the second one
this.player.one('canplaythrough', () => {
if (this.options.startTime()) {
debugLogger('Start the video at ' + this.options.startTime())
this.player.currentTime(timeToInt(this.options.startTime()))
}
if (this.options.stopTime()) {
const stopTime = timeToInt(this.options.stopTime())
this.stopTimeHandler = () => {
if (this.player.currentTime() <= stopTime) return
debugLogger('Stopping the video at ' + this.options.stopTime())
// Time top stop
this.player.pause()
this.player.trigger('auto-stopped')
this.player.off('timeupdate', this.stopTimeHandler)
this.stopTimeHandler = undefined
}
this.player.on('timeupdate', this.stopTimeHandler)
}
})
}
// Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
@ -314,6 +419,37 @@ class PeerTubePlugin extends Plugin {
this.update()
}
}
private getCaptionsButton () {
const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
return settingsButton.menu.getChild('captionsButton') as videojs.CaptionsButton
}
private getPlaybackRateButton () {
const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
return settingsButton.menu.getChild('playbackRateMenuButton')
}
// We don't use webtorrent anymore, so we can safely remove old chunks from IndexedDB
private deleteLegacyIndexedDB () {
try {
if (typeof window.indexedDB === 'undefined') return
if (!window.indexedDB) return
if (typeof window.indexedDB.databases !== 'function') return
window.indexedDB.databases()
.then(databases => {
for (const db of databases) {
window.indexedDB.deleteDatabase(db.name)
}
})
} catch (err) {
debugLogger('Cannot delete legacy indexed DB', err)
// Nothing to do
}
}
}
videojs.registerPlugin('peertube', PeerTubePlugin)

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

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 wrapper: HTMLElement
constructor (player: videojs.Player, options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu }) {
super(player, options as any)
options_: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions
// FIXME: eslint -> it's not a useless constructor, we need to extend constructor options typings
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor (
player: videojs.Player,
options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions
) {
super(player, options)
}
createEl () {
@ -40,20 +47,15 @@ class PlaylistButton extends ClickableComponent {
}
update () {
const options = this.options_ as PlaylistPluginOptions
this.playlistInfoElement.innerHTML = this.options_.getCurrentPosition() + '/' + this.options_.playlist.videosLength
this.playlistInfoElement.innerHTML = options.getCurrentPosition() + '/' + options.playlist.videosLength
this.wrapper.title = this.player().localize('Playlist: {1}', [ options.playlist.displayName ])
this.wrapper.title = this.player().localize('Playlist: {1}', [ this.options_.playlist.displayName ])
}
handleClick () {
const playlistMenu = this.getPlaylistMenu()
const playlistMenu = this.options_.playlistMenu
playlistMenu.open()
}
private getPlaylistMenu () {
return (this.options_ as any).playlistMenu as PlaylistMenu
}
}
videojs.registerComponent('PlaylistButton', PlaylistButton)

View File

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

View File

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

View File

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

View File

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

View File

@ -11,12 +11,12 @@ class ResolutionMenuButton extends MenuButton {
this.controlText('Quality')
player.peertubeResolutions().on('resolutionsAdded', () => this.buildQualities())
player.peertubeResolutions().on('resolutionRemoved', () => this.cleanupQualities())
player.peertubeResolutions().on('resolutions-added', () => this.update())
player.peertubeResolutions().on('resolutions-removed', () => this.update())
// For parent
player.peertubeResolutions().on('resolutionChanged', () => {
setTimeout(() => this.trigger('labelUpdated'))
player.peertubeResolutions().on('resolutions-changed', () => {
setTimeout(() => this.trigger('label-updated'))
})
}
@ -37,7 +37,34 @@ class ResolutionMenuButton extends MenuButton {
}
createMenu () {
return new Menu(this.player_)
const menu: videojs.Menu = new Menu(this.player_, { menuButton: this })
const resolutions = this.player().peertubeResolutions().getResolutions()
for (const r of resolutions) {
const label = r.label === '0p'
? this.player().localize('Audio-only')
: r.label
const component = new ResolutionMenuItem(
this.player_,
{
id: r.id + '',
resolutionId: r.id,
label,
selected: r.selected
}
)
menu.addItem(component)
}
return menu
}
update () {
super.update()
this.trigger('menu-changed')
}
buildCSSClass () {
@ -47,60 +74,6 @@ class ResolutionMenuButton extends MenuButton {
buildWrapperCSSClass () {
return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
}
private addClickListener (component: any) {
component.on('click', () => {
const children = this.menu.children()
for (const child of children) {
if (component !== child) {
(child as videojs.MenuItem).selected(false)
}
}
})
}
private buildQualities () {
for (const d of this.player().peertubeResolutions().getResolutions()) {
const label = d.label === '0p'
? this.player().localize('Audio-only')
: d.label
this.menu.addChild(new ResolutionMenuItem(
this.player_,
{
id: d.id + '',
resolutionId: d.id,
label,
selected: d.selected
})
)
}
for (const m of this.menu.children()) {
this.addClickListener(m)
}
this.trigger('menuChanged')
}
private cleanupQualities () {
const resolutions = this.player().peertubeResolutions().getResolutions()
this.menu.children().forEach((children: ResolutionMenuItem) => {
if (children.resolutionId === undefined) {
return
}
if (resolutions.find(r => r.id === children.resolutionId)) {
return
}
this.menu.removeChild(children)
})
this.trigger('menuChanged')
}
}
videojs.registerComponent('ResolutionMenuButton', ResolutionMenuButton)

View File

@ -10,35 +10,32 @@ class ResolutionMenuItem extends MenuItem {
readonly resolutionId: number
private readonly label: string
private autoResolutionEnabled: boolean
private autoResolutionChosen: string
private updateSelectionHandler: () => void
constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) {
options.selectable = true
super(player, { ...options, selectable: true })
super(player, options)
this.autoResolutionEnabled = true
this.autoResolutionChosen = ''
this.resolutionId = options.resolutionId
this.label = options.label
player.peertubeResolutions().on('resolutionChanged', () => this.updateSelection())
// We only want to disable the "Auto" item
if (this.resolutionId === -1) {
player.peertubeResolutions().on('autoResolutionEnabledChanged', () => this.updateAutoResolution())
this.updateSelectionHandler = () => this.updateSelection()
player.peertubeResolutions().on('resolutions-changed', this.updateSelectionHandler)
}
dispose () {
this.player().peertubeResolutions().off('resolutions-changed', this.updateSelectionHandler)
super.dispose()
}
handleClick (event: any) {
// Auto button disabled?
if (this.autoResolutionEnabled === false && this.resolutionId === -1) return
super.handleClick(event)
this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false })
this.player().peertubeResolutions().select({ id: this.resolutionId, fireCallback: true })
}
updateSelection () {
@ -51,19 +48,6 @@ class ResolutionMenuItem extends MenuItem {
this.selected(this.resolutionId === selectedResolution.id)
}
updateAutoResolution () {
const enabled = this.player().peertubeResolutions().isAutoResolutionEnabeld()
// Check if the auto resolution is enabled or not
if (enabled === false) {
this.addClass('disabled')
} else {
this.removeClass('disabled')
}
this.autoResolutionEnabled = enabled
}
getLabel () {
if (this.resolutionId === -1) {
return this.label + ' <small>' + this.autoResolutionChosen + '</small>'

View File

@ -28,6 +28,18 @@ class SettingsDialog extends Component {
'aria-describedby': dialogDescriptionId
})
}
show () {
this.player().addClass('vjs-settings-dialog-opened')
super.show()
}
hide () {
this.player().removeClass('vjs-settings-dialog-opened')
super.hide()
}
}
Component.registerComponent('SettingsDialog', SettingsDialog)

View File

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

View File

@ -70,17 +70,22 @@ class SettingsMenuItem extends MenuItem {
this.build()
// Update on rate change
if (subMenuName === 'PlaybackRateMenuButton') {
player.on('ratechange', this.submenuClickHandler)
}
if (subMenuName === 'CaptionsButton') {
// Hack to regenerate captions on HTTP fallback
player.on('captionsChanged', () => {
player.on('captions-changed', () => {
// Wait menu component rebuild
setTimeout(() => {
this.settingsSubMenuEl_.innerHTML = ''
this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el())
this.update()
this.bindClickEvents()
}, 0)
this.rebuildAfterMenuChange()
}, 150)
})
}
if (subMenuName === 'ResolutionMenuButton') {
this.subMenu.on('menu-changed', () => {
this.rebuildAfterMenuChange()
})
}
@ -89,6 +94,12 @@ class SettingsMenuItem extends MenuItem {
})
}
dispose () {
this.settingsSubMenuEl_.removeEventListener('transitionend', this.transitionEndHandler)
super.dispose()
}
eventHandlers () {
this.submenuClickHandler = this.onSubmenuClick.bind(this)
this.transitionEndHandler = this.onTransitionEnd.bind(this)
@ -190,27 +201,6 @@ class SettingsMenuItem extends MenuItem {
(button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText())
}
/**
* Add/remove prefixed event listener for CSS Transition
*
* @method PrefixedEvent
*/
PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') {
const prefix = [ 'webkit', 'moz', 'MS', 'o', '' ]
for (let p = 0; p < prefix.length; p++) {
if (!prefix[p]) {
type = type.toLowerCase()
}
if (action === 'addEvent') {
element.addEventListener(prefix[p] + type, callback, false)
} else if (action === 'removeEvent') {
element.removeEventListener(prefix[p] + type, callback, false)
}
}
}
onTransitionEnd (event: any) {
if (event.propertyName !== 'margin-right') {
return
@ -254,12 +244,7 @@ class SettingsMenuItem extends MenuItem {
}
build () {
this.subMenu.on('labelUpdated', () => {
this.update()
})
this.subMenu.on('menuChanged', () => {
this.bindClickEvents()
this.setSize()
this.subMenu.on('label-updated', () => {
this.update()
})
@ -272,25 +257,12 @@ class SettingsMenuItem extends MenuItem {
this.setSize()
this.bindClickEvents()
// prefixed event listeners for CSS TransitionEnd
this.PrefixedEvent(
this.settingsSubMenuEl_,
'TransitionEnd',
this.transitionEndHandler,
'addEvent'
)
this.settingsSubMenuEl_.addEventListener('transitionend', this.transitionEndHandler, false)
}
update (event?: any) {
let target: HTMLElement = null
const subMenu = this.subMenu.name()
if (event && event.type === 'tap') {
target = event.target
} else if (event) {
target = event.currentTarget
}
// Playback rate menu button doesn't get a vjs-selected class
// or sets options_['selected'] on the selected playback rate.
// Thus we get the submenu value based on the labelEl of playbackRateMenuButton
@ -321,6 +293,13 @@ class SettingsMenuItem extends MenuItem {
}
}
let target: HTMLElement = null
if (event && event.type === 'tap') {
target = event.target
} else if (event) {
target = event.currentTarget
}
if (target && !target.classList.contains('vjs-back-button')) {
this.settingsButton.hideDialog()
}
@ -369,6 +348,15 @@ class SettingsMenuItem extends MenuItem {
}
}
private rebuildAfterMenuChange () {
this.settingsSubMenuEl_.innerHTML = ''
this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el())
this.update()
this.createBackButton()
this.setSize()
this.bindClickEvents()
}
}
(SettingsMenuItem as any).prototype.contentElType = 'button'

View File

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

View File

@ -7,10 +7,6 @@ class StatsForNerdsPlugin extends Plugin {
private statsCard: StatsCard
constructor (player: videojs.Player, options: StatsCardOptions) {
const settings = {
...options
}
super(player)
this.player.ready(() => {
@ -19,7 +15,17 @@ class StatsForNerdsPlugin extends Plugin {
this.statsCard = new StatsCard(player, options)
player.addChild(this.statsCard, settings)
// Copy options
player.addChild(this.statsCard)
}
dispose () {
if (this.statsCard) {
this.statsCard.dispose()
this.player.removeChild(this.statsCard)
}
super.dispose()
}
show () {

View File

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

View File

@ -1,26 +1,24 @@
import videojs from 'video.js'
import { UpNextPluginOptions } from '../../types'
import { EndCardOptions } from './end-card'
const Plugin = videojs.getPlugin('plugin')
class UpNextPlugin extends Plugin {
constructor (player: videojs.Player, options: Partial<EndCardOptions> = {}) {
const settings = {
next: options.next,
getTitle: options.getTitle,
timeout: options.timeout || 5000,
cancelText: options.cancelText || 'Cancel',
headText: options.headText || 'Up Next',
suspendedText: options.suspendedText || 'Autoplay is suspended',
condition: options.condition,
suspended: options.suspended
}
constructor (player: videojs.Player, options: UpNextPluginOptions) {
super(player)
// UpNext plugin can be called later, so ensure the player is not disposed
if (this.player.isDisposed()) return
const settings: EndCardOptions = {
next: options.next,
getTitle: options.getTitle,
timeout: options.timeout,
cancelText: player.localize('Cancel'),
headText: player.localize('Up Next'),
suspendedText: player.localize('Autoplay is suspended'),
isDisplayed: options.isDisplayed,
isSuspended: options.isSuspended
}
this.player.ready(() => {
player.addClass('vjs-upnext')

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'

View File

@ -1,101 +1,117 @@
import { PluginsManager } from '@root-helpers/plugins-manager'
import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
export type PlayerMode = 'web-video' | 'p2p-media-loader'
export type WebtorrentOptions = {
videoFiles: VideoFile[]
}
export type PeerTubePlayerContructorOptions = {
playerElement: () => HTMLVideoElement
export type P2PMediaLoaderOptions = {
playlistUrl: string
segmentsSha256Url: string
trackerAnnounce: string[]
redundancyBaseUrls: string[]
videoFiles: VideoFile[]
}
controls: boolean
controlBar: boolean
export interface CustomizationOptions {
startTime: number | string
stopTime: number | string
muted: boolean
loop: boolean
controls?: boolean
controlBar?: boolean
muted?: boolean
loop?: boolean
subtitle?: string
resume?: string
peertubeLink: boolean
peertubeLink: () => boolean
playbackRate?: number | string
}
export interface CommonOptions extends CustomizationOptions {
playerElement: HTMLVideoElement
onPlayerElementChange: (element: HTMLVideoElement) => void
autoplay: boolean
forceAutoplay: boolean
p2pEnabled: boolean
nextVideo?: () => void
hasNextVideo?: () => boolean
previousVideo?: () => void
hasPreviousVideo?: () => boolean
playlist?: PlaylistPluginOptions
videoDuration: number
enableHotkeys: boolean
inactivityTimeout: number
poster: string
videoViewIntervalMs: number
instanceName: string
theaterButton: boolean
captions: boolean
videoViewUrl: string
authorizationHeader?: () => string
authorizationHeader: () => string
metricsUrl: string
serverUrl: string
errorNotifier: (message: string) => void
// Current web browser language
language: string
pluginsManager: PluginsManager
}
export type PeerTubePlayerLoadOptions = {
mode: PlayerMode
startTime?: number | string
stopTime?: number | string
autoplay: boolean
forceAutoplay: boolean
poster: string
subtitle?: string
videoViewUrl: string
embedUrl: string
embedTitle: string
isLive: boolean
liveOptions?: {
latencyMode: LiveVideoLatencyMode
}
language?: string
videoCaptions: VideoJSCaption[]
storyboard: VideoJSStoryboard
videoUUID: string
videoShortUUID: string
serverUrl: string
duration: number
requiresUserAuth: boolean
videoFileToken: () => string
requiresPassword: boolean
videoPassword: () => string
errorNotifier: (message: string) => void
nextVideo: {
enabled: boolean
getVideoTitle: () => string
handler?: () => void
displayControlBarButton: boolean
}
previousVideo: {
enabled: boolean
handler?: () => void
displayControlBarButton: boolean
}
upnext?: {
isEnabled: () => boolean
isSuspended: (player: videojs.VideoJsPlayer) => boolean
timeout: number
}
dock?: PeerTubeDockPluginOptions
playlist?: PlaylistPluginOptions
p2pEnabled: boolean
hls?: HLSOptions
webVideo?: WebVideoOptions
}
export type PeertubePlayerManagerOptions = {
common: CommonOptions
webtorrent: WebtorrentOptions
p2pMediaLoader?: P2PMediaLoaderOptions
pluginsManager: PluginsManager
export type WebVideoOptions = {
videoFiles: VideoFile[]
}
export type HLSOptions = {
playlistUrl: string
segmentsSha256Url: string
trackerAnnounce: string[]
redundancyBaseUrls: string[]
videoFiles: VideoFile[]
}

View File

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

View File

@ -3,20 +3,6 @@
@use '_mixins' as *;
@use './_player-variables' as *;
// Like the time tooltip
.video-js .vjs-progress-holder .vjs-storyboard-sprite-placeholder {
display: none;
}
.video-js .vjs-progress-control:hover .vjs-storyboard-sprite-placeholder,
.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-storyboard-sprite-placeholder {
display: block;
// Ensure that we maintain a font-size of ~10px.
font-size: 0.6em;
visibility: visible;
}
.video-js.vjs-peertube-skin .vjs-control-bar {
z-index: 100;
@ -26,11 +12,8 @@
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
transition: visibility 0.3s, opacity 0.3s !important;
&.control-bar-hidden {
display: none !important;
}
> button:first-child {
> button:not(.vjs-hidden):first-child,
> button.vjs-hidden + button:not(.vjs-hidden) {
@include margin-left($first-control-bar-element-margin-left);
}
@ -167,7 +150,7 @@
}
}
.vjs-live-control {
.vjs-pt-live-control {
padding: 5px 7px;
border-radius: 3px;
height: fit-content;
@ -245,6 +228,7 @@
.vjs-next-video,
.vjs-previous-video {
width: $control-bar-button-width - 4px;
cursor: pointer;
&.vjs-disabled {
cursor: default;

View File

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

View File

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

View File

@ -84,7 +84,9 @@ body {
}
// Do not display poster when video is starting
&.vjs-has-autoplay:not(.vjs-has-started) {
// Or if we change resolution manually
&.vjs-has-autoplay:not(.vjs-has-started),
&.vjs-updating-resolution {
.vjs-poster {
opacity: 0;
visibility: hidden;

View File

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

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

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

View File

@ -48,7 +48,7 @@
<div id="video-password-content"></div>
<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>
</form>
@ -60,8 +60,6 @@
<div id="video-wrapper"></div>
<div id="placeholder-preview"></div>
<script type="text/javascript">
// Can be called in embed.ts
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 { PeerTubeServerError } from 'src/types'
import videojs from 'video.js'
import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
import {
HTMLServerConfig,
ResultList,
@ -13,7 +12,7 @@ import {
VideoPlaylistElement,
VideoState
} from '../../../../shared/models'
import { PeertubePlayerManager } from '../../assets/player'
import { PeerTubePlayer } from '../../assets/player/peertube-player'
import { TranslationsManager } from '../../assets/player/translations-manager'
import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers'
import { PeerTubeEmbedApi } from './embed-api'
@ -21,7 +20,7 @@ import {
AuthHTTP,
LiveManager,
PeerTubePlugin,
PlayerManagerOptions,
PlayerOptionsBuilder,
PlaylistFetcher,
PlaylistTracker,
Translations,
@ -36,17 +35,23 @@ export class PeerTubeEmbed {
config: HTMLServerConfig
private translationsPromise: Promise<{ [id: string]: string }>
private PeertubePlayerManagerModulePromise: Promise<any>
private PeerTubePlayerManagerModulePromise: Promise<any>
private readonly http: AuthHTTP
private readonly videoFetcher: VideoFetcher
private readonly playlistFetcher: PlaylistFetcher
private readonly peertubePlugin: PeerTubePlugin
private readonly playerHTML: PlayerHTML
private readonly playerManagerOptions: PlayerManagerOptions
private readonly playerOptionsBuilder: PlayerOptionsBuilder
private readonly liveManager: LiveManager
private peertubePlayer: PeerTubePlayer
private playlistTracker: PlaylistTracker
private alreadyInitialized = false
private alreadyPlayed = false
private videoPassword: string
private requiresPassword: boolean
@ -59,7 +64,7 @@ export class PeerTubeEmbed {
this.playlistFetcher = new PlaylistFetcher(this.http)
this.peertubePlugin = new PeerTubePlugin(this.http)
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.requiresPassword = false
@ -81,14 +86,14 @@ export class PeerTubeEmbed {
}
getScope () {
return this.playerManagerOptions.getScope()
return this.playerOptionsBuilder.getScope()
}
// ---------------------------------------------------------------------------
async init () {
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
if (!this.config) {
@ -102,7 +107,7 @@ export class PeerTubeEmbed {
if (!videoId) return
return this.loadVideoAndBuildPlayer({ uuid: videoId, autoplayFromPreviousVideo: false, forceAutoplay: false })
return this.loadVideoAndBuildPlayer({ uuid: videoId, forceAutoplay: false })
}
private async initPlaylist () {
@ -137,7 +142,7 @@ export class PeerTubeEmbed {
}
private initializeApi () {
if (this.playerManagerOptions.hasAPIEnabled()) {
if (this.playerOptionsBuilder.hasAPIEnabled()) {
if (this.api) {
this.api.reInit()
return
@ -159,7 +164,7 @@ export class PeerTubeEmbed {
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 () {
@ -171,7 +176,7 @@ export class PeerTubeEmbed {
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 () {
@ -182,10 +187,9 @@ export class PeerTubeEmbed {
private async loadVideoAndBuildPlayer (options: {
uuid: string
autoplayFromPreviousVideo: boolean
forceAutoplay: boolean
}) {
const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options
const { uuid, forceAutoplay } = options
try {
const {
@ -194,7 +198,7 @@ export class PeerTubeEmbed {
storyboardsPromise
} = 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) {
if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
@ -206,20 +210,14 @@ export class PeerTubeEmbed {
videoResponse: Response
storyboardsPromise: Promise<Response>
captionsPromise: Promise<Response>
autoplayFromPreviousVideo: boolean
forceAutoplay: boolean
}) {
const { videoResponse, captionsPromise, storyboardsPromise, autoplayFromPreviousVideo, forceAutoplay } = options
this.resetPlayerElement()
const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options
const videoInfoPromise = videoResponse.json()
.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
? await this.videoFetcher.loadLive(videoInfo)
: undefined
@ -235,89 +233,78 @@ export class PeerTubeEmbed {
{ video, live, videoFileToken },
translations,
captionsResponse,
storyboardsResponse,
PeertubePlayerManagerModule
storyboardsResponse
] = await Promise.all([
videoInfoPromise,
this.translationsPromise,
captionsPromise,
storyboardsPromise,
this.PeertubePlayerManagerModulePromise
this.buildPlayerIfNeeded()
])
await this.peertubePlugin.loadPlugins(this.config, translations)
// If already played, we are in a playlist so we don't want to display the poster between videos
if (!this.alreadyPlayed) {
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,
captionsResponse,
autoplayFromPreviousVideo,
translations,
serverConfig: this.config,
storyboardsResponse,
authorizationHeader: () => this.http.getHeaderTokenValue(),
videoFileToken: () => videoFileToken,
videoPassword: () => this.videoPassword,
requiresPassword: this.requiresPassword,
onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }),
playlistTracker: this.playlistTracker,
playNextPlaylistVideo: () => this.playNextPlaylistVideo(),
playPreviousPlaylistVideo: () => this.playPreviousPlaylistVideo(),
playlist,
live,
forceAutoplay
forceAutoplay,
alreadyPlayed: this.alreadyPlayed
})
await this.peertubePlayer.load(loadOptions)
this.player = await PlayerManager.initialize(this.playerManagerOptions.getMode(), playerOptions, (player: videojs.Player) => {
this.player = player
})
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)
});
if (!this.alreadyInitialized) {
this.player = this.peertubePlayer.getPlayer();
(window as any)['videojsPlayer'] = this.player
this.buildCSS()
this.buildPlayerDock(video)
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) {
this.liveManager.listenForChanges({
video,
onPublishedVideo: () => {
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) {
this.liveManager.displayInfo({ state: video.state.id, translations })
this.disablePlayer()
this.peertubePlayer.disable()
} else {
this.correctlyHandleLiveEnding(translations)
}
@ -326,74 +313,15 @@ export class PeerTubeEmbed {
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 () {
const body = document.getElementById('custom-css')
if (this.playerManagerOptions.hasBigPlayBackgroundColor()) {
body.style.setProperty('--embedBigPlayBackgroundColor', this.playerManagerOptions.getBigPlayBackgroundColor())
if (this.playerOptionsBuilder.hasBigPlayBackgroundColor()) {
body.style.setProperty('--embedBigPlayBackgroundColor', this.playerOptionsBuilder.getBigPlayBackgroundColor())
}
if (this.playerManagerOptions.hasForegroundColor()) {
body.style.setProperty('--embedForegroundColor', this.playerManagerOptions.getForegroundColor())
if (this.playerOptionsBuilder.hasForegroundColor()) {
body.style.setProperty('--embedForegroundColor', this.playerOptionsBuilder.getForegroundColor())
}
}
@ -415,23 +343,10 @@ export class PeerTubeEmbed {
// Display the live ended information
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) {
let incorrectPassword: boolean = null
if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false
@ -447,6 +362,33 @@ export class PeerTubeEmbed {
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()

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import './test-embed.scss'
import { PeerTubeResolution, PlayerEventType } from '../player/definitions'
import { PeerTubePlayer } from '../player/player'
import { PeerTubeResolution, PlayerEventType } from '../embed-player-api/definitions'
import { PeerTubePlayer } from '../embed-player-api/player'
import { logger } from '../../root-helpers'
window.addEventListener('load', async () => {

View File

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

View File

@ -36,10 +36,7 @@ module.exports = function () {
fallback: {
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') ],
stream: [ path.resolve('src/shims/stream.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',
'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.',
'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)

View File

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

View File

@ -1,7 +1,7 @@
import { VideoResolution } from '../videos'
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
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.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:internal.common.svg-icons.get-content.params': true,
'filter:internal.common.svg-icons.get-content.result': true,