webtorrent-plugin.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. // FIXME: something weird with our path definition in tsconfig and typings
  2. // @ts-ignore
  3. import * as videojs from 'video.js'
  4. import * as WebTorrent from 'webtorrent'
  5. import { renderVideo } from './video-renderer'
  6. import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings'
  7. import { getRtcConfig, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from '../utils'
  8. import { PeertubeChunkStore } from './peertube-chunk-store'
  9. import {
  10. getAverageBandwidthInStore,
  11. getStoredMute,
  12. getStoredVolume,
  13. getStoredWebTorrentEnabled,
  14. saveAverageBandwidth
  15. } from '../peertube-player-local-storage'
  16. import { VideoFile } from '@shared/models'
  17. const CacheChunkStore = require('cache-chunk-store')
  18. type PlayOptions = {
  19. forcePlay?: boolean,
  20. seek?: number,
  21. delay?: number
  22. }
  23. const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
  24. class WebTorrentPlugin extends Plugin {
  25. private readonly playerElement: HTMLVideoElement
  26. private readonly autoplay: boolean = false
  27. private readonly startTime: number = 0
  28. private readonly savePlayerSrcFunction: Function
  29. private readonly videoFiles: VideoFile[]
  30. private readonly videoDuration: number
  31. private readonly CONSTANTS = {
  32. INFO_SCHEDULER: 1000, // Don't change this
  33. AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds
  34. AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
  35. AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
  36. AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
  37. BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth
  38. }
  39. private readonly webtorrent = new WebTorrent({
  40. tracker: {
  41. rtcConfig: getRtcConfig()
  42. },
  43. dht: false
  44. })
  45. private player: any
  46. private currentVideoFile: VideoFile
  47. private torrent: WebTorrent.Torrent
  48. private renderer: any
  49. private fakeRenderer: any
  50. private destroyingFakeRenderer = false
  51. private autoResolution = true
  52. private autoResolutionPossible = true
  53. private isAutoResolutionObservation = false
  54. private playerRefusedP2P = false
  55. private torrentInfoInterval: any
  56. private autoQualityInterval: any
  57. private addTorrentDelay: any
  58. private qualityObservationTimer: any
  59. private runAutoQualitySchedulerTimer: any
  60. private downloadSpeeds: number[] = []
  61. constructor (player: videojs.Player, options: WebtorrentPluginOptions) {
  62. super(player, options)
  63. this.startTime = timeToInt(options.startTime)
  64. // Disable auto play on iOS
  65. this.autoplay = options.autoplay && this.isIOS() === false
  66. this.playerRefusedP2P = !getStoredWebTorrentEnabled()
  67. this.videoFiles = options.videoFiles
  68. this.videoDuration = options.videoDuration
  69. this.savePlayerSrcFunction = this.player.src
  70. this.playerElement = options.playerElement
  71. this.player.ready(() => {
  72. const playerOptions = this.player.options_
  73. const volume = getStoredVolume()
  74. if (volume !== undefined) this.player.volume(volume)
  75. const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
  76. if (muted !== undefined) this.player.muted(muted)
  77. this.player.duration(options.videoDuration)
  78. this.initializePlayer()
  79. this.runTorrentInfoScheduler()
  80. this.player.one('play', () => {
  81. // Don't run immediately scheduler, wait some seconds the TCP connections are made
  82. this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
  83. })
  84. })
  85. }
  86. dispose () {
  87. clearTimeout(this.addTorrentDelay)
  88. clearTimeout(this.qualityObservationTimer)
  89. clearTimeout(this.runAutoQualitySchedulerTimer)
  90. clearInterval(this.torrentInfoInterval)
  91. clearInterval(this.autoQualityInterval)
  92. // Don't need to destroy renderer, video player will be destroyed
  93. this.flushVideoFile(this.currentVideoFile, false)
  94. this.destroyFakeRenderer()
  95. }
  96. getCurrentResolutionId () {
  97. return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1
  98. }
  99. updateVideoFile (
  100. videoFile?: VideoFile,
  101. options: {
  102. forcePlay?: boolean,
  103. seek?: number,
  104. delay?: number
  105. } = {},
  106. done: () => void = () => { /* empty */ }
  107. ) {
  108. // Automatically choose the adapted video file
  109. if (videoFile === undefined) {
  110. const savedAverageBandwidth = getAverageBandwidthInStore()
  111. videoFile = savedAverageBandwidth
  112. ? this.getAppropriateFile(savedAverageBandwidth)
  113. : this.pickAverageVideoFile()
  114. }
  115. // Don't add the same video file once again
  116. if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) {
  117. return
  118. }
  119. // Do not display error to user because we will have multiple fallback
  120. this.disableErrorDisplay()
  121. // Hack to "simulate" src link in video.js >= 6
  122. // Without this, we can't play the video after pausing it
  123. // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
  124. this.player.src = () => true
  125. const oldPlaybackRate = this.player.playbackRate()
  126. const previousVideoFile = this.currentVideoFile
  127. this.currentVideoFile = videoFile
  128. // Don't try on iOS that does not support MediaSource
  129. // Or don't use P2P if webtorrent is disabled
  130. if (this.isIOS() || this.playerRefusedP2P) {
  131. return this.fallbackToHttp(options, () => {
  132. this.player.playbackRate(oldPlaybackRate)
  133. return done()
  134. })
  135. }
  136. this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => {
  137. this.player.playbackRate(oldPlaybackRate)
  138. return done()
  139. })
  140. this.changeQuality()
  141. this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id })
  142. }
  143. updateResolution (resolutionId: number, delay = 0) {
  144. // Remember player state
  145. const currentTime = this.player.currentTime()
  146. const isPaused = this.player.paused()
  147. // Remove poster to have black background
  148. this.playerElement.poster = ''
  149. // Hide bigPlayButton
  150. if (!isPaused) {
  151. this.player.bigPlayButton.hide()
  152. }
  153. const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
  154. const options = {
  155. forcePlay: false,
  156. delay,
  157. seek: currentTime + (delay / 1000)
  158. }
  159. this.updateVideoFile(newVideoFile, options)
  160. }
  161. flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
  162. if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
  163. if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
  164. this.webtorrent.remove(videoFile.magnetUri)
  165. console.log('Removed ' + videoFile.magnetUri)
  166. }
  167. }
  168. enableAutoResolution () {
  169. this.autoResolution = true
  170. this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
  171. }
  172. disableAutoResolution (forbid = false) {
  173. if (forbid === true) this.autoResolutionPossible = false
  174. this.autoResolution = false
  175. this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible })
  176. this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
  177. }
  178. getTorrent () {
  179. return this.torrent
  180. }
  181. private addTorrent (
  182. magnetOrTorrentUrl: string,
  183. previousVideoFile: VideoFile,
  184. options: PlayOptions,
  185. done: Function
  186. ) {
  187. console.log('Adding ' + magnetOrTorrentUrl + '.')
  188. const oldTorrent = this.torrent
  189. const torrentOptions = {
  190. // Don't use arrow function: it breaks webtorrent (that uses `new` keyword)
  191. store: function (chunkLength: number, storeOpts: any) {
  192. return new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), {
  193. max: 100
  194. })
  195. }
  196. }
  197. this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => {
  198. console.log('Added ' + magnetOrTorrentUrl + '.')
  199. if (oldTorrent) {
  200. // Pause the old torrent
  201. this.stopTorrent(oldTorrent)
  202. // We use a fake renderer so we download correct pieces of the next file
  203. if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay)
  204. }
  205. // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
  206. this.addTorrentDelay = setTimeout(() => {
  207. // We don't need the fake renderer anymore
  208. this.destroyFakeRenderer()
  209. const paused = this.player.paused()
  210. this.flushVideoFile(previousVideoFile)
  211. // Update progress bar (just for the UI), do not wait rendering
  212. if (options.seek) this.player.currentTime(options.seek)
  213. const renderVideoOptions = { autoplay: false, controls: true }
  214. renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => {
  215. this.renderer = renderer
  216. if (err) return this.fallbackToHttp(options, done)
  217. return this.tryToPlay(err => {
  218. if (err) return done(err)
  219. if (options.seek) this.seek(options.seek)
  220. if (options.forcePlay === false && paused === true) this.player.pause()
  221. return done()
  222. })
  223. })
  224. }, options.delay || 0)
  225. })
  226. this.torrent.on('error', (err: any) => console.error(err))
  227. this.torrent.on('warning', (err: any) => {
  228. // We don't support HTTP tracker but we don't care -> we use the web socket tracker
  229. if (err.message.indexOf('Unsupported tracker protocol') !== -1) return
  230. // Users don't care about issues with WebRTC, but developers do so log it in the console
  231. if (err.message.indexOf('Ice connection failed') !== -1) {
  232. console.log(err)
  233. return
  234. }
  235. // Magnet hash is not up to date with the torrent file, add directly the torrent file
  236. if (err.message.indexOf('incorrect info hash') !== -1) {
  237. console.error('Incorrect info hash detected, falling back to torrent file.')
  238. const newOptions = { forcePlay: true, seek: options.seek }
  239. return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done)
  240. }
  241. // Remote instance is down
  242. if (err.message.indexOf('from xs param') !== -1) {
  243. this.handleError(err)
  244. }
  245. console.warn(err)
  246. })
  247. }
  248. private tryToPlay (done?: (err?: Error) => void) {
  249. if (!done) done = function () { /* empty */ }
  250. const playPromise = this.player.play()
  251. if (playPromise !== undefined) {
  252. return playPromise.then(done)
  253. .catch((err: Error) => {
  254. if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) {
  255. return
  256. }
  257. console.error(err)
  258. this.player.pause()
  259. this.player.posterImage.show()
  260. this.player.removeClass('vjs-has-autoplay')
  261. this.player.removeClass('vjs-has-big-play-button-clicked')
  262. return done()
  263. })
  264. }
  265. return done()
  266. }
  267. private seek (time: number) {
  268. this.player.currentTime(time)
  269. this.player.handleTechSeeked_()
  270. }
  271. private getAppropriateFile (averageDownloadSpeed?: number): VideoFile {
  272. if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined
  273. if (this.videoFiles.length === 1) return this.videoFiles[0]
  274. // Don't change the torrent is the play was ended
  275. if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile
  276. if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed()
  277. // Limit resolution according to player height
  278. const playerHeight = this.playerElement.offsetHeight
  279. // We take the first resolution just above the player height
  280. // Example: player height is 530px, we want the 720p file instead of 480p
  281. let maxResolution = this.videoFiles[0].resolution.id
  282. for (let i = this.videoFiles.length - 1; i >= 0; i--) {
  283. const resolutionId = this.videoFiles[i].resolution.id
  284. if (resolutionId >= playerHeight) {
  285. maxResolution = resolutionId
  286. break
  287. }
  288. }
  289. // Filter videos we can play according to our screen resolution and bandwidth
  290. const filteredFiles = this.videoFiles
  291. .filter(f => f.resolution.id <= maxResolution)
  292. .filter(f => {
  293. const fileBitrate = (f.size / this.videoDuration)
  294. let threshold = fileBitrate
  295. // If this is for a higher resolution or an initial load: add a margin
  296. if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) {
  297. threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100)
  298. }
  299. return averageDownloadSpeed > threshold
  300. })
  301. // If the download speed is too bad, return the lowest resolution we have
  302. if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles)
  303. return videoFileMaxByResolution(filteredFiles)
  304. }
  305. private getAndSaveActualDownloadSpeed () {
  306. const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0)
  307. const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length)
  308. if (lastDownloadSpeeds.length === 0) return -1
  309. const sum = lastDownloadSpeeds.reduce((a, b) => a + b)
  310. const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length)
  311. // Save the average bandwidth for future use
  312. saveAverageBandwidth(averageBandwidth)
  313. return averageBandwidth
  314. }
  315. private initializePlayer () {
  316. this.buildQualities()
  317. if (this.autoplay === true) {
  318. this.player.posterImage.hide()
  319. return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
  320. }
  321. // Proxy first play
  322. const oldPlay = this.player.play.bind(this.player)
  323. this.player.play = () => {
  324. this.player.addClass('vjs-has-big-play-button-clicked')
  325. this.player.play = oldPlay
  326. this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
  327. }
  328. }
  329. private runAutoQualityScheduler () {
  330. this.autoQualityInterval = setInterval(() => {
  331. // Not initialized or in HTTP fallback
  332. if (this.torrent === undefined || this.torrent === null) return
  333. if (this.autoResolution === false) return
  334. if (this.isAutoResolutionObservation === true) return
  335. const file = this.getAppropriateFile()
  336. let changeResolution = false
  337. let changeResolutionDelay = 0
  338. // Lower resolution
  339. if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) {
  340. console.log('Downgrading automatically the resolution to: %s', file.resolution.label)
  341. changeResolution = true
  342. } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution
  343. console.log('Upgrading automatically the resolution to: %s', file.resolution.label)
  344. changeResolution = true
  345. changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY
  346. }
  347. if (changeResolution === true) {
  348. this.updateResolution(file.resolution.id, changeResolutionDelay)
  349. // Wait some seconds in observation of our new resolution
  350. this.isAutoResolutionObservation = true
  351. this.qualityObservationTimer = setTimeout(() => {
  352. this.isAutoResolutionObservation = false
  353. }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME)
  354. }
  355. }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
  356. }
  357. private isPlayerWaiting () {
  358. return this.player && this.player.hasClass('vjs-waiting')
  359. }
  360. private runTorrentInfoScheduler () {
  361. this.torrentInfoInterval = setInterval(() => {
  362. // Not initialized yet
  363. if (this.torrent === undefined) return
  364. // Http fallback
  365. if (this.torrent === null) return this.player.trigger('p2pInfo', false)
  366. // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too
  367. if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
  368. return this.player.trigger('p2pInfo', {
  369. http: {
  370. downloadSpeed: 0,
  371. uploadSpeed: 0,
  372. downloaded: 0,
  373. uploaded: 0
  374. },
  375. p2p: {
  376. downloadSpeed: this.torrent.downloadSpeed,
  377. numPeers: this.torrent.numPeers,
  378. uploadSpeed: this.torrent.uploadSpeed,
  379. downloaded: this.torrent.downloaded,
  380. uploaded: this.torrent.uploaded
  381. }
  382. } as PlayerNetworkInfo)
  383. }, this.CONSTANTS.INFO_SCHEDULER)
  384. }
  385. private fallbackToHttp (options: PlayOptions, done?: Function) {
  386. const paused = this.player.paused()
  387. this.disableAutoResolution(true)
  388. this.flushVideoFile(this.currentVideoFile, true)
  389. this.torrent = null
  390. // Enable error display now this is our last fallback
  391. this.player.one('error', () => this.enableErrorDisplay())
  392. const httpUrl = this.currentVideoFile.fileUrl
  393. this.player.src = this.savePlayerSrcFunction
  394. this.player.src(httpUrl)
  395. this.changeQuality()
  396. // We changed the source, so reinit captions
  397. this.player.trigger('sourcechange')
  398. return this.tryToPlay(err => {
  399. if (err && done) return done(err)
  400. if (options.seek) this.seek(options.seek)
  401. if (options.forcePlay === false && paused === true) this.player.pause()
  402. if (done) return done()
  403. })
  404. }
  405. private handleError (err: Error | string) {
  406. return this.player.trigger('customError', { err })
  407. }
  408. private enableErrorDisplay () {
  409. this.player.addClass('vjs-error-display-enabled')
  410. }
  411. private disableErrorDisplay () {
  412. this.player.removeClass('vjs-error-display-enabled')
  413. }
  414. private isIOS () {
  415. return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform)
  416. }
  417. private pickAverageVideoFile () {
  418. if (this.videoFiles.length === 1) return this.videoFiles[0]
  419. return this.videoFiles[Math.floor(this.videoFiles.length / 2)]
  420. }
  421. private stopTorrent (torrent: WebTorrent.Torrent) {
  422. torrent.pause()
  423. // Pause does not remove actual peers (in particular the webseed peer)
  424. torrent.removePeer(torrent[ 'ws' ])
  425. }
  426. private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
  427. this.destroyingFakeRenderer = false
  428. const fakeVideoElem = document.createElement('video')
  429. renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
  430. this.fakeRenderer = renderer
  431. // The renderer returns an error when we destroy it, so skip them
  432. if (this.destroyingFakeRenderer === false && err) {
  433. console.error('Cannot render new torrent in fake video element.', err)
  434. }
  435. // Load the future file at the correct time (in delay MS - 2 seconds)
  436. fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
  437. })
  438. }
  439. private destroyFakeRenderer () {
  440. if (this.fakeRenderer) {
  441. this.destroyingFakeRenderer = true
  442. if (this.fakeRenderer.destroy) {
  443. try {
  444. this.fakeRenderer.destroy()
  445. } catch (err) {
  446. console.log('Cannot destroy correctly fake renderer.', err)
  447. }
  448. }
  449. this.fakeRenderer = undefined
  450. }
  451. }
  452. private buildQualities () {
  453. const qualityLevelsPayload = []
  454. for (const file of this.videoFiles) {
  455. const representation = {
  456. id: file.resolution.id,
  457. label: this.buildQualityLabel(file),
  458. height: file.resolution.id,
  459. _enabled: true
  460. }
  461. this.player.qualityLevels().addQualityLevel(representation)
  462. qualityLevelsPayload.push({
  463. id: representation.id,
  464. label: representation.label,
  465. selected: false
  466. })
  467. }
  468. const payload: LoadedQualityData = {
  469. qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d),
  470. qualityData: {
  471. video: qualityLevelsPayload
  472. }
  473. }
  474. this.player.tech_.trigger('loadedqualitydata', payload)
  475. }
  476. private buildQualityLabel (file: VideoFile) {
  477. let label = file.resolution.label
  478. if (file.fps && file.fps >= 50) {
  479. label += file.fps
  480. }
  481. return label
  482. }
  483. private qualitySwitchCallback (id: number) {
  484. if (id === -1) {
  485. if (this.autoResolutionPossible === true) this.enableAutoResolution()
  486. return
  487. }
  488. this.disableAutoResolution()
  489. this.updateResolution(id)
  490. }
  491. private changeQuality () {
  492. const resolutionId = this.currentVideoFile.resolution.id
  493. const qualityLevels = this.player.qualityLevels()
  494. if (resolutionId === -1) {
  495. qualityLevels.selectedIndex = -1
  496. return
  497. }
  498. for (let i = 0; i < qualityLevels; i++) {
  499. const q = this.player.qualityLevels[i]
  500. if (q.height === resolutionId) qualityLevels.selectedIndex = i
  501. }
  502. }
  503. }
  504. videojs.registerPlugin('webtorrent', WebTorrentPlugin)
  505. export { WebTorrentPlugin }