peertube-player-manager.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. import { VideoFile } from '../../../../shared/models/videos'
  2. import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'
  3. import 'videojs-hotkeys'
  4. import 'videojs-dock'
  5. import 'videojs-contextmenu-ui'
  6. import 'videojs-contrib-quality-levels'
  7. import './upnext/end-card'
  8. import './upnext/upnext-plugin'
  9. import './bezels/bezels-plugin'
  10. import './peertube-plugin'
  11. import './videojs-components/next-video-button'
  12. import './videojs-components/p2p-info-button'
  13. import './videojs-components/peertube-link-button'
  14. import './videojs-components/peertube-load-progress-bar'
  15. import './videojs-components/resolution-menu-button'
  16. import './videojs-components/resolution-menu-item'
  17. import './videojs-components/settings-dialog'
  18. import './videojs-components/settings-menu-button'
  19. import './videojs-components/settings-menu-item'
  20. import './videojs-components/settings-panel'
  21. import './videojs-components/settings-panel-child'
  22. import './videojs-components/theater-button'
  23. import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions } from './peertube-videojs-typings'
  24. import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig } from './utils'
  25. import { isDefaultLocale } from '../../../../shared/models/i18n/i18n'
  26. import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
  27. import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
  28. import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
  29. import { getStoredP2PEnabled } from './peertube-player-local-storage'
  30. import { TranslationsManager } from './translations-manager'
  31. // For VideoJS
  32. (window as any).WebVTT = require('vtt.js/lib/vtt.js').WebVTT;
  33. // Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
  34. (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
  35. const CaptionsButton = videojs.getComponent('CaptionsButton') as any
  36. // Change Captions to Subtitles/CC
  37. CaptionsButton.prototype.controlText_ = 'Subtitles/CC'
  38. // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
  39. CaptionsButton.prototype.label_ = ' '
  40. export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
  41. export type WebtorrentOptions = {
  42. videoFiles: VideoFile[]
  43. }
  44. export type P2PMediaLoaderOptions = {
  45. playlistUrl: string
  46. segmentsSha256Url: string
  47. trackerAnnounce: string[]
  48. redundancyBaseUrls: string[]
  49. videoFiles: VideoFile[]
  50. }
  51. export interface CustomizationOptions {
  52. startTime: number | string
  53. stopTime: number | string
  54. controls?: boolean
  55. muted?: boolean
  56. loop?: boolean
  57. subtitle?: string
  58. resume?: string
  59. peertubeLink: boolean
  60. }
  61. export interface CommonOptions extends CustomizationOptions {
  62. playerElement: HTMLVideoElement
  63. onPlayerElementChange: (element: HTMLVideoElement) => void
  64. autoplay: boolean
  65. nextVideo?: Function
  66. videoDuration: number
  67. enableHotkeys: boolean
  68. inactivityTimeout: number
  69. poster: string
  70. theaterButton: boolean
  71. captions: boolean
  72. videoViewUrl: string
  73. embedUrl: string
  74. language?: string
  75. videoCaptions: VideoJSCaption[]
  76. userWatching?: UserWatching
  77. serverUrl: string
  78. }
  79. export type PeertubePlayerManagerOptions = {
  80. common: CommonOptions,
  81. webtorrent: WebtorrentOptions,
  82. p2pMediaLoader?: P2PMediaLoaderOptions
  83. }
  84. export class PeertubePlayerManager {
  85. private static playerElementClassName: string
  86. private static onPlayerChange: (player: VideoJsPlayer) => void
  87. static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: VideoJsPlayer) => void) {
  88. let p2pMediaLoader: any
  89. this.onPlayerChange = onPlayerChange
  90. this.playerElementClassName = options.common.playerElement.className
  91. if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin')
  92. if (mode === 'p2p-media-loader') {
  93. [ p2pMediaLoader ] = await Promise.all([
  94. import('p2p-media-loader-hlsjs'),
  95. import('./p2p-media-loader/p2p-media-loader-plugin')
  96. ])
  97. }
  98. const videojsOptions = this.getVideojsOptions(mode, options, p2pMediaLoader)
  99. await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs)
  100. const self = this
  101. return new Promise(res => {
  102. videojs(options.common.playerElement, videojsOptions, function (this: VideoJsPlayer) {
  103. const player = this
  104. let alreadyFallback = false
  105. player.tech(true).one('error', () => {
  106. if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
  107. alreadyFallback = true
  108. })
  109. player.one('error', () => {
  110. if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
  111. alreadyFallback = true
  112. })
  113. self.addContextMenu(mode, player, options.common.embedUrl)
  114. player.bezels()
  115. return res(player)
  116. })
  117. })
  118. }
  119. private static async maybeFallbackToWebTorrent (currentMode: PlayerMode, player: any, options: PeertubePlayerManagerOptions) {
  120. if (currentMode === 'webtorrent') return
  121. console.log('Fallback to webtorrent.')
  122. const newVideoElement = document.createElement('video')
  123. newVideoElement.className = this.playerElementClassName
  124. // VideoJS wraps our video element inside a div
  125. let currentParentPlayerElement = options.common.playerElement.parentNode
  126. // Fix on IOS, don't ask me why
  127. if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(options.common.playerElement.id).parentNode
  128. currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement)
  129. options.common.playerElement = newVideoElement
  130. options.common.onPlayerElementChange(newVideoElement)
  131. player.dispose()
  132. await import('./webtorrent/webtorrent-plugin')
  133. const mode = 'webtorrent'
  134. const videojsOptions = this.getVideojsOptions(mode, options)
  135. const self = this
  136. videojs(newVideoElement, videojsOptions, function (this: VideoJsPlayer) {
  137. const player = this
  138. self.addContextMenu(mode, player, options.common.embedUrl)
  139. PeertubePlayerManager.onPlayerChange(player)
  140. })
  141. }
  142. private static getVideojsOptions (
  143. mode: PlayerMode,
  144. options: PeertubePlayerManagerOptions,
  145. p2pMediaLoaderModule?: any
  146. ): VideoJsPlayerOptions {
  147. const commonOptions = options.common
  148. let autoplay = commonOptions.autoplay
  149. let html5 = {}
  150. const plugins: VideoJSPluginOptions = {
  151. peertube: {
  152. mode,
  153. autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
  154. videoViewUrl: commonOptions.videoViewUrl,
  155. videoDuration: commonOptions.videoDuration,
  156. userWatching: commonOptions.userWatching,
  157. subtitle: commonOptions.subtitle,
  158. videoCaptions: commonOptions.videoCaptions,
  159. stopTime: commonOptions.stopTime
  160. }
  161. }
  162. if (commonOptions.enableHotkeys === true) {
  163. PeertubePlayerManager.addHotkeysOptions(plugins)
  164. }
  165. if (mode === 'p2p-media-loader') {
  166. const { hlsjs } = PeertubePlayerManager.addP2PMediaLoaderOptions(plugins, options, p2pMediaLoaderModule)
  167. html5 = hlsjs.html5
  168. }
  169. if (mode === 'webtorrent') {
  170. PeertubePlayerManager.addWebTorrentOptions(plugins, options)
  171. // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
  172. autoplay = false
  173. }
  174. const videojsOptions = {
  175. html5,
  176. // We don't use text track settings for now
  177. textTrackSettings: false as any, // FIXME: typings
  178. controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
  179. loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
  180. muted: commonOptions.muted !== undefined
  181. ? commonOptions.muted
  182. : undefined, // Undefined so the player knows it has to check the local storage
  183. autoplay: autoplay === true
  184. ? 'play' // Use 'any' instead of true to get notifier by videojs if autoplay fails
  185. : autoplay,
  186. poster: commonOptions.poster,
  187. inactivityTimeout: commonOptions.inactivityTimeout,
  188. playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
  189. plugins,
  190. controlBar: {
  191. children: this.getControlBarChildren(mode, {
  192. captions: commonOptions.captions,
  193. peertubeLink: commonOptions.peertubeLink,
  194. theaterButton: commonOptions.theaterButton,
  195. nextVideo: commonOptions.nextVideo
  196. }) as any // FIXME: typings
  197. }
  198. }
  199. if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
  200. Object.assign(videojsOptions, { language: commonOptions.language })
  201. }
  202. return videojsOptions
  203. }
  204. private static addP2PMediaLoaderOptions (
  205. plugins: VideoJSPluginOptions,
  206. options: PeertubePlayerManagerOptions,
  207. p2pMediaLoaderModule: any
  208. ) {
  209. const p2pMediaLoaderOptions = options.p2pMediaLoader
  210. const commonOptions = options.common
  211. const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce
  212. .filter(t => t.startsWith('ws'))
  213. const redundancyUrlManager = new RedundancyUrlManager(options.p2pMediaLoader.redundancyBaseUrls)
  214. const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
  215. redundancyUrlManager,
  216. type: 'application/x-mpegURL',
  217. startTime: commonOptions.startTime,
  218. src: p2pMediaLoaderOptions.playlistUrl
  219. }
  220. let consumeOnly = false
  221. // FIXME: typings
  222. if (navigator && (navigator as any).connection && (navigator as any).connection.type === 'cellular') {
  223. console.log('We are on a cellular connection: disabling seeding.')
  224. consumeOnly = true
  225. }
  226. const p2pMediaLoaderConfig = {
  227. loader: {
  228. trackerAnnounce,
  229. segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url),
  230. rtcConfig: getRtcConfig(),
  231. requiredSegmentsPriority: 5,
  232. segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
  233. useP2P: getStoredP2PEnabled(),
  234. consumeOnly
  235. },
  236. segments: {
  237. swarmId: p2pMediaLoaderOptions.playlistUrl
  238. }
  239. }
  240. const hlsjs = {
  241. levelLabelHandler: (level: { height: number, width: number }) => {
  242. const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === level.height)
  243. let label = file.resolution.label
  244. if (file.fps >= 50) label += file.fps
  245. return label
  246. },
  247. html5: {
  248. hlsjsConfig: {
  249. capLevelToPlayerSize: true,
  250. autoStartLoad: false,
  251. liveSyncDurationCount: 7,
  252. loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass()
  253. }
  254. }
  255. }
  256. const toAssign = { p2pMediaLoader, hlsjs }
  257. Object.assign(plugins, toAssign)
  258. return toAssign
  259. }
  260. private static addWebTorrentOptions (plugins: VideoJSPluginOptions, options: PeertubePlayerManagerOptions) {
  261. const commonOptions = options.common
  262. const webtorrentOptions = options.webtorrent
  263. const webtorrent = {
  264. autoplay: commonOptions.autoplay,
  265. videoDuration: commonOptions.videoDuration,
  266. playerElement: commonOptions.playerElement,
  267. videoFiles: webtorrentOptions.videoFiles,
  268. startTime: commonOptions.startTime
  269. }
  270. Object.assign(plugins, { webtorrent })
  271. }
  272. private static getControlBarChildren (mode: PlayerMode, options: {
  273. peertubeLink: boolean
  274. theaterButton: boolean,
  275. captions: boolean,
  276. nextVideo?: Function
  277. }) {
  278. const settingEntries = []
  279. const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar'
  280. // Keep an order
  281. settingEntries.push('playbackRateMenuButton')
  282. if (options.captions === true) settingEntries.push('captionsButton')
  283. settingEntries.push('resolutionMenuButton')
  284. const children = {
  285. 'playToggle': {}
  286. }
  287. if (options.nextVideo) {
  288. Object.assign(children, {
  289. 'nextVideoButton': {
  290. handler: options.nextVideo
  291. }
  292. })
  293. }
  294. Object.assign(children, {
  295. 'currentTimeDisplay': {},
  296. 'timeDivider': {},
  297. 'durationDisplay': {},
  298. 'liveDisplay': {},
  299. 'flexibleWidthSpacer': {},
  300. 'progressControl': {
  301. children: {
  302. 'seekBar': {
  303. children: {
  304. [loadProgressBar]: {},
  305. 'mouseTimeDisplay': {},
  306. 'playProgressBar': {}
  307. }
  308. }
  309. }
  310. },
  311. 'p2PInfoButton': {},
  312. 'muteToggle': {},
  313. 'volumeControl': {},
  314. 'settingsButton': {
  315. setup: {
  316. maxHeightOffset: 40
  317. },
  318. entries: settingEntries
  319. }
  320. })
  321. if (options.peertubeLink === true) {
  322. Object.assign(children, {
  323. 'peerTubeLinkButton': {}
  324. })
  325. }
  326. if (options.theaterButton === true) {
  327. Object.assign(children, {
  328. 'theaterButton': {}
  329. })
  330. }
  331. Object.assign(children, {
  332. 'fullscreenToggle': {}
  333. })
  334. return children
  335. }
  336. private static addContextMenu (mode: PlayerMode, player: VideoJsPlayer, videoEmbedUrl: string) {
  337. const content = [
  338. {
  339. label: player.localize('Copy the video URL'),
  340. listener: function () {
  341. copyToClipboard(buildVideoLink())
  342. }
  343. },
  344. {
  345. label: player.localize('Copy the video URL at the current time'),
  346. listener: function (this: VideoJsPlayer) {
  347. copyToClipboard(buildVideoLink({ startTime: this.currentTime() }))
  348. }
  349. },
  350. {
  351. label: player.localize('Copy embed code'),
  352. listener: () => {
  353. copyToClipboard(buildVideoEmbed(videoEmbedUrl))
  354. }
  355. }
  356. ]
  357. if (mode === 'webtorrent') {
  358. content.push({
  359. label: player.localize('Copy magnet URI'),
  360. listener: function (this: VideoJsPlayer) {
  361. copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri)
  362. }
  363. })
  364. }
  365. player.contextmenuUI({ content })
  366. }
  367. private static addHotkeysOptions (plugins: VideoJSPluginOptions) {
  368. Object.assign(plugins, {
  369. hotkeys: {
  370. enableVolumeScroll: false,
  371. enableModifiersForNumbers: false,
  372. fullscreenKey: function (event: KeyboardEvent) {
  373. // fullscreen with the f key or Ctrl+Enter
  374. return event.key === 'f' || (event.ctrlKey && event.key === 'Enter')
  375. },
  376. seekStep: function (event: KeyboardEvent) {
  377. // mimic VLC seek behavior, and default to 5 (original value is 5).
  378. if (event.ctrlKey && event.altKey) {
  379. return 5 * 60
  380. } else if (event.ctrlKey) {
  381. return 60
  382. } else if (event.altKey) {
  383. return 10
  384. } else {
  385. return 5
  386. }
  387. },
  388. customKeys: {
  389. increasePlaybackRateKey: {
  390. key: function (event: KeyboardEvent) {
  391. return event.key === '>'
  392. },
  393. handler: function (player: videojs.Player) {
  394. const newValue = Math.min(player.playbackRate() + 0.1, 5)
  395. player.playbackRate(parseFloat(newValue.toFixed(2)))
  396. }
  397. },
  398. decreasePlaybackRateKey: {
  399. key: function (event: KeyboardEvent) {
  400. return event.key === '<'
  401. },
  402. handler: function (player: videojs.Player) {
  403. const newValue = Math.max(player.playbackRate() - 0.1, 0.10)
  404. player.playbackRate(parseFloat(newValue.toFixed(2)))
  405. }
  406. },
  407. frameByFrame: {
  408. key: function (event: KeyboardEvent) {
  409. return event.key === '.'
  410. },
  411. handler: function (player: videojs.Player) {
  412. player.pause()
  413. // Calculate movement distance (assuming 30 fps)
  414. const dist = 1 / 30
  415. player.currentTime(player.currentTime() + dist)
  416. }
  417. }
  418. }
  419. }
  420. })
  421. }
  422. }
  423. // ############################################################################
  424. export {
  425. videojs
  426. }