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
This commit is contained in:
Chocobozzz 2025-02-05 06:36:05 +01:00
parent 2fe6ce79f1
commit 50b067f9cd
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
151 changed files with 868 additions and 578 deletions

View File

@ -1,4 +1,4 @@
last 1 Chrome version
last 2 Edge major versions
Firefox ESR
ios_saf >= 13.1
ios_saf >= 14

View File

@ -163,7 +163,8 @@
"@typescript-eslint/unbound-method": [
"error",
{ "ignoreStatic": true }
]
],
"import/no-named-default": "off"
}
},
{

2
client/.gitignore vendored
View File

@ -12,6 +12,8 @@
/e2e/local.log
/e2e/browserstack.err
/e2e/screenshots
/src/standalone/player/build
/src/standalone/player/dist
/src/standalone/embed-player-api/build
/src/standalone/embed-player-api/dist
/e2e/logs

View File

@ -183,7 +183,10 @@
"includePaths": [
"src/sass/include",
"."
]
],
"sass": {
"silenceDeprecations": [ "import", "mixed-decls", "color-functions", "global-builtin" ]
}
},
"assets": [
"src/assets/images",
@ -212,7 +215,9 @@
"is-plain-object",
"parse-srcset",
"deepmerge",
"core-js/features/reflect"
"core-js/features/reflect",
"hammerjs",
"jschannel"
],
"scripts": [],
"extractLicenses": false,
@ -241,7 +246,7 @@
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "100kb"
"maximumError": "120kb"
}
],
"fileReplacements": [

View File

@ -5,7 +5,7 @@
"noImplicitAny": false,
"esModuleInterop": true,
"module": "commonjs",
"target": "ES2015",
"target": "ES2018",
"typeRoots": [
"../node_modules/@types",
"../node_modules"

View File

@ -24,10 +24,13 @@
"net": false,
"stream": false,
"os": false,
"http": false,
"dgram": false,
"util": false
},
"workspaces": [
"../packages/*"
"../packages/*",
"./src/standalone/player"
],
"typings": "*.d.ts",
"devDependencies": {
@ -55,8 +58,6 @@
"@ngx-loading-bar/http-client": "^7.0.0",
"@ngx-loading-bar/router": "^7.0.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",
"@plussub/srt-vtt-parser": "^2.0.5",
"@popperjs/core": "^2.11.5",
@ -103,8 +104,11 @@
"markdown-it": "14.1.0",
"markdown-it-emoji": "^3.0.0",
"ngx-uploadx": "^7.0.0",
"p2p-media-loader-core": "^2.1.2",
"p2p-media-loader-hlsjs": "^2.1.2",
"primeng": "^17",
"rxjs": "^7.3.0",
"sass-embedded": "^1.83.4",
"sha.js": "^2.4.11",
"socket.io-client": "^4.5.4",
"stylelint": "^16.2.1",
@ -115,9 +119,9 @@
"tslib": "^2.4.0",
"typescript": "~5.7.3",
"video.js": "^7.19.2",
"vite": "^5.3.1",
"vite-plugin-checker": "^0.7.2",
"vite-plugin-node-polyfills": "^0.22.0",
"vite": "^6.0.11",
"vite-plugin-checker": "^0.8.0",
"vite-plugin-node-polyfills": "^0.23.0",
"zone.js": "~0.15.0"
},
"dependencies": {}

View File

@ -1,5 +1 @@
@use 'node_modules/video.js/dist/video-js';
$assets-path: '../../assets/';
@use '../../../sass/player/index';
@use '../../../standalone/player/build/peertube-player.css';

View File

@ -47,16 +47,18 @@ import { logger } from '@root-helpers/logger'
import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video'
import debug from 'debug'
import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
import { environment } from '../../../environments/environment'
import {
cleanupVideoWatch,
getStoredTheater,
getStoredVideoWatchHistory,
HLSOptions,
PeerTubePlayer,
PeerTubePlayerConstructorOptions,
PeerTubePlayerLoadOptions,
PlayerMode,
videojs
} from '../../../assets/player'
import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from '../../../assets/player/peertube-player-local-storage'
import { environment } from '../../../environments/environment'
} from '@peertube/player'
import { DateToggleComponent } from '../../shared/shared-main/date/date-toggle.component'
import { PluginPlaceholderComponent } from '../../shared/shared-main/plugins/plugin-placeholder.component'
import { VideoViewsCounterComponent } from '../../shared/shared-video/video-views-counter.component'

