Merge branch 'feature/remove-webtorrent' into develop
This commit is contained in:
commit
63e2f087c3
|
@ -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": {}
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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
|
||||
|
@ -51,7 +51,7 @@
|
|||
</div>
|
||||
|
||||
<my-action-buttons
|
||||
[video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions"
|
||||
[video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions"
|
||||
[playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
|
||||
></my-action-buttons>
|
||||
</div>
|
||||
|
|
|
@ -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
|
||||
if (this.video.isLive) {
|
||||
player.one('ended', () => {
|
||||
this.zone.run(() => {
|
||||
// We changed the video, it's not a live anymore
|
||||
if (!this.video.isLive) return
|
||||
|
||||
headText: $localize`Up Next`,
|
||||
cancelText: $localize`Cancel`,
|
||||
suspendedText: $localize`Autoplay is suspended`,
|
||||
this.video.state.id = VideoState.LIVE_ENDED
|
||||
|
||||
getTitle: () => this.nextVideoTitle,
|
||||
this.updatePlayerOnNoLive()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
next: () => this.zone.run(() => this.playNextVideoInAngularZone()),
|
||||
condition: () => {
|
||||
if (!this.playlist) return this.isAutoPlayNext()
|
||||
|
||||
// Don't wait timeout to play the next playlist video
|
||||
if (this.isPlaylistAutoPlayNext()) {
|
||||
this.playNextVideoInAngularZone()
|
||||
return undefined
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
suspended: () => {
|
||||
return (
|
||||
!isXPercentInViewport(this.player.el() as HTMLElement, 80) ||
|
||||
!document.getElementById('content').contains(document.activeElement)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.player.one('stopped', () => {
|
||||
if (this.playlist && this.isPlaylistAutoPlayNext()) {
|
||||
this.playNextVideoInAngularZone()
|
||||
}
|
||||
})
|
||||
|
||||
this.player.one('ended', () => {
|
||||
if (this.video.isLive) {
|
||||
this.zone.run(() => this.video.state.id = VideoState.LIVE_ENDED)
|
||||
}
|
||||
})
|
||||
|
||||
this.player.on('theaterChange', (_: any, enabled: boolean) => {
|
||||
player.on('theater-change', (_: any, enabled: boolean) => {
|
||||
this.zone.run(() => this.theaterEnabled = enabled)
|
||||
})
|
||||
|
||||
this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', {
|
||||
player: this.player,
|
||||
player,
|
||||
playlist: this.playlist,
|
||||
playlistPosition: this.playlistPosition,
|
||||
videojs,
|
||||
|
@ -601,15 +547,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
return true
|
||||
}
|
||||
|
||||
private playNextVideoInAngularZone () {
|
||||
private getNextVideoTitle () {
|
||||
if (this.playlist) {
|
||||
this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
|
||||
return
|
||||
return this.videoWatchPlaylist.getNextVideo()?.video?.name || ''
|
||||
}
|
||||
|
||||
if (this.nextVideoUUID) {
|
||||
this.router.navigate([ '/w', this.nextVideoUUID ])
|
||||
}
|
||||
return this.nextRecommendedVideoTitle
|
||||
}
|
||||
|
||||
private playNextVideoInAngularZone () {
|
||||
this.zone.run(() => {
|
||||
if (this.playlist) {
|
||||
this.videoWatchPlaylist.navigateToNextPlaylistVideo()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.nextRecommendedVideoUUID) {
|
||||
this.router.navigate([ '/w', this.nextRecommendedVideoUUID ])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private isAutoplay () {
|
||||
|
@ -637,19 +593,45 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
)
|
||||
}
|
||||
|
||||
private flushPlayer () {
|
||||
// Remove player if it exists
|
||||
if (!this.player) return
|
||||
private buildPeerTubePlayerConstructorOptions (options: {
|
||||
urlOptions: URLOptions
|
||||
}): PeerTubePlayerContructorOptions {
|
||||
const { urlOptions } = options
|
||||
|
||||
try {
|
||||
this.player.dispose()
|
||||
this.player = undefined
|
||||
} catch (err) {
|
||||
logger.error('Cannot dispose player.', err)
|
||||
return {
|
||||
playerElement: () => this.playerElement.nativeElement,
|
||||
|
||||
enableHotkeys: true,
|
||||
inactivityTimeout: 2500,
|
||||
|
||||
theaterButton: true,
|
||||
|
||||
controls: urlOptions.controls,
|
||||
controlBar: urlOptions.controlBar,
|
||||
|
||||
muted: urlOptions.muted,
|
||||
loop: urlOptions.loop,
|
||||
|
||||
playbackRate: urlOptions.playbackRate,
|
||||
|
||||
instanceName: this.serverConfig.instance.name,
|
||||
language: this.localeId,
|
||||
metricsUrl: environment.apiUrl + '/api/v1/metrics/playback',
|
||||
|
||||
videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS,
|
||||
authorizationHeader: () => this.authService.getRequestHeaderValue(),
|
||||
|
||||
serverUrl: environment.originServerUrl || window.location.origin,
|
||||
|
||||
errorNotifier: (message: string) => this.notifier.error(message),
|
||||
|
||||
peertubeLink: () => false,
|
||||
|
||||
pluginsManager: this.pluginService.getPluginsManager()
|
||||
}
|
||||
}
|
||||
|
||||
private buildPlayerManagerOptions (params: {
|
||||
private buildPeerTubePlayerLoadOptions (options: {
|
||||
video: VideoDetails
|
||||
liveVideo: LiveVideo
|
||||
videoCaptions: VideoCaption[]
|
||||
|
@ -658,12 +640,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
videoFileToken: string
|
||||
videoPassword: string
|
||||
|
||||
urlOptions: CustomizationOptions & { playerMode: PlayerMode }
|
||||
urlOptions: URLOptions
|
||||
|
||||
loggedInOrAnonymousUser: User
|
||||
forceAutoplay: boolean
|
||||
user?: AuthUser // Keep for plugins
|
||||
}) {
|
||||
}): PeerTubePlayerLoadOptions {
|
||||
const {
|
||||
video,
|
||||
liveVideo,
|
||||
|
@ -674,7 +656,30 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
urlOptions,
|
||||
loggedInOrAnonymousUser,
|
||||
forceAutoplay
|
||||
} = params
|
||||
} = options
|
||||
|
||||
let mode: PlayerMode
|
||||
|
||||
if (urlOptions.playerMode) {
|
||||
if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
|
||||
else mode = 'web-video'
|
||||
} else {
|
||||
if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
|
||||
else mode = 'web-video'
|
||||
}
|
||||
|
||||
let hlsOptions: HLSOptions
|
||||
if (video.hasHlsPlaylist()) {
|
||||
const hlsPlaylist = video.getHlsPlaylist()
|
||||
|
||||
hlsOptions = {
|
||||
playlistUrl: hlsPlaylist.playlistUrl,
|
||||
segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
|
||||
redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
|
||||
trackerAnnounce: video.trackerUrls,
|
||||
videoFiles: hlsPlaylist.files
|
||||
}
|
||||
}
|
||||
|
||||
const getStartTime = () => {
|
||||
const byUrl = urlOptions.startTime !== undefined
|
||||
|
@ -714,118 +719,80 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
? { latencyMode: liveVideo.latencyMode }
|
||||
: undefined
|
||||
|
||||
const options: PeertubePlayerManagerOptions = {
|
||||
common: {
|
||||
autoplay: this.isAutoplay(),
|
||||
forceAutoplay,
|
||||
p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
|
||||
return {
|
||||
mode,
|
||||
|
||||
hasNextVideo: () => this.hasNextVideo(),
|
||||
nextVideo: () => this.playNextVideoInAngularZone(),
|
||||
autoplay: this.isAutoplay(),
|
||||
forceAutoplay,
|
||||
|
||||
playerElement: this.playerElement,
|
||||
onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
|
||||
duration: this.video.duration,
|
||||
poster: video.previewUrl,
|
||||
p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
|
||||
|
||||
videoDuration: video.duration,
|
||||
enableHotkeys: true,
|
||||
inactivityTimeout: 2500,
|
||||
poster: video.previewUrl,
|
||||
startTime,
|
||||
stopTime: urlOptions.stopTime,
|
||||
|
||||
startTime,
|
||||
stopTime: urlOptions.stopTime,
|
||||
controlBar: urlOptions.controlBar,
|
||||
controls: urlOptions.controls,
|
||||
muted: urlOptions.muted,
|
||||
loop: urlOptions.loop,
|
||||
subtitle: urlOptions.subtitle,
|
||||
playbackRate: urlOptions.playbackRate,
|
||||
embedUrl: video.embedUrl,
|
||||
embedTitle: video.name,
|
||||
|
||||
peertubeLink: urlOptions.peertubeLink,
|
||||
isLive: video.isLive,
|
||||
liveOptions,
|
||||
|
||||
theaterButton: true,
|
||||
captions: videoCaptions.length !== 0,
|
||||
videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
|
||||
? this.videoService.getVideoViewUrl(video.uuid)
|
||||
: null,
|
||||
|
||||
embedUrl: video.embedUrl,
|
||||
embedTitle: video.name,
|
||||
instanceName: this.serverConfig.instance.name,
|
||||
videoFileToken: () => videoFileToken,
|
||||
requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
|
||||
requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
|
||||
!video.canAccessPasswordProtectedVideoWithoutPassword(this.user),
|
||||
videoPassword: () => videoPassword,
|
||||
|
||||
isLive: video.isLive,
|
||||
liveOptions,
|
||||
videoCaptions: playerCaptions,
|
||||
storyboard,
|
||||
|
||||
language: this.localeId,
|
||||
videoShortUUID: video.shortUUID,
|
||||
videoUUID: video.uuid,
|
||||
|
||||
metricsUrl: environment.apiUrl + '/api/v1/metrics/playback',
|
||||
previousVideo: {
|
||||
enabled: this.playlist && this.videoWatchPlaylist.hasPreviousVideo(),
|
||||
|
||||
videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
|
||||
? this.videoService.getVideoViewUrl(video.uuid)
|
||||
: null,
|
||||
videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS,
|
||||
authorizationHeader: () => this.authService.getRequestHeaderValue(),
|
||||
handler: this.playlist
|
||||
? () => this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo())
|
||||
: undefined,
|
||||
|
||||
serverUrl: environment.originServerUrl || window.location.origin,
|
||||
|
||||
videoFileToken: () => videoFileToken,
|
||||
requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
|
||||
requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
|
||||
!video.canAccessPasswordProtectedVideoWithoutPassword(this.user),
|
||||
videoPassword: () => videoPassword,
|
||||
|
||||
videoCaptions: playerCaptions,
|
||||
storyboard,
|
||||
|
||||
videoShortUUID: video.shortUUID,
|
||||
videoUUID: video.uuid,
|
||||
|
||||
errorNotifier: (message: string) => this.notifier.error(message)
|
||||
displayControlBarButton: !!this.playlist
|
||||
},
|
||||
|
||||
webtorrent: {
|
||||
nextVideo: {
|
||||
enabled: this.hasNextVideo(),
|
||||
handler: () => this.playNextVideoInAngularZone(),
|
||||
getVideoTitle: () => this.getNextVideoTitle(),
|
||||
displayControlBarButton: this.hasNextVideo()
|
||||
},
|
||||
|
||||
upnext: {
|
||||
isEnabled: () => {
|
||||
if (this.playlist) return this.isPlaylistAutoPlayNext()
|
||||
|
||||
return this.isAutoPlayNext()
|
||||
},
|
||||
|
||||
isSuspended: (player: videojs.Player) => {
|
||||
return !isXPercentInViewport(player.el() as HTMLElement, 80)
|
||||
},
|
||||
|
||||
timeout: this.playlist
|
||||
? 0 // Don't wait to play next video in playlist
|
||||
: 5000 // 5 seconds for a recommended video
|
||||
},
|
||||
|
||||
hls: hlsOptions,
|
||||
|
||||
webVideo: {
|
||||
videoFiles: video.files
|
||||
},
|
||||
|
||||
pluginsManager: this.pluginService.getPluginsManager()
|
||||
}
|
||||
|
||||
// Only set this if we're in a playlist
|
||||
if (this.playlist) {
|
||||
options.common.hasPreviousVideo = () => this.videoWatchPlaylist.hasPreviousVideo()
|
||||
|
||||
options.common.previousVideo = () => {
|
||||
this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo())
|
||||
}
|
||||
}
|
||||
|
||||
let mode: PlayerMode
|
||||
|
||||
if (urlOptions.playerMode) {
|
||||
if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
|
||||
else mode = 'webtorrent'
|
||||
} else {
|
||||
if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
|
||||
else mode = 'webtorrent'
|
||||
}
|
||||
|
||||
// FIXME: remove, we don't support these old web browsers anymore
|
||||
// p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available
|
||||
if (typeof TextEncoder === 'undefined') {
|
||||
mode = 'webtorrent'
|
||||
}
|
||||
|
||||
if (mode === 'p2p-media-loader') {
|
||||
const hlsPlaylist = video.getHlsPlaylist()
|
||||
|
||||
const p2pMediaLoader = {
|
||||
playlistUrl: hlsPlaylist.playlistUrl,
|
||||
segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
|
||||
redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
|
||||
trackerAnnounce: video.trackerUrls,
|
||||
videoFiles: hlsPlaylist.files
|
||||
} as P2PMediaLoaderOptions
|
||||
|
||||
Object.assign(options, { p2pMediaLoader })
|
||||
}
|
||||
|
||||
return { playerMode: mode, playerOptions: options }
|
||||
}
|
||||
|
||||
private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) {
|
||||
|
@ -873,6 +840,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
this.video.viewers = newViewers
|
||||
}
|
||||
|
||||
private updatePlayerOnNoLive () {
|
||||
this.peertubePlayer.unload()
|
||||
this.peertubePlayer.disable()
|
||||
this.peertubePlayer.setPoster(this.video.previewPath)
|
||||
}
|
||||
|
||||
private buildHotkeysHelp (video: Video) {
|
||||
if (this.hotkeys.length !== 0) {
|
||||
this.hotkeysService.remove(this.hotkeys)
|
||||
|
@ -944,4 +917,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
this.metaService.setTag('og:url', window.location.href)
|
||||
this.metaService.setTag('url', window.location.href)
|
||||
}
|
||||
|
||||
private getUrlOptions (): URLOptions {
|
||||
const queryParams = this.route.snapshot.queryParams
|
||||
|
||||
return {
|
||||
resume: queryParams.resume,
|
||||
|
||||
startTime: queryParams.start,
|
||||
stopTime: queryParams.stop,
|
||||
|
||||
muted: toBoolean(queryParams.muted),
|
||||
loop: toBoolean(queryParams.loop),
|
||||
subtitle: queryParams.subtitle,
|
||||
|
||||
playerMode: queryParams.mode,
|
||||
playbackRate: queryParams.playbackRate,
|
||||
|
||||
controlBar: toBoolean(queryParams.controlBar),
|
||||
|
||||
peertubeLink: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export * from './peertube-player-manager'
|
||||
export * from './peertube-player'
|
||||
export * from './types'
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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() })
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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 () {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,6 +31,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
|
|||
|
||||
dispose () {
|
||||
document.removeEventListener('keydown', this.handleKeyFunction)
|
||||
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
private onKeyDown (event: KeyboardEvent) {
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export * from './manager-options-builder'
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -1,14 +1,15 @@
|
|||
import debug from 'debug'
|
||||
import videojs from 'video.js'
|
||||
import { PlaybackMetricCreate } from '../../../../../../shared/models'
|
||||
import { MetricsPluginOptions, PlayerMode, PlayerNetworkInfo } from '../../types'
|
||||
import { logger } from '@root-helpers/logger'
|
||||
import { PlaybackMetricCreate } from '../../../../../../shared/models'
|
||||
import { MetricsPluginOptions, PlayerNetworkInfo } from '../../types'
|
||||
|
||||
const debugLogger = debug('peertube:player:metrics')
|
||||
|
||||
const Plugin = videojs.getPlugin('plugin')
|
||||
|
||||
class MetricsPlugin extends Plugin {
|
||||
private readonly metricsUrl: string
|
||||
private readonly videoUUID: string
|
||||
private readonly mode: PlayerMode
|
||||
options_: MetricsPluginOptions
|
||||
|
||||
private downloadedBytesP2P = 0
|
||||
private downloadedBytesHTTP = 0
|
||||
|
@ -28,29 +29,54 @@ class MetricsPlugin extends Plugin {
|
|||
constructor (player: videojs.Player, options: MetricsPluginOptions) {
|
||||
super(player)
|
||||
|
||||
this.metricsUrl = options.metricsUrl
|
||||
this.videoUUID = options.videoUUID
|
||||
this.mode = options.mode
|
||||
this.options_ = options
|
||||
|
||||
this.player.one('play', () => {
|
||||
this.runMetricsInterval()
|
||||
this.trackBytes()
|
||||
this.trackResolutionChange()
|
||||
this.trackErrors()
|
||||
|
||||
this.trackBytes()
|
||||
this.trackResolutionChange()
|
||||
this.trackErrors()
|
||||
this.one('play', () => {
|
||||
this.player.on('video-change', () => {
|
||||
this.runMetricsIntervalOnPlay()
|
||||
})
|
||||
})
|
||||
|
||||
this.runMetricsIntervalOnPlay()
|
||||
}
|
||||
|
||||
dispose () {
|
||||
if (this.metricsInterval) clearInterval(this.metricsInterval)
|
||||
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
private runMetricsIntervalOnPlay () {
|
||||
this.downloadedBytesP2P = 0
|
||||
this.downloadedBytesHTTP = 0
|
||||
this.uploadedBytesP2P = 0
|
||||
|
||||
this.resolutionChanges = 0
|
||||
this.errors = 0
|
||||
|
||||
this.lastPlayerNetworkInfo = undefined
|
||||
|
||||
debugLogger('Will track metrics on next play')
|
||||
|
||||
this.player.one('play', () => {
|
||||
debugLogger('Tracking metrics')
|
||||
|
||||
this.runMetricsInterval()
|
||||
})
|
||||
}
|
||||
|
||||
private runMetricsInterval () {
|
||||
if (this.metricsInterval) clearInterval(this.metricsInterval)
|
||||
|
||||
this.metricsInterval = setInterval(() => {
|
||||
let resolution: number
|
||||
let fps: number
|
||||
|
||||
if (this.mode === 'p2p-media-loader') {
|
||||
if (this.player.usingPlugin('p2pMediaLoader')) {
|
||||
const level = this.player.p2pMediaLoader().getCurrentLevel()
|
||||
if (!level) return
|
||||
|
||||
|
@ -60,21 +86,23 @@ class MetricsPlugin extends Plugin {
|
|||
fps = framerate
|
||||
? parseInt(framerate, 10)
|
||||
: undefined
|
||||
} else { // webtorrent
|
||||
const videoFile = this.player.webtorrent().getCurrentVideoFile()
|
||||
} else if (this.player.usingPlugin('webVideo')) {
|
||||
const videoFile = this.player.webVideo().getCurrentVideoFile()
|
||||
if (!videoFile) return
|
||||
|
||||
resolution = videoFile.resolution.id
|
||||
fps = videoFile.fps && videoFile.fps !== -1
|
||||
? videoFile.fps
|
||||
: undefined
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
const body: PlaybackMetricCreate = {
|
||||
resolution,
|
||||
fps,
|
||||
|
||||
playerMode: this.mode,
|
||||
playerMode: this.options_.mode(),
|
||||
|
||||
resolutionChanges: this.resolutionChanges,
|
||||
|
||||
|
@ -85,7 +113,7 @@ class MetricsPlugin extends Plugin {
|
|||
|
||||
uploadedBytesP2P: this.uploadedBytesP2P,
|
||||
|
||||
videoId: this.videoUUID
|
||||
videoId: this.options_.videoUUID()
|
||||
}
|
||||
|
||||
this.resolutionChanges = 0
|
||||
|
@ -99,15 +127,13 @@ class MetricsPlugin extends Plugin {
|
|||
|
||||
const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
|
||||
|
||||
return fetch(this.metricsUrl, { method: 'POST', body: JSON.stringify(body), headers })
|
||||
return fetch(this.options_.metricsUrl(), { method: 'POST', body: JSON.stringify(body), headers })
|
||||
.catch(err => logger.error('Cannot send metrics to the server.', err))
|
||||
}, this.CONSTANTS.METRICS_INTERVAL)
|
||||
}
|
||||
|
||||
private trackBytes () {
|
||||
this.player.on('p2pInfo', (_event, data: PlayerNetworkInfo) => {
|
||||
if (!data) return
|
||||
|
||||
this.player.on('p2p-info', (_event, data: PlayerNetworkInfo) => {
|
||||
this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0)
|
||||
this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0)
|
||||
|
||||
|
@ -115,10 +141,18 @@ class MetricsPlugin extends Plugin {
|
|||
|
||||
this.lastPlayerNetworkInfo = data
|
||||
})
|
||||
|
||||
this.player.on('http-info', (_event, data: PlayerNetworkInfo) => {
|
||||
this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0)
|
||||
})
|
||||
}
|
||||
|
||||
private trackResolutionChange () {
|
||||
this.player.on('engineResolutionChange', () => {
|
||||
this.player.on('engine-resolution-change', () => {
|
||||
this.resolutionChanges++
|
||||
})
|
||||
|
||||
this.player.on('user-resolution-change', () => {
|
||||
this.resolutionChanges++
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -14,6 +14,10 @@ type Metadata = {
|
|||
levels: Level[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source handler registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type HookFn = (player: videojs.Player, hljs: Hlsjs) => void
|
||||
|
||||
const registerSourceHandler = function (vjs: typeof videojs) {
|
||||
|
@ -25,10 +29,13 @@ const registerSourceHandler = function (vjs: typeof videojs) {
|
|||
const html5 = vjs.getTech('Html5')
|
||||
|
||||
if (!html5) {
|
||||
logger.error('No Hml5 tech found in videojs')
|
||||
logger.error('No "Html5" tech found in videojs')
|
||||
return
|
||||
}
|
||||
|
||||
// Already registered
|
||||
if ((html5 as any).canPlaySource({ type: 'application/x-mpegURL' })) return
|
||||
|
||||
// FIXME: typings
|
||||
(html5 as any).registerSourceHandler({
|
||||
canHandleSource: function (source: videojs.Tech.SourceObject) {
|
||||
|
@ -56,32 +63,55 @@ const registerSourceHandler = function (vjs: typeof videojs) {
|
|||
(vjs as any).Html5Hlsjs = Html5Hlsjs
|
||||
}
|
||||
|
||||
function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) {
|
||||
const player = this
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS options plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (!options) return
|
||||
const Plugin = videojs.getPlugin('plugin')
|
||||
|
||||
if (!player.srOptions_) {
|
||||
player.srOptions_ = {}
|
||||
class HLSJSConfigHandler extends Plugin {
|
||||
|
||||
constructor (player: videojs.Player, options: HlsjsConfigHandlerOptions) {
|
||||
super(player, options)
|
||||
|
||||
if (!options) return
|
||||
|
||||
if (!player.srOptions_) {
|
||||
player.srOptions_ = {}
|
||||
}
|
||||
|
||||
if (!player.srOptions_.hlsjsConfig) {
|
||||
player.srOptions_.hlsjsConfig = options.hlsjsConfig
|
||||
}
|
||||
|
||||
if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) {
|
||||
player.srOptions_.levelLabelHandler = options.levelLabelHandler
|
||||
}
|
||||
|
||||
registerSourceHandler(videojs)
|
||||
}
|
||||
|
||||
if (!player.srOptions_.hlsjsConfig) {
|
||||
player.srOptions_.hlsjsConfig = options.hlsjsConfig
|
||||
}
|
||||
dispose () {
|
||||
this.player.srOptions_ = undefined
|
||||
|
||||
if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) {
|
||||
player.srOptions_.levelLabelHandler = options.levelLabelHandler
|
||||
const tech = this.player.tech(true) as any
|
||||
if (tech.hlsProvider) {
|
||||
tech.hlsProvider.dispose()
|
||||
tech.hlsProvider = undefined
|
||||
}
|
||||
|
||||
super.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const registerConfigPlugin = function (vjs: typeof videojs) {
|
||||
// Used in Brightcove since we don't pass options directly there
|
||||
const registerVjsPlugin = vjs.registerPlugin || vjs.plugin
|
||||
registerVjsPlugin('hlsjs', hlsjsConfigHandler)
|
||||
}
|
||||
videojs.registerPlugin('hlsjs', HLSJSConfigHandler)
|
||||
|
||||
class Html5Hlsjs {
|
||||
private static readonly hooks: { [id: string]: HookFn[] } = {}
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS JS source handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class Html5Hlsjs {
|
||||
private static hooks: { [id: string]: HookFn[] } = {}
|
||||
|
||||
private readonly videoElement: HTMLVideoElement
|
||||
private readonly errorCounts: ErrorCounts = {}
|
||||
|
@ -101,8 +131,9 @@ class Html5Hlsjs {
|
|||
private dvrDuration: number = null
|
||||
private edgeMargin: number = null
|
||||
|
||||
private handlers: { [ id in 'play' ]: EventListener } = {
|
||||
play: null
|
||||
private handlers: { [ id in 'play' | 'error' ]: EventListener } = {
|
||||
play: null,
|
||||
error: null
|
||||
}
|
||||
|
||||
constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
|
||||
|
@ -115,7 +146,7 @@ class Html5Hlsjs {
|
|||
this.videoElement = tech.el() as HTMLVideoElement
|
||||
this.player = vjs((tech.options_ as any).playerId)
|
||||
|
||||
this.videoElement.addEventListener('error', event => {
|
||||
this.handlers.error = event => {
|
||||
let errorTxt: string
|
||||
const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error
|
||||
|
||||
|
@ -143,7 +174,8 @@ class Html5Hlsjs {
|
|||
}
|
||||
|
||||
logger.error(`MEDIA_ERROR: ${errorTxt}`)
|
||||
})
|
||||
}
|
||||
this.videoElement.addEventListener('error', this.handlers.error)
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
@ -174,6 +206,7 @@ class Html5Hlsjs {
|
|||
// See comment for `initialize` method.
|
||||
dispose () {
|
||||
this.videoElement.removeEventListener('play', this.handlers.play)
|
||||
this.videoElement.removeEventListener('error', this.handlers.error)
|
||||
|
||||
// FIXME: https://github.com/video-dev/hls.js/issues/4092
|
||||
const untypedHLS = this.hls as any
|
||||
|
@ -200,6 +233,10 @@ class Html5Hlsjs {
|
|||
return true
|
||||
}
|
||||
|
||||
static removeAllHooks () {
|
||||
Html5Hlsjs.hooks = {}
|
||||
}
|
||||
|
||||
private _executeHooksFor (type: string) {
|
||||
if (Html5Hlsjs.hooks[type] === undefined) {
|
||||
return
|
||||
|
@ -421,7 +458,7 @@ class Html5Hlsjs {
|
|||
? data.level
|
||||
: -1
|
||||
|
||||
this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true })
|
||||
this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false })
|
||||
})
|
||||
|
||||
this.hls.attachMedia(this.videoElement)
|
||||
|
@ -433,9 +470,3 @@ class Html5Hlsjs {
|
|||
this._initHlsjs()
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
Html5Hlsjs,
|
||||
registerSourceHandler,
|
||||
registerConfigPlugin
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,30 +9,29 @@ type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string
|
|||
|
||||
const maxRetries = 10
|
||||
|
||||
function segmentValidatorFactory (options: {
|
||||
serverUrl: string
|
||||
segmentsSha256Url: string
|
||||
authorizationHeader: () => string
|
||||
requiresUserAuth: boolean
|
||||
requiresPassword: boolean
|
||||
videoPassword: () => string
|
||||
}) {
|
||||
const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options
|
||||
export class SegmentValidator {
|
||||
|
||||
let segmentsJSON = fetchSha256Segments({
|
||||
serverUrl,
|
||||
segmentsSha256Url,
|
||||
authorizationHeader,
|
||||
requiresUserAuth,
|
||||
requiresPassword,
|
||||
videoPassword
|
||||
})
|
||||
const regex = /bytes=(\d+)-(\d+)/
|
||||
private readonly bytesRangeRegex = /bytes=(\d+)-(\d+)/
|
||||
|
||||
private destroyed = false
|
||||
|
||||
constructor (private readonly options: {
|
||||
serverUrl: string
|
||||
segmentsSha256Url: string
|
||||
authorizationHeader: () => string
|
||||
requiresUserAuth: boolean
|
||||
requiresPassword: boolean
|
||||
videoPassword: () => string
|
||||
}) {
|
||||
|
||||
}
|
||||
|
||||
async validate (segment: Segment, _method: string, _peerId: string, retry = 1) {
|
||||
if (this.destroyed) return
|
||||
|
||||
return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) {
|
||||
const filename = basename(removeQueryParams(segment.url))
|
||||
|
||||
const segmentValue = (await segmentsJSON)[filename]
|
||||
const segmentValue = (await this.fetchSha256Segments())[filename]
|
||||
|
||||
if (!segmentValue && retry > maxRetries) {
|
||||
throw new Error(`Unknown segment name ${filename} in segment validator`)
|
||||
|
@ -43,15 +42,7 @@ function segmentValidatorFactory (options: {
|
|||
|
||||
await wait(500)
|
||||
|
||||
segmentsJSON = fetchSha256Segments({
|
||||
serverUrl,
|
||||
segmentsSha256Url,
|
||||
authorizationHeader,
|
||||
requiresUserAuth,
|
||||
requiresPassword,
|
||||
videoPassword
|
||||
})
|
||||
await segmentValidator(segment, _method, _peerId, retry + 1)
|
||||
await this.validate(segment, _method, _peerId, retry + 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
@ -62,7 +53,7 @@ function segmentValidatorFactory (options: {
|
|||
if (typeof segmentValue === 'string') {
|
||||
hashShouldBe = segmentValue
|
||||
} else {
|
||||
const captured = regex.exec(segment.range)
|
||||
const captured = this.bytesRangeRegex.exec(segment.range)
|
||||
range = captured[1] + '-' + captured[2]
|
||||
|
||||
hashShouldBe = segmentValue[range]
|
||||
|
@ -72,7 +63,7 @@ function segmentValidatorFactory (options: {
|
|||
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
|
||||
}
|
||||
|
||||
const calculatedSha = await sha256Hex(segment.data)
|
||||
const calculatedSha = await this.sha256Hex(segment.data)
|
||||
if (calculatedSha !== hashShouldBe) {
|
||||
throw new Error(
|
||||
`Hashes does not correspond for segment ${filename}/${range}` +
|
||||
|
@ -80,65 +71,53 @@ function segmentValidatorFactory (options: {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
segmentValidatorFactory
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fetchSha256Segments (options: {
|
||||
serverUrl: string
|
||||
segmentsSha256Url: string
|
||||
authorizationHeader: () => string
|
||||
requiresUserAuth: boolean
|
||||
requiresPassword: boolean
|
||||
videoPassword: () => string
|
||||
}): Promise<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 })
|
||||
.then(res => res.json() as Promise<SegmentsJSON>)
|
||||
.catch(err => {
|
||||
logger.error('Cannot get sha256 segments', err)
|
||||
return {}
|
||||
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 {}
|
||||
})
|
||||
}
|
||||
|
||||
private async sha256Hex (data?: ArrayBuffer) {
|
||||
if (!data) return undefined
|
||||
|
||||
if (window.crypto.subtle) {
|
||||
return window.crypto.subtle.digest('SHA-256', data)
|
||||
.then(data => this.bufferToHex(data))
|
||||
}
|
||||
|
||||
// Fallback for non HTTPS context
|
||||
const shaModule = (await import('sha.js') as any).default
|
||||
// eslint-disable-next-line new-cap
|
||||
return new shaModule.sha256().update(Buffer.from(data)).digest('hex')
|
||||
}
|
||||
|
||||
// Thanks: https://stackoverflow.com/a/53307879
|
||||
private bufferToHex (buffer?: ArrayBuffer) {
|
||||
if (!buffer) return ''
|
||||
|
||||
let s = ''
|
||||
const h = '0123456789abcdef'
|
||||
const o = new Uint8Array(buffer)
|
||||
|
||||
o.forEach((v: any) => {
|
||||
s += h[v >> 4] + h[v & 15]
|
||||
})
|
||||
}
|
||||
|
||||
async function sha256Hex (data?: ArrayBuffer) {
|
||||
if (!data) return undefined
|
||||
|
||||
if (window.crypto.subtle) {
|
||||
return window.crypto.subtle.digest('SHA-256', data)
|
||||
.then(data => bufferToHex(data))
|
||||
return s
|
||||
}
|
||||
|
||||
// Fallback for non HTTPS context
|
||||
const shaModule = (await import('sha.js') as any).default
|
||||
// eslint-disable-next-line new-cap
|
||||
return new shaModule.sha256().update(Buffer.from(data)).digest('hex')
|
||||
}
|
||||
|
||||
// Thanks: https://stackoverflow.com/a/53307879
|
||||
function bufferToHex (buffer?: ArrayBuffer) {
|
||||
if (!buffer) return ''
|
||||
|
||||
let s = ''
|
||||
const h = '0123456789abcdef'
|
||||
const o = new Uint8Array(buffer)
|
||||
|
||||
o.forEach((v: any) => {
|
||||
s += h[v >> 4] + h[v & 15]
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import debug from 'debug'
|
||||
import videojs from 'video.js'
|
||||
import { logger } from '@root-helpers/logger'
|
||||
import { isMobile } from '@root-helpers/web-browser'
|
||||
import { isIOS, isMobile } from '@root-helpers/web-browser'
|
||||
import { timeToInt } from '@shared/core-utils'
|
||||
import { VideoView, VideoViewEvent } from '@shared/models/videos'
|
||||
import {
|
||||
|
@ -13,7 +13,7 @@ import {
|
|||
saveVideoWatchHistory,
|
||||
saveVolumeInStore
|
||||
} from '../../peertube-player-local-storage'
|
||||
import { PeerTubePluginOptions, VideoJSCaption } from '../../types'
|
||||
import { PeerTubePluginOptions } from '../../types'
|
||||
import { SettingsButton } from '../settings/settings-menu-button'
|
||||
|
||||
const debugLogger = debug('peertube:player:peertube')
|
||||
|
@ -21,43 +21,59 @@ const debugLogger = debug('peertube:player:peertube')
|
|||
const Plugin = videojs.getPlugin('plugin')
|
||||
|
||||
class PeerTubePlugin extends Plugin {
|
||||
private readonly videoViewUrl: string
|
||||
private readonly videoViewUrl: () => string
|
||||
private readonly authorizationHeader: () => string
|
||||
private readonly initialInactivityTimeout: number
|
||||
|
||||
private readonly videoUUID: string
|
||||
private readonly startTime: number
|
||||
private readonly hasAutoplay: () => videojs.Autoplay
|
||||
|
||||
private readonly videoViewIntervalMs: number
|
||||
|
||||
private videoCaptions: VideoJSCaption[]
|
||||
private defaultSubtitle: string
|
||||
private currentSubtitle: string
|
||||
private currentPlaybackRate: number
|
||||
|
||||
private videoViewInterval: any
|
||||
|
||||
private menuOpened = false
|
||||
private mouseInControlBar = false
|
||||
private mouseInSettings = false
|
||||
private readonly initialInactivityTimeout: number
|
||||
|
||||
constructor (player: videojs.Player, options?: PeerTubePluginOptions) {
|
||||
private videoViewOnPlayHandler: (...args: any[]) => void
|
||||
private videoViewOnSeekedHandler: (...args: any[]) => void
|
||||
private videoViewOnEndedHandler: (...args: any[]) => void
|
||||
|
||||
private stopTimeHandler: (...args: any[]) => void
|
||||
|
||||
constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) {
|
||||
super(player)
|
||||
|
||||
this.videoViewUrl = options.videoViewUrl
|
||||
this.authorizationHeader = options.authorizationHeader
|
||||
this.videoUUID = options.videoUUID
|
||||
this.startTime = timeToInt(options.startTime)
|
||||
this.videoViewIntervalMs = options.videoViewIntervalMs
|
||||
this.hasAutoplay = options.hasAutoplay
|
||||
|
||||
this.videoCaptions = options.videoCaptions
|
||||
this.initialInactivityTimeout = this.player.options_.inactivityTimeout
|
||||
|
||||
if (options.autoplay !== false) this.player.addClass('vjs-has-autoplay')
|
||||
this.currentSubtitle = this.options.subtitle() || getStoredLastSubtitle()
|
||||
|
||||
this.initializePlayer()
|
||||
this.initOnVideoChange()
|
||||
|
||||
this.deleteLegacyIndexedDB()
|
||||
|
||||
this.player.on('autoplay-failure', () => {
|
||||
debugLogger('Autoplay failed')
|
||||
|
||||
this.player.removeClass('vjs-has-autoplay')
|
||||
|
||||
// Fix a bug on iOS where the big play button is not displayed when autoplay fails
|
||||
if (isIOS()) this.player.hasStarted(false)
|
||||
})
|
||||
|
||||
this.player.ready(() => {
|
||||
this.player.on('ratechange', () => {
|
||||
this.currentPlaybackRate = this.player.playbackRate()
|
||||
|
||||
this.player.defaultPlaybackRate(this.currentPlaybackRate)
|
||||
})
|
||||
|
||||
this.player.one('canplay', () => {
|
||||
const playerOptions = this.player.options_
|
||||
|
||||
const volume = getStoredVolume()
|
||||
|
@ -65,28 +81,15 @@ class PeerTubePlugin extends Plugin {
|
|||
|
||||
const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
|
||||
if (muted !== undefined) this.player.muted(muted)
|
||||
})
|
||||
|
||||
this.defaultSubtitle = options.subtitle || getStoredLastSubtitle()
|
||||
this.player.ready(() => {
|
||||
|
||||
this.player.on('volumechange', () => {
|
||||
saveVolumeInStore(this.player.volume())
|
||||
saveMuteInStore(this.player.muted())
|
||||
})
|
||||
|
||||
if (options.stopTime) {
|
||||
const stopTime = timeToInt(options.stopTime)
|
||||
const self = this
|
||||
|
||||
this.player.on('timeupdate', function onTimeUpdate () {
|
||||
if (self.player.currentTime() > stopTime) {
|
||||
self.player.pause()
|
||||
self.player.trigger('stopped')
|
||||
|
||||
self.player.off('timeupdate', onTimeUpdate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.player.textTracks().addEventListener('change', () => {
|
||||
const showing = this.player.textTracks().tracks_.find(t => {
|
||||
return t.kind === 'captions' && t.mode === 'showing'
|
||||
|
@ -94,23 +97,24 @@ class PeerTubePlugin extends Plugin {
|
|||
|
||||
if (!showing) {
|
||||
saveLastSubtitle('off')
|
||||
this.currentSubtitle = undefined
|
||||
return
|
||||
}
|
||||
|
||||
this.currentSubtitle = showing.language
|
||||
saveLastSubtitle(showing.language)
|
||||
})
|
||||
|
||||
this.player.on('sourcechange', () => this.initCaptions())
|
||||
|
||||
this.player.duration(options.videoDuration)
|
||||
|
||||
this.initializePlayer()
|
||||
this.runUserViewing()
|
||||
this.player.on('video-change', () => {
|
||||
this.initOnVideoChange()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
dispose () {
|
||||
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
|
||||
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
onMenuOpened () {
|
||||
|
@ -162,40 +166,70 @@ class PeerTubePlugin extends Plugin {
|
|||
|
||||
this.initSmoothProgressBar()
|
||||
|
||||
this.initCaptions()
|
||||
|
||||
this.listenControlBarMouse()
|
||||
this.player.ready(() => {
|
||||
this.listenControlBarMouse()
|
||||
})
|
||||
|
||||
this.listenFullScreenChange()
|
||||
}
|
||||
|
||||
private initOnVideoChange () {
|
||||
if (this.hasAutoplay() !== false) this.player.addClass('vjs-has-autoplay')
|
||||
else this.player.removeClass('vjs-has-autoplay')
|
||||
|
||||
if (this.currentPlaybackRate && this.currentPlaybackRate !== 1) {
|
||||
debugLogger('Setting playback rate to ' + this.currentPlaybackRate)
|
||||
|
||||
this.player.playbackRate(this.currentPlaybackRate)
|
||||
}
|
||||
|
||||
this.player.ready(() => {
|
||||
this.initCaptions()
|
||||
this.updateControlBar()
|
||||
})
|
||||
|
||||
this.handleStartStopTime()
|
||||
this.runUserViewing()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private runUserViewing () {
|
||||
let lastCurrentTime = this.startTime
|
||||
const startTime = timeToInt(this.options.startTime())
|
||||
|
||||
let lastCurrentTime = startTime
|
||||
let lastViewEvent: VideoViewEvent
|
||||
|
||||
this.player.one('play', () => {
|
||||
this.notifyUserIsWatching(this.startTime, lastViewEvent)
|
||||
})
|
||||
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
|
||||
if (this.videoViewOnPlayHandler) this.player.off('play', this.videoViewOnPlayHandler)
|
||||
if (this.videoViewOnSeekedHandler) this.player.off('seeked', this.videoViewOnSeekedHandler)
|
||||
if (this.videoViewOnEndedHandler) this.player.off('ended', this.videoViewOnEndedHandler)
|
||||
|
||||
this.player.on('seeked', () => {
|
||||
this.videoViewOnPlayHandler = () => {
|
||||
this.notifyUserIsWatching(startTime, lastViewEvent)
|
||||
}
|
||||
|
||||
this.videoViewOnSeekedHandler = () => {
|
||||
const diff = Math.floor(this.player.currentTime()) - lastCurrentTime
|
||||
|
||||
// Don't take into account small forwards
|
||||
if (diff > 0 && diff < 3) return
|
||||
|
||||
lastViewEvent = 'seek'
|
||||
})
|
||||
}
|
||||
|
||||
this.player.one('ended', () => {
|
||||
this.videoViewOnEndedHandler = () => {
|
||||
const currentTime = Math.floor(this.player.duration())
|
||||
lastCurrentTime = currentTime
|
||||
|
||||
this.notifyUserIsWatching(currentTime, lastViewEvent)
|
||||
|
||||
lastViewEvent = undefined
|
||||
})
|
||||
}
|
||||
|
||||
this.player.one('play', this.videoViewOnPlayHandler)
|
||||
this.player.on('seeked', this.videoViewOnSeekedHandler)
|
||||
this.player.one('ended', this.videoViewOnEndedHandler)
|
||||
|
||||
this.videoViewInterval = setInterval(() => {
|
||||
const currentTime = Math.floor(this.player.currentTime())
|
||||
|
@ -209,13 +243,13 @@ class PeerTubePlugin extends Plugin {
|
|||
.catch(err => logger.error('Cannot notify user is watching.', err))
|
||||
|
||||
lastViewEvent = undefined
|
||||
}, this.videoViewIntervalMs)
|
||||
}, this.options.videoViewIntervalMs)
|
||||
}
|
||||
|
||||
private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) {
|
||||
// Server won't save history, so save the video position in local storage
|
||||
if (!this.authorizationHeader()) {
|
||||
saveVideoWatchHistory(this.videoUUID, currentTime)
|
||||
saveVideoWatchHistory(this.options.videoUUID(), currentTime)
|
||||
}
|
||||
|
||||
if (!this.videoViewUrl) return Promise.resolve(true)
|
||||
|
@ -225,7 +259,7 @@ class PeerTubePlugin extends Plugin {
|
|||
const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
|
||||
if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())
|
||||
|
||||
return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers })
|
||||
return fetch(this.videoViewUrl(), { method: 'POST', body: JSON.stringify(body), headers })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -279,18 +313,89 @@ class PeerTubePlugin extends Plugin {
|
|||
}
|
||||
|
||||
private initCaptions () {
|
||||
for (const caption of this.videoCaptions) {
|
||||
debugLogger('Init captions with current subtitle ' + this.currentSubtitle)
|
||||
|
||||
this.player.tech(true).clearTracks('text')
|
||||
|
||||
for (const caption of this.options.videoCaptions()) {
|
||||
this.player.addRemoteTextTrack({
|
||||
kind: 'captions',
|
||||
label: caption.label,
|
||||
language: caption.language,
|
||||
id: caption.language,
|
||||
src: caption.src,
|
||||
default: this.defaultSubtitle === caption.language
|
||||
}, false)
|
||||
default: this.currentSubtitle === caption.language
|
||||
}, true)
|
||||
}
|
||||
|
||||
this.player.trigger('captionsChanged')
|
||||
this.player.trigger('captions-changed')
|
||||
}
|
||||
|
||||
private updateControlBar () {
|
||||
debugLogger('Updating control bar')
|
||||
|
||||
if (this.options.isLive()) {
|
||||
this.getPlaybackRateButton().hide()
|
||||
|
||||
this.player.controlBar.getChild('progressControl').hide()
|
||||
this.player.controlBar.getChild('currentTimeDisplay').hide()
|
||||
this.player.controlBar.getChild('timeDivider').hide()
|
||||
this.player.controlBar.getChild('durationDisplay').hide()
|
||||
|
||||
this.player.controlBar.getChild('peerTubeLiveDisplay').show()
|
||||
} else {
|
||||
this.getPlaybackRateButton().show()
|
||||
|
||||
this.player.controlBar.getChild('progressControl').show()
|
||||
this.player.controlBar.getChild('currentTimeDisplay').show()
|
||||
this.player.controlBar.getChild('timeDivider').show()
|
||||
this.player.controlBar.getChild('durationDisplay').show()
|
||||
|
||||
this.player.controlBar.getChild('peerTubeLiveDisplay').hide()
|
||||
}
|
||||
|
||||
if (this.options.videoCaptions().length === 0) {
|
||||
this.getCaptionsButton().hide()
|
||||
} else {
|
||||
this.getCaptionsButton().show()
|
||||
}
|
||||
}
|
||||
|
||||
private handleStartStopTime () {
|
||||
this.player.duration(this.options.videoDuration())
|
||||
|
||||
if (this.stopTimeHandler) {
|
||||
this.player.off('timeupdate', this.stopTimeHandler)
|
||||
this.stopTimeHandler = undefined
|
||||
}
|
||||
|
||||
// Prefer canplaythrough instead of canplay because Chrome has issues with the second one
|
||||
this.player.one('canplaythrough', () => {
|
||||
if (this.options.startTime()) {
|
||||
debugLogger('Start the video at ' + this.options.startTime())
|
||||
|
||||
this.player.currentTime(timeToInt(this.options.startTime()))
|
||||
}
|
||||
|
||||
if (this.options.stopTime()) {
|
||||
const stopTime = timeToInt(this.options.stopTime())
|
||||
|
||||
this.stopTimeHandler = () => {
|
||||
if (this.player.currentTime() <= stopTime) return
|
||||
|
||||
debugLogger('Stopping the video at ' + this.options.stopTime())
|
||||
|
||||
// Time top stop
|
||||
this.player.pause()
|
||||
this.player.trigger('auto-stopped')
|
||||
|
||||
this.player.off('timeupdate', this.stopTimeHandler)
|
||||
this.stopTimeHandler = undefined
|
||||
}
|
||||
|
||||
this.player.on('timeupdate', this.stopTimeHandler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
|
||||
|
@ -314,6 +419,37 @@ class PeerTubePlugin extends Plugin {
|
|||
this.update()
|
||||
}
|
||||
}
|
||||
|
||||
private getCaptionsButton () {
|
||||
const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
|
||||
|
||||
return settingsButton.menu.getChild('captionsButton') as videojs.CaptionsButton
|
||||
}
|
||||
|
||||
private getPlaybackRateButton () {
|
||||
const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
|
||||
|
||||
return settingsButton.menu.getChild('playbackRateMenuButton')
|
||||
}
|
||||
|
||||
// We don't use webtorrent anymore, so we can safely remove old chunks from IndexedDB
|
||||
private deleteLegacyIndexedDB () {
|
||||
try {
|
||||
if (typeof window.indexedDB === 'undefined') return
|
||||
if (!window.indexedDB) return
|
||||
if (typeof window.indexedDB.databases !== 'function') return
|
||||
|
||||
window.indexedDB.databases()
|
||||
.then(databases => {
|
||||
for (const db of databases) {
|
||||
window.indexedDB.deleteDatabase(db.name)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
debugLogger('Cannot delete legacy indexed DB', err)
|
||||
// Nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
videojs.registerPlugin('peertube', PeerTubePlugin)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
.filter(t => t.startsWith('ws'))
|
||||
const trackerAnnounce = this.options.hls.trackerAnnounce
|
||||
.filter(t => t.startsWith('ws'))
|
||||
|
||||
const specificLiveOrVODOptions = this.options.common.isLive
|
||||
const specificLiveOrVODOptions = this.options.isLive
|
||||
? this.getP2PMediaLoaderLiveOptions()
|
||||
: this.getP2PMediaLoaderVODOptions()
|
||||
|
||||
|
@ -88,35 +101,28 @@ export class HLSOptionsBuilder {
|
|||
httpFailedSegmentTimeout: 1000,
|
||||
|
||||
xhrSetup: (xhr, url) => {
|
||||
const { requiresUserAuth, requiresPassword } = this.options.common
|
||||
const { requiresUserAuth, requiresPassword } = this.options
|
||||
|
||||
if (!(requiresUserAuth || requiresPassword)) return
|
||||
|
||||
if (!isSameOrigin(this.options.common.serverUrl, url)) return
|
||||
if (!isSameOrigin(this.options.serverUrl, url)) return
|
||||
|
||||
if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.common.videoPassword())
|
||||
if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.videoPassword())
|
||||
|
||||
else xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader())
|
||||
else xhr.setRequestHeader('Authorization', this.options.authorizationHeader())
|
||||
},
|
||||
|
||||
segmentValidator: segmentValidatorFactory({
|
||||
segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url,
|
||||
authorizationHeader: this.options.common.authorizationHeader,
|
||||
requiresUserAuth: this.options.common.requiresUserAuth,
|
||||
serverUrl: this.options.common.serverUrl,
|
||||
requiresPassword: this.options.common.requiresPassword,
|
||||
videoPassword: this.options.common.videoPassword
|
||||
}),
|
||||
segmentValidator: segmentValidator.validate.bind(segmentValidator),
|
||||
|
||||
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
|
||||
|
||||
useP2P: this.options.common.p2pEnabled,
|
||||
useP2P: this.options.p2pEnabled,
|
||||
consumeOnly,
|
||||
|
||||
...specificLiveOrVODOptions
|
||||
},
|
||||
segments: {
|
||||
swarmId: this.options.p2pMediaLoader.playlistUrl,
|
||||
swarmId: this.options.hls.playlistUrl,
|
||||
forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20
|
||||
}
|
||||
}
|
||||
|
@ -127,7 +133,7 @@ export class HLSOptionsBuilder {
|
|||
requiredSegmentsPriority: 1
|
||||
}
|
||||
|
||||
const latencyMode = this.options.common.liveOptions.latencyMode
|
||||
const latencyMode = this.options.liveOptions.latencyMode
|
||||
|
||||
switch (latencyMode) {
|
||||
case LiveVideoLatencyMode.SMALL_LATENCY:
|
||||
|
@ -165,7 +171,7 @@ export class HLSOptionsBuilder {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
private getHLSJSOptions (loader: P2PMediaLoader) {
|
||||
const specificLiveOrVODOptions = this.options.common.isLive
|
||||
const specificLiveOrVODOptions = this.options.isLive
|
||||
? this.getHLSLiveOptions()
|
||||
: this.getHLSVODOptions()
|
||||
|
||||
|
@ -193,7 +199,7 @@ export class HLSOptionsBuilder {
|
|||
}
|
||||
|
||||
private getHLSLiveOptions () {
|
||||
const latencyMode = this.options.common.liveOptions.latencyMode
|
||||
const latencyMode = this.options.liveOptions.latencyMode
|
||||
|
||||
switch (latencyMode) {
|
||||
case LiveVideoLatencyMode.SMALL_LATENCY:
|
|
@ -0,0 +1,3 @@
|
|||
export * from './control-bar-options-builder'
|
||||
export * from './hls-options-builder'
|
||||
export * from './web-video-options-builder'
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -10,35 +10,32 @@ class ResolutionMenuItem extends MenuItem {
|
|||
readonly resolutionId: number
|
||||
private readonly label: string
|
||||
|
||||
private autoResolutionEnabled: boolean
|
||||
private autoResolutionChosen: string
|
||||
|
||||
private updateSelectionHandler: () => void
|
||||
|
||||
constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) {
|
||||
options.selectable = true
|
||||
super(player, { ...options, selectable: true })
|
||||
|
||||
super(player, options)
|
||||
|
||||
this.autoResolutionEnabled = true
|
||||
this.autoResolutionChosen = ''
|
||||
|
||||
this.resolutionId = options.resolutionId
|
||||
this.label = options.label
|
||||
|
||||
player.peertubeResolutions().on('resolutionChanged', () => this.updateSelection())
|
||||
this.updateSelectionHandler = () => this.updateSelection()
|
||||
player.peertubeResolutions().on('resolutions-changed', this.updateSelectionHandler)
|
||||
}
|
||||
|
||||
// We only want to disable the "Auto" item
|
||||
if (this.resolutionId === -1) {
|
||||
player.peertubeResolutions().on('autoResolutionEnabledChanged', () => this.updateAutoResolution())
|
||||
}
|
||||
dispose () {
|
||||
this.player().peertubeResolutions().off('resolutions-changed', this.updateSelectionHandler)
|
||||
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
handleClick (event: any) {
|
||||
// Auto button disabled?
|
||||
if (this.autoResolutionEnabled === false && this.resolutionId === -1) return
|
||||
|
||||
super.handleClick(event)
|
||||
|
||||
this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false })
|
||||
this.player().peertubeResolutions().select({ id: this.resolutionId, fireCallback: true })
|
||||
}
|
||||
|
||||
updateSelection () {
|
||||
|
@ -51,19 +48,6 @@ class ResolutionMenuItem extends MenuItem {
|
|||
this.selected(this.resolutionId === selectedResolution.id)
|
||||
}
|
||||
|
||||
updateAutoResolution () {
|
||||
const enabled = this.player().peertubeResolutions().isAutoResolutionEnabeld()
|
||||
|
||||
// Check if the auto resolution is enabled or not
|
||||
if (enabled === false) {
|
||||
this.addClass('disabled')
|
||||
} else {
|
||||
this.removeClass('disabled')
|
||||
}
|
||||
|
||||
this.autoResolutionEnabled = enabled
|
||||
}
|
||||
|
||||
getLabel () {
|
||||
if (this.resolutionId === -1) {
|
||||
return this.label + ' <small>' + this.autoResolutionChosen + '</small>'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -70,17 +70,22 @@ class SettingsMenuItem extends MenuItem {
|
|||
this.build()
|
||||
|
||||
// Update on rate change
|
||||
player.on('ratechange', this.submenuClickHandler)
|
||||
if (subMenuName === 'PlaybackRateMenuButton') {
|
||||
player.on('ratechange', this.submenuClickHandler)
|
||||
}
|
||||
|
||||
if (subMenuName === 'CaptionsButton') {
|
||||
// Hack to regenerate captions on HTTP fallback
|
||||
player.on('captionsChanged', () => {
|
||||
player.on('captions-changed', () => {
|
||||
// Wait menu component rebuild
|
||||
setTimeout(() => {
|
||||
this.settingsSubMenuEl_.innerHTML = ''
|
||||
this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el())
|
||||
this.update()
|
||||
this.bindClickEvents()
|
||||
}, 0)
|
||||
this.rebuildAfterMenuChange()
|
||||
}, 150)
|
||||
})
|
||||
}
|
||||
|
||||
if (subMenuName === 'ResolutionMenuButton') {
|
||||
this.subMenu.on('menu-changed', () => {
|
||||
this.rebuildAfterMenuChange()
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -89,6 +94,12 @@ class SettingsMenuItem extends MenuItem {
|
|||
})
|
||||
}
|
||||
|
||||
dispose () {
|
||||
this.settingsSubMenuEl_.removeEventListener('transitionend', this.transitionEndHandler)
|
||||
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
eventHandlers () {
|
||||
this.submenuClickHandler = this.onSubmenuClick.bind(this)
|
||||
this.transitionEndHandler = this.onTransitionEnd.bind(this)
|
||||
|
@ -190,27 +201,6 @@ class SettingsMenuItem extends MenuItem {
|
|||
(button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText())
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/remove prefixed event listener for CSS Transition
|
||||
*
|
||||
* @method PrefixedEvent
|
||||
*/
|
||||
PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') {
|
||||
const prefix = [ 'webkit', 'moz', 'MS', 'o', '' ]
|
||||
|
||||
for (let p = 0; p < prefix.length; p++) {
|
||||
if (!prefix[p]) {
|
||||
type = type.toLowerCase()
|
||||
}
|
||||
|
||||
if (action === 'addEvent') {
|
||||
element.addEventListener(prefix[p] + type, callback, false)
|
||||
} else if (action === 'removeEvent') {
|
||||
element.removeEventListener(prefix[p] + type, callback, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTransitionEnd (event: any) {
|
||||
if (event.propertyName !== 'margin-right') {
|
||||
return
|
||||
|
@ -254,12 +244,7 @@ class SettingsMenuItem extends MenuItem {
|
|||
}
|
||||
|
||||
build () {
|
||||
this.subMenu.on('labelUpdated', () => {
|
||||
this.update()
|
||||
})
|
||||
this.subMenu.on('menuChanged', () => {
|
||||
this.bindClickEvents()
|
||||
this.setSize()
|
||||
this.subMenu.on('label-updated', () => {
|
||||
this.update()
|
||||
})
|
||||
|
||||
|
@ -272,25 +257,12 @@ class SettingsMenuItem extends MenuItem {
|
|||
this.setSize()
|
||||
this.bindClickEvents()
|
||||
|
||||
// prefixed event listeners for CSS TransitionEnd
|
||||
this.PrefixedEvent(
|
||||
this.settingsSubMenuEl_,
|
||||
'TransitionEnd',
|
||||
this.transitionEndHandler,
|
||||
'addEvent'
|
||||
)
|
||||
this.settingsSubMenuEl_.addEventListener('transitionend', this.transitionEndHandler, false)
|
||||
}
|
||||
|
||||
update (event?: any) {
|
||||
let target: HTMLElement = null
|
||||
const subMenu = this.subMenu.name()
|
||||
|
||||
if (event && event.type === 'tap') {
|
||||
target = event.target
|
||||
} else if (event) {
|
||||
target = event.currentTarget
|
||||
}
|
||||
|
||||
// Playback rate menu button doesn't get a vjs-selected class
|
||||
// or sets options_['selected'] on the selected playback rate.
|
||||
// Thus we get the submenu value based on the labelEl of playbackRateMenuButton
|
||||
|
@ -321,6 +293,13 @@ class SettingsMenuItem extends MenuItem {
|
|||
}
|
||||
}
|
||||
|
||||
let target: HTMLElement = null
|
||||
if (event && event.type === 'tap') {
|
||||
target = event.target
|
||||
} else if (event) {
|
||||
target = event.currentTarget
|
||||
}
|
||||
|
||||
if (target && !target.classList.contains('vjs-back-button')) {
|
||||
this.settingsButton.hideDialog()
|
||||
}
|
||||
|
@ -369,6 +348,15 @@ class SettingsMenuItem extends MenuItem {
|
|||
}
|
||||
}
|
||||
|
||||
private rebuildAfterMenuChange () {
|
||||
this.settingsSubMenuEl_.innerHTML = ''
|
||||
this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el())
|
||||
this.update()
|
||||
this.createBackButton()
|
||||
this.setSize()
|
||||
this.bindClickEvents()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
(SettingsMenuItem as any).prototype.contentElType = 'button'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 }
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 }
|
|
@ -1,2 +1,2 @@
|
|||
export * from './manager-options'
|
||||
export * from './peertube-player-options'
|
||||
export * from './peertube-videojs-typings'
|
||||
|
|
|
@ -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[]
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -10,3 +10,4 @@
|
|||
@use './playlist';
|
||||
@use './stats';
|
||||
@use './offline-notification';
|
||||
@use './storyboard.scss';
|
||||
|
|
|
@ -170,7 +170,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.vjs-scrubbing {
|
||||
&.vjs-scrubbing,
|
||||
&.vjs-mobile-sliding {
|
||||
.vjs-mobile-buttons-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
module.exports = require('stream-http')
|
|
@ -1 +0,0 @@
|
|||
module.exports = require('https-browserify')
|
|
@ -1 +0,0 @@
|
|||
module.exports = require('stream-browserify')
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,11 +44,11 @@
|
|||
<div id="video-password-block">
|
||||
<!-- eslint-disable-next-line @angular-eslint/template/elements-content -->
|
||||
<h1 id="video-password-title"></h1>
|
||||
|
||||
|
||||
<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 () {
|
||||
|
|
|
@ -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
|
||||
})
|
||||
if (!this.alreadyInitialized) {
|
||||
this.player = this.peertubePlayer.getPlayer();
|
||||
|
||||
this.player.on('customError', (event: any, data: any) => {
|
||||
const message = data?.err?.message || ''
|
||||
if (!message.includes('from xs param')) return
|
||||
(window as any)['videojsPlayer'] = this.player
|
||||
|
||||
this.player.dispose()
|
||||
this.playerHTML.removePlayerElement()
|
||||
this.playerHTML.displayError('This video is not available because the remote instance is not responding.', translations)
|
||||
});
|
||||
|
||||
(window as any)['videojsPlayer'] = this.player
|
||||
|
||||
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.buildCSS()
|
||||
this.initializeApi()
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
forceAutoplay,
|
||||
return {
|
||||
mode: this.mode,
|
||||
|
||||
controls: this.controls,
|
||||
controlBar: this.controlBar,
|
||||
autoplay: forceAutoplay || alreadyPlayed || this.autoplay,
|
||||
forceAutoplay,
|
||||
|
||||
muted: this.muted,
|
||||
loop: this.loop,
|
||||
p2pEnabled: this.p2pEnabled,
|
||||
|
||||
p2pEnabled: this.p2pEnabled,
|
||||
subtitle: this.subtitle,
|
||||
|
||||
captions: videoCaptions.length !== 0,
|
||||
subtitle: this.subtitle,
|
||||
storyboard,
|
||||
|
||||
storyboard,
|
||||
startTime: playlist
|
||||
? playlist.playlistTracker.getCurrentElement().startTimestamp
|
||||
: this.startTime,
|
||||
stopTime: playlist
|
||||
? playlist.playlistTracker.getCurrentElement().stopTimestamp
|
||||
: this.stopTime,
|
||||
|
||||
startTime: playlistTracker
|
||||
? playlistTracker.getCurrentElement().startTimestamp
|
||||
: this.startTime,
|
||||
stopTime: playlistTracker
|
||||
? playlistTracker.getCurrentElement().stopTimestamp
|
||||
: this.stopTime,
|
||||
videoCaptions,
|
||||
videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
|
||||
|
||||
playbackRate: this.playbackRate,
|
||||
videoShortUUID: video.shortUUID,
|
||||
videoUUID: video.uuid,
|
||||
|
||||
videoCaptions,
|
||||
inactivityTimeout: 2500,
|
||||
videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
|
||||
videoViewIntervalMs: 5000,
|
||||
metricsUrl: window.location.origin + '/api/v1/metrics/playback',
|
||||
duration: video.duration,
|
||||
|
||||
videoShortUUID: video.shortUUID,
|
||||
videoUUID: video.uuid,
|
||||
poster: window.location.origin + video.previewPath,
|
||||
|
||||
playerElement: this.playerHTML.getPlayerElement(),
|
||||
onPlayerElementChange: (element: HTMLVideoElement) => {
|
||||
this.playerHTML.setPlayerElement(element)
|
||||
},
|
||||
embedUrl: window.location.origin + video.embedPath,
|
||||
embedTitle: video.name,
|
||||
|
||||
videoDuration: video.duration,
|
||||
enableHotkeys: true,
|
||||
requiresUserAuth: videoRequiresUserAuth(video),
|
||||
videoFileToken,
|
||||
|
||||
peertubeLink: this.peertubeLink,
|
||||
instanceName: serverConfig.instance.name,
|
||||
requiresPassword,
|
||||
videoPassword,
|
||||
|
||||
poster: window.location.origin + video.previewPath,
|
||||
theaterButton: false,
|
||||
...this.buildLiveOptions(video, live),
|
||||
|
||||
serverUrl: window.location.origin,
|
||||
language: navigator.language,
|
||||
embedUrl: window.location.origin + video.embedPath,
|
||||
embedTitle: video.name,
|
||||
...this.buildPlaylistOptions(playlist),
|
||||
|
||||
requiresUserAuth: videoRequiresUserAuth(video),
|
||||
authorizationHeader,
|
||||
videoFileToken,
|
||||
dock: this.buildDockOptions(video),
|
||||
|
||||
requiresPassword,
|
||||
videoPassword,
|
||||
|
||||
errorNotifier: () => {
|
||||
// Empty, we don't have a notifier in the embed
|
||||
},
|
||||
|
||||
...this.buildLiveOptions(video, live),
|
||||
|
||||
...this.buildPlaylistOptions(options)
|
||||
},
|
||||
|
||||
webtorrent: {
|
||||
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
|
||||
playlistUrl: hlsPlaylist.playlistUrl,
|
||||
segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
|
||||
redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
|
||||
trackerAnnounce: video.trackerUrls,
|
||||
videoFiles: hlsPlaylist.files
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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),
|
|
@ -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 () => {
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
|
|
|
@ -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') ]
|
||||
}
|
||||
},
|
||||
|
|
784
client/yarn.lock
784
client/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 ])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue