aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorWilliam Lahti <wilahti@gmail.com>2018-07-10 08:47:56 -0700
committerChocobozzz <me@florianbigard.com>2018-07-10 17:47:56 +0200
commit999417328bde0e60cd59318fc1c18672356254ce (patch)
tree22673fcbd4dc333e3362912b2c813e97a41c765f
parent0b755f3b27190ea4d9c301ede0955b2736605f4c (diff)
downloadPeerTube-999417328bde0e60cd59318fc1c18672356254ce.tar.gz
PeerTube-999417328bde0e60cd59318fc1c18672356254ce.tar.zst
PeerTube-999417328bde0e60cd59318fc1c18672356254ce.zip
Ability to programmatically control embeds (#776)
* first stab at jschannel based player api * semicolon purge * more method-level docs; consolidate definitions * missing definitions * better match peertube's class conventions * styling for embed tester * basic docs * add `getVolume` * document the test-embed feature
-rw-r--r--client/package.json3
-rw-r--r--client/src/assets/player/peertube-player.ts9
-rw-r--r--client/src/standalone/player/definitions.ts18
-rw-r--r--client/src/standalone/player/events.ts48
-rw-r--r--client/src/standalone/player/player.ts190
-rw-r--r--client/src/standalone/videos/embed.ts306
-rw-r--r--client/src/standalone/videos/test-embed.html51
-rw-r--r--client/src/standalone/videos/test-embed.scss149
-rw-r--r--client/src/standalone/videos/test-embed.ts98
-rw-r--r--client/webpack/webpack.video-embed.js24
-rw-r--r--server/controllers/client.ts5
-rw-r--r--support/doc/api/embeds.md122
12 files changed, 962 insertions, 61 deletions
diff --git a/client/package.json b/client/package.json
index 1264891ec..617c7cb49 100644
--- a/client/package.json
+++ b/client/package.json
@@ -52,6 +52,7 @@
52 "@types/core-js": "^0.9.28", 52 "@types/core-js": "^0.9.28",
53 "@types/jasmine": "^2.8.7", 53 "@types/jasmine": "^2.8.7",
54 "@types/jasminewd2": "^2.0.3", 54 "@types/jasminewd2": "^2.0.3",
55 "@types/jschannel": "^1.0.0",
55 "@types/lodash-es": "^4.17.0", 56 "@types/lodash-es": "^4.17.0",
56 "@types/markdown-it": "^0.0.4", 57 "@types/markdown-it": "^0.0.4",
57 "@types/node": "^9.3.0", 58 "@types/node": "^9.3.0",
@@ -70,9 +71,11 @@
70 "extract-text-webpack-plugin": "4.0.0-beta.0", 71 "extract-text-webpack-plugin": "4.0.0-beta.0",
71 "file-loader": "^1.1.5", 72 "file-loader": "^1.1.5",
72 "html-webpack-plugin": "^3.2.0", 73 "html-webpack-plugin": "^3.2.0",
74 "html-loader": "^0.5.5",
73 "https-browserify": "^1.0.0", 75 "https-browserify": "^1.0.0",
74 "jasmine-core": "^3.1.0", 76 "jasmine-core": "^3.1.0",
75 "jasmine-spec-reporter": "^4.2.1", 77 "jasmine-spec-reporter": "^4.2.1",
78 "jschannel": "^1.0.2",
76 "karma": "^2.0.2", 79 "karma": "^2.0.2",
77 "karma-chrome-launcher": "^2.2.0", 80 "karma-chrome-launcher": "^2.2.0",
78 "karma-coverage-istanbul-reporter": "^2.0.1", 81 "karma-coverage-istanbul-reporter": "^2.0.1",
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts
index 7e339990c..baae740fe 100644
--- a/client/src/assets/player/peertube-player.ts
+++ b/client/src/assets/player/peertube-player.ts
@@ -29,10 +29,15 @@ function getVideojsOptions (options: {
29 peertubeLink: boolean, 29 peertubeLink: boolean,
30 poster: string, 30 poster: string,
31 startTime: number 31 startTime: number
32 theaterMode: boolean 32 theaterMode: boolean,
33 controls?: boolean,
34 muted?: boolean,
35 loop?: boolean
33}) { 36}) {
34 const videojsOptions = { 37 const videojsOptions = {
35 controls: true, 38 controls: options.controls !== undefined ? options.controls : true,
39 muted: options.controls !== undefined ? options.muted : false,
40 loop: options.loop !== undefined ? options.loop : false,
36 poster: options.poster, 41 poster: options.poster,
37 autoplay: false, 42 autoplay: false,
38 inactivityTimeout: options.inactivityTimeout, 43 inactivityTimeout: options.inactivityTimeout,
diff --git a/client/src/standalone/player/definitions.ts b/client/src/standalone/player/definitions.ts
new file mode 100644
index 000000000..6920672a7
--- /dev/null
+++ b/client/src/standalone/player/definitions.ts
@@ -0,0 +1,18 @@
1
2export interface EventHandler<T> {
3 (ev : T) : void
4}
5
6export type PlayerEventType =
7 'pause' | 'play' |
8 'playbackStatusUpdate' |
9 'playbackStatusChange' |
10 'resolutionUpdate'
11;
12
13export interface PeerTubeResolution {
14 id : any
15 label : string
16 src : string
17 active : boolean
18} \ No newline at end of file
diff --git a/client/src/standalone/player/events.ts b/client/src/standalone/player/events.ts
new file mode 100644
index 000000000..c01328352
--- /dev/null
+++ b/client/src/standalone/player/events.ts
@@ -0,0 +1,48 @@
1import { EventHandler } from "./definitions"
2
3interface PlayerEventRegistrar {
4 registrations : Function[]
5}
6
7interface PlayerEventRegistrationMap {
8 [name : string] : PlayerEventRegistrar
9}
10
11export class EventRegistrar {
12
13 private eventRegistrations : PlayerEventRegistrationMap = {}
14
15 public bindToChannel(channel : Channel.MessagingChannel) {
16 for (let name of Object.keys(this.eventRegistrations))
17 channel.bind(name, (txn, params) => this.fire(name, params))
18 }
19
20 public registerTypes(names : string[]) {
21 for (let name of names)
22 this.eventRegistrations[name] = { registrations: [] }
23 }
24
25 public fire<T>(name : string, event : T) {
26 this.eventRegistrations[name].registrations.forEach(x => x(event))
27 }
28
29 public addListener<T>(name : string, handler : EventHandler<T>) {
30 if (!this.eventRegistrations[name]) {
31 console.warn(`PeerTube: addEventListener(): The event '${name}' is not supported`)
32 return false
33 }
34
35 this.eventRegistrations[name].registrations.push(handler)
36 return true
37 }
38
39 public removeListener<T>(name : string, handler : EventHandler<T>) {
40 if (!this.eventRegistrations[name])
41 return false
42
43 this.eventRegistrations[name].registrations =
44 this.eventRegistrations[name].registrations.filter(x => x === handler)
45
46 return true
47 }
48}
diff --git a/client/src/standalone/player/player.ts b/client/src/standalone/player/player.ts
new file mode 100644
index 000000000..9fc648d25
--- /dev/null
+++ b/client/src/standalone/player/player.ts
@@ -0,0 +1,190 @@
1import * as Channel from 'jschannel'
2import { EventRegistrar } from './events'
3import { EventHandler, PlayerEventType, PeerTubeResolution } from './definitions'
4
5const PASSTHROUGH_EVENTS = [
6 'pause', 'play',
7 'playbackStatusUpdate',
8 'playbackStatusChange',
9 'resolutionUpdate'
10]
11
12/**
13 * Allows for programmatic control of a PeerTube embed running in an <iframe>
14 * within a web page.
15 */
16export class PeerTubePlayer {
17 /**
18 * Construct a new PeerTubePlayer for the given PeerTube embed iframe.
19 * Optionally provide a `scope` to ensure that messages are not crossed
20 * between multiple PeerTube embeds. The string passed here must match the
21 * `scope=` query parameter on the embed URL.
22 *
23 * @param embedElement
24 * @param scope
25 */
26 constructor(
27 private embedElement : HTMLIFrameElement,
28 private scope? : string
29 ) {
30 this.eventRegistrar.registerTypes(PASSTHROUGH_EVENTS)
31
32 this.constructChannel()
33 this.prepareToBeReady()
34 }
35
36 private eventRegistrar : EventRegistrar = new EventRegistrar()
37 private channel : Channel.MessagingChannel
38 private readyPromise : Promise<void>
39
40 /**
41 * Destroy the player object and remove the associated player from the DOM.
42 */
43 destroy() {
44 this.embedElement.remove()
45 }
46
47 /**
48 * Listen to an event emitted by this player.
49 *
50 * @param event One of the supported event types
51 * @param handler A handler which will be passed an event object (or undefined if no event object is included)
52 */
53 addEventListener(event : PlayerEventType, handler : EventHandler<any>): boolean {
54 return this.eventRegistrar.addListener(event, handler)
55 }
56
57 /**
58 * Remove an event listener previously added with addEventListener().
59 *
60 * @param event The name of the event previously listened to
61 * @param handler
62 */
63 removeEventListener(event : PlayerEventType, handler : EventHandler<any>): boolean {
64 return this.eventRegistrar.removeListener(event, handler)
65 }
66
67 /**
68 * Promise resolves when the player is ready.
69 */
70 get ready(): Promise<void> {
71 return this.readyPromise
72 }
73
74 /**
75 * Tell the embed to start/resume playback
76 */
77 async play() {
78 await this.sendMessage('play')
79 }
80
81 /**
82 * Tell the embed to pause playback.
83 */
84 async pause() {
85 await this.sendMessage('pause')
86 }
87
88 /**
89 * Tell the embed to change the audio volume
90 * @param value A number from 0 to 1
91 */
92 async setVolume(value : number) {
93 await this.sendMessage('setVolume', value)
94 }
95
96 /**
97 * Get the current volume level in the embed.
98 * @param value A number from 0 to 1
99 */
100 async getVolume(): Promise<number> {
101 return await this.sendMessage<void, number>('setVolume')
102 }
103
104 /**
105 * Tell the embed to seek to a specific position (in seconds)
106 * @param seconds
107 */
108 async seek(seconds : number) {
109 await this.sendMessage('seek', seconds)
110 }
111
112 /**
113 * Tell the embed to switch resolutions to the resolution identified
114 * by the given ID.
115 *
116 * @param resolutionId The ID of the resolution as found with getResolutions()
117 */
118 async setResolution(resolutionId : any) {
119 await this.sendMessage('setResolution', resolutionId)
120 }
121
122 /**
123 * Retrieve a list of the available resolutions. This may change later, listen to the
124 * `resolutionUpdate` event with `addEventListener` in order to be updated as the available
125 * resolutions change.
126 */
127 async getResolutions(): Promise<PeerTubeResolution[]> {
128 return await this.sendMessage<void, PeerTubeResolution[]>('getResolutions')
129 }
130
131 /**
132 * Retrieve a list of available playback rates.
133 */
134 async getPlaybackRates() : Promise<number[]> {
135 return await this.sendMessage<void, number[]>('getPlaybackRates')
136 }
137
138 /**
139 * Get the current playback rate. Defaults to 1 (1x playback rate).
140 */
141 async getPlaybackRate() : Promise<number> {
142 return await this.sendMessage<void, number>('getPlaybackRate')
143 }
144
145 /**
146 * Set the playback rate. Should be one of the options returned by getPlaybackRates().
147 * Passing 0.5 means half speed, 1 means normal, 2 means 2x speed, etc.
148 *
149 * @param rate
150 */
151 async setPlaybackRate(rate : number) {
152 await this.sendMessage('setPlaybackRate', rate)
153 }
154
155 private constructChannel() {
156 this.channel = Channel.build({
157 window: this.embedElement.contentWindow,
158 origin: '*',
159 scope: this.scope || 'peertube'
160 })
161 this.eventRegistrar.bindToChannel(this.channel)
162 }
163
164 private prepareToBeReady() {
165 let readyResolve, readyReject
166 this.readyPromise = new Promise<void>((res, rej) => {
167 readyResolve = res
168 readyReject = rej
169 })
170
171 this.channel.bind('ready', success => success ? readyResolve() : readyReject())
172 this.channel.call({
173 method: 'isReady',
174 success: isReady => isReady ? readyResolve() : null
175 })
176 }
177
178 private sendMessage<TIn, TOut>(method : string, params? : TIn): Promise<TOut> {
179 return new Promise<TOut>((resolve, reject) => {
180 this.channel.call({
181 method, params,
182 success: result => resolve(result),
183 error: error => reject(error)
184 })
185 })
186 }
187}
188
189// put it on the window as well as the export
190window['PeerTubePlayer'] = PeerTubePlayer \ No newline at end of file
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index d8db2a119..e9baf64d0 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -17,100 +17,296 @@ import 'core-js/es6/set'
17// For google bot that uses Chrome 41 and does not understand fetch 17// For google bot that uses Chrome 41 and does not understand fetch
18import 'whatwg-fetch' 18import 'whatwg-fetch'
19 19
20import * as videojs from 'video.js' 20import * as vjs from 'video.js'
21import * as Channel from 'jschannel'
21 22
22import { VideoDetails } from '../../../../shared' 23import { VideoDetails } from '../../../../shared'
23import { addContextMenu, getVideojsOptions, loadLocale } from '../../assets/player/peertube-player' 24import { addContextMenu, getVideojsOptions, loadLocale } from '../../assets/player/peertube-player'
25import { PeerTubeResolution } from '../player/definitions';
24 26
25function getVideoUrl (id: string) { 27/**
26 return window.location.origin + '/api/v1/videos/' + id 28 * Embed API exposes control of the embed player to the outside world via
27} 29 * JSChannels and window.postMessage
30 */
31class PeerTubeEmbedApi {
32 constructor(
33 private embed : PeerTubeEmbed
34 ) {
35 }
28 36
29function loadVideoInfo (videoId: string): Promise<Response> { 37 private channel : Channel.MessagingChannel
30 return fetch(getVideoUrl(videoId)) 38 private isReady = false
31} 39 private resolutions : PeerTubeResolution[] = null
32 40
33function removeElement (element: HTMLElement) { 41 initialize() {
34 element.parentElement.removeChild(element) 42 this.constructChannel()
35} 43 this.setupStateTracking()
36 44
37function displayError (videoElement: HTMLVideoElement, text: string) { 45 // We're ready!
38 // Remove video element
39 removeElement(videoElement)
40 46
41 document.title = 'Sorry - ' + text 47 this.notifyReady()
48 }
49
50 private get element() {
51 return this.embed.videoElement
52 }
42 53
43 const errorBlock = document.getElementById('error-block') 54 private constructChannel() {
44 errorBlock.style.display = 'flex' 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)
45 68
46 const errorText = document.getElementById('error-content') 69 this.channel = channel
47 errorText.innerHTML = text 70 }
48}
49 71
50function videoNotFound (videoElement: HTMLVideoElement) { 72 private setResolution(resolutionId : number) {
51 const text = 'This video does not exist.' 73 if (resolutionId === -1 && this.embed.player.peertube().isAutoResolutionForbidden())
52 displayError(videoElement, text) 74 return
53} 75
76 // Auto resolution
77 if (resolutionId === -1) {
78 this.embed.player.peertube().enableAutoResolution()
79 return
80 }
81
82 this.embed.player.peertube().disableAutoResolution()
83 this.embed.player.peertube().updateResolution(resolutionId)
84 }
85
86 /**
87 * Let the host know that we're ready to go!
88 */
89 private notifyReady() {
90 this.isReady = true
91 this.channel.notify({ method: 'ready', params: true })
92 }
93
94 private setupStateTracking() {
95
96 let currentState : 'playing' | 'paused' | 'unstarted' = 'unstarted'
97
98 setInterval(() => {
99 let position = this.element.currentTime
100 let volume = this.element.volume
101
102 this.channel.notify({
103 method: 'playbackStatusUpdate',
104 params: {
105 position,
106 volume,
107 playbackState: currentState,
108 }
109 })
110 }, 500)
111
112 this.element.addEventListener('play', ev => {
113 currentState = 'playing'
114 this.channel.notify({ method: 'playbackStatusChange', params: 'playing' })
115 })
116
117 this.element.addEventListener('pause', ev => {
118 currentState = 'paused'
119 this.channel.notify({ method: 'playbackStatusChange', params: 'paused' })
120 })
121
122 // PeerTube specific capabilities
123
124 this.embed.player.peertube().on('autoResolutionUpdate', () => this.loadResolutions())
125 this.embed.player.peertube().on('videoFileUpdate', () => this.loadResolutions())
126 }
127
128 private loadResolutions() {
129 let resolutions = []
130 let currentResolutionId = this.embed.player.peertube().getCurrentResolutionId()
131
132 for (const videoFile of this.embed.player.peertube().videoFiles) {
133 let label = videoFile.resolution.label
134 if (videoFile.fps && videoFile.fps >= 50) {
135 label += videoFile.fps
136 }
54 137
55function videoFetchError (videoElement: HTMLVideoElement) { 138 resolutions.push({
56 const text = 'We cannot fetch the video. Please try again later.' 139 id: videoFile.resolution.id,
57 displayError(videoElement, text) 140 label,
141 src: videoFile.magnetUri,
142 active: videoFile.resolution.id === currentResolutionId
143 })
144 }
145
146 this.resolutions = resolutions
147 this.channel.notify({
148 method: 'resolutionUpdate',
149 params: this.resolutions
150 })
151 }
58} 152}
59 153
60const urlParts = window.location.href.split('/') 154class PeerTubeEmbed {
61const lastPart = urlParts[urlParts.length - 1] 155 constructor(
62const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[0] 156 private videoContainerId : string
157 ) {
158 this.videoElement = document.getElementById(videoContainerId) as HTMLVideoElement
159 }
63 160
64loadLocale(window.location.origin, videojs, navigator.language) 161 videoElement : HTMLVideoElement
65 .then(() => loadVideoInfo(videoId)) 162 player : any
66 .then(async response => { 163 playerOptions : any
164 api : PeerTubeEmbedApi = null
165 autoplay : boolean = false
166 controls : boolean = true
167 muted : boolean = false
168 loop : boolean = false
169 enableApi : boolean = false
170 startTime : number = 0
171 scope : string = 'peertube'
172
173 static async main() {
67 const videoContainerId = 'video-container' 174 const videoContainerId = 'video-container'
68 const videoElement = document.getElementById(videoContainerId) as HTMLVideoElement 175 const embed = new PeerTubeEmbed(videoContainerId)
176 await embed.init()
177 }
178
179 getVideoUrl (id: string) {
180 return window.location.origin + '/api/v1/videos/' + id
181 }
69 182
70 if (!response.ok) { 183 loadVideoInfo (videoId: string): Promise<Response> {
71 if (response.status === 404) return videoNotFound(videoElement) 184 return fetch(this.getVideoUrl(videoId))
185 }
72 186
73 return videoFetchError(videoElement) 187 removeElement (element: HTMLElement) {
74 } 188 element.parentElement.removeChild(element)
189 }
75 190
76 const videoInfo: VideoDetails = await response.json() 191 displayError (videoElement: HTMLVideoElement, text: string) {
192 // Remove video element
193 this.removeElement(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 (videoElement: HTMLVideoElement) {
205 const text = 'This video does not exist.'
206 this.displayError(videoElement, text)
207 }
208
209 videoFetchError (videoElement: HTMLVideoElement) {
210 const text = 'We cannot fetch the video. Please try again later.'
211 this.displayError(videoElement, 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 }
77 217
78 let autoplay = false 218 getParamString (params: URLSearchParams, name: string, defaultValue: string) {
79 let startTime = 0 219 return params.has(name) ? params.get(name) : defaultValue
220 }
80 221
222 private initializeApi() {
223 if (!this.enableApi)
224 return
225
226 this.api = new PeerTubeEmbedApi(this)
227 this.api.initialize()
228 }
229
230 async init() {
231 try {
232 await this.initCore()
233 } catch (e) {
234 console.error(e)
235 }
236 }
237
238 private loadParams() {
81 try { 239 try {
82 let params = new URL(window.location.toString()).searchParams 240 let params = new URL(window.location.toString()).searchParams
83 autoplay = params.has('autoplay') && (params.get('autoplay') === '1' || params.get('autoplay') === 'true') 241
242 this.autoplay = this.getParamToggle(params, 'autoplay', this.autoplay)
243 this.controls = this.getParamToggle(params, 'controls', this.controls)
244 this.muted = this.getParamToggle(params, 'muted', this.muted)
245 this.loop = this.getParamToggle(params, 'loop', this.loop)
246 this.enableApi = this.getParamToggle(params, 'api', this.enableApi)
247 this.scope = this.getParamString(params, 'scope', this.scope)
84 248
85 const startTimeParamString = params.get('start') 249 const startTimeParamString = params.get('start')
86 const startTimeParamNumber = parseInt(startTimeParamString, 10) 250 const startTimeParamNumber = parseInt(startTimeParamString, 10)
87 if (isNaN(startTimeParamNumber) === false) startTime = startTimeParamNumber 251 if (isNaN(startTimeParamNumber) === false)
252 this.startTime = startTimeParamNumber
88 } catch (err) { 253 } catch (err) {
89 console.error('Cannot get params from URL.', err) 254 console.error('Cannot get params from URL.', err)
90 } 255 }
256 }
257
258 private async initCore() {
259 const urlParts = window.location.href.split('/')
260 const lastPart = urlParts[urlParts.length - 1]
261 const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[0]
262
263 await loadLocale(window.location.origin, vjs, navigator.language)
264 let response = await this.loadVideoInfo(videoId)
265
266 if (!response.ok) {
267 if (response.status === 404)
268 return this.videoNotFound(this.videoElement)
269
270 return this.videoFetchError(this.videoElement)
271 }
272
273 const videoInfo: VideoDetails = await response.json()
274
275 this.loadParams()
91 276
92 const videojsOptions = getVideojsOptions({ 277 const videojsOptions = getVideojsOptions({
93 autoplay, 278 autoplay: this.autoplay,
279 controls: this.controls,
280 muted: this.muted,
281 loop: this.loop,
282 startTime : this.startTime,
283
94 inactivityTimeout: 1500, 284 inactivityTimeout: 1500,
95 videoViewUrl: getVideoUrl(videoId) + '/views', 285 videoViewUrl: this.getVideoUrl(videoId) + '/views',
96 playerElement: videoElement, 286 playerElement: this.videoElement,
97 videoFiles: videoInfo.files, 287 videoFiles: videoInfo.files,
98 videoDuration: videoInfo.duration, 288 videoDuration: videoInfo.duration,
99 enableHotkeys: true, 289 enableHotkeys: true,
100 peertubeLink: true, 290 peertubeLink: true,
101 poster: window.location.origin + videoInfo.previewPath, 291 poster: window.location.origin + videoInfo.previewPath,
102 startTime,
103 theaterMode: false 292 theaterMode: false
104 }) 293 })
105 videojs(videoContainerId, videojsOptions, function () {
106 const player = this
107 294
108 player.dock({ 295 this.playerOptions = videojsOptions
109 title: videoInfo.name, 296 this.player = vjs(this.videoContainerId, videojsOptions, () => {
110 description: player.localize('Uses P2P, others may know your IP is downloading this video.')
111 })
112 297
113 addContextMenu(player, window.location.origin + videoInfo.embedPath) 298 window['videojsPlayer'] = this.player
299
300 if (this.controls) {
301 (this.player as any).dock({
302 title: videoInfo.name,
303 description: this.player.localize('Uses P2P, others may know your IP is downloading this video.')
304 })
305 }
306 addContextMenu(this.player, window.location.origin + videoInfo.embedPath)
307 this.initializeApi()
114 }) 308 })
115 }) 309 }
116 .catch(err => console.error(err)) 310}
311
312PeerTubeEmbed.main()
diff --git a/client/src/standalone/videos/test-embed.html b/client/src/standalone/videos/test-embed.html
new file mode 100644
index 000000000..a60ba2899
--- /dev/null
+++ b/client/src/standalone/videos/test-embed.html
@@ -0,0 +1,51 @@
1<html>
2<head>
3 <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
4</head>
5<body>
6 <header>
7 <div class="logo">
8 <div class="icon">
9 <img src="../../assets/images/logo.svg">
10 </div>
11 <div>
12 PeerTube
13 </div>
14 </div>
15
16 <div class="spacer"></div>
17 <h1>Embed Playground</h1>
18 </header>
19 <main>
20 <aside>
21 <div id="host"></div>
22 </aside>
23 <div id="controls">
24 <div>
25 <button onclick="player.play()">Play</button>
26 <button onclick="player.pause()">Pause</button>
27 <button onclick="player.seek(parseInt(prompt('Enter position to seek to (in seconds)')))">Seek</button>
28 </div>
29 <br/>
30
31 <div id="options">
32 <fieldset>
33 <legend>Resolutions:</legend>
34 <div id="resolution-list"></div>
35 <br/>
36 </fieldset>
37
38 <fieldset>
39 <legend>Rates:</legend>
40 <div id="rate-list"></div>
41 </fieldset>
42 </div>
43
44 </div>
45 </main>
46
47 <!-- iframes are used dynamically -->
48 <iframe hidden></iframe>
49 <a hidden></a>
50</body>
51</html>
diff --git a/client/src/standalone/videos/test-embed.scss b/client/src/standalone/videos/test-embed.scss
new file mode 100644
index 000000000..df3d69f21
--- /dev/null
+++ b/client/src/standalone/videos/test-embed.scss
@@ -0,0 +1,149 @@
1
2* {
3 font-family: sans-serif;
4}
5
6html {
7 width: 100%;
8 overflow-x: hidden;
9 overflow-y: auto;
10}
11
12body {
13 margin: 0;
14 padding: 0;
15}
16
17iframe {
18 border: none;
19 border-radius: 8px;
20 min-width: 200px;
21 width: 100%;
22 height: 100%;
23 pointer-events: none;
24}
25
26aside {
27 width: 33vw;
28 margin: 0 .5em .5em 0;
29 height: calc(33vw * 0.5625);
30}
31
32.logo {
33 font-size: 150%;
34 height: 100%;
35 font-weight: bold;
36 display: flex;
37 flex-direction: row;
38 align-items: center;
39 margin-right: 0.5em;
40
41 .icon {
42 height: 100%;
43 padding: 0 18px 0 32px;
44 background: white;
45 display: flex;
46 align-items: center;
47 margin-right: 0.5em;
48 }
49}
50
51main {
52 padding: 0 1em;
53 display: flex;
54 align-items: flex-start;
55}
56
57.spacer {
58 flex: 1;
59}
60
61header {
62 width: 100%;
63 height: 3.2em;
64 background-color: #F1680D;
65 color: white;
66 //background-image: url(../../assets/images/backdrop/network-o.png);
67 display: flex;
68 flex-direction: row;
69 align-items: center;
70 margin-bottom: 1em;
71 box-shadow: 1px 0px 10px rgba(0,0,0,0.6);
72 background-size: 50%;
73 background-position: top left;
74 padding-right: 1em;
75
76 h1 {
77 margin: 0;
78 padding: 0 1em 0 0;
79 font-size: inherit;
80 font-weight: 100;
81 position: relative;
82 top: 2px;
83 }
84}
85
86#options {
87 display: flex;
88 flex-wrap: wrap;
89
90 & > * {
91 flex-grow: 0;
92 }
93}
94
95fieldset {
96 border: none;
97 min-width: 8em;
98 legend {
99 border-bottom: 1px solid #ccc;
100 width: 100%;
101 }
102}
103
104button {
105 background: #F1680D;
106 color: white;
107 font-weight: bold;
108 border-radius: 5px;
109 margin: 0;
110 padding: 1em 1.25em;
111 border: none;
112}
113
114a {
115 text-decoration: none;
116
117 &:hover {
118 text-decoration: underline;
119 }
120
121 &, &:hover, &:focus, &:visited, &:active {
122 color: #F44336;
123 }
124}
125
126@media (max-width: 900px) {
127 aside {
128 width: 50vw;
129 height: calc(50vw * 0.5625);
130 }
131}
132
133@media (max-width: 600px) {
134 main {
135 flex-direction: column;
136 }
137
138 aside {
139 width: calc(100vw - 2em);
140 height: calc(56.25vw - 2em * 0.5625);
141 }
142}
143
144@media (min-width: 1800px) {
145 aside {
146 width: 50vw;
147 height: calc(50vw * 0.5625);
148 }
149} \ No newline at end of file
diff --git a/client/src/standalone/videos/test-embed.ts b/client/src/standalone/videos/test-embed.ts
new file mode 100644
index 000000000..721514488
--- /dev/null
+++ b/client/src/standalone/videos/test-embed.ts
@@ -0,0 +1,98 @@
1import './test-embed.scss'
2import { PeerTubePlayer } from '../player/player';
3import { PlayerEventType } from '../player/definitions';
4
5window.addEventListener('load', async () => {
6
7 const urlParts = window.location.href.split('/')
8 const lastPart = urlParts[urlParts.length - 1]
9 const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[0]
10
11 let iframe = document.createElement('iframe')
12 iframe.src = `/videos/embed/${videoId}?autoplay=1&controls=0&api=1`
13 let mainElement = document.querySelector('#host')
14 mainElement.appendChild(iframe);
15
16 console.log(`Document finished loading.`)
17 let player = new PeerTubePlayer(document.querySelector('iframe'))
18
19 window['player'] = player
20
21 console.log(`Awaiting player ready...`)
22 await player.ready
23 console.log(`Player is ready.`)
24
25 let monitoredEvents = [
26 'pause', 'play',
27 'playbackStatusUpdate',
28 'playbackStatusChange'
29 ]
30
31 monitoredEvents.forEach(e => {
32 player.addEventListener(<PlayerEventType>e, () => console.log(`PLAYER: event '${e}' received`))
33 console.log(`PLAYER: now listening for event '${e}'`)
34 })
35
36 let playbackRates = []
37 let activeRate = 1
38 let currentRate = await player.getPlaybackRate()
39
40 let updateRates = async () => {
41
42 let rateListEl = document.querySelector('#rate-list')
43 rateListEl.innerHTML = ''
44
45 playbackRates.forEach(rate => {
46 if (currentRate == rate) {
47 let itemEl = document.createElement('strong')
48 itemEl.innerText = `${rate} (active)`
49 itemEl.style.display = 'block'
50 rateListEl.appendChild(itemEl)
51 } else {
52 let itemEl = document.createElement('a')
53 itemEl.href = 'javascript:;'
54 itemEl.innerText = rate
55 itemEl.addEventListener('click', () => {
56 player.setPlaybackRate(rate)
57 currentRate = rate
58 updateRates()
59 })
60 itemEl.style.display = 'block'
61 rateListEl.appendChild(itemEl)
62 }
63 })
64 }
65
66 player.getPlaybackRates().then(rates => {
67 playbackRates = rates
68 updateRates()
69 })
70
71 let updateResolutions = resolutions => {
72 let resolutionListEl = document.querySelector('#resolution-list')
73 resolutionListEl.innerHTML = ''
74
75 resolutions.forEach(resolution => {
76 if (resolution.active) {
77 let itemEl = document.createElement('strong')
78 itemEl.innerText = `${resolution.label} (active)`
79 itemEl.style.display = 'block'
80 resolutionListEl.appendChild(itemEl)
81 } else {
82 let itemEl = document.createElement('a')
83 itemEl.href = 'javascript:;'
84 itemEl.innerText = resolution.label
85 itemEl.addEventListener('click', () => {
86 player.setResolution(resolution.id)
87 })
88 itemEl.style.display = 'block'
89 resolutionListEl.appendChild(itemEl)
90 }
91 })
92 }
93
94 player.getResolutions().then(
95 resolutions => updateResolutions(resolutions))
96 player.addEventListener('resolutionUpdate',
97 resolutions => updateResolutions(resolutions))
98}) \ No newline at end of file
diff --git a/client/webpack/webpack.video-embed.js b/client/webpack/webpack.video-embed.js
index 403a65930..979da0dff 100644
--- a/client/webpack/webpack.video-embed.js
+++ b/client/webpack/webpack.video-embed.js
@@ -14,7 +14,9 @@ module.exports = function () {
14 14
15 const configuration = { 15 const configuration = {
16 entry: { 16 entry: {
17 'video-embed': './src/standalone/videos/embed.ts' 17 'video-embed': './src/standalone/videos/embed.ts',
18 'player': './src/standalone/player/player.ts',
19 'test-embed': './src/standalone/videos/test-embed.ts'
18 }, 20 },
19 21
20 resolve: { 22 resolve: {
@@ -89,7 +91,8 @@ module.exports = function () {
89 use: 'raw-loader', 91 use: 'raw-loader',
90 exclude: [ 92 exclude: [
91 helpers.root('src/index.html'), 93 helpers.root('src/index.html'),
92 helpers.root('src/standalone/videos/embed.html') 94 helpers.root('src/standalone/videos/embed.html'),
95 helpers.root('src/standalone/videos/test-embed.html')
93 ] 96 ]
94 }, 97 },
95 98
@@ -110,7 +113,10 @@ module.exports = function () {
110 }), 113 }),
111 114
112 new PurifyCSSPlugin({ 115 new PurifyCSSPlugin({
113 paths: [ helpers.root('src/standalone/videos/embed.ts') ], 116 paths: [
117 helpers.root('src/standalone/videos/embed.ts'),
118 helpers.root('src/standalone/videos/test-embed.html')
119 ],
114 purifyOptions: { 120 purifyOptions: {
115 minify: true, 121 minify: true,
116 whitelist: [ '*vjs*', '*video-js*' ] 122 whitelist: [ '*vjs*', '*video-js*' ]
@@ -124,7 +130,17 @@ module.exports = function () {
124 filename: 'embed.html', 130 filename: 'embed.html',
125 title: 'PeerTube', 131 title: 'PeerTube',
126 chunksSortMode: 'dependency', 132 chunksSortMode: 'dependency',
127 inject: 'body' 133 inject: 'body',
134 chunks: ['video-embed']
135 }),
136
137 new HtmlWebpackPlugin({
138 template: '!!html-loader!src/standalone/videos/test-embed.html',
139 filename: 'test-embed.html',
140 title: 'PeerTube',
141 chunksSortMode: 'dependency',
142 inject: 'body',
143 chunks: ['test-embed']
128 }), 144 }),
129 145
130 /** 146 /**
diff --git a/server/controllers/client.ts b/server/controllers/client.ts
index dfffe5487..5413f61e8 100644
--- a/server/controllers/client.ts
+++ b/server/controllers/client.ts
@@ -21,6 +21,7 @@ const clientsRouter = express.Router()
21const distPath = join(root(), 'client', 'dist') 21const distPath = join(root(), 'client', 'dist')
22const assetsImagesPath = join(root(), 'client', 'dist', 'assets', 'images') 22const assetsImagesPath = join(root(), 'client', 'dist', 'assets', 'images')
23const embedPath = join(distPath, 'standalone', 'videos', 'embed.html') 23const embedPath = join(distPath, 'standalone', 'videos', 'embed.html')
24const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html')
24 25
25// Special route that add OpenGraph and oEmbed tags 26// Special route that add OpenGraph and oEmbed tags
26// Do not use a template engine for a so little thing 27// Do not use a template engine for a so little thing
@@ -32,6 +33,10 @@ clientsRouter.use('' +
32 '/videos/embed', (req: express.Request, res: express.Response, next: express.NextFunction) => { 33 '/videos/embed', (req: express.Request, res: express.Response, next: express.NextFunction) => {
33 res.sendFile(embedPath) 34 res.sendFile(embedPath)
34}) 35})
36clientsRouter.use('' +
37 '/videos/test-embed', (req: express.Request, res: express.Response, next: express.NextFunction) => {
38 res.sendFile(testEmbedPath)
39})
35 40
36// Static HTML/CSS/JS client files 41// Static HTML/CSS/JS client files
37 42
diff --git a/support/doc/api/embeds.md b/support/doc/api/embeds.md
new file mode 100644
index 000000000..3a35a539c
--- /dev/null
+++ b/support/doc/api/embeds.md
@@ -0,0 +1,122 @@
1# PeerTube Embed API
2
3PeerTube lets you embed videos and programmatically control their playback. This documentation covers how to interact with the PeerTube Embed API.
4
5## Playground
6
7Any PeerTube embed URL (ie `https://my-instance.example.com/videos/embed/52a10666-3a18-4e73-93da-e8d3c12c305a`) can be viewed as an embedding playground which
8allows you to test various aspects of PeerTube embeds. Simply replace `/embed` with `/test-embed` and visit the URL in a browser.
9For instance, the playground URL for the above embed URL is `https://my-instance.example.com/videos/test-embed/52a10666-3a18-4e73-93da-e8d3c12c305a`.
10
11## Quick Start
12
13Given an existing PeerTube embed `<iframe>`, one can use the PeerTube Embed API to control it by first including the library. You can include it via Yarn with:
14
15```
16yarn add @peertube/embed-api
17```
18
19Now just use the `PeerTubePlayer` class exported by the module:
20
21```typescript
22import { PeerTubePlayer } from '@peertube/embed-api'
23
24let player = new PeerTubePlayer(document.querySelector('iframe'))
25await player.ready // wait for the player to be ready
26
27// now you can use it!
28player.play()
29player.seek(32)
30player.stop()
31```
32
33# Methods
34
35## `play() : Promise<void>`
36
37Starts playback, or resumes playback if it is paused.
38
39## `pause() : Promise<void>`
40
41Pauses playback.
42
43## `seek(positionInSeconds : number)`
44
45Seek to the given position, as specified in seconds into the video.
46
47## `addEventListener(eventName : string, handler : Function)`
48
49Add a listener for a specific event. See below for the available events.
50
51## `getResolutions() : Promise<PeerTubeResolution[]>`
52
53Get the available resolutions. A `PeerTubeResolution` looks like:
54
55```json
56{
57 "id": 3,
58 "label": "720p",
59 "src": "//src-url-here",
60 "active": true
61}
62```
63
64`active` is true if the resolution is the currently selected resolution.
65
66## `setResolution(resolutionId : number): Promise<void>`
67
68Change the current resolution. Pass `-1` for automatic resolution (when available).
69Otherwise, `resolutionId` should be the ID of an object returned by `getResolutions()`
70
71## `getPlaybackRates() : Promise<number[]>`
72
73Get the available playback rates, where `1` represents normal speed, `0.5` is half speed, `2` is double speed, etc.
74
75## `getPlaybackRates() : Promise<number>`
76
77Get the current playback rate. See `getPlaybackRates()` for more information.
78
79## `setPlaybackRate(rate : number) : Promise<void>`
80
81Set the current playback rate. The passed rate should be a value as returned by `getPlaybackRates()`.
82
83## `setVolume(factor : number) : Promise<void>`
84
85Set the playback volume. Value should be between `0` and `1`.
86
87## `getVolume(): Promise<number>`
88
89Get the playback volume. Returns a value between `0` and `1`.
90# Events
91
92You can subscribe to events by using `addEventListener()`. See above for details.
93
94## Event `play`
95
96Fired when playback begins or is resumed after pausing.
97
98## Event `pause`
99
100Fired when playback is paused.
101
102## Event `playbackStatusUpdate`
103
104Fired every half second to provide the current status of playback. The parameter of the callback will resemble:
105
106```json
107{
108 "position": 22.3,
109 "volume": 0.9,
110 "playbackState": "playing"
111}
112```
113
114The `volume` field contains the volume from `0` (silent) to `1` (full volume). The `playbackState` can be `playing` or `paused`. More states may be added later.
115
116## Event `playbackStatusChange`
117
118Fired when playback transitions between states, such as `pausing` and `playing`. More states may be added later.
119
120## Event `resolutionUpdate`
121
122Fired when the available resolutions have changed, or when the currently selected resolution has changed. Listener should call `getResolutions()` to get the updated information. \ No newline at end of file