Improve frontend accessibility
[oweals/peertube.git] / client / src / standalone / videos / embed.ts
1 import './embed.scss'
2
3 import 'core-js/es6/symbol'
4 import 'core-js/es6/object'
5 import 'core-js/es6/function'
6 import 'core-js/es6/parse-int'
7 import 'core-js/es6/parse-float'
8 import 'core-js/es6/number'
9 import 'core-js/es6/math'
10 import 'core-js/es6/string'
11 import 'core-js/es6/date'
12 import 'core-js/es6/array'
13 import 'core-js/es6/regexp'
14 import 'core-js/es6/map'
15 import 'core-js/es6/weak-map'
16 import 'core-js/es6/set'
17 // For google bot that uses Chrome 41 and does not understand fetch
18 import 'whatwg-fetch'
19
20 import * as vjs from 'video.js'
21 import * as Channel from 'jschannel'
22
23 import { ResultList, VideoDetails } from '../../../../shared'
24 import { addContextMenu, getVideojsOptions, loadLocale } from '../../assets/player/peertube-player'
25 import { PeerTubeResolution } from '../player/definitions'
26 import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
27 import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
28
29 /**
30  * Embed API exposes control of the embed player to the outside world via
31  * JSChannels and window.postMessage
32  */
33 class PeerTubeEmbedApi {
34   private channel: Channel.MessagingChannel
35   private isReady = false
36   private resolutions: PeerTubeResolution[] = null
37
38   constructor (private embed: PeerTubeEmbed) {
39   }
40
41   initialize () {
42     this.constructChannel()
43     this.setupStateTracking()
44
45     // We're ready!
46
47     this.notifyReady()
48   }
49
50   private get element () {
51     return this.embed.videoElement
52   }
53
54   private constructChannel () {
55     let channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.scope })
56
57     channel.bind('play', (txn, params) => this.embed.player.play())
58     channel.bind('pause', (txn, params) => this.embed.player.pause())
59     channel.bind('seek', (txn, time) => this.embed.player.currentTime(time))
60     channel.bind('setVolume', (txn, value) => this.embed.player.volume(value))
61     channel.bind('getVolume', (txn, value) => this.embed.player.volume())
62     channel.bind('isReady', (txn, params) => this.isReady)
63     channel.bind('setResolution', (txn, resolutionId) => this.setResolution(resolutionId))
64     channel.bind('getResolutions', (txn, params) => this.resolutions)
65     channel.bind('setPlaybackRate', (txn, playbackRate) => this.embed.player.playbackRate(playbackRate))
66     channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate())
67     channel.bind('getPlaybackRates', (txn, params) => this.embed.playerOptions.playbackRates)
68
69     this.channel = channel
70   }
71
72   private setResolution (resolutionId: number) {
73     if (resolutionId === -1 && this.embed.player.peertube().isAutoResolutionForbidden()) return
74
75     // Auto resolution
76     if (resolutionId === -1) {
77       this.embed.player.peertube().enableAutoResolution()
78       return
79     }
80
81     this.embed.player.peertube().disableAutoResolution()
82     this.embed.player.peertube().updateResolution(resolutionId)
83   }
84
85   /**
86    * Let the host know that we're ready to go!
87    */
88   private notifyReady () {
89     this.isReady = true
90     this.channel.notify({ method: 'ready', params: true })
91   }
92
93   private setupStateTracking () {
94     let currentState: 'playing' | 'paused' | 'unstarted' = 'unstarted'
95
96     setInterval(() => {
97       let position = this.element.currentTime
98       let volume = this.element.volume
99
100       this.channel.notify({
101         method: 'playbackStatusUpdate',
102         params: {
103           position,
104           volume,
105           playbackState: currentState
106         }
107       })
108     }, 500)
109
110     this.element.addEventListener('play', ev => {
111       currentState = 'playing'
112       this.channel.notify({ method: 'playbackStatusChange', params: 'playing' })
113     })
114
115     this.element.addEventListener('pause', ev => {
116       currentState = 'paused'
117       this.channel.notify({ method: 'playbackStatusChange', params: 'paused' })
118     })
119
120     // PeerTube specific capabilities
121
122     this.embed.player.peertube().on('autoResolutionUpdate', () => this.loadResolutions())
123     this.embed.player.peertube().on('videoFileUpdate', () => this.loadResolutions())
124   }
125
126   private loadResolutions () {
127     let resolutions = []
128     let currentResolutionId = this.embed.player.peertube().getCurrentResolutionId()
129
130     for (const videoFile of this.embed.player.peertube().videoFiles) {
131       let label = videoFile.resolution.label
132       if (videoFile.fps && videoFile.fps >= 50) {
133         label += videoFile.fps
134       }
135
136       resolutions.push({
137         id: videoFile.resolution.id,
138         label,
139         src: videoFile.magnetUri,
140         active: videoFile.resolution.id === currentResolutionId
141       })
142     }
143
144     this.resolutions = resolutions
145     this.channel.notify({
146       method: 'resolutionUpdate',
147       params: this.resolutions
148     })
149   }
150 }
151
152 class PeerTubeEmbed {
153   videoElement: HTMLVideoElement
154   player: any
155   playerOptions: any
156   api: PeerTubeEmbedApi = null
157   autoplay = false
158   controls = true
159   muted = false
160   loop = false
161   enableApi = false
162   startTime: number | string = 0
163   scope = 'peertube'
164
165   static async main () {
166     const videoContainerId = 'video-container'
167     const embed = new PeerTubeEmbed(videoContainerId)
168     await embed.init()
169   }
170
171   constructor (private videoContainerId: string) {
172     this.videoElement = document.getElementById(videoContainerId) as HTMLVideoElement
173   }
174
175   getVideoUrl (id: string) {
176     return window.location.origin + '/api/v1/videos/' + id
177   }
178
179   loadVideoInfo (videoId: string): Promise<Response> {
180     return fetch(this.getVideoUrl(videoId))
181   }
182
183   loadVideoCaptions (videoId: string): Promise<Response> {
184     return fetch(this.getVideoUrl(videoId) + '/captions')
185   }
186
187   removeElement (element: HTMLElement) {
188     element.parentElement.removeChild(element)
189   }
190
191   displayError (text: string) {
192     // Remove video element
193     if (this.videoElement) this.removeElement(this.videoElement)
194
195     document.title = 'Sorry - ' + text
196
197     const errorBlock = document.getElementById('error-block')
198     errorBlock.style.display = 'flex'
199
200     const errorText = document.getElementById('error-content')
201     errorText.innerHTML = text
202   }
203
204   videoNotFound () {
205     const text = 'This video does not exist.'
206     this.displayError(text)
207   }
208
209   videoFetchError () {
210     const text = 'We cannot fetch the video. Please try again later.'
211     this.displayError(text)
212   }
213
214   getParamToggle (params: URLSearchParams, name: string, defaultValue: boolean) {
215     return params.has(name) ? (params.get(name) === '1' || params.get(name) === 'true') : defaultValue
216   }
217
218   getParamString (params: URLSearchParams, name: string, defaultValue: string) {
219     return params.has(name) ? params.get(name) : defaultValue
220   }
221
222   async init () {
223     try {
224       await this.initCore()
225     } catch (e) {
226       console.error(e)
227     }
228   }
229
230   private initializeApi () {
231     if (!this.enableApi) return
232
233     this.api = new PeerTubeEmbedApi(this)
234     this.api.initialize()
235   }
236
237   private loadParams () {
238     try {
239       let params = new URL(window.location.toString()).searchParams
240
241       this.autoplay = this.getParamToggle(params, 'autoplay', this.autoplay)
242       this.controls = this.getParamToggle(params, 'controls', this.controls)
243       this.muted = this.getParamToggle(params, 'muted', this.muted)
244       this.loop = this.getParamToggle(params, 'loop', this.loop)
245       this.enableApi = this.getParamToggle(params, 'api', this.enableApi)
246       this.scope = this.getParamString(params, 'scope', this.scope)
247
248       const startTimeParamString = params.get('start')
249       if (startTimeParamString) this.startTime = startTimeParamString
250     } catch (err) {
251       console.error('Cannot get params from URL.', err)
252     }
253   }
254
255   private async initCore () {
256     const urlParts = window.location.href.split('/')
257     const lastPart = urlParts[ urlParts.length - 1 ]
258     const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[ 0 ]
259
260     await loadLocale(window.location.origin, vjs, navigator.language)
261     const [ videoResponse, captionsResponse ] = await Promise.all([
262       this.loadVideoInfo(videoId),
263       this.loadVideoCaptions(videoId)
264     ])
265
266     if (!videoResponse.ok) {
267       if (videoResponse.status === 404) return this.videoNotFound()
268
269       return this.videoFetchError()
270     }
271
272     const videoInfo: VideoDetails = await videoResponse.json()
273     let videoCaptions: VideoJSCaption[] = []
274     if (captionsResponse.ok) {
275       const { data } = (await captionsResponse.json()) as ResultList<VideoCaption>
276       videoCaptions = data.map(c => ({
277         label: c.language.label,
278         language: c.language.id,
279         src: window.location.origin + c.captionPath
280       }))
281     }
282
283     this.loadParams()
284
285     const videojsOptions = getVideojsOptions({
286       autoplay: this.autoplay,
287       controls: this.controls,
288       muted: this.muted,
289       loop: this.loop,
290       startTime: this.startTime,
291
292       videoCaptions,
293       inactivityTimeout: 1500,
294       videoViewUrl: this.getVideoUrl(videoId) + '/views',
295       playerElement: this.videoElement,
296       videoFiles: videoInfo.files,
297       videoDuration: videoInfo.duration,
298       enableHotkeys: true,
299       peertubeLink: true,
300       poster: window.location.origin + videoInfo.previewPath,
301       theaterMode: false
302     })
303
304     this.playerOptions = videojsOptions
305     this.player = vjs(this.videoContainerId, videojsOptions, () => {
306       this.player.on('customError', (event, data) => this.handleError(data.err))
307
308       window[ 'videojsPlayer' ] = this.player
309
310       if (this.controls) {
311         this.player.dock({
312           title: videoInfo.name,
313           description: this.player.localize('Uses P2P, others may know your IP is downloading this video.')
314         })
315       }
316
317       addContextMenu(this.player, window.location.origin + videoInfo.embedPath)
318
319       this.initializeApi()
320     })
321   }
322
323   private handleError (err: Error) {
324     if (err.message.indexOf('from xs param') !== -1) {
325       this.player.dispose()
326       this.videoElement = null
327       this.displayError('This video is not available because the remote instance is not responding.')
328       return
329     }
330   }
331 }
332
333 PeerTubeEmbed.main()
334   .catch(err => console.error('Cannot init embed.', err))