mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2024-05-16 20:02:40 +00:00
5cb3e6a0b8
Breaking: YAML config `ip_view_expiration` is renamed `view_expiration` Breaking: Views are taken into account after 10 seconds instead of 30 seconds (can be changed in YAML config) Purpose of this commit is to get closer to other video platforms where some platforms count views on play (mux, vimeo) or others use a very low delay (instagram, tiktok) We also want to improve the viewer identification, where we no longer use the IP but the `sessionId` generated by the web browser. Multiple viewers behind a NAT can now be able to be identified as independent viewers (this method is also used by vimeo or mux)
620 lines
18 KiB
TypeScript
620 lines
18 KiB
TypeScript
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
|
|
} from '../../peertube-player-local-storage'
|
|
import { PeerTubePluginOptions } from '../../types'
|
|
import { SettingsButton } from '../settings/settings-menu-button'
|
|
|
|
const debugLogger = debug('peertube:player:peertube')
|
|
|
|
const Plugin = videojs.getPlugin('plugin')
|
|
|
|
class PeerTubePlugin extends Plugin {
|
|
private readonly videoViewUrl: () => string
|
|
private readonly authorizationHeader: () => string
|
|
private readonly initialInactivityTimeout: number
|
|
|
|
private readonly hasAutoplay: () => videojs.Autoplay
|
|
|
|
private currentSubtitle: string
|
|
private currentPlaybackRate: number
|
|
|
|
private videoViewInterval: any
|
|
|
|
private menuOpened = false
|
|
private mouseInControlBar = false
|
|
private mouseInSettings = false
|
|
|
|
private errorModal: videojs.ModalDialog
|
|
|
|
private hasInitialSeek = false
|
|
|
|
private videoViewOnPlayHandler: (...args: any[]) => void
|
|
private videoViewOnSeekedHandler: (...args: any[]) => void
|
|
private videoViewOnEndedHandler: (...args: any[]) => void
|
|
|
|
private stopTimeHandler: (...args: any[]) => void
|
|
|
|
private resizeObserver: ResizeObserver
|
|
|
|
constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) {
|
|
super(player)
|
|
|
|
this.videoViewUrl = options.videoViewUrl
|
|
this.authorizationHeader = options.authorizationHeader
|
|
this.hasAutoplay = options.hasAutoplay
|
|
|
|
this.initialInactivityTimeout = this.player.options_.inactivityTimeout
|
|
|
|
this.currentSubtitle = this.options.subtitle() || getStoredLastSubtitle()
|
|
|
|
this.initializePlayer()
|
|
this.initOnVideoChange()
|
|
|
|
this.player.removeClass('vjs-can-play')
|
|
|
|
this.deleteLegacyIndexedDB()
|
|
|
|
this.player.on('autoplay-failure', () => {
|
|
debugLogger('Autoplay failed')
|
|
|
|
this.player.removeClass('vjs-has-autoplay')
|
|
|
|
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)
|
|
})
|
|
|
|
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')
|
|
})
|
|
|
|
this.player.ready(() => {
|
|
|
|
this.player.on('volumechange', () => {
|
|
saveVolumeInStore(this.player.volume())
|
|
saveMuteInStore(this.player.muted())
|
|
})
|
|
|
|
this.player.textTracks().addEventListener('change', () => {
|
|
const showing = this.player.textTracks().tracks_.find(t => {
|
|
return t.kind === 'captions' && t.mode === 'showing'
|
|
})
|
|
|
|
if (!showing) {
|
|
saveLastSubtitle('off')
|
|
this.currentSubtitle = undefined
|
|
return
|
|
}
|
|
|
|
this.currentSubtitle = showing.language
|
|
saveLastSubtitle(showing.language)
|
|
savePreferredSubtitle(showing.language)
|
|
})
|
|
|
|
this.player.on('video-change', () => {
|
|
this.initOnVideoChange()
|
|
|
|
this.hideFatalError()
|
|
})
|
|
|
|
this.updatePlayerSizeClasses()
|
|
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.updatePlayerSizeClasses()
|
|
})
|
|
|
|
this.resizeObserver.observe(this.player.el())
|
|
}
|
|
})
|
|
|
|
this.player.on('resolution-change', (_: any, { resolution }: { resolution: number }) => {
|
|
if (this.player.paused()) {
|
|
this.player.on('play', () => this.adaptPosterForAudioOnly(resolution))
|
|
return
|
|
}
|
|
|
|
this.adaptPosterForAudioOnly(resolution)
|
|
})
|
|
|
|
this.initOnRatioChange()
|
|
}
|
|
|
|
dispose () {
|
|
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
|
|
if (this.resizeObserver) this.resizeObserver.disconnect()
|
|
|
|
super.dispose()
|
|
}
|
|
|
|
onMenuOpened () {
|
|
this.menuOpened = true
|
|
this.alterInactivity()
|
|
}
|
|
|
|
onMenuClosed () {
|
|
this.menuOpened = false
|
|
this.alterInactivity()
|
|
}
|
|
|
|
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')
|
|
|
|
this.player.addClass('vjs-error-display-enabled')
|
|
}
|
|
|
|
hideFatalError () {
|
|
if (!this.errorModal) return
|
|
|
|
debugLogger('Hiding fatal error')
|
|
|
|
this.player.removeClass('vjs-error-display-enabled')
|
|
this.player.removeChild(this.errorModal)
|
|
this.errorModal.close()
|
|
this.errorModal = undefined
|
|
|
|
if (this.player.loadingSpinner) {
|
|
this.player.loadingSpinner.show()
|
|
}
|
|
}
|
|
|
|
private initializePlayer () {
|
|
if (isMobile()) this.player.addClass('vjs-is-mobile')
|
|
|
|
this.initSmoothProgressBar()
|
|
|
|
this.player.ready(() => {
|
|
this.listenControlBarMouse()
|
|
})
|
|
|
|
this.listenFullScreenChange()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
private initOnRatioChange () {
|
|
if (!this.options.autoPlayerRatio) return
|
|
|
|
const defaultRatio = getComputedStyle(this.player.el()).getPropertyValue(this.options.autoPlayerRatio.cssRatioVariable)
|
|
|
|
const tryToUpdateRatioFromOptions = () => {
|
|
if (!this.options.videoRatio()) return
|
|
|
|
this.adaptPlayerFromRatio({ ratio: this.options.videoRatio(), defaultRatio })
|
|
this.updatePlayerSizeClasses()
|
|
}
|
|
|
|
tryToUpdateRatioFromOptions()
|
|
|
|
this.player.on('video-change', () => tryToUpdateRatioFromOptions())
|
|
|
|
this.player.on('video-ratio-changed', (_event, data: { ratio: number }) => {
|
|
if (this.options.videoRatio()) return
|
|
|
|
this.adaptPlayerFromRatio({ ratio: data.ratio, defaultRatio })
|
|
this.updatePlayerSizeClasses()
|
|
})
|
|
}
|
|
|
|
private adaptPlayerFromRatio (options: {
|
|
ratio: number
|
|
defaultRatio: string
|
|
}) {
|
|
const { ratio, defaultRatio } = options
|
|
|
|
const el = this.player.el() as HTMLElement
|
|
|
|
// 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 + '')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private runUserViewing () {
|
|
const startTime = timeToInt(this.options.startTime())
|
|
|
|
let lastCurrentTime = startTime
|
|
let lastViewEvent: VideoViewEvent
|
|
let ended = false // player.ended() is too "slow", so handle ended manually
|
|
|
|
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)
|
|
|
|
this.videoViewOnPlayHandler = () => {
|
|
debugLogger('Notify user is watching on play: ' + startTime)
|
|
|
|
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
|
|
|
|
debugLogger('Detected seek event for user watching')
|
|
|
|
lastViewEvent = 'seek'
|
|
}
|
|
|
|
this.videoViewOnEndedHandler = () => {
|
|
ended = true
|
|
|
|
if (this.options.isLive()) return
|
|
|
|
const currentTime = Math.floor(this.player.duration())
|
|
lastCurrentTime = currentTime
|
|
|
|
debugLogger('Notify user is watching on end: ' + currentTime)
|
|
|
|
this.notifyUserIsWatching(currentTime, lastViewEvent)
|
|
|
|
lastViewEvent = undefined
|
|
}
|
|
|
|
this.player.one('play', this.videoViewOnPlayHandler)
|
|
this.player.on('seeked', this.videoViewOnSeekedHandler)
|
|
this.player.one('ended', this.videoViewOnEndedHandler)
|
|
|
|
this.videoViewInterval = setInterval(() => {
|
|
if (ended) return
|
|
|
|
const currentTime = Math.floor(this.player.currentTime())
|
|
|
|
// No need to update
|
|
if (currentTime === lastCurrentTime) return
|
|
|
|
debugLogger('Notify user is watching: ' + currentTime)
|
|
|
|
lastCurrentTime = currentTime
|
|
|
|
this.notifyUserIsWatching(currentTime, lastViewEvent)
|
|
.catch(err => logger.error('Cannot notify user is watching.', err))
|
|
|
|
lastViewEvent = undefined
|
|
}, this.options.videoViewIntervalMs)
|
|
}
|
|
|
|
private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) {
|
|
// Server won't save history, so save the video position in local storage
|
|
if (!this.authorizationHeader()) {
|
|
saveVideoWatchHistory(this.options.videoUUID(), currentTime)
|
|
}
|
|
|
|
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' })
|
|
if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())
|
|
|
|
return fetch(this.videoViewUrl(), { method: 'POST', body: JSON.stringify(body), headers })
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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('')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private updatePlayerSizeClasses () {
|
|
requestAnimationFrame(() => {
|
|
if (!this.player) return
|
|
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private listenFullScreenChange () {
|
|
this.player.on('fullscreenchange', () => {
|
|
if (this.player.isFullscreen()) this.player.focus()
|
|
})
|
|
}
|
|
|
|
private listenControlBarMouse () {
|
|
const controlBar = this.player.controlBar
|
|
const settingsButton: SettingsButton = (controlBar as any).settingsButton
|
|
|
|
controlBar.on('mouseenter', () => {
|
|
this.mouseInControlBar = true
|
|
this.alterInactivity()
|
|
})
|
|
|
|
controlBar.on('mouseleave', () => {
|
|
this.mouseInControlBar = false
|
|
this.alterInactivity()
|
|
})
|
|
|
|
settingsButton.dialog.on('mouseenter', () => {
|
|
this.mouseInSettings = true
|
|
this.alterInactivity()
|
|
})
|
|
|
|
settingsButton.dialog.on('mouseleave', () => {
|
|
this.mouseInSettings = false
|
|
this.alterInactivity()
|
|
})
|
|
}
|
|
|
|
private alterInactivity () {
|
|
if (this.menuOpened || this.mouseInSettings || this.mouseInControlBar) {
|
|
this.setInactivityTimeout(0)
|
|
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
|
|
}
|
|
|
|
private initCaptions () {
|
|
if (this.currentSubtitle) debugLogger('Init captions with current subtitle ' + this.currentSubtitle)
|
|
else debugLogger('Init captions without current subtitle')
|
|
|
|
this.player.tech(true).clearTracks('text')
|
|
|
|
this.player.removeClass('vjs-has-captions')
|
|
|
|
for (const caption of this.options.videoCaptions()) {
|
|
this.player.addRemoteTextTrack({
|
|
kind: 'captions',
|
|
label: caption.label,
|
|
language: caption.language,
|
|
id: caption.language,
|
|
src: caption.src,
|
|
default: this.currentSubtitle === caption.language
|
|
}, true)
|
|
|
|
this.player.addClass('vjs-has-captions')
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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()
|
|
|
|
if (startTime !== null && startTime !== undefined) {
|
|
debugLogger('Start the video at ' + startTime)
|
|
|
|
this.hasInitialSeek = true
|
|
this.player.currentTime(timeToInt(startTime))
|
|
}
|
|
|
|
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 () {
|
|
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()
|
|
}
|
|
}
|
|
|
|
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 }
|