View File

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

View File

@ -2,7 +2,7 @@
import debug from 'debug'
import { firstValueFrom, ReplaySubject } from 'rxjs'
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 {
ClientDoAction,
@ -83,6 +83,7 @@ class PluginsManager {
private readonly onFormFields: OnFormFields
private readonly onSettingsScripts: OnSettingsScripts
private readonly onClientRoute: OnClientRoute
private readonly backendUrl: string
constructor (options: {
doAction?: ClientDoAction
@ -90,12 +91,14 @@ class PluginsManager {
onFormFields?: OnFormFields
onSettingsScripts?: OnSettingsScripts
onClientRoute?: OnClientRoute
backendUrl?: string
}) {
this.doAction = options.doAction
this.peertubeHelpersFactory = options.peertubeHelpersFactory
this.onFormFields = options.onFormFields
this.onSettingsScripts = options.onSettingsScripts
this.onClientRoute = options.onClientRoute
this.backendUrl = options.backendUrl
}
static getPluginPathPrefix (isTheme: boolean) {
@ -281,7 +284,7 @@ class PluginsManager {
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)
.then((script: ClientScript) => {
return script.register({

View File

@ -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') }
]
}

View File

@ -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": {}
}

View File

@ -0,0 +1,5 @@
export * from './peertube-player'
export * from './peertube-player-local-storage'
export * from './types'
import './sass/player.scss'

View File

@ -31,6 +31,8 @@ import './shared/metrics/metrics-plugin'
import './shared/p2p-media-loader/hls-plugin'
import './shared/p2p-media-loader/p2p-media-loader-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 { logger } from '@root-helpers/logger'
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 { saveAverageBandwidth } from './peertube-player-local-storage'
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'
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)

View File

@ -0,0 +1,2 @@
@use '../../../node_modules/video.js/dist/video-js';
@use './shared/index.scss';

View File

@ -22,5 +22,3 @@ $control-bar-total-height: $control-bar-height - $control-bar-slider-top;
$progress-margin: 10px;
$dock-padding: 20px;
$assets-path: '../../assets/' !default;

View File

@ -50,8 +50,8 @@ $context-menu-width: 350px;
@each $icon in $icons {
&[class$="-#{$icon}"] {
mask-image: url('#{$assets-path}/player/images/#{$icon}.svg');
-webkit-mask-image: url('#{$assets-path}/player/images/#{$icon}.svg');
mask-image: url('./svg/#{$icon}.svg');
-webkit-mask-image: url('./svg/#{$icon}.svg');
}
}

View File

@ -270,11 +270,11 @@ $chapter-marker-size: 9px;
@include margin-right(2px);
&.icon-download {
background-image: url('#{$assets-path}/player/images/arrow-down.svg');
background-image: url('./svg/arrow-down.svg');
}
&.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-next,
&.icon-previous {
mask-image: url('#{$assets-path}/player/images/next.svg');
-webkit-mask-image: url('#{$assets-path}/player/images/next.svg');
mask-image: url('./svg/next.svg');
-webkit-mask-image: url('./svg/next.svg');
mask-size: cover;
-webkit-mask-size: cover;
@ -319,7 +319,7 @@ $chapter-marker-size: 9px;
width: $control-bar-icon-size;
height: $control-bar-icon-size;
vertical-align: middle;
background: url('#{$assets-path}/player/images/volume.svg') no-repeat;
background: url('./svg/volume.svg') no-repeat;
background-size: contain;
&::before {
@ -328,7 +328,7 @@ $chapter-marker-size: 9px;
}
&.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;
}
}
@ -415,7 +415,7 @@ $chapter-marker-size: 9px;
height: $control-bar-icon-size - 4px;
width: $control-bar-icon-size - 4px;
vertical-align: middle;
background: url('#{$assets-path}/player/images/settings.svg') no-repeat;
background: url('./svg/settings.svg') no-repeat;
background-size: contain;
&::before {
@ -448,7 +448,7 @@ $chapter-marker-size: 9px;
width: $control-bar-icon-size - 4px;
height: $control-bar-icon-size - 4px;
vertical-align: middle;
background: url('#{$assets-path}/player/images/theater.svg') no-repeat;
background: url('./svg/theater.svg') no-repeat;
background-size: contain;
&::before {
@ -493,7 +493,7 @@ $chapter-marker-size: 9px;
width: $control-bar-icon-size;
height: $control-bar-icon-size;
vertical-align: middle;
background: url('#{$assets-path}/player/images/fullscreen.svg') no-repeat;
background: url('./svg/fullscreen.svg') no-repeat;
background-size: contain;
&::before {
@ -515,14 +515,6 @@ $chapter-marker-size: 9px;
.vjs-theater-control {
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 {
@ -540,6 +532,12 @@ $chapter-marker-size: 9px;
.vjs-peertube-displayed {
display: none !important;
}
.icon,
.download-speed-text,
.upload-speed-text {
display: none !important;
}
}
.vjs-peertube-link {

View File

@ -60,7 +60,7 @@ body {
.vjs-icon-placeholder::before {
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);
}

View File

@ -44,8 +44,8 @@ $playlist-menu-width: 350px;
}
.cross {
mask-image: url('#{$assets-path}/images/feather/x.svg');
-webkit-mask-image: url('#{$assets-path}/images/feather/x.svg');
mask-image: url('./svg/x.svg');
-webkit-mask-image: url('./svg/x.svg');
mask-size: cover;
-webkit-mask-size: cover;
@ -92,8 +92,8 @@ $playlist-menu-width: 350px;
}
.vjs-playlist-icon {
mask-image: url('#{$assets-path}/images/feather/playlists.svg');
-webkit-mask-image: url('#{$assets-path}/images/feather/playlists.svg');
mask-image: url('./svg/playlists.svg');
-webkit-mask-image: url('./svg/playlists.svg');
mask-size: cover;
-webkit-mask-size: cover;

View File

@ -145,7 +145,7 @@ $setting-transition-easing: ease-out;
position: absolute;
content: ' ';
margin-top: 1px;
background-image: url('#{$assets-path}/player/images/tick-white.svg');
background-image: url('./svg/tick-white.svg');
@include icon(15px);
@include left(15px);

View File

Before

Width:  |  Height:  |  Size: 893 B

After

Width:  |  Height:  |  Size: 893 B

View File

Before

Width:  |  Height:  |  Size: 887 B

After

Width:  |  Height:  |  Size: 887 B

View File

Before

Width:  |  Height:  |  Size: 629 B

After

Width:  |  Height:  |  Size: 629 B

View File

Before

Width:  |  Height:  |  Size: 307 B

After

Width:  |  Height:  |  Size: 307 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 347 B

After

Width:  |  Height:  |  Size: 347 B

View File

Before

Width:  |  Height:  |  Size: 355 B

After

Width:  |  Height:  |  Size: 355 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

View File

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 392 B

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 702 B

View File

Before

Width:  |  Height:  |  Size: 692 B

After

Width:  |  Height:  |  Size: 692 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

View File

@ -5,6 +5,10 @@ import { logger } from '@root-helpers/logger'
import Hlsjs, { ErrorData, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
import videojs from 'video.js'
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: string ]: number
@ -14,8 +18,6 @@ type ErrorCounts = {
// Source handler registration
// ---------------------------------------------------------------------------
type HookFn = (player: videojs.Player, hljs: Hlsjs) => void
let alreadyRegistered = false
const registerSourceHandler = function (vjs: typeof videojs) {
@ -110,8 +112,6 @@ videojs.registerPlugin('hlsjs', HLSJSConfigHandler)
// ---------------------------------------------------------------------------
export class Html5Hlsjs {
private static hooks: { [id: string]: HookFn[] } = {}
private readonly videoElement: HTMLVideoElement
private readonly errorCounts: ErrorCounts = {}
private readonly player: videojs.Player
@ -121,7 +121,7 @@ export class Html5Hlsjs {
private maxNetworkErrorRecovery = 5
private hls: Hlsjs
private hls: HlsWithP2PInstance<Hlsjs>
private hlsjsConfig: HLSPluginOptions = null
private _duration: number = null
@ -410,10 +410,15 @@ export class Html5Hlsjs {
this.videoElement.addEventListener('play', this.handlers.play)
}
const loader = this.hlsjsConfig.loaderBuilder()
this.hls = new Hlsjs({ ...this.hlsjsConfig, loader })
this.hls = new HlsWithP2P({
...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.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
@ -462,17 +467,21 @@ export class Html5Hlsjs {
this.hlsjsConfig.autoStartLoad = true
this.player.autoplay('play')
const loader = this.hlsjsConfig.loaderBuilder()
this.hls = new Hlsjs({
...this.hlsjsConfig,
loader,
this.hls = new HlsWithP2P({
...omit(this.hlsjsConfig, [ 'p2pMediaLoaderOptions' ]),
p2p: {
core: this.hlsjsConfig.p2pMediaLoaderOptions
},
startPosition: this.duration() === Infinity
? undefined
: currentTime,
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.registerLevelEventSwitch()

View File

@ -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 { logger } from '@root-helpers/logger'
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 { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
import { SettingsButton } from '../settings/settings-menu-button'
@ -14,8 +14,7 @@ const Plugin = videojs.getPlugin('plugin')
class P2pMediaLoaderPlugin extends Plugin {
declare private readonly options: P2PMediaLoaderPluginOptions
declare private hlsjs: Hlsjs
declare private p2pEngine: Engine
declare private hlsjs: HlsWithP2PInstance<Hlsjs>
declare private statsP2PBytes: {
pendingDownload: number[]
pendingUpload: number[]
@ -33,6 +32,9 @@ class P2pMediaLoaderPlugin extends Plugin {
declare private liveEnded: boolean
declare private connectedPeers: Set<string>
declare private totalHTTPPeers: number
constructor (player: videojs.Player, options?: P2PMediaLoaderPluginOptions) {
super(player)
@ -76,15 +78,13 @@ class P2pMediaLoaderPlugin extends Plugin {
}
{
const onHLSJSInitialized = (_: any, { hlsjs, engine }: { hlsjs: Hlsjs, engine: Engine }) => {
this.p2pEngine?.removeAllListeners()
this.p2pEngine?.destroy()
const onHLSJSInitialized = (_: any, { hlsjs }: { hlsjs: HlsWithP2PInstance<Hlsjs> }) => {
this.hlsjs?.p2pEngine?.destroy()
clearInterval(this.networkInfoInterval)
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())
}
@ -116,8 +116,7 @@ class P2pMediaLoaderPlugin extends Plugin {
}
dispose () {
this.p2pEngine?.removeAllListeners()
this.p2pEngine?.destroy()
this.hlsjs?.p2pEngine?.destroy()
this.hlsjs?.destroy()
this.options.segmentValidator?.destroy()
@ -152,15 +151,22 @@ class P2pMediaLoaderPlugin extends Plugin {
}
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) => {
if (navigator.onLine === false || this.liveEnded) return
logger.clientError(`Segment ${segment.id} error.`, err)
const segment = details.segment
logger.clientError(`Segment ${segment.runtimeId} error.`, details)
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()
: 0
this.statsP2PBytes.peersWithWebSeed = 1 + redundancyUrlsCount
this.totalHTTPPeers = 1 + redundancyUrlsCount
this.statsP2PBytes.peersWithWebSeed = this.totalHTTPPeers
this.runStats()
@ -200,30 +207,44 @@ class P2pMediaLoaderPlugin extends Plugin {
}
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
elem.pendingDownload.push(bytes)
elem.totalDownload += bytes
})
this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, _segment, bytes: number) => {
if (method !== 'p2p') {
logger.error(`Received upload from unknown method ${method}`)
return
}
this.hlsjs.p2pEngine.addEventListener('onChunkUploaded', (bytes: number) => {
this.statsP2PBytes.pendingUpload.push(bytes)
this.statsP2PBytes.totalUpload += bytes
})
this.p2pEngine.on(Events.PeerConnect, () => {
this.statsP2PBytes.peersWithWebSeed++
this.statsP2PBytes.peersP2POnly++
this.hlsjs.p2pEngine.addEventListener('onPeerConnect', peer => {
if (peer.streamType !== 'main') return
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.statsP2PBytes.peersWithWebSeed--
this.statsP2PBytes.peersP2POnly--
this.hlsjs.p2pEngine.addEventListener('onPeerClose', peer => {
if (peer.streamType !== 'main') return
this.connectedPeers.delete(peer.peerId)
this.statsP2PBytes.peersP2POnly = this.connectedPeers.size
this.statsP2PBytes.peersWithWebSeed = this.totalHTTPPeers + this.statsP2PBytes.peersP2POnly
})
this.networkInfoInterval = setInterval(() => {

View File

@ -1,22 +1,32 @@
import { logger } from '@root-helpers/logger'
class RedundancyUrlManager {
private map = new Map<string, string>()
constructor (private baseUrls: string[] = []) {
// empty
}
removeBySegmentUrl (segmentUrl: string) {
const baseUrl = getBaseUrl(segmentUrl)
onSegmentError (segmentUrl: string) {
if (!this.map.has(segmentUrl)) return
const customSegmentUrl = this.map.get(segmentUrl)
this.map.delete(segmentUrl)
const baseUrl = getBaseUrl(customSegmentUrl)
const oldLength = baseUrl.length
this.baseUrls = this.baseUrls.filter(u => u !== baseUrl && u !== baseUrl + '/')
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) {
const max = this.baseUrls.length + 1
const i = this.getRandomInt(max)
@ -26,7 +36,11 @@ class RedundancyUrlManager {
const newBaseUrl = this.baseUrls[i]
const slashPart = newBaseUrl.endsWith('/') ? '' : '/'
return newBaseUrl + slashPart + getFilename(url)
const newUrl = newBaseUrl + slashPart + getFilename(url)
this.map.set(url, newUrl)
return newUrl
}
countBaseUrls () {

View File

@ -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'
export function segmentUrlBuilderFactory (redundancyUrlManager: RedundancyUrlManager | null) {

View File

@ -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 { wait } from '@root-helpers/utils'
import { removeQueryParams } from '@peertube/peertube-core-utils'
import { isSameOrigin } from '../common'
import debug from 'debug'
import { isSameOrigin } from '../common'
const debugLogger = debug('peertube:player:segment-validator')
@ -12,9 +12,6 @@ type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string
const maxRetries = 10
export class SegmentValidator {
private readonly bytesRangeRegex = /bytes=(\d+)-(\d+)/
private destroyed = false
private segmentJSONPromise: Promise<SegmentsJSON>
@ -29,17 +26,18 @@ export class SegmentValidator {
}) {
}
async validate (segment: Segment, _method: string, _peerId: string, retry = 1) {
if (this.destroyed) return
async validate (url: string, byteRange: ByteRange | undefined, data: ArrayBuffer, retry = 1): Promise<boolean> {
if (this.destroyed) return false
this.loadSha256SegmentsPromiseIfNeeded()
const filename = removeQueryParams(segment.url).split('/').pop()
const filename = removeQueryParams(url).split('/').pop()
const segmentValue = (await this.segmentJSONPromise)[filename]
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) {
@ -49,9 +47,7 @@ export class SegmentValidator {
this.loadSha256SegmentsPromise()
await this.validate(segment, _method, _peerId, retry + 1)
return
return this.validate(url, byteRange, data, retry + 1)
}
let hashShouldBe: string
@ -60,24 +56,25 @@ export class SegmentValidator {
if (typeof segmentValue === 'string') {
hashShouldBe = segmentValue
} else {
const captured = this.bytesRangeRegex.exec(segment.range)
range = captured[1] + '-' + captured[2]
range = byteRange.start + '-' + byteRange.end
hashShouldBe = segmentValue[range]
}
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) {
throw new Error(
`Hashes does not correspond for segment ${filename}/${range}` +
`(expected: ${hashShouldBe} instead of ${calculatedSha})`
logger.clientError(
`Hashes does not correspond for segment ${filename}/${range} (expected: ${hashShouldBe} instead of ${calculatedSha})`
)
return true
}
}
@ -106,7 +103,7 @@ export class SegmentValidator {
return fetch(this.options.segmentsSha256Url, { headers })
.then(res => res.json() as Promise<SegmentsJSON>)
.catch(err => {
logger.error('Cannot get sha256 segments', err)
logger.clientError('Cannot get sha256 segments', err)
return {}
})
}

View File

@ -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 { LiveVideoLatencyMode } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import debug from 'debug'
import { Level } from 'hls.js'
import type { CoreConfig, StreamConfig } from 'p2p-media-loader-core'
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
import {
HLSLoaderClass,
HLSPluginOptions,
P2PMediaLoaderPluginOptions,
PeerTubePlayerConstructorOptions,
@ -15,9 +14,7 @@ import {
} from '../../types'
import { getRtcConfig, isSameOrigin } from '../common'
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
import { SegmentValidator } from '../p2p-media-loader/segment-validator'
import debug from 'debug'
const debugLogger = debug('peertube:player:hls')
@ -58,7 +55,6 @@ export class HLSOptionsBuilder {
'filter:internal.player.p2p-media-loader.options.result',
this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator })
)
const loaderBuilder = () => new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as HLSLoaderClass
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
requiresUserAuth: this.options.requiresUserAuth,
@ -73,7 +69,7 @@ export class HLSOptionsBuilder {
}
const hlsjs = {
hlsjsConfig: this.getHLSJSOptions(loaderBuilder),
hlsjsConfig: this.getHLSJSOptions(p2pMediaLoaderConfig),
levelLabelHandler: (level: Level, player: videojs.VideoJsPlayer) => {
const resolution = Math.min(level.height || 0, level.width || 0)
@ -99,65 +95,81 @@ export class HLSOptionsBuilder {
private getP2PMediaLoaderOptions (options: {
redundancyUrlManager: RedundancyUrlManager | null
segmentValidator: SegmentValidator | null
}): HlsJsEngineSettings {
}) {
const { redundancyUrlManager, segmentValidator } = options
let consumeOnly = false
let isP2PUploadDisabled = false
if (
(navigator as any)?.connection?.type === 'cellular' ||
peertubeLocalStorage.getItem('peertube-videojs-p2p-consume-only') === 'true' // Use for E2E testing
) {
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'))
const specificLiveOrVODOptions = this.options.isLive
? this.getP2PMediaLoaderLiveOptions()
: this.getP2PMediaLoaderVODOptions()
return {
loader: {
trackerAnnounce,
rtcConfig: getRtcConfig(this.options.stunServers),
// TODO: remove validateHTTPSegment typing when p2p-media-loader-core is updated
const loaderOptions: Partial<StreamConfig> & { validateHTTPSegment: any } = {
announceTrackers,
rtcConfig: getRtcConfig(this.options.stunServers),
simultaneousHttpDownloads: 1,
httpFailedSegmentTimeout: 1000,
httpRequestSetup: (segmentUrlArg, segmentByteRange, requestAbortSignal, requestByteRange) => {
const { requiresUserAuth, requiresPassword } = this.options
xhrSetup: (xhr, url) => {
const { requiresUserAuth, requiresPassword } = this.options
const segmentUrl = redundancyUrlManager
? redundancyUrlManager.buildUrl(segmentUrlArg)
: segmentUrlArg
if (!(requiresUserAuth || requiresPassword)) return
const headers = new Headers()
if (!isSameOrigin(this.options.serverUrl, url)) return
if (requestByteRange) {
headers.set('Range', `bytes=${requestByteRange.start}-${requestByteRange.end ?? ''}`)
}
if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.videoPassword())
else xhr.setRequestHeader('Authorization', this.options.authorizationHeader())
},
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())
}
}
segmentValidator: segmentValidator
? segmentValidator.validate.bind(segmentValidator)
: null,
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
useP2P: this.options.p2pEnabled,
consumeOnly,
...specificLiveOrVODOptions
return Promise.resolve(
new Request(segmentUrl, {
headers,
signal: requestAbortSignal
})
)
},
segments: {
swarmId: this.options.hls.playlistUrl,
forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20
}
validateP2PSegment: segmentValidator
? segmentValidator.validate.bind(segmentValidator)
: null,
validateHTTPSegment: segmentValidator
? segmentValidator.validate.bind(segmentValidator)
: null,
isP2PDisabled: !this.options.p2pEnabled,
isP2PUploadDisabled,
swarmId: this.options.hls.playlistUrl,
...specificLiveOrVODOptions
}
return { loader: loaderOptions }
}
private getP2PMediaLoaderLiveOptions (): Partial<HybridLoaderSettings> {
private getP2PMediaLoaderLiveOptions (): Partial<CoreConfig> {
const base = {
requiredSegmentsPriority: 1
highDemandTimeWindow: 4
}
const latencyMode = this.options.liveOptions.latencyMode
@ -167,8 +179,7 @@ export class HLSOptionsBuilder {
return {
...base,
useP2P: false,
requiredSegmentsPriority: 10
isP2PDisabled: true
}
case LiveVideoLatencyMode.HIGH_LATENCY:
@ -179,34 +190,39 @@ export class HLSOptionsBuilder {
}
}
private getP2PMediaLoaderVODOptions (): Partial<HybridLoaderSettings> {
private getP2PMediaLoaderVODOptions (): Partial<CoreConfig> {
return {
requiredSegmentsPriority: 3,
skipSegmentBuilderPriority: 1,
highDemandTimeWindow: 15,
cachedSegmentExpiration: 86400000,
cachedSegmentsCount: 100,
httpDownloadMaxPriority: 9,
httpDownloadProbability: 0.06,
httpDownloadProbabilitySkipIfNoPeers: true,
p2pDownloadMaxPriority: 50
segmentMemoryStorageLimit: 1024
}
}
// ---------------------------------------------------------------------------
private getHLSJSOptions (loaderBuilder: () => HLSLoaderClass): HLSPluginOptions {
private getHLSJSOptions (p2pMediaLoaderConfig: { loader: CoreConfig }): HLSPluginOptions {
const specificLiveOrVODOptions = this.options.isLive
? this.getHLSLiveOptions()
: this.getHLSVODOptions()
const base = {
const base: HLSPluginOptions = {
capLevelToPlayerSize: true,
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
}

Some files were not shown because too many files have changed in this diff Show More