PeerTube/client/src/assets/player/shared/peertube/peertube-plugin.ts

620 lines
18 KiB
TypeScript
Raw Normal View History

import { timeToInt } from '@peertube/peertube-core-utils'
import { VideoView, VideoViewEvent } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { isIOS, isMobile, isSafari } from '@root-helpers/web-browser'
import debug from 'debug'
import videojs from 'video.js'
import {
getPlayerSessionId,
getStoredLastSubtitle,
getStoredMute,
getStoredVolume,
saveLastSubtitle,
saveMuteInStore,
savePreferredSubtitle,
saveVideoWatchHistory,
saveVolumeInStore
2022-03-14 14:28:20 +01:00
} from '../../peertube-player-local-storage'
2023-06-29 15:55:00 +02:00
import { PeerTubePluginOptions } from '../../types'
2022-03-14 14:28:20 +01:00
import { SettingsButton } from '../settings/settings-menu-button'
2022-01-11 11:26:35 +01:00
const debugLogger = debug('peertube:player:peertube')
2020-01-28 17:29:50 +01:00
const Plugin = videojs.getPlugin('plugin')
class PeerTubePlugin extends Plugin {
2023-06-29 15:55:00 +02:00
private readonly videoViewUrl: () => string
private readonly authorizationHeader: () => string
2023-06-29 15:55:00 +02:00
private readonly initialInactivityTimeout: number
2022-04-05 14:03:52 +02:00
2023-06-29 15:55:00 +02:00
private readonly hasAutoplay: () => videojs.Autoplay
2023-06-29 15:55:00 +02:00
private currentSubtitle: string
private currentPlaybackRate: number
private videoViewInterval: any
2020-12-07 15:58:57 +01:00
2019-08-07 10:17:19 +02:00
private menuOpened = false
private mouseInControlBar = false
private mouseInSettings = false
2019-08-07 10:17:19 +02:00
private errorModal: videojs.ModalDialog
private hasInitialSeek = false
2023-06-29 15:55:00 +02:00
private videoViewOnPlayHandler: (...args: any[]) => void
private videoViewOnSeekedHandler: (...args: any[]) => void
private videoViewOnEndedHandler: (...args: any[]) => void
private stopTimeHandler: (...args: any[]) => void
2024-02-28 14:09:12 +01:00
private resizeObserver: ResizeObserver
2023-06-29 15:55:00 +02:00
constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) {
2020-01-28 17:29:50 +01:00
super(player)
this.videoViewUrl = options.videoViewUrl
2022-04-05 14:03:52 +02:00
this.authorizationHeader = options.authorizationHeader
2023-06-29 15:55:00 +02:00
this.hasAutoplay = options.hasAutoplay
2022-04-05 14:03:52 +02:00
this.initialInactivityTimeout = this.player.options_.inactivityTimeout
2019-08-07 10:17:19 +02:00
2023-06-29 15:55:00 +02:00
this.currentSubtitle = this.options.subtitle() || getStoredLastSubtitle()
this.initializePlayer()
this.initOnVideoChange()
this.player.removeClass('vjs-can-play')
2023-06-29 15:55:00 +02:00
this.deleteLegacyIndexedDB()
2019-02-06 10:39:50 +01:00
this.player.on('autoplay-failure', () => {
2023-06-29 15:55:00 +02:00
debugLogger('Autoplay failed')
2019-02-06 10:39:50 +01:00
this.player.removeClass('vjs-has-autoplay')
2023-06-29 15:55:00 +02:00
this.player.poster(options.poster())
// Fix a bug on iOS/Safari where the big play button is not displayed when autoplay fails
if (isIOS() || isSafari()) this.player.hasStarted(false)
2019-02-06 10:39:50 +01:00
})
2023-06-29 15:55:00 +02:00
this.player.on('ratechange', () => {
this.currentPlaybackRate = this.player.playbackRate()
this.player.defaultPlaybackRate(this.currentPlaybackRate)
})
this.player.one('canplay', () => {
const playerOptions = this.player.options_
const volume = getStoredVolume()
if (volume !== undefined) this.player.volume(volume)
const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
if (muted !== undefined) this.player.muted(muted)
this.player.addClass('vjs-can-play')
2023-06-29 15:55:00 +02:00
})
2023-06-29 15:55:00 +02:00
this.player.ready(() => {
this.player.on('volumechange', () => {
saveVolumeInStore(this.player.volume())
saveMuteInStore(this.player.muted())
})
2021-09-06 16:08:59 +02:00
this.player.textTracks().addEventListener('change', () => {
2020-01-28 17:29:50 +01:00
const showing = this.player.textTracks().tracks_.find(t => {
return t.kind === 'captions' && t.mode === 'showing'
})
if (!showing) {
saveLastSubtitle('off')
2023-06-29 15:55:00 +02:00
this.currentSubtitle = undefined
return
}
2023-06-29 15:55:00 +02:00
this.currentSubtitle = showing.language
saveLastSubtitle(showing.language)
savePreferredSubtitle(showing.language)
})
2023-06-29 15:55:00 +02:00
this.player.on('video-change', () => {
this.initOnVideoChange()
this.hideFatalError()
2023-06-29 15:55:00 +02:00
})
2024-02-28 14:09:12 +01:00
this.updatePlayerSizeClasses()
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => {
this.updatePlayerSizeClasses()
})
this.resizeObserver.observe(this.player.el())
}
})
2023-08-18 09:48:45 +02:00
2024-02-28 10:46:20 +01:00
this.player.on('resolution-change', (_: any, { resolution }: { resolution: number }) => {
2024-02-28 14:09:12 +01:00
if (this.player.paused()) {
this.player.on('play', () => this.adaptPosterForAudioOnly(resolution))
return
}
2024-02-28 10:46:20 +01:00
this.adaptPosterForAudioOnly(resolution)
})
2023-08-18 09:48:45 +02:00
this.initOnRatioChange()
}
dispose () {
2019-03-07 17:06:00 +01:00
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
2024-02-28 14:09:12 +01:00
if (this.resizeObserver) this.resizeObserver.disconnect()
2023-06-29 15:55:00 +02:00
super.dispose()
}
onMenuOpened () {
this.menuOpened = true
2019-08-07 10:17:19 +02:00
this.alterInactivity()
}
onMenuClosed () {
this.menuOpened = false
2019-08-07 10:17:19 +02:00
this.alterInactivity()
}
2022-02-02 11:16:23 +01:00
displayFatalError () {
// Already displayed an error
if (this.errorModal) return
debugLogger('Display fatal error')
this.player.loadingSpinner.hide()
const buildModal = (error: MediaError) => {
const localize = this.player.localize.bind(this.player)
const wrapper = document.createElement('div')
const header = document.createElement('h1')
header.innerText = localize('Failed to play video')
wrapper.appendChild(header)
const desc = document.createElement('div')
desc.innerText = localize('The video failed to play due to technical issues.')
wrapper.appendChild(desc)
const details = document.createElement('p')
details.classList.add('error-details')
details.innerText = error.message
wrapper.appendChild(details)
return wrapper
}
this.errorModal = this.player.createModal(buildModal(this.player.error()), {
temporary: true,
uncloseable: true
})
this.errorModal.addClass('vjs-custom-error-display')
2022-02-02 11:16:23 +01:00
this.player.addClass('vjs-error-display-enabled')
}
hideFatalError () {
if (!this.errorModal) return
debugLogger('Hiding fatal error')
2022-02-02 11:16:23 +01:00
this.player.removeClass('vjs-error-display-enabled')
this.player.removeChild(this.errorModal)
this.errorModal.close()
this.errorModal = undefined
2024-02-28 10:02:49 +01:00
if (this.player.loadingSpinner) {
this.player.loadingSpinner.show()
}
2022-02-02 11:16:23 +01:00
}
private initializePlayer () {
if (isMobile()) this.player.addClass('vjs-is-mobile')
this.initSmoothProgressBar()
2023-06-29 15:55:00 +02:00
this.player.ready(() => {
this.listenControlBarMouse()
})
2021-12-09 09:41:26 +01:00
this.listenFullScreenChange()
}
2023-06-29 15:55:00 +02:00
private initOnVideoChange () {
if (this.hasAutoplay() !== false) this.player.addClass('vjs-has-autoplay')
else this.player.removeClass('vjs-has-autoplay')
if (this.currentPlaybackRate && this.currentPlaybackRate !== 1) {
debugLogger('Setting playback rate to ' + this.currentPlaybackRate)
this.player.playbackRate(this.currentPlaybackRate)
}
this.player.ready(() => {
this.initCaptions()
this.updateControlBar()
})
this.handleStartStopTime()
this.runUserViewing()
}
2023-08-18 09:48:45 +02:00
private initOnRatioChange () {
if (!this.options.autoPlayerRatio) return
const defaultRatio = getComputedStyle(this.player.el()).getPropertyValue(this.options.autoPlayerRatio.cssRatioVariable)
2024-02-28 14:09:12 +01:00
const tryToUpdateRatioFromOptions = () => {
if (!this.options.videoRatio()) return
2024-02-27 16:24:48 +01:00
this.adaptPlayerFromRatio({ ratio: this.options.videoRatio(), defaultRatio })
2024-02-28 14:09:12 +01:00
this.updatePlayerSizeClasses()
2024-02-27 16:24:48 +01:00
}
2024-02-28 14:09:12 +01:00
tryToUpdateRatioFromOptions()
2024-02-28 14:09:12 +01:00
this.player.on('video-change', () => tryToUpdateRatioFromOptions())
2023-08-18 09:48:45 +02:00
this.player.on('video-ratio-changed', (_event, data: { ratio: number }) => {
if (this.options.videoRatio()) return
2024-02-27 16:24:48 +01:00
this.adaptPlayerFromRatio({ ratio: data.ratio, defaultRatio })
2024-02-28 14:09:12 +01:00
this.updatePlayerSizeClasses()
2024-02-27 16:24:48 +01:00
})
}
2023-08-18 09:48:45 +02:00
2024-02-27 16:24:48 +01:00
private adaptPlayerFromRatio (options: {
ratio: number
defaultRatio: string
}) {
const { ratio, defaultRatio } = options
2023-08-18 09:48:45 +02:00
2024-02-27 16:24:48 +01:00
const el = this.player.el() as HTMLElement
2023-08-18 09:48:45 +02:00
2024-02-27 16:24:48 +01:00
// In portrait screen mode, we allow player with bigger height size than width
const portraitMode = getComputedStyle(el).getPropertyValue(this.options.autoPlayerRatio.cssPlayerPortraitModeVariable) === '1'
const currentRatio = isNaN(ratio) || (!portraitMode && ratio < 1)
? defaultRatio
: ratio
el.style.setProperty('--player-ratio', currentRatio + '')
2023-08-18 09:48:45 +02:00
}
// ---------------------------------------------------------------------------
2022-04-05 14:03:52 +02:00
private runUserViewing () {
2023-06-29 15:55:00 +02:00
const startTime = timeToInt(this.options.startTime())
let lastCurrentTime = startTime
2022-04-05 14:03:52 +02:00
let lastViewEvent: VideoViewEvent
2023-12-14 11:07:55 +01:00
let ended = false // player.ended() is too "slow", so handle ended manually
2023-06-29 15:55:00 +02:00
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
if (this.videoViewOnPlayHandler) this.player.off('play', this.videoViewOnPlayHandler)
if (this.videoViewOnSeekedHandler) this.player.off('seeked', this.videoViewOnSeekedHandler)
if (this.videoViewOnEndedHandler) this.player.off('ended', this.videoViewOnEndedHandler)
2023-06-29 15:55:00 +02:00
this.videoViewOnPlayHandler = () => {
2023-12-14 11:07:55 +01:00
debugLogger('Notify user is watching on play: ' + startTime)
2023-06-29 15:55:00 +02:00
this.notifyUserIsWatching(startTime, lastViewEvent)
}
this.videoViewOnSeekedHandler = () => {
// Bypass the first initial seek
if (this.hasInitialSeek) {
this.hasInitialSeek = false
return
}
const diff = Math.floor(this.player.currentTime()) - lastCurrentTime
// Don't take into account small forwards
if (diff > 0 && diff < 3) return
2023-12-14 11:07:55 +01:00
debugLogger('Detected seek event for user watching')
2022-04-05 14:03:52 +02:00
lastViewEvent = 'seek'
2023-06-29 15:55:00 +02:00
}
2023-06-29 15:55:00 +02:00
this.videoViewOnEndedHandler = () => {
2023-12-14 11:07:55 +01:00
ended = true
if (this.options.isLive()) return
const currentTime = Math.floor(this.player.duration())
2022-04-05 14:03:52 +02:00
lastCurrentTime = currentTime
2023-12-14 11:07:55 +01:00
debugLogger('Notify user is watching on end: ' + currentTime)
2022-04-05 14:03:52 +02:00
this.notifyUserIsWatching(currentTime, lastViewEvent)
2022-04-05 14:03:52 +02:00
lastViewEvent = undefined
2023-06-29 15:55:00 +02:00
}
this.player.one('play', this.videoViewOnPlayHandler)
this.player.on('seeked', this.videoViewOnSeekedHandler)
this.player.one('ended', this.videoViewOnEndedHandler)
2022-04-05 14:03:52 +02:00
this.videoViewInterval = setInterval(() => {
2023-12-14 11:07:55 +01:00
if (ended) return
const currentTime = Math.floor(this.player.currentTime())
2022-04-05 14:03:52 +02:00
// No need to update
if (currentTime === lastCurrentTime) return
2023-12-14 11:07:55 +01:00
debugLogger('Notify user is watching: ' + currentTime)
2022-04-05 14:03:52 +02:00
lastCurrentTime = currentTime
2022-04-05 14:03:52 +02:00
this.notifyUserIsWatching(currentTime, lastViewEvent)
.catch(err => logger.error('Cannot notify user is watching.', err))
2022-04-05 14:03:52 +02:00
lastViewEvent = undefined
2023-06-29 15:55:00 +02:00
}, this.options.videoViewIntervalMs)
}
2022-04-05 14:03:52 +02:00
private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) {
// Server won't save history, so save the video position in local storage
if (!this.authorizationHeader()) {
2023-06-29 15:55:00 +02:00
saveVideoWatchHistory(this.options.videoUUID(), currentTime)
2022-04-05 14:03:52 +02:00
}
2023-11-07 10:46:08 +01:00
if (!this.videoViewUrl()) return Promise.resolve(true)
const sessionId = getPlayerSessionId()
const body: VideoView = { currentTime, viewEvent, sessionId }
const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
2022-10-26 14:28:38 +02:00
if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())
2023-06-29 15:55:00 +02:00
return fetch(this.videoViewUrl(), { method: 'POST', body: JSON.stringify(body), headers })
}
// ---------------------------------------------------------------------------
2024-02-28 10:46:20 +01:00
private adaptPosterForAudioOnly (resolution: number) {
debugLogger('Check if we need to adapt player for audio only', resolution)
if (resolution === 0) {
this.player.audioPosterMode(true)
this.player.poster(this.options.poster())
return
}
this.player.audioPosterMode(false)
this.player.poster('')
}
// ---------------------------------------------------------------------------
2024-02-28 14:09:12 +01:00
private updatePlayerSizeClasses () {
requestAnimationFrame(() => {
2024-03-06 10:36:40 +01:00
if (!this.player) return
2024-02-28 14:09:12 +01:00
debugLogger('Updating player size classes')
const width = this.player.currentWidth()
const breakpoints = [ 350, 570, 750 ]
for (const breakpoint of breakpoints) {
if (width <= breakpoint) {
this.player.addClass('vjs-size-' + breakpoint)
} else {
this.player.removeClass('vjs-size-' + breakpoint)
}
}
})
}
// ---------------------------------------------------------------------------
2021-12-09 09:41:26 +01:00
private listenFullScreenChange () {
this.player.on('fullscreenchange', () => {
if (this.player.isFullscreen()) this.player.focus()
})
}
2019-08-07 10:17:19 +02:00
private listenControlBarMouse () {
const controlBar = this.player.controlBar
const settingsButton: SettingsButton = (controlBar as any).settingsButton
controlBar.on('mouseenter', () => {
2019-08-07 10:17:19 +02:00
this.mouseInControlBar = true
this.alterInactivity()
})
controlBar.on('mouseleave', () => {
2019-08-07 10:17:19 +02:00
this.mouseInControlBar = false
this.alterInactivity()
})
settingsButton.dialog.on('mouseenter', () => {
this.mouseInSettings = true
this.alterInactivity()
})
settingsButton.dialog.on('mouseleave', () => {
this.mouseInSettings = false
this.alterInactivity()
})
2019-08-07 10:17:19 +02:00
}
2019-08-07 10:17:19 +02:00
private alterInactivity () {
2022-01-11 11:26:35 +01:00
if (this.menuOpened || this.mouseInSettings || this.mouseInControlBar) {
this.setInactivityTimeout(0)
2019-08-07 10:17:19 +02:00
return
}
this.setInactivityTimeout(this.initialInactivityTimeout)
this.player.reportUserActivity(true)
}
private setInactivityTimeout (timeout: number) {
(this.player as any).cache_.inactivityTimeout = timeout
this.player.options_.inactivityTimeout = timeout
2020-07-02 15:10:06 +02:00
}
private initCaptions () {
2023-07-13 15:49:47 +02:00
if (this.currentSubtitle) debugLogger('Init captions with current subtitle ' + this.currentSubtitle)
else debugLogger('Init captions without current subtitle')
2023-06-29 15:55:00 +02:00
this.player.tech(true).clearTracks('text')
this.player.removeClass('vjs-has-captions')
2023-06-29 15:55:00 +02:00
for (const caption of this.options.videoCaptions()) {
this.player.addRemoteTextTrack({
kind: 'captions',
label: caption.label,
language: caption.language,
id: caption.language,
src: caption.src,
2023-06-29 15:55:00 +02:00
default: this.currentSubtitle === caption.language
}, true)
this.player.addClass('vjs-has-captions')
2023-06-29 15:55:00 +02:00
}
this.player.trigger('captions-changed')
}
private updateControlBar () {
debugLogger('Updating control bar')
if (this.options.isLive()) {
this.getPlaybackRateButton().hide()
this.player.controlBar.getChild('progressControl').hide()
this.player.controlBar.getChild('currentTimeDisplay').hide()
this.player.controlBar.getChild('timeDivider').hide()
this.player.controlBar.getChild('durationDisplay').hide()
this.player.controlBar.getChild('peerTubeLiveDisplay').show()
} else {
this.getPlaybackRateButton().show()
this.player.controlBar.getChild('progressControl').show()
this.player.controlBar.getChild('currentTimeDisplay').show()
this.player.controlBar.getChild('timeDivider').show()
this.player.controlBar.getChild('durationDisplay').show()
this.player.controlBar.getChild('peerTubeLiveDisplay').hide()
}
2023-06-29 15:55:00 +02:00
if (this.options.videoCaptions().length === 0) {
this.getCaptionsButton().hide()
} else {
this.getCaptionsButton().show()
}
}
private handleStartStopTime () {
this.player.duration(this.options.videoDuration())
if (this.stopTimeHandler) {
this.player.off('timeupdate', this.stopTimeHandler)
this.stopTimeHandler = undefined
}
// Prefer canplaythrough instead of canplay because Chrome has issues with the second one
this.player.one('canplaythrough', () => {
const startTime = this.options.startTime()
2023-06-29 15:55:00 +02:00
if (startTime !== null && startTime !== undefined) {
debugLogger('Start the video at ' + startTime)
this.hasInitialSeek = true
this.player.currentTime(timeToInt(startTime))
}
2023-06-29 15:55:00 +02:00
if (this.options.stopTime()) {
const stopTime = timeToInt(this.options.stopTime())
this.stopTimeHandler = () => {
if (this.player.currentTime() <= stopTime) return
debugLogger('Stopping the video at ' + this.options.stopTime())
// Time top stop
this.player.pause()
this.player.trigger('auto-stopped')
this.player.off('timeupdate', this.stopTimeHandler)
this.stopTimeHandler = undefined
}
this.player.on('timeupdate', this.stopTimeHandler)
}
})
}
// Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
private initSmoothProgressBar () {
2020-01-28 17:29:50 +01:00
const SeekBar = videojs.getComponent('SeekBar') as any
SeekBar.prototype.getPercent = function getPercent () {
// Allows for smooth scrubbing, when player can't keep up.
// const time = (this.player_.scrubbing()) ?
// this.player_.getCache().currentTime :
// this.player_.currentTime()
const time = this.player_.currentTime()
const percent = time / this.player_.duration()
return percent >= 1 ? 1 : percent
}
SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) {
let newTime = this.calculateDistance(event) * this.player_.duration()
if (newTime === this.player_.duration()) {
newTime = newTime - 0.1
}
this.player_.currentTime(newTime)
this.update()
}
}
2023-06-29 15:55:00 +02:00
private getCaptionsButton () {
const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
return settingsButton.menu.getChild('captionsButton') as videojs.CaptionsButton
}
private getPlaybackRateButton () {
const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
return settingsButton.menu.getChild('playbackRateMenuButton')
}
// We don't use webtorrent anymore, so we can safely remove old chunks from IndexedDB
private deleteLegacyIndexedDB () {
try {
if (typeof window.indexedDB === 'undefined') return
if (!window.indexedDB) return
if (typeof window.indexedDB.databases !== 'function') return
window.indexedDB.databases()
.then(databases => {
for (const db of databases) {
window.indexedDB.deleteDatabase(db.name)
}
})
} catch (err) {
debugLogger('Cannot delete legacy indexed DB', err)
// Nothing to do
}
}
}
videojs.registerPlugin('peertube', PeerTubePlugin)
export { PeerTubePlugin }