Commit 2adfc7ea authored by Chocobozzz's avatar Chocobozzz Committed by Chocobozzz

Refractor videojs player

Add fake p2p-media-loader plugin
parent 7eeb6a0b
......@@ -85,6 +85,7 @@
"@ngx-loading-bar/router": "^3.0.0",
"@ngx-meta/core": "^6.0.0-rc.1",
"@ngx-translate/i18n-polyfill": "^1.0.0",
"@streamroot/videojs-hlsjs-plugin": "^1.0.7",
"@types/core-js": "^2.5.0",
"@types/jasmine": "^2.8.7",
"@types/jasminewd2": "^2.0.3",
......@@ -131,6 +132,7 @@
"ngx-qrcode2": "^0.0.9",
"node-sass": "^4.9.3",
"npm-font-source-sans-pro": "^1.0.2",
"p2p-media-loader-hlsjs": "^0.3.0",
"path-browserify": "^1.0.0",
"primeng": "^7.0.0",
"process": "^0.11.10",
......@@ -152,6 +154,7 @@
"typescript": "3.1.6",
"video.js": "^7",
"videojs-contextmenu-ui": "^5.0.0",
"videojs-contrib-quality-levels": "^2.0.9",
"videojs-dock": "^2.0.2",
"videojs-hotkeys": "^0.2.21",
"webpack-bundle-analyzer": "^3.0.2",
......
......@@ -7,14 +7,9 @@ import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-supp
import { MetaService } from '@ngx-meta/core'
import { Notifier, ServerService } from '@app/core'
import { forkJoin, Subscription } from 'rxjs'
// FIXME: something weird with our path definition in tsconfig and typings
// @ts-ignore
import videojs from 'video.js'
import 'videojs-hotkeys'
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import * as WebTorrent from 'webtorrent'
import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
import '../../../assets/player/peertube-videojs-plugin'
import { AuthService, ConfirmService } from '../../core'
import { RestExtractor, VideoBlacklistService } from '../../shared'
import { VideoDetails } from '../../shared/video/video-details.model'
......@@ -24,12 +19,11 @@ import { VideoReportComponent } from './modal/video-report.component'
import { VideoShareComponent } from './modal/video-share.component'
import { VideoBlacklistComponent } from './modal/video-blacklist.component'
import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
import { addContextMenu, getVideojsOptions, loadLocaleInVideoJS } from '../../../assets/player/peertube-player'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { environment } from '../../../environments/environment'
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
import { VideoCaptionService } from '@app/shared/video-caption'
import { MarkdownService } from '@app/shared/renderer'
import { PeertubePlayerManager } from '../../../assets/player/peertube-player-manager'
@Component({
selector: 'my-video-watch',
......@@ -46,7 +40,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
@ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
@ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
player: videojs.Player
player: any
playerElement: HTMLVideoElement
userRating: UserVideoRateType = null
video: VideoDetails = null
......@@ -61,7 +55,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
remoteServerDown = false
hotkeys: Hotkey[]
private videojsLocaleLoaded = false
private paramsSub: Subscription
constructor (
......@@ -402,41 +395,45 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
src: environment.apiUrl + c.captionPath
}))
const videojsOptions = getVideojsOptions({
autoplay: this.isAutoplay(),
inactivityTimeout: 2500,
videoFiles: this.video.files,
videoCaptions: playerCaptions,
playerElement: this.playerElement,
videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
videoDuration: this.video.duration,
enableHotkeys: true,
peertubeLink: false,
poster: this.video.previewUrl,
startTime,
subtitle: urlOptions.subtitle,
theaterMode: true,
language: this.localeId,
userWatching: this.user && this.user.videosHistoryEnabled === true ? {
url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
authorizationHeader: this.authService.getRequestHeaderValue()
} : undefined
})
const options = {
common: {
autoplay: this.isAutoplay(),
playerElement: this.playerElement,
videoDuration: this.video.duration,
enableHotkeys: true,
inactivityTimeout: 2500,
poster: this.video.previewUrl,
startTime,
theaterMode: true,
captions: videoCaptions.length !== 0,
peertubeLink: false,
videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
embedUrl: this.video.embedUrl,
language: this.localeId,
subtitle: urlOptions.subtitle,
if (this.videojsLocaleLoaded === false) {
await loadLocaleInVideoJS(environment.apiUrl, videojs, isOnDevLocale() ? getDevLocale() : this.localeId)
this.videojsLocaleLoaded = true
userWatching: this.user && this.user.videosHistoryEnabled === true ? {
url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
authorizationHeader: this.authService.getRequestHeaderValue()
} : undefined,
serverUrl: environment.apiUrl,
videoCaptions: playerCaptions
},
webtorrent: {
videoFiles: this.video.files
}
}
const self = this
this.zone.runOutsideAngular(async () => {
videojs(this.playerElement, videojsOptions, function (this: videojs.Player) {
self.player = this
this.on('customError', ({ err }: { err: any }) => self.handleError(err))
addContextMenu(self.player, self.video.embedUrl)
})
this.player = await PeertubePlayerManager.initialize('webtorrent', options)
this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
})
this.setVideoDescriptionHTML()
......
// FIXME: something weird with our path definition in tsconfig and typings
// @ts-ignore
import * as videojs from 'video.js'
import { P2PMediaLoaderPluginOptions, VideoJSComponentInterface } from './peertube-videojs-typings'
// videojs-hlsjs-plugin needs videojs in window
window['videojs'] = videojs
import '@streamroot/videojs-hlsjs-plugin'
import { initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
// import { Events } from '../p2p-media-loader/p2p-media-loader-core/lib'
const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
class P2pMediaLoaderPlugin extends Plugin {
constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) {
super(player, options)
initVideoJsContribHlsJsPlayer(player)
console.log(options)
player.src({
type: options.type,
src: options.src
})
}
}
videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)
export { P2pMediaLoaderPlugin }
This diff is collapsed.
import { VideoFile } from '../../../../shared/models/videos'
import 'videojs-hotkeys'
import 'videojs-dock'
import 'videojs-contextmenu-ui'
import './peertube-link-button'
import './resolution-menu-button'
import './settings-menu-button'
import './webtorrent-info-button'
import './peertube-videojs-plugin'
import './peertube-load-progress-bar'
import './theater-button'
import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
// FIXME: something weird with our path definition in tsconfig and typings
// @ts-ignore
import { Player } from 'video.js'
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
// Change Captions to Subtitles/CC
videojsUntyped.getComponent('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)
videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
function getVideojsOptions (options: {
autoplay: boolean
playerElement: HTMLVideoElement
videoViewUrl: string
videoDuration: number
videoFiles: VideoFile[]
enableHotkeys: boolean
inactivityTimeout: number
peertubeLink: boolean
poster: string
startTime: number | string
theaterMode: boolean
videoCaptions: VideoJSCaption[]
language?: string
controls?: boolean
muted?: boolean
loop?: boolean
subtitle?: string
userWatching?: UserWatching
}) {
const videojsOptions = {
// We don't use text track settings for now
textTrackSettings: false,
controls: options.controls !== undefined ? options.controls : true,
loop: options.loop !== undefined ? options.loop : false,
muted: options.muted !== undefined ? options.muted : undefined, // Undefined so the player knows it has to check the local storage
poster: options.poster,
autoplay: false,
inactivityTimeout: options.inactivityTimeout,
playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
plugins: {
peertube: {
autoplay: options.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
videoCaptions: options.videoCaptions,
videoFiles: options.videoFiles,
playerElement: options.playerElement,
videoViewUrl: options.videoViewUrl,
videoDuration: options.videoDuration,
startTime: options.startTime,
userWatching: options.userWatching,
subtitle: options.subtitle
}
},
controlBar: {
children: getControlBarChildren(options)
}
}
if (options.enableHotkeys === true) {
Object.assign(videojsOptions.plugins, {
hotkeys: {
enableVolumeScroll: false,
enableModifiersForNumbers: false,
fullscreenKey: function (event: KeyboardEvent) {
// fullscreen with the f key or Ctrl+Enter
return event.key === 'f' || (event.ctrlKey && event.key === 'Enter')
},
seekStep: function (event: KeyboardEvent) {
// mimic VLC seek behavior, and default to 5 (original value is 5).
if (event.ctrlKey && event.altKey) {
return 5 * 60
} else if (event.ctrlKey) {
return 60
} else if (event.altKey) {
return 10
} else {
return 5
}
},
customKeys: {
increasePlaybackRateKey: {
key: function (event: KeyboardEvent) {
return event.key === '>'
},
handler: function (player: Player) {
player.playbackRate((player.playbackRate() + 0.1).toFixed(2))
}
},
decreasePlaybackRateKey: {
key: function (event: KeyboardEvent) {
return event.key === '<'
},
handler: function (player: Player) {
player.playbackRate((player.playbackRate() - 0.1).toFixed(2))
}
},
frameByFrame: {
key: function (event: KeyboardEvent) {
return event.key === '.'
},
handler: function (player: Player) {
player.pause()
// Calculate movement distance (assuming 30 fps)
const dist = 1 / 30
player.currentTime(player.currentTime() + dist)
}
}
}
}
})
}
if (options.language && !isDefaultLocale(options.language)) {
Object.assign(videojsOptions, { language: options.language })
}
return videojsOptions
}
function getControlBarChildren (options: {
peertubeLink: boolean
theaterMode: boolean,
videoCaptions: VideoJSCaption[]
}) {
const settingEntries = []
// Keep an order
settingEntries.push('playbackRateMenuButton')
if (options.videoCaptions.length !== 0) settingEntries.push('captionsButton')
settingEntries.push('resolutionMenuButton')
const children = {
'playToggle': {},
'currentTimeDisplay': {},
'timeDivider': {},
'durationDisplay': {},
'liveDisplay': {},
'flexibleWidthSpacer': {},
'progressControl': {
children: {
'seekBar': {
children: {
'peerTubeLoadProgressBar': {},
'mouseTimeDisplay': {},
'playProgressBar': {}
}
}
}
},
'webTorrentButton': {},
'muteToggle': {},
'volumeControl': {},
'settingsButton': {
setup: {
maxHeightOffset: 40
},
entries: settingEntries
}
}
if (options.peertubeLink === true) {
Object.assign(children, {
'peerTubeLinkButton': {}
})
}
if (options.theaterMode === true) {
Object.assign(children, {
'theaterButton': {}
})
}
Object.assign(children, {
'fullscreenToggle': {}
})
return children
}
function addContextMenu (player: any, videoEmbedUrl: string) {
player.contextmenuUI({
content: [
{
label: player.localize('Copy the video URL'),
listener: function () {
copyToClipboard(buildVideoLink())
}
},
{
label: player.localize('Copy the video URL at the current time'),
listener: function () {
const player = this as Player
copyToClipboard(buildVideoLink(player.currentTime()))
}
},
{
label: player.localize('Copy embed code'),
listener: () => {
copyToClipboard(buildVideoEmbed(videoEmbedUrl))
}
},
{
label: player.localize('Copy magnet URI'),
listener: function () {
const player = this as Player
copyToClipboard(player.peertube().getCurrentVideoFile().magnetUri)
}
}
]
})
}
function loadLocaleInVideoJS (serverUrl: string, videojs: any, locale: string) {
const path = getLocalePath(serverUrl, locale)
// It is the default locale, nothing to translate
if (!path) return Promise.resolve(undefined)
let p: Promise<any>
if (loadLocaleInVideoJS.cache[path]) {
p = Promise.resolve(loadLocaleInVideoJS.cache[path])
} else {
p = fetch(path + '/player.json')
.then(res => res.json())
.then(json => {
loadLocaleInVideoJS.cache[path] = json
return json
})
.catch(err => {
console.error('Cannot get player translations', err)
return undefined
})
}
const completeLocale = getCompleteLocale(locale)
return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json))
}
namespace loadLocaleInVideoJS {
export const cache: { [ path: string ]: any } = {}
}
function getServerTranslations (serverUrl: string, locale: string) {
const path = getLocalePath(serverUrl, locale)
// It is the default locale, nothing to translate
if (!path) return Promise.resolve(undefined)
return fetch(path + '/server.json')
.then(res => res.json())
.catch(err => {
console.error('Cannot get server translations', err)
return undefined
})
}
// ############################################################################
export {
getServerTranslations,
loadLocaleInVideoJS,
getVideojsOptions,
addContextMenu
}
// ############################################################################
function getLocalePath (serverUrl: string, locale: string) {
const completeLocale = getCompleteLocale(locale)
if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined
return serverUrl + '/client/locales/' + completeLocale
}
// FIXME: something weird with our path definition in tsconfig and typings
// @ts-ignore
import * as videojs from 'video.js'
import './videojs-components/settings-menu-button'
import { PeerTubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { isMobile, timeToInt } from './utils'
import {
getStoredLastSubtitle,
getStoredMute,
getStoredVolume,
saveLastSubtitle,
saveMuteInStore,
saveVolumeInStore
} from './peertube-player-local-storage'
const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
class PeerTubePlugin extends Plugin {
private readonly autoplay: boolean = false
private readonly startTime: number = 0
private readonly videoViewUrl: string
private readonly videoDuration: number
private readonly CONSTANTS = {
USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
}
private player: any
private videoCaptions: VideoJSCaption[]
private defaultSubtitle: string
private videoViewInterval: any
private userWatchingVideoInterval: any
private qualityObservationTimer: any
constructor (player: videojs.Player, options: PeerTubePluginOptions) {
super(player, options)
this.startTime = timeToInt(options.startTime)
this.videoViewUrl = options.videoViewUrl
this.videoDuration = options.videoDuration
this.videoCaptions = options.videoCaptions
if (this.autoplay === true) this.player.addClass('vjs-has-autoplay')
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.defaultSubtitle = options.subtitle || getStoredLastSubtitle()
this.player.on('volumechange', () => {
saveVolumeInStore(this.player.volume())
saveMuteInStore(this.player.muted())
})
this.player.textTracks().on('change', () => {
const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => {
return t.kind === 'captions' && t.mode === 'showing'
})
if (!showing) {
saveLastSubtitle('off')
return
}
saveLastSubtitle(showing.language)
})
this.player.on('sourcechange', () => this.initCaptions())
this.player.duration(options.videoDuration)
this.initializePlayer()
this.runViewAdd()
if (options.userWatching) this.runUserWatchVideo(options.userWatching)
})
}
dispose () {
clearTimeout(this.qualityObservationTimer)
clearInterval(this.videoViewInterval)
if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
}
private initializePlayer () {
if (isMobile()) this.player.addClass('vjs-is-mobile')
this.initSmoothProgressBar()
this.initCaptions()
this.alterInactivity()
}
private runViewAdd () {
this.clearVideoViewInterval()
// After 30 seconds (or 3/4 of the video), add a view to the video
let minSecondsToView = 30
if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4
let secondsViewed = 0
this.videoViewInterval = setInterval(() => {
if (this.player && !this.player.paused()) {
secondsViewed += 1
if (secondsViewed > minSecondsToView) {
this.clearVideoViewInterval()
this.addViewToVideo().catch(err => console.error(err))
}
}
}, 1000)
}
private runUserWatchVideo (options: UserWatching) {
let lastCurrentTime = 0
this.userWatchingVideoInterval = setInterval(() => {
const currentTime = Math.floor(this.player.currentTime())
if (currentTime - lastCurrentTime >= 1) {
lastCurrentTime = currentTime
this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
.catch(err => console.error('Cannot notify user is watching.', err))
}
}, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
}
private clearVideoViewInterval () {
if (this.videoViewInterval !== undefined) {
clearInterval(this.videoViewInterval)
this.videoViewInterval = undefined
}
}
private addViewToVideo () {
if (!this.videoViewUrl) return Promise.resolve(undefined)
return fetch(this.videoViewUrl, { method: 'POST' })
}
private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
const body = new URLSearchParams()
body.append('currentTime', currentTime.toString())
const headers = new Headers({ 'Authorization': authorizationHeader })
return fetch(url, { method: 'PUT', body, headers })
}
private alterInactivity () {
let saveInactivityTimeout: number
const disableInactivity = () => {
saveInactivityTimeout = this.player.options_.inactivityTimeout
this.player.options_.inactivityTimeout = 0
}
const enableInactivity = () => {
this.player.options_.inactivityTimeout = saveInactivityTimeout
}
const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog')
this.player.controlBar.on('mouseenter', () => disableInactivity())
settingsDialog.on('mouseenter', () => disableInactivity())
this.player.controlBar.on('mouseleave', () => enableInactivity())
settingsDialog.on('mouseleave', () => enableInactivity())
}
private initCaptions () {
for (const caption of this.videoCaptions) {
this.player.addRemoteTextTrack({
kind: 'captions',
label: caption.label,
language: caption.language,
id: caption.language,
src: caption.src,
default: this.defaultSubtitle === caption.language
}, false)
}
this.player.trigger('captionsChanged')
}
// Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
private initSmoothProgressBar () {
const SeekBar = videojsUntyped.getComponent('SeekBar')
SeekBar.prototype.getPercent = function getPercent () {
// Allows for smooth scrubbing, when player can't keep up.
// const time = (this.player_.scrubbing()) ?
// this.player_.getCache().currentTime :
// this.player_.currentTime()
const time = this.player_.currentTime()
const percent = time / this.player_.duration()
return percent >= 1 ? 1 : percent
}
SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) {
let newTime = this.calculateDistance(event) * this.player_.duration()
if (newTime === this.player_.duration()) {
newTime = newTime - 0.1
}
this.player_.currentTime(newTime)
this.update()
}
}
}
videojs.registerPlugin('peertube', PeerTubePlugin)
export { PeerTubePlugin }
......@@ -3,11 +3,13 @@
import * as videojs from 'video.js'
import { VideoFile } from '../../../../shared/models/videos/video.model'
import { PeerTubePlugin } from './peertube-videojs-plugin'
import { PeerTubePlugin } from './peertube-plugin'
import { WebTorrentPlugin } from './webtorrent-plugin'
declare namespace videojs {
interface Player {
peertube (): PeerTubePlugin
webtorrent (): WebTorrentPlugin
}
}
......@@ -30,26 +32,73 @@ type UserWatching = {
authorizationHeader: string
}
type PeertubePluginOptions = {
videoFiles: VideoFile[]
playerElement: HTMLVideoElement
type PeerTubePluginOptions = {
autoplay: boolean
videoViewUrl: string
videoDuration: number
startTime: number | string
autoplay: boolean,
videoCaptions: VideoJSCaption[]
subtitle?: string
userWatching?: UserWatching
subtitle?: string
videoCaptions: VideoJSCaption[]
}
type WebtorrentPluginOptions = {
playerElement: HTMLVideoElement
autoplay: boolean
videoDuration: number
videoFiles: VideoFile[]
}
type P2PMediaLoaderPluginOptions = {
type: string
src: string
}
type VideoJSPluginOptions = {
peertube: PeerTubePluginOptions
webtorrent?: WebtorrentPluginOptions
p2pMediaLoader?: P2PMediaLoaderPluginOptions
}
// videojs typings don't have some method we need
const videojsUntyped = videojs as any
type LoadedQualityData = {
qualitySwitchCallback: Function,
qualityData: {
video: {
id: number
label: string
selected: boolean
}[]
}
}
type ResolutionUpdateData = {
auto: boolean,
resolutionId: number
}
type AutoResolutionUpdateData = {
possible: boolean
}
export {
ResolutionUpdateData,
AutoResolutionUpdateData,
VideoJSComponentInterface,
PeertubePluginOptions,
videojsUntyped,
VideoJSCaption,
UserWatching
UserWatching,
PeerTubePluginOptions,
WebtorrentPluginOptions,
P2PMediaLoaderPluginOptions,
VideoJSPluginOptions,
LoadedQualityData
}
import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { bytes } from './utils'
import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
import { bytes } from '../utils'
const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
class WebtorrentInfoButton extends Button {
class P2pInfoButton extends Button {
createEl () {
const div = videojsUntyped.dom.createEl('div', {
......@@ -65,7 +65,7 @@ class WebtorrentInfoButton extends Button {
subDivHttp.appendChild(subDivHttpText)
div.appendChild(subDivHttp)
this.player_.peertube().on('torrentInfo', (event: any, data: any) => {
this.player_.on('p2pInfo', (event: any, data: any) => {
// We are in HTTP fallback
if (!data) {
subDivHttp.className = 'vjs-peertube-displayed'
......@@ -99,4 +99,4 @@ class WebtorrentInfoButton extends Button {
return div
}
}
Button.registerComponent('WebTorrentButton', WebtorrentInfoButton)
Button.registerComponent('P2PInfoButton', P2pInfoButton)
import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { buildVideoLink } from './utils'
import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
import { buildVideoLink } from '../utils'
// FIXME: something weird with our path definition in tsconfig and typings
// @ts-ignore
import { Player } from 'video.js'
......
import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
// FIXME: something weird with our path definition in tsconfig and typings
// @ts-ignore
import { Player } from 'video.js'
......@@ -27,7 +27,7 @@ class PeerTubeLoadProgressBar extends Component {
}
update () {
const torrent = this.player().peertube().getTorrent()
const torrent = this.player().webtorrent().getTorrent()
if (!torrent) return
this.el_.style.width = (torrent.progress * 100) + '%'
......
......@@ -2,7 +2,7 @@
// @ts-ignore
import { Player } from 'video.js'
import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { LoadedQualityData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
import { ResolutionMenuItem } from './resolution-menu-item'
const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu')
......@@ -14,16 +14,18 @@ class ResolutionMenuButton extends MenuButton {
super(player, options)
this.player = player
player.peertube().on('videoFileUpdate', () => this.updateLabel())
player.peertube().on('autoResolutionUpdate', () => this.updateLabel())
player.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data))
if (player.webtorrent) {
player.webtorrent().on('videoFileUpdate', () => setTimeout(() => this.trigger('updateLabel'), 0))
}
}
createEl () {
const el = super.createEl()
this.labelEl_ = videojsUntyped.dom.createEl('div', {
className: 'vjs-resolution-value',
innerHTML: this.buildLabelHTML()
className: 'vjs-resolution-value'
})
el.appendChild(this.labelEl_)
......@@ -36,51 +38,45 @@ class ResolutionMenuButton extends MenuButton {
}
createMenu () {
const menu = new Menu(this.player_)
for (const videoFile of this.player_.peertube().videoFiles) {
let label = videoFile.resolution.label
if (videoFile.fps && videoFile.fps >= 50) {
label += videoFile.fps
}
return new Menu(this.player_)
}
buildCSSClass () {
return super.buildCSSClass() + ' vjs-resolution-button'
}
menu.addChild(new ResolutionMenuItem(
buildWrapperCSSClass () {
return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
}
private buildQualities (data: LoadedQualityData) {
// The automatic resolution item will need other labels
const labels: { [ id: number ]: string } = {}
for (const d of data.qualityData.video) {
this.menu.addChild(new ResolutionMenuItem(
this.player_,
{
id: videoFile.resolution.id,
label,
src: videoFile.magnetUri
id: d.id,
label: d.label,
selected: d.selected,
callback: data.qualitySwitchCallback
})
)
labels[d.id] = d.label
}
menu.addChild(new ResolutionMenuItem(
this.menu.addChild(new ResolutionMenuItem(
this.player_,
{
id: -1,
label: this.player_.localize('Auto'),
src: null
labels,
callback: data.qualitySwitchCallback,
selected: true // By default, in auto mode
}
))
return menu
}
updateLabel () {
if (!this.labelEl_) return
this.labelEl_.innerHTML = this.buildLabelHTML()
}
buildCSSClass () {
return super.buildCSSClass() + ' vjs-resolution-button'
}
buildWrapperCSSClass () {
return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
}
private buildLabelHTML () {
return this.player_.peertube().getCurrentResolutionLabel()
}
}
ResolutionMenuButton.prototype.controlText_ = 'Quality'
......
......@@ -2,61 +2,81 @@
// @ts-ignore
import { Player } from 'video.js'
import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { AutoResolutionUpdateData, ResolutionUpdateData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
class ResolutionMenuItem extends MenuItem {
private readonly id: number
private readonly label: string
// Only used for the automatic item
private readonly labels: { [id: number]: string }
private readonly callback: Function
private autoResolutionPossible: boolean
private currentResolutionLabel: string
constructor (player: Player, options: any) {
const currentResolutionId = player.peertube().getCurrentResolutionId()
options.selectable = true
options.selected = options.id === currentResolutionId
super(player, options)
this.autoResolutionPossible = true
this.currentResolutionLabel = ''
this.label = options.label
this.labels = options.labels
this.id = options.id
this.callback = options.callback
if (player.webtorrent) {
player.webtorrent().on('videoFileUpdate', (_: any, data: ResolutionUpdateData) => this.updateSelection(data))
// We only want to disable the "Auto" item
if (this.id === -1) {
player.webtorrent().on('autoResolutionUpdate', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data))
}
}
player.peertube().on('videoFileUpdate', () => this.updateSelection())
player.peertube().on('autoResolutionUpdate', () => this.updateSelection())
// TODO: update on HLS change
}
handleClick (event: any) {
if (this.id === -1 && this.player_.peertube().isAutoResolutionForbidden()) return
// Auto button disabled?
if (this.autoResolutionPossible === false && this.id === -1) return
super.handleClick(event)
// Auto resolution
if (this.id === -1) {
this.player_.peertube().enableAutoResolution()
return
}
this.player_.peertube().disableAutoResolution()
this.player_.peertube().updateResolution(this.id)
this.callback(this.id)
}
updateSelection () {
// Check if auto resolution is forbidden or not
updateSelection (data: ResolutionUpdateData) {
if (this.id === -1) {
if (this.player_.peertube().isAutoResolutionForbidden()) {
this.addClass('disabled')
} else {
this.removeClass('disabled')
}
this.currentResolutionLabel = this.labels[data.resolutionId]
}
if (this.player_.peertube().isAutoResolutionOn()) {
// Automatic resolution only
if (data.auto === true) {
this.selected(this.id === -1)
return
}
this.selected(this.player_.peertube().getCurrentResolutionId() === this.id)
this.selected(this.id === data.resolutionId)
}
updateAutoResolution (data: AutoResolutionUpdateData) {
// Check if the auto resolution is enabled or not
if (data.possible === false) {
this.addClass('disabled')
} else {
this.removeClass('disabled')
}
this.autoResolutionPossible = data.possible
}
getLabel () {
if (this.id === -1) {
return this.label + ' <small>' + this.player_.peertube().getCurrentResolutionLabel() + '</small>'
return this.label + ' <small>' + this.currentResolutionLabel + '</small>'
}
return this.label
......
......@@ -6,8 +6,8 @@
import * as videojs from 'video.js'
import { SettingsMenuItem } from './settings-menu-item'
import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { toTitleCase } from './utils'
import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
import { toTitleCase } from '../utils'
const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu')
......
......@@ -5,8 +5,8 @@
// @ts-ignore
import * as videojs from 'video.js'
import { toTitleCase } from './utils'
import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { toTitleCase } from '../utils'
import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
......@@ -220,12 +220,9 @@ class SettingsMenuItem extends MenuItem {
}
build () {
const saveUpdateLabel = this.subMenu.updateLabel
this.subMenu.updateLabel = () => {
this.subMenu.on('updateLabel', () => {
this.update()
saveUpdateLabel.call(this.subMenu)
}
})
this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_)
this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_)
......
......@@ -2,8 +2,8 @@
// @ts-ignore
import * as videojs from 'video.js'
import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { saveTheaterInStore, getStoredTheater } from './peertube-player-local-storage'
import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
import { saveTheaterInStore, getStoredTheater } from '../peertube-player-local-storage'
const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
class TheaterButton extends Button {
......
......@@ -17,17 +17,13 @@ import 'core-js/es6/set'
// For google bot that uses Chrome 41 and does not understand fetch
import 'whatwg-fetch'
// FIXME: something weird with our path definition in tsconfig and typings
// @ts-ignore
import * as vjs from 'video.js'
import * as Channel from 'jschannel'
import { peertubeTranslate, ResultList, VideoDetails } from '../../../../shared'
import { addContextMenu, getServerTranslations, getVideojsOptions, loadLocaleInVideoJS } from '../../assets/player/peertube-player'
import { PeerTubeResolution } from '../player/definitions'
import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model'
import { PeertubePlayerManager, PeertubePlayerManagerOptions } from '../../assets/player/peertube-player-manager'
/**
* Embed API exposes control of the embed player to the outside world via
......@@ -73,16 +69,16 @@ class PeerTubeEmbedApi {
}
private setResolution (resolutionId: number) {
if (resolutionId === -1 && this.embed.player.peertube().isAutoResolutionForbidden()) return
if (resolutionId === -1 && this.embed.player.webtorrent().isAutoResolutionForbidden()) return
// Auto resolution
if (resolutionId === -1) {
this.embed.player.peertube().enableAutoResolution()
this.embed.player.webtorrent().enableAutoResolution()
return
}
this.embed.player.peertube().disableAutoResolution()
this.embed.player.peertube().updateResolution(resolutionId)
this.embed.player.webtorrent().disableAutoResolution()
this.embed.player.webtorrent().updateResolution(resolutionId)
}
/**
......@@ -122,15 +118,17 @@ class PeerTubeEmbedApi {
// PeerTube specific capabilities
this.embed.player.peertube().on('autoResolutionUpdate', () => this.loadResolutions())
this.embed.player.peertube().on('videoFileUpdate', () => this.loadResolutions())
if (this.embed.player.webtorrent) {
this.embed.player.webtorrent().on('autoResolutionUpdate', () => this.loadWebTorrentResolutions())
this.embed.player.webtorrent().on('videoFileUpdate', () => this.loadWebTorrentResolutions())
}
}
private loadResolutions () {
private loadWebTorrentResolutions () {
let resolutions = []
let currentResolutionId = this.embed.player.peertube().getCurrentResolutionId()
let currentResolutionId = this.embed.player.webtorrent().getCurrentResolutionId()
for (const videoFile of this.embed.player.peertube().videoFiles) {
for (const videoFile of this.embed.player.webtorrent().videoFiles) {
let label = videoFile.resolution.label
if (videoFile.fps && videoFile.fps >= 50) {
label += videoFile.fps
......@@ -266,9 +264,8 @@ class PeerTubeEmbed {
const urlParts = window.location.pathname.split('/')
const videoId = urlParts[ urlParts.length - 1 ]
const [ , serverTranslations, videoResponse, captionsResponse ] = await Promise.all([
loadLocaleInVideoJS(window.location.origin, vjs, navigator.language),
getServerTranslations(window.location.origin, navigator.language),
const [ serverTranslations, videoResponse, captionsResponse ] = await Promise.all([
PeertubePlayerManager.getServerTranslations(window.location.origin, navigator.language),
this.loadVideoInfo(videoId),
this.loadVideoCaptions(videoId)
])
......@@ -292,43 +289,56 @@ class PeerTubeEmbed {
this.loadParams()
const videojsOptions = getVideojsOptions({
autoplay: this.autoplay,
controls: this.controls,
muted: this.muted,
loop: this.loop,
startTime: this.startTime,
subtitle: this.subtitle,
videoCaptions,
inactivityTimeout: 1500,
videoViewUrl: this.getVideoUrl(videoId) + '/views',
playerElement: this.videoElement,
videoFiles: videoInfo.files,
videoDuration: videoInfo.duration,
enableHotkeys: true,
peertubeLink: true,
poster: window.location.origin + videoInfo.previewPath,
theaterMode: false
})
const options: PeertubePlayerManagerOptions = {
common: {
autoplay: this.autoplay,
controls: this.controls,
muted: this.muted,
loop: this.loop,
captions: videoCaptions.length !== 0,
startTime: this.startTime,
subtitle: this.subtitle,
videoCaptions,
inactivityTimeout: 1500,
videoViewUrl: this.getVideoUrl(videoId) + '/views',
playerElement: this.videoElement,
videoDuration: videoInfo.duration,
enableHotkeys: true,
peertubeLink: true,
poster: window.location.origin + videoInfo.previewPath,
theaterMode: false,
serverUrl: window.location.origin,
language: navigator.language,
embedUrl: window.location.origin + videoInfo.embedPath
},
webtorrent: {
videoFiles: videoInfo.files
}
// p2pMediaLoader: {
// // playlistUrl: 'https://akamai-axtest.akamaized.net/routes/lapd-v1-acceptance/www_c4/Manifest.m3u8'
// // playlistUrl: 'https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8'
// playlistUrl: 'https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'
// }
}
this.playerOptions = videojsOptions
this.player = vjs(this.videoContainerId, videojsOptions, () => {
this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))
this.player = await PeertubePlayerManager.initialize('webtorrent', options)
window[ 'videojsPlayer' ] = this.player
this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))
if (this.controls) {
this.player.dock({
title: videoInfo.name,
description: this.player.localize('Uses P2P, others may know your IP is downloading this video.')
})
}
window[ 'videojsPlayer' ] = this.player
addContextMenu(this.player, window.location.origin + videoInfo.embedPath)
if (this.controls) {
this.player.dock({
title: videoInfo.name,
description: this.player.localize('Uses P2P, others may know your IP is downloading this video.')
})
}
this.initializeApi()
})
this.initializeApi()
}
private handleError (err: Error, translations?: { [ id: string ]: string }) {
......
......@@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "../out-tsc/app",
"baseUrl": "./",
"module": "es2015",
"module": "esnext",
"types": [],
"lib": [
"es2017",
......
......@@ -394,6 +394,11 @@
semver "5.5.1"
semver-intersect "1.4.0"
"@streamroot/videojs-hlsjs-plugin@^1.0.7":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@streamroot/videojs-hlsjs-plugin/-/videojs-hlsjs-plugin-1.0.7.tgz#581aecdf6a966162b404c60bd3ab8264eb89d334"
integrity sha512-7oAIOhEFxkfLOYWDfg7Oh3+OrnoTElRvUE3Jblg2B+SHmnrw4YXQnAwYJ0AHjNIBKoHnQubzZGttLaHAFJVspQ==
"@types/bittorrent-protocol@*":
version "2.2.2"
resolved "https://registry.yarnpkg.com/@types/bittorrent-protocol/-/bittorrent-protocol-2.2.2.tgz#169e9633e1bd18e6b830d11cf42e611b1972cb83"
......@@ -1445,7 +1450,7 @@ bittorrent-protocol@^3.0.0:
unordered-array-remove "^1.0.2"
xtend "^4.0.0"
bittorrent-tracker@^9.0.0:
bittorrent-tracker@^9.0.0, bittorrent-tracker@^9.10.1:
version "9.10.1"
resolved "https://registry.yarnpkg.com/bittorrent-tracker/-/bittorrent-tracker-9.10.1.tgz#5de14aac012a287af394d3cc9eda1ec6cc956f11"
integrity sha512-n5zTL/g6Wt0rb2EnkiyiaGYhth7I/N0/xMqGUpvGX/7g1scDGBVPhJnXR8lfp3/OMj681fv40o4q/otECMtZSA==
......@@ -3305,6 +3310,11 @@ events@^1.0.0:
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=
events@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
integrity sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==
eventsource@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.0.7.tgz#8fbc72c93fcd34088090bc0a4e64f4b5cee6d8d0"
......@@ -3900,7 +3910,7 @@ genfun@^5.0.0:
resolved "https://registry.yarnpkg.com/genfun/-/genfun-5.0.0.tgz#9dd9710a06900a5c4a5bf57aca5da4e52fe76537"
integrity sha512-KGDOARWVga7+rnB3z9Sd2Letx515owfk0hSxHGuqjANb1M+x2bGZGqHLiozPsYMdM2OubeMni/Hpwmjq6qIUhA==
get-browser-rtc@^1.0.0:
get-browser-rtc@^1.0.0, get-browser-rtc@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/get-browser-rtc/-/get-browser-rtc-1.0.2.tgz#bbcd40c8451a7ed4ef5c373b8169a409dd1d11d9"
integrity sha1-u81AyEUaftTvXDc7gWmkCd0dEdk=
......@@ -6108,6 +6118,13 @@ [email protected]:
resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.2.0.tgz#c8e0785fd17f741f4408b49466889274a9e36447"
integrity sha512-LVHw0U6IPJjwk9i9f7Xe26NqaUHTNlIt4SSWoEfYFROeVKHN6MIjOhbRheI3dg8Jbq5WCuMFQ0QU3EgZpmzFPg==
m3u8-parser@^4.2.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.3.0.tgz#4b4e988f87b6d8b2401d209a1d17798285a9da04"
integrity sha512-bVbjuBMoVIgFL1vpXVIxjeaoB5TPDJRb0m5qiTdM738SGqv/LAmsnVVPlKjM4fulm/rr1XZsKM+owHm+zvqxYA==
dependencies:
global "^4.3.2"
magic-string@^0.25.0:
version "0.25.1"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.1.tgz#b1c248b399cd7485da0fe7385c2fc7011843266e"
......@@ -7214,6 +7231,26 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
integrity sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==
p2p-media-loader-core@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/p2p-media-loader-core/-/p2p-media-loader-core-0.3.0.tgz#75687d7d7bee835d5c6c2f17d346add2dbe43b83"
integrity sha512-WKB9ONdA0kDQHXr6nixIL8t0UZuTD9Pqi/BIuaTiPUGDwYXUS/Mf5YynLAUupniLkIaDYD7/jmSLWqpZUDsAyw==
dependencies:
bittorrent-tracker "^9.10.1"
debug "^4.1.0"
events "^3.0.0"
get-browser-rtc "^1.0.2"
sha.js "^2.4.11"
p2p-media-loader-hlsjs@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-0.3.0.tgz#4ee15d4d1a23aa0322a5be2bc6c329b6c913028d"
integrity sha512-U7PzMG5X7CVQ15OtMPRQjW68Msu0fuw8Pp0PRznX5uK0p26tSYMT/ZYCNeYCoDg3wGgJHM+327ed3M7TRJ4lcw==
dependencies:
events "^3.0.0"
m3u8-parser "^4.2.0"
p2p-media-loader-core "^0.3.0"
package-json-versionify@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/package-json-versionify/-/package-json-versionify-1.0.4.tgz#5860587a944873a6b7e6d26e8e51ffb22315bf17"
......@@ -8699,7 +8736,7 @@ [email protected]:
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
sha.js@^2.4.0, sha.js@^2.4.8:
sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8:
version "2.4.11"
resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7"
integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==
......@@ -10090,6 +10127,14 @@ videojs-contextmenu-ui@^5.0.0:
global "^4.3.2"
video.js "^6 || ^7"
videojs-contrib-quality-levels@^2.0.9:
version "2.0.9"
resolved "https://registry.yarnpkg.com/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-2.0.9.tgz#b5d533d5092a6fc7d29eae1b43e4597d89bd527b"
integrity sha512-HJeaJJQdSufi9Y5T7jlyyhkeq+mWPCog86q6ypoTi66boBMMJTo2abiOSHS9KaOGAJjH72gfvrjVY5FRdjlxYA==
dependencies:
global "^4.3.2"
video.js "^6 || ^7"
videojs-dock@^2.0.2:
version "2.1.4"
resolved "https://registry.yarnpkg.com/videojs-dock/-/videojs-dock-2.1.4.tgz#0ebd198b5d48990e3523fdc87dbfdb9fe96f804c"
......
......@@ -4,7 +4,7 @@ set -eu
if [ ! -f "./client/dist/en_US/index.html" ]; then
echo "client/dist/en_US/index.html does not exist, compile client files..."
npm run build:client
npm run build:client -- --light
fi
npm run watch:server
......@@ -16,7 +16,7 @@ const baseDirectives = Object.assign({},
baseUri: ["'self'"],
manifestSrc: ["'self'"],
frameSrc: ["'self'"], // instead of deprecated child-src / self because of test-embed
workerSrc: ["'self'"] // instead of deprecated child-src
workerSrc: ["'self'", 'blob:'] // instead of deprecated child-src
},
CONFIG.SERVICES['CSP-LOGGER'] ? { reportUri: CONFIG.SERVICES['CSP-LOGGER'] } : {},
CONFIG.WEBSERVER.SCHEME === 'https' ? { upgradeInsecureRequests: true } : {}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment