Add logic to handle playlist in embed
This commit is contained in:
parent
a4ff3100d3
commit
5abc96fca2
10 changed files with 244 additions and 51 deletions
|
@ -163,6 +163,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
// Unsubscribe subscriptions
|
||||
if (this.paramsSub) this.paramsSub.unsubscribe()
|
||||
if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
|
||||
if (this.configSub) this.configSub.unsubscribe()
|
||||
|
||||
// Unbind hotkeys
|
||||
this.hotkeysService.remove(this.hotkeys)
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { PeerTubePlugin } from './peertube-plugin'
|
||||
import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin'
|
||||
import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin'
|
||||
import { PlayerMode } from './peertube-player-manager'
|
||||
import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
|
||||
import { VideoFile } from '@shared/models'
|
||||
import videojs from 'video.js'
|
||||
import { Config, Level } from 'hls.js'
|
||||
import videojs from 'video.js'
|
||||
import { VideoFile } from '@shared/models'
|
||||
import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin'
|
||||
import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
|
||||
import { PlayerMode } from './peertube-player-manager'
|
||||
import { PeerTubePlugin } from './peertube-plugin'
|
||||
import { EndCardOptions } from './upnext/end-card'
|
||||
import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin'
|
||||
|
||||
declare module 'video.js' {
|
||||
|
||||
|
@ -42,6 +43,8 @@ declare module 'video.js' {
|
|||
}
|
||||
|
||||
dock (options: { title: string, description: string }): void
|
||||
|
||||
upnext (options: Partial<EndCardOptions>): void
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from
|
|||
export class TranslationsManager {
|
||||
private static videojsLocaleCache: { [ path: string ]: any } = {}
|
||||
|
||||
static getServerTranslations (serverUrl: string, locale: string) {
|
||||
static getServerTranslations (serverUrl: string, locale: string): Promise<{ [id: string]: string }> {
|
||||
const path = TranslationsManager.getLocalePath(serverUrl, locale)
|
||||
// It is the default locale, nothing to translate
|
||||
if (!path) return Promise.resolve(undefined)
|
||||
|
|
|
@ -308,8 +308,10 @@ body {
|
|||
.icon {
|
||||
&.icon-next {
|
||||
mask-image: url('#{$assets-path}/player/images/next.svg');
|
||||
-webkit-mask-image: url('#{$assets-path}/player/images/next.svg');
|
||||
background-color: white;
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
transform: scale(2.2);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ export class PeerTubeEmbedApi {
|
|||
}
|
||||
|
||||
private get element () {
|
||||
return this.embed.videoElement
|
||||
return this.embed.playerElement
|
||||
}
|
||||
|
||||
private constructChannel () {
|
||||
|
@ -108,7 +108,6 @@ export class PeerTubeEmbedApi {
|
|||
setInterval(() => {
|
||||
const position = this.element.currentTime
|
||||
const volume = this.element.volume
|
||||
const duration = this.element.duration
|
||||
|
||||
this.channel.notify({
|
||||
method: 'playbackStatusUpdate',
|
||||
|
|
|
@ -19,10 +19,9 @@
|
|||
<div id="error-content"></div>
|
||||
</div>
|
||||
|
||||
<video playsinline="true" id="video-container" class="video-js vjs-peertube-skin">
|
||||
</video>
|
||||
<div id="video-wrapper"></div>
|
||||
|
||||
<div id="placeholder-preview" />
|
||||
<div id="placeholder-preview"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -27,6 +27,11 @@ html, body {
|
|||
background-color: #000;
|
||||
}
|
||||
|
||||
#video-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-js.vjs-peertube-skin {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
|
|
@ -9,6 +9,8 @@ import {
|
|||
UserRefreshToken,
|
||||
VideoCaption,
|
||||
VideoDetails,
|
||||
VideoPlaylist,
|
||||
VideoPlaylistElement,
|
||||
VideoStreamingPlaylistType
|
||||
} from '../../../../shared/models'
|
||||
import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../assets/player/peertube-player-manager'
|
||||
|
@ -19,9 +21,10 @@ import { PeerTubeEmbedApi } from './embed-api'
|
|||
type Translations = { [ id: string ]: string }
|
||||
|
||||
export class PeerTubeEmbed {
|
||||
videoElement: HTMLVideoElement
|
||||
playerElement: HTMLVideoElement
|
||||
player: videojs.Player
|
||||
api: PeerTubeEmbedApi = null
|
||||
|
||||
autoplay: boolean
|
||||
controls: boolean
|
||||
muted: boolean
|
||||
|
@ -47,14 +50,24 @@ export class PeerTubeEmbed {
|
|||
CLIENT_SECRET: 'client_secret'
|
||||
}
|
||||
|
||||
private translationsPromise: Promise<{ [id: string]: string }>
|
||||
private configPromise: Promise<ServerConfig>
|
||||
private PeertubePlayerManagerModulePromise: Promise<any>
|
||||
|
||||
private playlist: VideoPlaylist
|
||||
private playlistElements: VideoPlaylistElement[]
|
||||
private currentPlaylistElement: VideoPlaylistElement
|
||||
|
||||
private wrapperElement: HTMLElement
|
||||
|
||||
static async main () {
|
||||
const videoContainerId = 'video-container'
|
||||
const videoContainerId = 'video-wrapper'
|
||||
const embed = new PeerTubeEmbed(videoContainerId)
|
||||
await embed.init()
|
||||
}
|
||||
|
||||
constructor (private videoContainerId: string) {
|
||||
this.videoElement = document.getElementById(videoContainerId) as HTMLVideoElement
|
||||
constructor (private videoWrapperId: string) {
|
||||
this.wrapperElement = document.getElementById(this.videoWrapperId)
|
||||
}
|
||||
|
||||
getVideoUrl (id: string) {
|
||||
|
@ -114,6 +127,10 @@ export class PeerTubeEmbed {
|
|||
})
|
||||
}
|
||||
|
||||
getPlaylistUrl (id: string) {
|
||||
return window.location.origin + '/api/v1/video-playlists/' + id
|
||||
}
|
||||
|
||||
loadVideoInfo (videoId: string): Promise<Response> {
|
||||
return this.refreshFetch(this.getVideoUrl(videoId), { headers: this.headers })
|
||||
}
|
||||
|
@ -122,8 +139,17 @@ export class PeerTubeEmbed {
|
|||
return fetch(this.getVideoUrl(videoId) + '/captions')
|
||||
}
|
||||
|
||||
loadConfig (): Promise<Response> {
|
||||
loadPlaylistInfo (playlistId: string): Promise<Response> {
|
||||
return fetch(this.getPlaylistUrl(playlistId))
|
||||
}
|
||||
|
||||
loadPlaylistElements (playlistId: string): Promise<Response> {
|
||||
return fetch(this.getPlaylistUrl(playlistId) + '/videos')
|
||||
}
|
||||
|
||||
loadConfig (): Promise<ServerConfig> {
|
||||
return fetch('/api/v1/config')
|
||||
.then(res => res.json())
|
||||
}
|
||||
|
||||
removeElement (element: HTMLElement) {
|
||||
|
@ -132,7 +158,10 @@ export class PeerTubeEmbed {
|
|||
|
||||
displayError (text: string, translations?: Translations) {
|
||||
// Remove video element
|
||||
if (this.videoElement) this.removeElement(this.videoElement)
|
||||
if (this.playerElement) {
|
||||
this.removeElement(this.playerElement)
|
||||
this.playerElement = undefined
|
||||
}
|
||||
|
||||
const translatedText = peertubeTranslate(text, translations)
|
||||
const translatedSorry = peertubeTranslate('Sorry', translations)
|
||||
|
@ -159,6 +188,16 @@ export class PeerTubeEmbed {
|
|||
this.displayError(text, translations)
|
||||
}
|
||||
|
||||
playlistNotFound (translations?: Translations) {
|
||||
const text = 'This playlist does not exist.'
|
||||
this.displayError(text, translations)
|
||||
}
|
||||
|
||||
playlistFetchError (translations?: Translations) {
|
||||
const text = 'We cannot fetch the playlist. Please try again later.'
|
||||
this.displayError(text, translations)
|
||||
}
|
||||
|
||||
getParamToggle (params: URLSearchParams, name: string, defaultValue?: boolean) {
|
||||
return params.has(name) ? (params.get(name) === '1' || params.get(name) === 'true') : defaultValue
|
||||
}
|
||||
|
@ -218,34 +257,129 @@ export class PeerTubeEmbed {
|
|||
}
|
||||
}
|
||||
|
||||
private async initCore () {
|
||||
const urlParts = window.location.pathname.split('/')
|
||||
const videoId = urlParts[ urlParts.length - 1 ]
|
||||
private async loadPlaylist (playlistId: string) {
|
||||
const playlistPromise = this.loadPlaylistInfo(playlistId)
|
||||
const playlistElementsPromise = this.loadPlaylistElements(playlistId)
|
||||
|
||||
if (this.userTokens) this.setHeadersFromTokens()
|
||||
const playlistResponse = await playlistPromise
|
||||
|
||||
if (!playlistResponse.ok) {
|
||||
const serverTranslations = await this.translationsPromise
|
||||
|
||||
if (playlistResponse.status === 404) {
|
||||
this.playlistNotFound(serverTranslations)
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.playlistFetchError(serverTranslations)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { playlistResponse, videosResponse: await playlistElementsPromise }
|
||||
}
|
||||
|
||||
private async loadVideo (videoId: string) {
|
||||
const videoPromise = this.loadVideoInfo(videoId)
|
||||
const captionsPromise = this.loadVideoCaptions(videoId)
|
||||
const configPromise = this.loadConfig()
|
||||
|
||||
const translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
|
||||
const videoResponse = await videoPromise
|
||||
|
||||
if (!videoResponse.ok) {
|
||||
const serverTranslations = await translationsPromise
|
||||
const serverTranslations = await this.translationsPromise
|
||||
|
||||
if (videoResponse.status === 404) return this.videoNotFound(serverTranslations)
|
||||
if (videoResponse.status === 404) {
|
||||
this.videoNotFound(serverTranslations)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.videoFetchError(serverTranslations)
|
||||
this.videoFetchError(serverTranslations)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const videoInfo: VideoDetails = await videoResponse.json()
|
||||
this.loadPlaceholder(videoInfo)
|
||||
const captionsPromise = this.loadVideoCaptions(videoId)
|
||||
|
||||
const PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager')
|
||||
return { captionsPromise, videoResponse }
|
||||
}
|
||||
|
||||
const promises = [ translationsPromise, captionsPromise, configPromise, PeertubePlayerManagerModulePromise ]
|
||||
const [ serverTranslations, captionsResponse, configResponse, PeertubePlayerManagerModule ] = await Promise.all(promises)
|
||||
private async buildPlaylistManager () {
|
||||
const translations = await this.translationsPromise
|
||||
|
||||
this.player.upnext({
|
||||
timeout: 10000, // 10s
|
||||
headText: peertubeTranslate('Up Next', translations),
|
||||
cancelText: peertubeTranslate('Cancel', translations),
|
||||
suspendedText: peertubeTranslate('Autoplay is suspended', translations),
|
||||
getTitle: () => this.nextVideoTitle(),
|
||||
next: () => this.autoplayNext(),
|
||||
condition: () => !!this.getNextPlaylistElement(),
|
||||
suspended: () => false
|
||||
})
|
||||
}
|
||||
|
||||
private async autoplayNext () {
|
||||
const next = this.getNextPlaylistElement()
|
||||
if (!next) {
|
||||
console.log('Next element not found in playlist.')
|
||||
return
|
||||
}
|
||||
|
||||
this.currentPlaylistElement = next
|
||||
|
||||
const res = await this.loadVideo(this.currentPlaylistElement.video.uuid)
|
||||
if (res === undefined) return
|
||||
|
||||
return this.buildVideoPlayer(res.videoResponse, res.captionsPromise)
|
||||
}
|
||||
|
||||
private nextVideoTitle () {
|
||||
const next = this.getNextPlaylistElement()
|
||||
if (!next) return ''
|
||||
|
||||
return next.video.name
|
||||
}
|
||||
|
||||
private getNextPlaylistElement (position?: number): VideoPlaylistElement {
|
||||
if (!position) position = this.currentPlaylistElement.position + 1
|
||||
|
||||
if (position > this.playlist.videosLength) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const next = this.playlistElements.find(e => e.position === position)
|
||||
|
||||
if (!next || !next.video) {
|
||||
return this.getNextPlaylistElement(position + 1)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
private async buildVideoPlayer (videoResponse: Response, captionsPromise: Promise<Response>) {
|
||||
let alreadyHadPlayer = false
|
||||
|
||||
if (this.player) {
|
||||
this.player.dispose()
|
||||
alreadyHadPlayer = true
|
||||
}
|
||||
|
||||
this.playerElement = document.createElement('video')
|
||||
this.playerElement.className = 'video-js vjs-peertube-skin'
|
||||
this.playerElement.setAttribute('playsinline', 'true')
|
||||
this.wrapperElement.appendChild(this.playerElement)
|
||||
|
||||
const videoInfoPromise = videoResponse.json()
|
||||
.then((videoInfo: VideoDetails) => {
|
||||
if (!alreadyHadPlayer) this.loadPlaceholder(videoInfo)
|
||||
|
||||
return videoInfo
|
||||
})
|
||||
|
||||
const [ videoInfo, serverTranslations, captionsResponse, config, PeertubePlayerManagerModule ] = await Promise.all([
|
||||
videoInfoPromise,
|
||||
this.translationsPromise,
|
||||
captionsPromise,
|
||||
this.configPromise,
|
||||
this.PeertubePlayerManagerModulePromise
|
||||
])
|
||||
|
||||
const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
|
||||
const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse)
|
||||
|
@ -254,7 +388,8 @@ export class PeerTubeEmbed {
|
|||
|
||||
const options: PeertubePlayerManagerOptions = {
|
||||
common: {
|
||||
autoplay: this.autoplay,
|
||||
// Autoplay in playlist mode
|
||||
autoplay: alreadyHadPlayer ? true : this.autoplay,
|
||||
controls: this.controls,
|
||||
muted: this.muted,
|
||||
loop: this.loop,
|
||||
|
@ -263,12 +398,14 @@ export class PeerTubeEmbed {
|
|||
stopTime: this.stopTime,
|
||||
subtitle: this.subtitle,
|
||||
|
||||
nextVideo: () => this.autoplayNext(),
|
||||
|
||||
videoCaptions,
|
||||
inactivityTimeout: 2500,
|
||||
videoViewUrl: this.getVideoUrl(videoId) + '/views',
|
||||
videoViewUrl: this.getVideoUrl(videoInfo.uuid) + '/views',
|
||||
|
||||
playerElement: this.videoElement,
|
||||
onPlayerElementChange: (element: HTMLVideoElement) => this.videoElement = element,
|
||||
playerElement: this.playerElement,
|
||||
onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
|
||||
|
||||
videoDuration: videoInfo.duration,
|
||||
enableHotkeys: true,
|
||||
|
@ -307,23 +444,58 @@ export class PeerTubeEmbed {
|
|||
|
||||
this.buildCSS()
|
||||
|
||||
await this.buildDock(videoInfo, configResponse)
|
||||
await this.buildDock(videoInfo, config)
|
||||
|
||||
this.initializeApi()
|
||||
|
||||
this.removePlaceholder()
|
||||
|
||||
if (this.isPlaylistEmbed()) {
|
||||
await this.buildPlaylistManager()
|
||||
}
|
||||
}
|
||||
|
||||
private async initCore () {
|
||||
if (this.userTokens) this.setHeadersFromTokens()
|
||||
|
||||
this.configPromise = this.loadConfig()
|
||||
this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
|
||||
this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager')
|
||||
|
||||
let videoId: string
|
||||
|
||||
if (this.isPlaylistEmbed()) {
|
||||
const playlistId = this.getResourceId()
|
||||
const res = await this.loadPlaylist(playlistId)
|
||||
if (!res) return undefined
|
||||
|
||||
this.playlist = await res.playlistResponse.json()
|
||||
|
||||
const playlistElementResult = await res.videosResponse.json()
|
||||
this.playlistElements = playlistElementResult.data
|
||||
|
||||
this.currentPlaylistElement = this.playlistElements[0]
|
||||
videoId = this.currentPlaylistElement.video.uuid
|
||||
} else {
|
||||
videoId = this.getResourceId()
|
||||
}
|
||||
|
||||
const res = await this.loadVideo(videoId)
|
||||
if (res === undefined) return
|
||||
|
||||
return this.buildVideoPlayer(res.videoResponse, res.captionsPromise)
|
||||
}
|
||||
|
||||
private handleError (err: Error, translations?: { [ id: string ]: string }) {
|
||||
if (err.message.indexOf('from xs param') !== -1) {
|
||||
this.player.dispose()
|
||||
this.videoElement = null
|
||||
this.playerElement = null
|
||||
this.displayError('This video is not available because the remote instance is not responding.', translations)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async buildDock (videoInfo: VideoDetails, configResponse: Response) {
|
||||
private async buildDock (videoInfo: VideoDetails, config: ServerConfig) {
|
||||
if (!this.controls) return
|
||||
|
||||
// On webtorrent fallback, player may have been disposed
|
||||
|
@ -331,7 +503,6 @@ export class PeerTubeEmbed {
|
|||
|
||||
const title = this.title ? videoInfo.name : undefined
|
||||
|
||||
const config: ServerConfig = await configResponse.json()
|
||||
const description = config.tracker.enabled && this.warningTitle
|
||||
? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>'
|
||||
: undefined
|
||||
|
@ -373,11 +544,12 @@ export class PeerTubeEmbed {
|
|||
|
||||
const url = window.location.origin + video.previewPath
|
||||
placeholder.style.backgroundImage = `url("${url}")`
|
||||
placeholder.style.display = 'block'
|
||||
}
|
||||
|
||||
private removePlaceholder () {
|
||||
const placeholder = this.getPlaceholderElement()
|
||||
placeholder.parentElement.removeChild(placeholder)
|
||||
placeholder.style.display = 'none'
|
||||
}
|
||||
|
||||
private getPlaceholderElement () {
|
||||
|
@ -387,6 +559,15 @@ export class PeerTubeEmbed {
|
|||
private setHeadersFromTokens () {
|
||||
this.headers.set('Authorization', `${this.userTokens.tokenType} ${this.userTokens.accessToken}`)
|
||||
}
|
||||
|
||||
private getResourceId () {
|
||||
const urlParts = window.location.pathname.split('/')
|
||||
return urlParts[ urlParts.length - 1 ]
|
||||
}
|
||||
|
||||
private isPlaylistEmbed () {
|
||||
return window.location.pathname.split('/')[1] === 'video-playlists'
|
||||
}
|
||||
}
|
||||
|
||||
PeerTubeEmbed.main()
|
||||
|
|
|
@ -48,7 +48,9 @@ values(VIDEO_CATEGORIES)
|
|||
'This video does not exist.',
|
||||
'We cannot fetch the video. Please try again later.',
|
||||
'Sorry',
|
||||
'This video is not available because the remote instance is not responding.'
|
||||
'This video is not available because the remote instance is not responding.',
|
||||
'This playlist does not exist',
|
||||
'We cannot fetch the playlist. Please try again later.'
|
||||
])
|
||||
.forEach(v => { serverKeys[v] = v })
|
||||
|
||||
|
|
|
@ -22,19 +22,20 @@ clientsRouter.use('/videos/watch/:id', asyncMiddleware(generateWatchHtmlPage))
|
|||
clientsRouter.use('/accounts/:nameWithHost', asyncMiddleware(generateAccountHtmlPage))
|
||||
clientsRouter.use('/video-channels/:nameWithHost', asyncMiddleware(generateVideoChannelHtmlPage))
|
||||
|
||||
const embedCSPMiddleware = CONFIG.CSP.ENABLED
|
||||
? embedCSP
|
||||
: (req: express.Request, res: express.Response, next: express.NextFunction) => next()
|
||||
const embedMiddlewares = [
|
||||
CONFIG.CSP.ENABLED
|
||||
? embedCSP
|
||||
: (req: express.Request, res: express.Response, next: express.NextFunction) => next(),
|
||||
|
||||
clientsRouter.use(
|
||||
'/videos/embed',
|
||||
embedCSPMiddleware,
|
||||
(req: express.Request, res: express.Response) => {
|
||||
res.removeHeader('X-Frame-Options')
|
||||
// Don't cache HTML file since it's an index to the immutable JS/CSS files
|
||||
res.sendFile(embedPath, { maxAge: 0 })
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
clientsRouter.use('/videos/embed', ...embedMiddlewares)
|
||||
clientsRouter.use('/video-playlists/embed', ...embedMiddlewares)
|
||||
clientsRouter.use(
|
||||
'/videos/test-embed',
|
||||
(req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
|
||||
|
|
Loading…
Reference in a new issue