Separate player in dedicated build
* Separate player in a dedicated build, that we can control using vite. We had too many issues with Angular build system and we can now have the same build between the embed and the client. We can also embed SVG directly in the CSS * Upgrade p2p-media-loader to v2 * Update internal infohashes to reflect this p2p-media-loader protocol change (they are updated at PeerTube startup) * Minimum required iOS version is now v14
|
@ -1,4 +1,4 @@
|
||||||
last 1 Chrome version
|
last 1 Chrome version
|
||||||
last 2 Edge major versions
|
last 2 Edge major versions
|
||||||
Firefox ESR
|
Firefox ESR
|
||||||
ios_saf >= 13.1
|
ios_saf >= 14
|
||||||
|
|
|
@ -163,7 +163,8 @@
|
||||||
"@typescript-eslint/unbound-method": [
|
"@typescript-eslint/unbound-method": [
|
||||||
"error",
|
"error",
|
||||||
{ "ignoreStatic": true }
|
{ "ignoreStatic": true }
|
||||||
]
|
],
|
||||||
|
"import/no-named-default": "off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -12,6 +12,8 @@
|
||||||
/e2e/local.log
|
/e2e/local.log
|
||||||
/e2e/browserstack.err
|
/e2e/browserstack.err
|
||||||
/e2e/screenshots
|
/e2e/screenshots
|
||||||
|
/src/standalone/player/build
|
||||||
|
/src/standalone/player/dist
|
||||||
/src/standalone/embed-player-api/build
|
/src/standalone/embed-player-api/build
|
||||||
/src/standalone/embed-player-api/dist
|
/src/standalone/embed-player-api/dist
|
||||||
/e2e/logs
|
/e2e/logs
|
||||||
|
|
|
@ -183,7 +183,10 @@
|
||||||
"includePaths": [
|
"includePaths": [
|
||||||
"src/sass/include",
|
"src/sass/include",
|
||||||
"."
|
"."
|
||||||
]
|
],
|
||||||
|
"sass": {
|
||||||
|
"silenceDeprecations": [ "import", "mixed-decls", "color-functions", "global-builtin" ]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/assets/images",
|
"src/assets/images",
|
||||||
|
@ -212,7 +215,9 @@
|
||||||
"is-plain-object",
|
"is-plain-object",
|
||||||
"parse-srcset",
|
"parse-srcset",
|
||||||
"deepmerge",
|
"deepmerge",
|
||||||
"core-js/features/reflect"
|
"core-js/features/reflect",
|
||||||
|
"hammerjs",
|
||||||
|
"jschannel"
|
||||||
],
|
],
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
|
@ -241,7 +246,7 @@
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "6kb",
|
"maximumWarning": "6kb",
|
||||||
"maximumError": "100kb"
|
"maximumError": "120kb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"target": "ES2015",
|
"target": "ES2018",
|
||||||
"typeRoots": [
|
"typeRoots": [
|
||||||
"../node_modules/@types",
|
"../node_modules/@types",
|
||||||
"../node_modules"
|
"../node_modules"
|
||||||
|
|
|
@ -24,10 +24,13 @@
|
||||||
"net": false,
|
"net": false,
|
||||||
"stream": false,
|
"stream": false,
|
||||||
"os": false,
|
"os": false,
|
||||||
|
"http": false,
|
||||||
|
"dgram": false,
|
||||||
"util": false
|
"util": false
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"../packages/*"
|
"../packages/*",
|
||||||
|
"./src/standalone/player"
|
||||||
],
|
],
|
||||||
"typings": "*.d.ts",
|
"typings": "*.d.ts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -55,8 +58,6 @@
|
||||||
"@ngx-loading-bar/http-client": "^7.0.0",
|
"@ngx-loading-bar/http-client": "^7.0.0",
|
||||||
"@ngx-loading-bar/router": "^7.0.0",
|
"@ngx-loading-bar/router": "^7.0.0",
|
||||||
"@peertube/maildev": "^1.2.0",
|
"@peertube/maildev": "^1.2.0",
|
||||||
"@peertube/p2p-media-loader-core": "^1.0.20",
|
|
||||||
"@peertube/p2p-media-loader-hlsjs": "^1.0.20",
|
|
||||||
"@peertube/xliffmerge": "^2.0.3",
|
"@peertube/xliffmerge": "^2.0.3",
|
||||||
"@plussub/srt-vtt-parser": "^2.0.5",
|
"@plussub/srt-vtt-parser": "^2.0.5",
|
||||||
"@popperjs/core": "^2.11.5",
|
"@popperjs/core": "^2.11.5",
|
||||||
|
@ -103,8 +104,11 @@
|
||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"markdown-it-emoji": "^3.0.0",
|
"markdown-it-emoji": "^3.0.0",
|
||||||
"ngx-uploadx": "^7.0.0",
|
"ngx-uploadx": "^7.0.0",
|
||||||
|
"p2p-media-loader-core": "^2.1.2",
|
||||||
|
"p2p-media-loader-hlsjs": "^2.1.2",
|
||||||
"primeng": "^17",
|
"primeng": "^17",
|
||||||
"rxjs": "^7.3.0",
|
"rxjs": "^7.3.0",
|
||||||
|
"sass-embedded": "^1.83.4",
|
||||||
"sha.js": "^2.4.11",
|
"sha.js": "^2.4.11",
|
||||||
"socket.io-client": "^4.5.4",
|
"socket.io-client": "^4.5.4",
|
||||||
"stylelint": "^16.2.1",
|
"stylelint": "^16.2.1",
|
||||||
|
@ -115,9 +119,9 @@
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.4.0",
|
||||||
"typescript": "~5.7.3",
|
"typescript": "~5.7.3",
|
||||||
"video.js": "^7.19.2",
|
"video.js": "^7.19.2",
|
||||||
"vite": "^5.3.1",
|
"vite": "^6.0.11",
|
||||||
"vite-plugin-checker": "^0.7.2",
|
"vite-plugin-checker": "^0.8.0",
|
||||||
"vite-plugin-node-polyfills": "^0.22.0",
|
"vite-plugin-node-polyfills": "^0.23.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
|
|
|
@ -1,5 +1 @@
|
||||||
@use 'node_modules/video.js/dist/video-js';
|
@use '../../../standalone/player/build/peertube-player.css';
|
||||||
|
|
||||||
$assets-path: '../../assets/';
|
|
||||||
|
|
||||||
@use '../../../sass/player/index';
|
|
||||||
|
|
|
@ -47,16 +47,18 @@ import { logger } from '@root-helpers/logger'
|
||||||
import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video'
|
import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video'
|
||||||
import debug from 'debug'
|
import debug from 'debug'
|
||||||
import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
|
import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
|
||||||
|
import { environment } from '../../../environments/environment'
|
||||||
import {
|
import {
|
||||||
|
cleanupVideoWatch,
|
||||||
|
getStoredTheater,
|
||||||
|
getStoredVideoWatchHistory,
|
||||||
HLSOptions,
|
HLSOptions,
|
||||||
PeerTubePlayer,
|
PeerTubePlayer,
|
||||||
PeerTubePlayerConstructorOptions,
|
PeerTubePlayerConstructorOptions,
|
||||||
PeerTubePlayerLoadOptions,
|
PeerTubePlayerLoadOptions,
|
||||||
PlayerMode,
|
PlayerMode,
|
||||||
videojs
|
videojs
|
||||||
} from '../../../assets/player'
|
} from '@peertube/player'
|
||||||
import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from '../../../assets/player/peertube-player-local-storage'
|
|
||||||
import { environment } from '../../../environments/environment'
|
|
||||||
import { DateToggleComponent } from '../../shared/shared-main/date/date-toggle.component'
|
import { DateToggleComponent } from '../../shared/shared-main/date/date-toggle.component'
|
||||||
import { PluginPlaceholderComponent } from '../../shared/shared-main/plugins/plugin-placeholder.component'
|
import { PluginPlaceholderComponent } from '../../shared/shared-main/plugins/plugin-placeholder.component'
|
||||||
import { VideoViewsCounterComponent } from '../../shared/shared-video/video-views-counter.component'
|
import { VideoViewsCounterComponent } from '../../shared/shared-video/video-views-counter.component'
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './peertube-player'
|
|
||||||
export * from './types'
|
|
|
@ -2,7 +2,7 @@
|
||||||
import debug from 'debug'
|
import debug from 'debug'
|
||||||
import { firstValueFrom, ReplaySubject } from 'rxjs'
|
import { firstValueFrom, ReplaySubject } from 'rxjs'
|
||||||
import { first, shareReplay } from 'rxjs/operators'
|
import { first, shareReplay } from 'rxjs/operators'
|
||||||
import { RegisterClientHelpers } from 'src/types/register-client-option.model'
|
import { RegisterClientHelpers } from '../types/register-client-option.model'
|
||||||
import { getExternalAuthHref, getHookType, internalRunHook } from '@peertube/peertube-core-utils'
|
import { getExternalAuthHref, getHookType, internalRunHook } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
ClientDoAction,
|
ClientDoAction,
|
||||||
|
@ -83,6 +83,7 @@ class PluginsManager {
|
||||||
private readonly onFormFields: OnFormFields
|
private readonly onFormFields: OnFormFields
|
||||||
private readonly onSettingsScripts: OnSettingsScripts
|
private readonly onSettingsScripts: OnSettingsScripts
|
||||||
private readonly onClientRoute: OnClientRoute
|
private readonly onClientRoute: OnClientRoute
|
||||||
|
private readonly backendUrl: string
|
||||||
|
|
||||||
constructor (options: {
|
constructor (options: {
|
||||||
doAction?: ClientDoAction
|
doAction?: ClientDoAction
|
||||||
|
@ -90,12 +91,14 @@ class PluginsManager {
|
||||||
onFormFields?: OnFormFields
|
onFormFields?: OnFormFields
|
||||||
onSettingsScripts?: OnSettingsScripts
|
onSettingsScripts?: OnSettingsScripts
|
||||||
onClientRoute?: OnClientRoute
|
onClientRoute?: OnClientRoute
|
||||||
|
backendUrl?: string
|
||||||
}) {
|
}) {
|
||||||
this.doAction = options.doAction
|
this.doAction = options.doAction
|
||||||
this.peertubeHelpersFactory = options.peertubeHelpersFactory
|
this.peertubeHelpersFactory = options.peertubeHelpersFactory
|
||||||
this.onFormFields = options.onFormFields
|
this.onFormFields = options.onFormFields
|
||||||
this.onSettingsScripts = options.onSettingsScripts
|
this.onSettingsScripts = options.onSettingsScripts
|
||||||
this.onClientRoute = options.onClientRoute
|
this.onClientRoute = options.onClientRoute
|
||||||
|
this.backendUrl = options.backendUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPluginPathPrefix (isTheme: boolean) {
|
static getPluginPathPrefix (isTheme: boolean) {
|
||||||
|
@ -281,7 +284,7 @@ class PluginsManager {
|
||||||
|
|
||||||
logger.info(`Loading script ${clientScript.script} of plugin ${plugin.name}`)
|
logger.info(`Loading script ${clientScript.script} of plugin ${plugin.name}`)
|
||||||
|
|
||||||
const absURL = (environment.apiUrl || window.location.origin) + clientScript.script
|
const absURL = (this.backendUrl || environment.apiUrl || window.location.origin) + clientScript.script
|
||||||
return dynamicImport(absURL)
|
return dynamicImport(absURL)
|
||||||
.then((script: ClientScript) => {
|
.then((script: ClientScript) => {
|
||||||
return script.register({
|
return script.register({
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export function getCSSConfig (root: string) {
|
||||||
|
return {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
api: 'modern-compiler',
|
||||||
|
loadPaths: [ resolve(root, './src/sass/include') ],
|
||||||
|
// FIXME: Wait for bootstrap upgrade that fixes deprecated sass utils
|
||||||
|
silenceDeprecations: [ 'import', 'mixed-decls', 'color-functions', 'global-builtin' ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAliasConfig (root: string) {
|
||||||
|
return [
|
||||||
|
{ find: /^video.js$/, replacement: resolve(root, './node_modules/video.js/core.js') },
|
||||||
|
{ find: '@root-helpers', replacement: resolve(root, './src/root-helpers') }
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "@peertube/player",
|
||||||
|
"private": false,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rm -rf ./build && ../../../node_modules/.bin/vite build --mode production --config ./vite.config.mjs",
|
||||||
|
"dev": "../../../node_modules/.bin/vite build --mode dev --watch --config ./vite.config.mjs"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/Chocobozzz/PeerTube.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"peertube",
|
||||||
|
"embed"
|
||||||
|
],
|
||||||
|
"main": "./build/peertube-player.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./build/peertube-player.js"
|
||||||
|
},
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"author": "Chocobozzz",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": true,
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/Chocobozzz/PeerTube/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/Chocobozzz/PeerTube#readme",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './peertube-player'
|
||||||
|
export * from './peertube-player-local-storage'
|
||||||
|
export * from './types'
|
||||||
|
|
||||||
|
import './sass/player.scss'
|
|
@ -31,6 +31,8 @@ import './shared/metrics/metrics-plugin'
|
||||||
import './shared/p2p-media-loader/hls-plugin'
|
import './shared/p2p-media-loader/hls-plugin'
|
||||||
import './shared/p2p-media-loader/p2p-media-loader-plugin'
|
import './shared/p2p-media-loader/p2p-media-loader-plugin'
|
||||||
import './shared/web-video/web-video-plugin'
|
import './shared/web-video/web-video-plugin'
|
||||||
|
import './shared/dock/peertube-dock-component'
|
||||||
|
import './shared/dock/peertube-dock-plugin'
|
||||||
import videojs, { VideoJsPlayer } from 'video.js'
|
import videojs, { VideoJsPlayer } from 'video.js'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { PluginsManager } from '@root-helpers/plugins-manager'
|
import { PluginsManager } from '@root-helpers/plugins-manager'
|
||||||
|
@ -40,7 +42,7 @@ import { isMobile } from '@root-helpers/web-browser'
|
||||||
import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@peertube/peertube-core-utils'
|
import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@peertube/peertube-core-utils'
|
||||||
import { saveAverageBandwidth } from './peertube-player-local-storage'
|
import { saveAverageBandwidth } from './peertube-player-local-storage'
|
||||||
import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder'
|
import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder'
|
||||||
import { TranslationsManager } from './translations-manager'
|
import { TranslationsManager } from '@root-helpers/translations-manager'
|
||||||
import { PeerTubePlayerConstructorOptions, PeerTubePlayerLoadOptions, PlayerNetworkInfo, VideoJSPluginOptions } from './types'
|
import { PeerTubePlayerConstructorOptions, PeerTubePlayerLoadOptions, PlayerNetworkInfo, VideoJSPluginOptions } from './types'
|
||||||
|
|
||||||
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
|
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
|
|
@ -0,0 +1,2 @@
|
||||||
|
@use '../../../node_modules/video.js/dist/video-js';
|
||||||
|
@use './shared/index.scss';
|
|
@ -22,5 +22,3 @@ $control-bar-total-height: $control-bar-height - $control-bar-slider-top;
|
||||||
$progress-margin: 10px;
|
$progress-margin: 10px;
|
||||||
|
|
||||||
$dock-padding: 20px;
|
$dock-padding: 20px;
|
||||||
|
|
||||||
$assets-path: '../../assets/' !default;
|
|
|
@ -50,8 +50,8 @@ $context-menu-width: 350px;
|
||||||
|
|
||||||
@each $icon in $icons {
|
@each $icon in $icons {
|
||||||
&[class$="-#{$icon}"] {
|
&[class$="-#{$icon}"] {
|
||||||
mask-image: url('#{$assets-path}/player/images/#{$icon}.svg');
|
mask-image: url('./svg/#{$icon}.svg');
|
||||||
-webkit-mask-image: url('#{$assets-path}/player/images/#{$icon}.svg');
|
-webkit-mask-image: url('./svg/#{$icon}.svg');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -270,11 +270,11 @@ $chapter-marker-size: 9px;
|
||||||
@include margin-right(2px);
|
@include margin-right(2px);
|
||||||
|
|
||||||
&.icon-download {
|
&.icon-download {
|
||||||
background-image: url('#{$assets-path}/player/images/arrow-down.svg');
|
background-image: url('./svg/arrow-down.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&.icon-upload {
|
&.icon-upload {
|
||||||
background-image: url('#{$assets-path}/player/images/arrow-up.svg');
|
background-image: url('./svg/arrow-up.svg');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -291,8 +291,8 @@ $chapter-marker-size: 9px;
|
||||||
.icon {
|
.icon {
|
||||||
&.icon-next,
|
&.icon-next,
|
||||||
&.icon-previous {
|
&.icon-previous {
|
||||||
mask-image: url('#{$assets-path}/player/images/next.svg');
|
mask-image: url('./svg/next.svg');
|
||||||
-webkit-mask-image: url('#{$assets-path}/player/images/next.svg');
|
-webkit-mask-image: url('./svg/next.svg');
|
||||||
mask-size: cover;
|
mask-size: cover;
|
||||||
-webkit-mask-size: cover;
|
-webkit-mask-size: cover;
|
||||||
|
|
||||||
|
@ -319,7 +319,7 @@ $chapter-marker-size: 9px;
|
||||||
width: $control-bar-icon-size;
|
width: $control-bar-icon-size;
|
||||||
height: $control-bar-icon-size;
|
height: $control-bar-icon-size;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
background: url('#{$assets-path}/player/images/volume.svg') no-repeat;
|
background: url('./svg/volume.svg') no-repeat;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
@ -328,7 +328,7 @@ $chapter-marker-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.vjs-vol-0 .vjs-icon-placeholder {
|
&.vjs-vol-0 .vjs-icon-placeholder {
|
||||||
background: url('#{$assets-path}/player/images/volume-mute.svg') no-repeat;
|
background: url('./svg/volume-mute.svg') no-repeat;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -415,7 +415,7 @@ $chapter-marker-size: 9px;
|
||||||
height: $control-bar-icon-size - 4px;
|
height: $control-bar-icon-size - 4px;
|
||||||
width: $control-bar-icon-size - 4px;
|
width: $control-bar-icon-size - 4px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
background: url('#{$assets-path}/player/images/settings.svg') no-repeat;
|
background: url('./svg/settings.svg') no-repeat;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
@ -448,7 +448,7 @@ $chapter-marker-size: 9px;
|
||||||
width: $control-bar-icon-size - 4px;
|
width: $control-bar-icon-size - 4px;
|
||||||
height: $control-bar-icon-size - 4px;
|
height: $control-bar-icon-size - 4px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
background: url('#{$assets-path}/player/images/theater.svg') no-repeat;
|
background: url('./svg/theater.svg') no-repeat;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
@ -493,7 +493,7 @@ $chapter-marker-size: 9px;
|
||||||
width: $control-bar-icon-size;
|
width: $control-bar-icon-size;
|
||||||
height: $control-bar-icon-size;
|
height: $control-bar-icon-size;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
background: url('#{$assets-path}/player/images/fullscreen.svg') no-repeat;
|
background: url('./svg/fullscreen.svg') no-repeat;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
@ -515,14 +515,6 @@ $chapter-marker-size: 9px;
|
||||||
.vjs-theater-control {
|
.vjs-theater-control {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vjs-peertube {
|
|
||||||
.icon,
|
|
||||||
.download-speed-text,
|
|
||||||
.upload-speed-text {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-js.vjs-peertube-skin.vjs-size-570 .vjs-control-bar {
|
.video-js.vjs-peertube-skin.vjs-size-570 .vjs-control-bar {
|
||||||
|
@ -540,6 +532,12 @@ $chapter-marker-size: 9px;
|
||||||
.vjs-peertube-displayed {
|
.vjs-peertube-displayed {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon,
|
||||||
|
.download-speed-text,
|
||||||
|
.upload-speed-text {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.vjs-peertube-link {
|
.vjs-peertube-link {
|
|
@ -60,7 +60,7 @@ body {
|
||||||
|
|
||||||
.vjs-icon-placeholder::before {
|
.vjs-icon-placeholder::before {
|
||||||
content: '';
|
content: '';
|
||||||
background-image: url('#{$assets-path}/player/images/big-play-button.svg');
|
background-image: url('./svg/big-play-button.svg');
|
||||||
|
|
||||||
@include big-play-button-triangle-size(45px);
|
@include big-play-button-triangle-size(45px);
|
||||||
}
|
}
|
|
@ -44,8 +44,8 @@ $playlist-menu-width: 350px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cross {
|
.cross {
|
||||||
mask-image: url('#{$assets-path}/images/feather/x.svg');
|
mask-image: url('./svg/x.svg');
|
||||||
-webkit-mask-image: url('#{$assets-path}/images/feather/x.svg');
|
-webkit-mask-image: url('./svg/x.svg');
|
||||||
mask-size: cover;
|
mask-size: cover;
|
||||||
-webkit-mask-size: cover;
|
-webkit-mask-size: cover;
|
||||||
|
|
||||||
|
@ -92,8 +92,8 @@ $playlist-menu-width: 350px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vjs-playlist-icon {
|
.vjs-playlist-icon {
|
||||||
mask-image: url('#{$assets-path}/images/feather/playlists.svg');
|
mask-image: url('./svg/playlists.svg');
|
||||||
-webkit-mask-image: url('#{$assets-path}/images/feather/playlists.svg');
|
-webkit-mask-image: url('./svg/playlists.svg');
|
||||||
mask-size: cover;
|
mask-size: cover;
|
||||||
-webkit-mask-size: cover;
|
-webkit-mask-size: cover;
|
||||||
|
|
|
@ -145,7 +145,7 @@ $setting-transition-easing: ease-out;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
content: ' ';
|
content: ' ';
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
background-image: url('#{$assets-path}/player/images/tick-white.svg');
|
background-image: url('./svg/tick-white.svg');
|
||||||
|
|
||||||
@include icon(15px);
|
@include icon(15px);
|
||||||
@include left(15px);
|
@include left(15px);
|
Before Width: | Height: | Size: 893 B After Width: | Height: | Size: 893 B |
Before Width: | Height: | Size: 887 B After Width: | Height: | Size: 887 B |
Before Width: | Height: | Size: 629 B After Width: | Height: | Size: 629 B |
Before Width: | Height: | Size: 307 B After Width: | Height: | Size: 307 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 347 B After Width: | Height: | Size: 347 B |
Before Width: | Height: | Size: 355 B After Width: | Height: | Size: 355 B |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
@ -0,0 +1,77 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
version="1.1"
|
||||||
|
id="svg6"
|
||||||
|
sodipodi:docname="playlists.svg"
|
||||||
|
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs6" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview6"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="30.708333"
|
||||||
|
inkscape:cx="12.016282"
|
||||||
|
inkscape:cy="15.989145"
|
||||||
|
inkscape:window-width="1916"
|
||||||
|
inkscape:window-height="1036"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="svg6" />
|
||||||
|
<path
|
||||||
|
d="M 8.70016,4.8 H 21.90019"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path1" />
|
||||||
|
<path
|
||||||
|
d="M 2.10001,4.8 H 2.650009"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path2" />
|
||||||
|
<path
|
||||||
|
d="M 8.70016,19.2 H 21.90019"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path3" />
|
||||||
|
<path
|
||||||
|
d="M 2.10001,19.2 H 2.650009"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path4" />
|
||||||
|
<path
|
||||||
|
d="M 8.699719,12 H 21.89974"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path5" />
|
||||||
|
<path
|
||||||
|
d="M 2.10001,12 H 2.650009"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="path6" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 392 B |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 702 B After Width: | Height: | Size: 702 B |
Before Width: | Height: | Size: 692 B After Width: | Height: | Size: 692 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
After Width: | Height: | Size: 299 B |
|
@ -5,6 +5,10 @@ import { logger } from '@root-helpers/logger'
|
||||||
import Hlsjs, { ErrorData, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
|
import Hlsjs, { ErrorData, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import { HLSPluginOptions, HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types'
|
import { HLSPluginOptions, HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types'
|
||||||
|
import { HlsJsP2PEngine, HlsWithP2PInstance } from 'p2p-media-loader-hlsjs'
|
||||||
|
import { omit } from '@peertube/peertube-core-utils'
|
||||||
|
|
||||||
|
const HlsWithP2P = HlsJsP2PEngine.injectMixin(Hlsjs)
|
||||||
|
|
||||||
type ErrorCounts = {
|
type ErrorCounts = {
|
||||||
[ type: string ]: number
|
[ type: string ]: number
|
||||||
|
@ -14,8 +18,6 @@ type ErrorCounts = {
|
||||||
// Source handler registration
|
// Source handler registration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type HookFn = (player: videojs.Player, hljs: Hlsjs) => void
|
|
||||||
|
|
||||||
let alreadyRegistered = false
|
let alreadyRegistered = false
|
||||||
|
|
||||||
const registerSourceHandler = function (vjs: typeof videojs) {
|
const registerSourceHandler = function (vjs: typeof videojs) {
|
||||||
|
@ -110,8 +112,6 @@ videojs.registerPlugin('hlsjs', HLSJSConfigHandler)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class Html5Hlsjs {
|
export class Html5Hlsjs {
|
||||||
private static hooks: { [id: string]: HookFn[] } = {}
|
|
||||||
|
|
||||||
private readonly videoElement: HTMLVideoElement
|
private readonly videoElement: HTMLVideoElement
|
||||||
private readonly errorCounts: ErrorCounts = {}
|
private readonly errorCounts: ErrorCounts = {}
|
||||||
private readonly player: videojs.Player
|
private readonly player: videojs.Player
|
||||||
|
@ -121,7 +121,7 @@ export class Html5Hlsjs {
|
||||||
|
|
||||||
private maxNetworkErrorRecovery = 5
|
private maxNetworkErrorRecovery = 5
|
||||||
|
|
||||||
private hls: Hlsjs
|
private hls: HlsWithP2PInstance<Hlsjs>
|
||||||
private hlsjsConfig: HLSPluginOptions = null
|
private hlsjsConfig: HLSPluginOptions = null
|
||||||
|
|
||||||
private _duration: number = null
|
private _duration: number = null
|
||||||
|
@ -410,10 +410,15 @@ export class Html5Hlsjs {
|
||||||
this.videoElement.addEventListener('play', this.handlers.play)
|
this.videoElement.addEventListener('play', this.handlers.play)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loader = this.hlsjsConfig.loaderBuilder()
|
this.hls = new HlsWithP2P({
|
||||||
this.hls = new Hlsjs({ ...this.hlsjsConfig, loader })
|
...omit(this.hlsjsConfig, [ 'p2pMediaLoaderOptions' ]),
|
||||||
|
|
||||||
this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() })
|
p2p: {
|
||||||
|
core: this.hlsjsConfig.p2pMediaLoaderOptions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.player.trigger('hlsjs-initialized', { hlsjs: this.hls })
|
||||||
|
|
||||||
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
|
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
|
||||||
this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
|
this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
|
||||||
|
@ -462,17 +467,21 @@ export class Html5Hlsjs {
|
||||||
this.hlsjsConfig.autoStartLoad = true
|
this.hlsjsConfig.autoStartLoad = true
|
||||||
this.player.autoplay('play')
|
this.player.autoplay('play')
|
||||||
|
|
||||||
const loader = this.hlsjsConfig.loaderBuilder()
|
this.hls = new HlsWithP2P({
|
||||||
this.hls = new Hlsjs({
|
...omit(this.hlsjsConfig, [ 'p2pMediaLoaderOptions' ]),
|
||||||
...this.hlsjsConfig,
|
|
||||||
loader,
|
p2p: {
|
||||||
|
core: this.hlsjsConfig.p2pMediaLoaderOptions
|
||||||
|
},
|
||||||
|
|
||||||
startPosition: this.duration() === Infinity
|
startPosition: this.duration() === Infinity
|
||||||
? undefined
|
? undefined
|
||||||
: currentTime,
|
: currentTime,
|
||||||
|
|
||||||
startLevel
|
startLevel
|
||||||
})
|
})
|
||||||
|
|
||||||
this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() })
|
this.player.trigger('hlsjs-initialized', { hlsjs: this.hls })
|
||||||
|
|
||||||
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
|
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
|
||||||
this.registerLevelEventSwitch()
|
this.registerLevelEventSwitch()
|
|
@ -1,9 +1,9 @@
|
||||||
import { Events, Segment } from '@peertube/p2p-media-loader-core'
|
|
||||||
import { Engine, initHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
|
|
||||||
import { addQueryParams } from '@peertube/peertube-core-utils'
|
import { addQueryParams } from '@peertube/peertube-core-utils'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import debug from 'debug'
|
import debug from 'debug'
|
||||||
import Hlsjs from 'hls.js'
|
import { FragLoadedData, default as Hlsjs } from 'hls.js'
|
||||||
|
import type { DownloadSource, SegmentErrorDetails, SegmentLoadDetails } from 'p2p-media-loader-core'
|
||||||
|
import type { HlsWithP2PInstance } from 'p2p-media-loader-hlsjs'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
|
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
|
||||||
import { SettingsButton } from '../settings/settings-menu-button'
|
import { SettingsButton } from '../settings/settings-menu-button'
|
||||||
|
@ -14,8 +14,7 @@ const Plugin = videojs.getPlugin('plugin')
|
||||||
class P2pMediaLoaderPlugin extends Plugin {
|
class P2pMediaLoaderPlugin extends Plugin {
|
||||||
declare private readonly options: P2PMediaLoaderPluginOptions
|
declare private readonly options: P2PMediaLoaderPluginOptions
|
||||||
|
|
||||||
declare private hlsjs: Hlsjs
|
declare private hlsjs: HlsWithP2PInstance<Hlsjs>
|
||||||
declare private p2pEngine: Engine
|
|
||||||
declare private statsP2PBytes: {
|
declare private statsP2PBytes: {
|
||||||
pendingDownload: number[]
|
pendingDownload: number[]
|
||||||
pendingUpload: number[]
|
pendingUpload: number[]
|
||||||
|
@ -33,6 +32,9 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
|
|
||||||
declare private liveEnded: boolean
|
declare private liveEnded: boolean
|
||||||
|
|
||||||
|
declare private connectedPeers: Set<string>
|
||||||
|
declare private totalHTTPPeers: number
|
||||||
|
|
||||||
constructor (player: videojs.Player, options?: P2PMediaLoaderPluginOptions) {
|
constructor (player: videojs.Player, options?: P2PMediaLoaderPluginOptions) {
|
||||||
super(player)
|
super(player)
|
||||||
|
|
||||||
|
@ -76,15 +78,13 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const onHLSJSInitialized = (_: any, { hlsjs, engine }: { hlsjs: Hlsjs, engine: Engine }) => {
|
const onHLSJSInitialized = (_: any, { hlsjs }: { hlsjs: HlsWithP2PInstance<Hlsjs> }) => {
|
||||||
this.p2pEngine?.removeAllListeners()
|
this.hlsjs?.p2pEngine?.destroy()
|
||||||
this.p2pEngine?.destroy()
|
|
||||||
clearInterval(this.networkInfoInterval)
|
clearInterval(this.networkInfoInterval)
|
||||||
|
|
||||||
this.hlsjs = hlsjs
|
this.hlsjs = hlsjs
|
||||||
this.p2pEngine = engine
|
|
||||||
|
|
||||||
debugLogger('hls.js initialized, initializing p2p-media-loader plugin', { hlsjs, engine })
|
debugLogger('hls.js initialized, initializing p2p-media-loader plugin', { hlsjs })
|
||||||
|
|
||||||
player.ready(() => this.initializePlugin())
|
player.ready(() => this.initializePlugin())
|
||||||
}
|
}
|
||||||
|
@ -116,8 +116,7 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose () {
|
dispose () {
|
||||||
this.p2pEngine?.removeAllListeners()
|
this.hlsjs?.p2pEngine?.destroy()
|
||||||
this.p2pEngine?.destroy()
|
|
||||||
|
|
||||||
this.hlsjs?.destroy()
|
this.hlsjs?.destroy()
|
||||||
this.options.segmentValidator?.destroy()
|
this.options.segmentValidator?.destroy()
|
||||||
|
@ -152,15 +151,22 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializePlugin () {
|
private initializePlugin () {
|
||||||
initHlsJsPlayer(this.player, this.hlsjs)
|
this.hlsjs.p2pEngine.addEventListener('onSegmentError', (details: SegmentErrorDetails) => {
|
||||||
|
if (navigator.onLine === false || this.liveEnded || details.downloadSource !== 'http') return
|
||||||
|
|
||||||
this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => {
|
const segment = details.segment
|
||||||
if (navigator.onLine === false || this.liveEnded) return
|
logger.clientError(`Segment ${segment.runtimeId} error.`, details)
|
||||||
|
|
||||||
logger.clientError(`Segment ${segment.id} error.`, err)
|
|
||||||
|
|
||||||
if (this.options.redundancyUrlManager) {
|
if (this.options.redundancyUrlManager) {
|
||||||
this.options.redundancyUrlManager.removeBySegmentUrl(segment.requestUrl)
|
this.options.redundancyUrlManager.onSegmentError(segment.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.hlsjs.p2pEngine.addEventListener('onSegmentLoaded', (details: SegmentLoadDetails) => {
|
||||||
|
if (details.downloadSource !== 'http') return
|
||||||
|
|
||||||
|
if (this.options.redundancyUrlManager) {
|
||||||
|
this.options.redundancyUrlManager.onSegmentSuccess(details.segmentUrl)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -168,7 +174,8 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
? this.options.redundancyUrlManager.countBaseUrls()
|
? this.options.redundancyUrlManager.countBaseUrls()
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
this.statsP2PBytes.peersWithWebSeed = 1 + redundancyUrlsCount
|
this.totalHTTPPeers = 1 + redundancyUrlsCount
|
||||||
|
this.statsP2PBytes.peersWithWebSeed = this.totalHTTPPeers
|
||||||
|
|
||||||
this.runStats()
|
this.runStats()
|
||||||
|
|
||||||
|
@ -200,30 +207,44 @@ class P2pMediaLoaderPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
private runStats () {
|
private runStats () {
|
||||||
this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, _segment, bytes: number) => {
|
this.connectedPeers = new Set()
|
||||||
|
|
||||||
|
if (this.hlsjs.p2pEngine.getConfig().core.mainStream.isP2PDisabled) {
|
||||||
|
this.hlsjs.on(Hlsjs.Events.FRAG_LOADED, (e, data: FragLoadedData) => {
|
||||||
|
const bytes = data.frag.stats.loaded
|
||||||
|
|
||||||
|
this.statsHTTPBytes.pendingDownload.push(bytes)
|
||||||
|
this.statsHTTPBytes.totalDownload += bytes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hlsjs.p2pEngine.addEventListener('onChunkDownloaded', (bytes: number, method: DownloadSource) => {
|
||||||
const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes
|
const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes
|
||||||
|
|
||||||
elem.pendingDownload.push(bytes)
|
elem.pendingDownload.push(bytes)
|
||||||
elem.totalDownload += bytes
|
elem.totalDownload += bytes
|
||||||
})
|
})
|
||||||
|
|
||||||
this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, _segment, bytes: number) => {
|
this.hlsjs.p2pEngine.addEventListener('onChunkUploaded', (bytes: number) => {
|
||||||
if (method !== 'p2p') {
|
|
||||||
logger.error(`Received upload from unknown method ${method}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.statsP2PBytes.pendingUpload.push(bytes)
|
this.statsP2PBytes.pendingUpload.push(bytes)
|
||||||
this.statsP2PBytes.totalUpload += bytes
|
this.statsP2PBytes.totalUpload += bytes
|
||||||
})
|
})
|
||||||
|
|
||||||
this.p2pEngine.on(Events.PeerConnect, () => {
|
this.hlsjs.p2pEngine.addEventListener('onPeerConnect', peer => {
|
||||||
this.statsP2PBytes.peersWithWebSeed++
|
if (peer.streamType !== 'main') return
|
||||||
this.statsP2PBytes.peersP2POnly++
|
|
||||||
|
this.connectedPeers.add(peer.peerId)
|
||||||
|
this.statsP2PBytes.peersP2POnly = this.connectedPeers.size
|
||||||
|
|
||||||
|
this.statsP2PBytes.peersWithWebSeed = this.totalHTTPPeers + this.statsP2PBytes.peersP2POnly
|
||||||
})
|
})
|
||||||
this.p2pEngine.on(Events.PeerClose, () => {
|
this.hlsjs.p2pEngine.addEventListener('onPeerClose', peer => {
|
||||||
this.statsP2PBytes.peersWithWebSeed--
|
if (peer.streamType !== 'main') return
|
||||||
this.statsP2PBytes.peersP2POnly--
|
|
||||||
|
this.connectedPeers.delete(peer.peerId)
|
||||||
|
this.statsP2PBytes.peersP2POnly = this.connectedPeers.size
|
||||||
|
|
||||||
|
this.statsP2PBytes.peersWithWebSeed = this.totalHTTPPeers + this.statsP2PBytes.peersP2POnly
|
||||||
})
|
})
|
||||||
|
|
||||||
this.networkInfoInterval = setInterval(() => {
|
this.networkInfoInterval = setInterval(() => {
|
|
@ -1,22 +1,32 @@
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
|
|
||||||
class RedundancyUrlManager {
|
class RedundancyUrlManager {
|
||||||
|
private map = new Map<string, string>()
|
||||||
|
|
||||||
constructor (private baseUrls: string[] = []) {
|
constructor (private baseUrls: string[] = []) {
|
||||||
// empty
|
// empty
|
||||||
}
|
}
|
||||||
|
|
||||||
removeBySegmentUrl (segmentUrl: string) {
|
onSegmentError (segmentUrl: string) {
|
||||||
const baseUrl = getBaseUrl(segmentUrl)
|
if (!this.map.has(segmentUrl)) return
|
||||||
|
|
||||||
|
const customSegmentUrl = this.map.get(segmentUrl)
|
||||||
|
this.map.delete(segmentUrl)
|
||||||
|
|
||||||
|
const baseUrl = getBaseUrl(customSegmentUrl)
|
||||||
const oldLength = baseUrl.length
|
const oldLength = baseUrl.length
|
||||||
|
|
||||||
this.baseUrls = this.baseUrls.filter(u => u !== baseUrl && u !== baseUrl + '/')
|
this.baseUrls = this.baseUrls.filter(u => u !== baseUrl && u !== baseUrl + '/')
|
||||||
|
|
||||||
if (oldLength !== this.baseUrls.length) {
|
if (oldLength !== this.baseUrls.length) {
|
||||||
logger.info(`Removed redundancy of segment URL ${segmentUrl}.`)
|
logger.info(`Removed redundancy of segment URL ${customSegmentUrl}.`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSegmentSuccess (segmentUrl: string) {
|
||||||
|
this.map.delete(segmentUrl)
|
||||||
|
}
|
||||||
|
|
||||||
buildUrl (url: string) {
|
buildUrl (url: string) {
|
||||||
const max = this.baseUrls.length + 1
|
const max = this.baseUrls.length + 1
|
||||||
const i = this.getRandomInt(max)
|
const i = this.getRandomInt(max)
|
||||||
|
@ -26,7 +36,11 @@ class RedundancyUrlManager {
|
||||||
const newBaseUrl = this.baseUrls[i]
|
const newBaseUrl = this.baseUrls[i]
|
||||||
const slashPart = newBaseUrl.endsWith('/') ? '' : '/'
|
const slashPart = newBaseUrl.endsWith('/') ? '' : '/'
|
||||||
|
|
||||||
return newBaseUrl + slashPart + getFilename(url)
|
const newUrl = newBaseUrl + slashPart + getFilename(url)
|
||||||
|
|
||||||
|
this.map.set(url, newUrl)
|
||||||
|
|
||||||
|
return newUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
countBaseUrls () {
|
countBaseUrls () {
|
|
@ -1,4 +1,4 @@
|
||||||
import { Segment } from '@peertube/p2p-media-loader-core'
|
import type { Segment } from 'p2p-media-loader-core'
|
||||||
import { RedundancyUrlManager } from './redundancy-url-manager'
|
import { RedundancyUrlManager } from './redundancy-url-manager'
|
||||||
|
|
||||||
export function segmentUrlBuilderFactory (redundancyUrlManager: RedundancyUrlManager | null) {
|
export function segmentUrlBuilderFactory (redundancyUrlManager: RedundancyUrlManager | null) {
|
|
@ -1,9 +1,9 @@
|
||||||
import { Segment } from '@peertube/p2p-media-loader-core'
|
import type { ByteRange } from 'p2p-media-loader-core'
|
||||||
|
import { removeQueryParams } from '@peertube/peertube-core-utils'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { wait } from '@root-helpers/utils'
|
import { wait } from '@root-helpers/utils'
|
||||||
import { removeQueryParams } from '@peertube/peertube-core-utils'
|
|
||||||
import { isSameOrigin } from '../common'
|
|
||||||
import debug from 'debug'
|
import debug from 'debug'
|
||||||
|
import { isSameOrigin } from '../common'
|
||||||
|
|
||||||
const debugLogger = debug('peertube:player:segment-validator')
|
const debugLogger = debug('peertube:player:segment-validator')
|
||||||
|
|
||||||
|
@ -12,9 +12,6 @@ type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string
|
||||||
const maxRetries = 10
|
const maxRetries = 10
|
||||||
|
|
||||||
export class SegmentValidator {
|
export class SegmentValidator {
|
||||||
|
|
||||||
private readonly bytesRangeRegex = /bytes=(\d+)-(\d+)/
|
|
||||||
|
|
||||||
private destroyed = false
|
private destroyed = false
|
||||||
|
|
||||||
private segmentJSONPromise: Promise<SegmentsJSON>
|
private segmentJSONPromise: Promise<SegmentsJSON>
|
||||||
|
@ -29,17 +26,18 @@ export class SegmentValidator {
|
||||||
}) {
|
}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate (segment: Segment, _method: string, _peerId: string, retry = 1) {
|
async validate (url: string, byteRange: ByteRange | undefined, data: ArrayBuffer, retry = 1): Promise<boolean> {
|
||||||
if (this.destroyed) return
|
if (this.destroyed) return false
|
||||||
|
|
||||||
this.loadSha256SegmentsPromiseIfNeeded()
|
this.loadSha256SegmentsPromiseIfNeeded()
|
||||||
|
|
||||||
const filename = removeQueryParams(segment.url).split('/').pop()
|
const filename = removeQueryParams(url).split('/').pop()
|
||||||
|
|
||||||
const segmentValue = (await this.segmentJSONPromise)[filename]
|
const segmentValue = (await this.segmentJSONPromise)[filename]
|
||||||
|
|
||||||
if (!segmentValue && retry > maxRetries) {
|
if (!segmentValue && retry > maxRetries) {
|
||||||
throw new Error(`Unknown segment name ${filename} in segment validator`)
|
logger.clientError(`Unknown segment name ${filename} in segment validator`)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!segmentValue) {
|
if (!segmentValue) {
|
||||||
|
@ -49,9 +47,7 @@ export class SegmentValidator {
|
||||||
|
|
||||||
this.loadSha256SegmentsPromise()
|
this.loadSha256SegmentsPromise()
|
||||||
|
|
||||||
await this.validate(segment, _method, _peerId, retry + 1)
|
return this.validate(url, byteRange, data, retry + 1)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let hashShouldBe: string
|
let hashShouldBe: string
|
||||||
|
@ -60,24 +56,25 @@ export class SegmentValidator {
|
||||||
if (typeof segmentValue === 'string') {
|
if (typeof segmentValue === 'string') {
|
||||||
hashShouldBe = segmentValue
|
hashShouldBe = segmentValue
|
||||||
} else {
|
} else {
|
||||||
const captured = this.bytesRangeRegex.exec(segment.range)
|
range = byteRange.start + '-' + byteRange.end
|
||||||
range = captured[1] + '-' + captured[2]
|
|
||||||
|
|
||||||
hashShouldBe = segmentValue[range]
|
hashShouldBe = segmentValue[range]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hashShouldBe === undefined) {
|
if (hashShouldBe === undefined) {
|
||||||
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
|
logger.clientError(`Unknown segment name ${filename}/${range} in segment validator`)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLogger(`Validating ${filename}` + (segment.range ? ` range ${segment.range}` : ''))
|
debugLogger(`Validating ${filename}` + (range ? ` range ${range}` : ''))
|
||||||
|
|
||||||
const calculatedSha = await this.sha256Hex(segment.data)
|
const calculatedSha = await this.sha256Hex(data)
|
||||||
if (calculatedSha !== hashShouldBe) {
|
if (calculatedSha !== hashShouldBe) {
|
||||||
throw new Error(
|
logger.clientError(
|
||||||
`Hashes does not correspond for segment ${filename}/${range}` +
|
`Hashes does not correspond for segment ${filename}/${range} (expected: ${hashShouldBe} instead of ${calculatedSha})`
|
||||||
`(expected: ${hashShouldBe} instead of ${calculatedSha})`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,7 +103,7 @@ export class SegmentValidator {
|
||||||
return fetch(this.options.segmentsSha256Url, { headers })
|
return fetch(this.options.segmentsSha256Url, { headers })
|
||||||
.then(res => res.json() as Promise<SegmentsJSON>)
|
.then(res => res.json() as Promise<SegmentsJSON>)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
logger.error('Cannot get sha256 segments', err)
|
logger.clientError('Cannot get sha256 segments', err)
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
}
|
}
|
|
@ -1,13 +1,12 @@
|
||||||
import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core'
|
|
||||||
import { Engine, HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
|
|
||||||
import { getResolutionAndFPSLabel, getResolutionLabel } from '@peertube/peertube-core-utils'
|
import { getResolutionAndFPSLabel, getResolutionLabel } from '@peertube/peertube-core-utils'
|
||||||
import { LiveVideoLatencyMode } from '@peertube/peertube-models'
|
import { LiveVideoLatencyMode } from '@peertube/peertube-models'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
||||||
|
import debug from 'debug'
|
||||||
import { Level } from 'hls.js'
|
import { Level } from 'hls.js'
|
||||||
|
import type { CoreConfig, StreamConfig } from 'p2p-media-loader-core'
|
||||||
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
|
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
|
||||||
import {
|
import {
|
||||||
HLSLoaderClass,
|
|
||||||
HLSPluginOptions,
|
HLSPluginOptions,
|
||||||
P2PMediaLoaderPluginOptions,
|
P2PMediaLoaderPluginOptions,
|
||||||
PeerTubePlayerConstructorOptions,
|
PeerTubePlayerConstructorOptions,
|
||||||
|
@ -15,9 +14,7 @@ import {
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { getRtcConfig, isSameOrigin } from '../common'
|
import { getRtcConfig, isSameOrigin } from '../common'
|
||||||
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
|
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
|
||||||
import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
|
|
||||||
import { SegmentValidator } from '../p2p-media-loader/segment-validator'
|
import { SegmentValidator } from '../p2p-media-loader/segment-validator'
|
||||||
import debug from 'debug'
|
|
||||||
|
|
||||||
const debugLogger = debug('peertube:player:hls')
|
const debugLogger = debug('peertube:player:hls')
|
||||||
|
|
||||||
|
@ -58,7 +55,6 @@ export class HLSOptionsBuilder {
|
||||||
'filter:internal.player.p2p-media-loader.options.result',
|
'filter:internal.player.p2p-media-loader.options.result',
|
||||||
this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator })
|
this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator })
|
||||||
)
|
)
|
||||||
const loaderBuilder = () => new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as HLSLoaderClass
|
|
||||||
|
|
||||||
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
|
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
|
||||||
requiresUserAuth: this.options.requiresUserAuth,
|
requiresUserAuth: this.options.requiresUserAuth,
|
||||||
|
@ -73,7 +69,7 @@ export class HLSOptionsBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hlsjs = {
|
const hlsjs = {
|
||||||
hlsjsConfig: this.getHLSJSOptions(loaderBuilder),
|
hlsjsConfig: this.getHLSJSOptions(p2pMediaLoaderConfig),
|
||||||
|
|
||||||
levelLabelHandler: (level: Level, player: videojs.VideoJsPlayer) => {
|
levelLabelHandler: (level: Level, player: videojs.VideoJsPlayer) => {
|
||||||
const resolution = Math.min(level.height || 0, level.width || 0)
|
const resolution = Math.min(level.height || 0, level.width || 0)
|
||||||
|
@ -99,65 +95,81 @@ export class HLSOptionsBuilder {
|
||||||
private getP2PMediaLoaderOptions (options: {
|
private getP2PMediaLoaderOptions (options: {
|
||||||
redundancyUrlManager: RedundancyUrlManager | null
|
redundancyUrlManager: RedundancyUrlManager | null
|
||||||
segmentValidator: SegmentValidator | null
|
segmentValidator: SegmentValidator | null
|
||||||
}): HlsJsEngineSettings {
|
}) {
|
||||||
const { redundancyUrlManager, segmentValidator } = options
|
const { redundancyUrlManager, segmentValidator } = options
|
||||||
|
|
||||||
let consumeOnly = false
|
let isP2PUploadDisabled = false
|
||||||
if (
|
if (
|
||||||
(navigator as any)?.connection?.type === 'cellular' ||
|
(navigator as any)?.connection?.type === 'cellular' ||
|
||||||
peertubeLocalStorage.getItem('peertube-videojs-p2p-consume-only') === 'true' // Use for E2E testing
|
peertubeLocalStorage.getItem('peertube-videojs-p2p-consume-only') === 'true' // Use for E2E testing
|
||||||
) {
|
) {
|
||||||
logger.info('We are on a cellular connection: disabling seeding.')
|
logger.info('We are on a cellular connection: disabling seeding.')
|
||||||
consumeOnly = true
|
isP2PUploadDisabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackerAnnounce = this.options.hls.trackerAnnounce
|
const announceTrackers = this.options.hls.trackerAnnounce
|
||||||
.filter(t => t.startsWith('ws'))
|
.filter(t => t.startsWith('ws'))
|
||||||
|
|
||||||
const specificLiveOrVODOptions = this.options.isLive
|
const specificLiveOrVODOptions = this.options.isLive
|
||||||
? this.getP2PMediaLoaderLiveOptions()
|
? this.getP2PMediaLoaderLiveOptions()
|
||||||
: this.getP2PMediaLoaderVODOptions()
|
: this.getP2PMediaLoaderVODOptions()
|
||||||
|
|
||||||
return {
|
// TODO: remove validateHTTPSegment typing when p2p-media-loader-core is updated
|
||||||
loader: {
|
const loaderOptions: Partial<StreamConfig> & { validateHTTPSegment: any } = {
|
||||||
trackerAnnounce,
|
announceTrackers,
|
||||||
rtcConfig: getRtcConfig(this.options.stunServers),
|
rtcConfig: getRtcConfig(this.options.stunServers),
|
||||||
|
|
||||||
simultaneousHttpDownloads: 1,
|
httpRequestSetup: (segmentUrlArg, segmentByteRange, requestAbortSignal, requestByteRange) => {
|
||||||
httpFailedSegmentTimeout: 1000,
|
|
||||||
|
|
||||||
xhrSetup: (xhr, url) => {
|
|
||||||
const { requiresUserAuth, requiresPassword } = this.options
|
const { requiresUserAuth, requiresPassword } = this.options
|
||||||
|
|
||||||
if (!(requiresUserAuth || requiresPassword)) return
|
const segmentUrl = redundancyUrlManager
|
||||||
|
? redundancyUrlManager.buildUrl(segmentUrlArg)
|
||||||
|
: segmentUrlArg
|
||||||
|
|
||||||
if (!isSameOrigin(this.options.serverUrl, url)) return
|
const headers = new Headers()
|
||||||
|
|
||||||
if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.videoPassword())
|
if (requestByteRange) {
|
||||||
else xhr.setRequestHeader('Authorization', this.options.authorizationHeader())
|
headers.set('Range', `bytes=${requestByteRange.start}-${requestByteRange.end ?? ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSameOrigin(this.options.serverUrl, segmentUrl)) {
|
||||||
|
if (requiresPassword) {
|
||||||
|
headers.set('x-peertube-video-password', this.options.videoPassword())
|
||||||
|
} else if (requiresUserAuth) {
|
||||||
|
headers.set('Authorization', this.options.authorizationHeader())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
new Request(segmentUrl, {
|
||||||
|
headers,
|
||||||
|
signal: requestAbortSignal
|
||||||
|
})
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
segmentValidator: segmentValidator
|
validateP2PSegment: segmentValidator
|
||||||
? segmentValidator.validate.bind(segmentValidator)
|
? segmentValidator.validate.bind(segmentValidator)
|
||||||
: null,
|
: null,
|
||||||
|
|
||||||
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
|
validateHTTPSegment: segmentValidator
|
||||||
|
? segmentValidator.validate.bind(segmentValidator)
|
||||||
|
: null,
|
||||||
|
|
||||||
useP2P: this.options.p2pEnabled,
|
isP2PDisabled: !this.options.p2pEnabled,
|
||||||
consumeOnly,
|
isP2PUploadDisabled,
|
||||||
|
|
||||||
|
swarmId: this.options.hls.playlistUrl,
|
||||||
|
|
||||||
...specificLiveOrVODOptions
|
...specificLiveOrVODOptions
|
||||||
},
|
|
||||||
segments: {
|
|
||||||
swarmId: this.options.hls.playlistUrl,
|
|
||||||
forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getP2PMediaLoaderLiveOptions (): Partial<HybridLoaderSettings> {
|
return { loader: loaderOptions }
|
||||||
|
}
|
||||||
|
|
||||||
|
private getP2PMediaLoaderLiveOptions (): Partial<CoreConfig> {
|
||||||
const base = {
|
const base = {
|
||||||
requiredSegmentsPriority: 1
|
highDemandTimeWindow: 4
|
||||||
}
|
}
|
||||||
|
|
||||||
const latencyMode = this.options.liveOptions.latencyMode
|
const latencyMode = this.options.liveOptions.latencyMode
|
||||||
|
@ -167,8 +179,7 @@ export class HLSOptionsBuilder {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
|
||||||
useP2P: false,
|
isP2PDisabled: true
|
||||||
requiredSegmentsPriority: 10
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case LiveVideoLatencyMode.HIGH_LATENCY:
|
case LiveVideoLatencyMode.HIGH_LATENCY:
|
||||||
|
@ -179,34 +190,39 @@ export class HLSOptionsBuilder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getP2PMediaLoaderVODOptions (): Partial<HybridLoaderSettings> {
|
private getP2PMediaLoaderVODOptions (): Partial<CoreConfig> {
|
||||||
return {
|
return {
|
||||||
requiredSegmentsPriority: 3,
|
highDemandTimeWindow: 15,
|
||||||
skipSegmentBuilderPriority: 1,
|
|
||||||
|
|
||||||
cachedSegmentExpiration: 86400000,
|
segmentMemoryStorageLimit: 1024
|
||||||
cachedSegmentsCount: 100,
|
|
||||||
|
|
||||||
httpDownloadMaxPriority: 9,
|
|
||||||
httpDownloadProbability: 0.06,
|
|
||||||
httpDownloadProbabilitySkipIfNoPeers: true,
|
|
||||||
|
|
||||||
p2pDownloadMaxPriority: 50
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
private getHLSJSOptions (loaderBuilder: () => HLSLoaderClass): HLSPluginOptions {
|
private getHLSJSOptions (p2pMediaLoaderConfig: { loader: CoreConfig }): HLSPluginOptions {
|
||||||
const specificLiveOrVODOptions = this.options.isLive
|
const specificLiveOrVODOptions = this.options.isLive
|
||||||
? this.getHLSLiveOptions()
|
? this.getHLSLiveOptions()
|
||||||
: this.getHLSVODOptions()
|
: this.getHLSVODOptions()
|
||||||
|
|
||||||
const base = {
|
const base: HLSPluginOptions = {
|
||||||
capLevelToPlayerSize: true,
|
capLevelToPlayerSize: true,
|
||||||
autoStartLoad: false,
|
autoStartLoad: false,
|
||||||
|
|
||||||
loaderBuilder,
|
p2pMediaLoaderOptions: p2pMediaLoaderConfig.loader,
|
||||||
|
|
||||||
|
// p2p-media-loader uses hls.js loader to fetch m3u8 playlists
|
||||||
|
xhrSetup: (xhr, url) => {
|
||||||
|
const { requiresUserAuth, requiresPassword } = this.options
|
||||||
|
|
||||||
|
if (isSameOrigin(this.options.serverUrl, url)) {
|
||||||
|
if (requiresPassword) {
|
||||||
|
xhr.setRequestHeader('x-peertube-video-password', this.options.videoPassword())
|
||||||
|
} else if (requiresUserAuth) {
|
||||||
|
xhr.setRequestHeader('Authorization', this.options.authorizationHeader())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
...specificLiveOrVODOptions
|
...specificLiveOrVODOptions
|
||||||
}
|
}
|