diff options
author | William Lahti <wilahti@gmail.com> | 2018-07-10 08:47:56 -0700 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2018-07-10 17:47:56 +0200 |
commit | 999417328bde0e60cd59318fc1c18672356254ce (patch) | |
tree | 22673fcbd4dc333e3362912b2c813e97a41c765f /client | |
parent | 0b755f3b27190ea4d9c301ede0955b2736605f4c (diff) | |
download | PeerTube-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
Diffstat (limited to 'client')
-rw-r--r-- | client/package.json | 3 | ||||
-rw-r--r-- | client/src/assets/player/peertube-player.ts | 9 | ||||
-rw-r--r-- | client/src/standalone/player/definitions.ts | 18 | ||||
-rw-r--r-- | client/src/standalone/player/events.ts | 48 | ||||
-rw-r--r-- | client/src/standalone/player/player.ts | 190 | ||||
-rw-r--r-- | client/src/standalone/videos/embed.ts | 306 | ||||
-rw-r--r-- | client/src/standalone/videos/test-embed.html | 51 | ||||
-rw-r--r-- | client/src/standalone/videos/test-embed.scss | 149 | ||||
-rw-r--r-- | client/src/standalone/videos/test-embed.ts | 98 | ||||
-rw-r--r-- | client/webpack/webpack.video-embed.js | 24 |
10 files changed, 835 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 | |||
2 | export interface EventHandler<T> { | ||
3 | (ev : T) : void | ||
4 | } | ||
5 | |||
6 | export type PlayerEventType = | ||
7 | 'pause' | 'play' | | ||
8 | 'playbackStatusUpdate' | | ||
9 | 'playbackStatusChange' | | ||
10 | 'resolutionUpdate' | ||
11 | ; | ||
12 | |||
13 | export 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 @@ | |||
1 | import { EventHandler } from "./definitions" | ||
2 | |||
3 | interface PlayerEventRegistrar { | ||
4 | registrations : Function[] | ||
5 | } | ||
6 | |||
7 | interface PlayerEventRegistrationMap { | ||
8 | [name : string] : PlayerEventRegistrar | ||
9 | } | ||
10 | |||
11 | export 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 @@ | |||
1 | import * as Channel from 'jschannel' | ||
2 | import { EventRegistrar } from './events' | ||
3 | import { EventHandler, PlayerEventType, PeerTubeResolution } from './definitions' | ||
4 | |||
5 | const 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 | */ | ||
16 | export 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 | ||
190 | window['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 |
18 | import 'whatwg-fetch' | 18 | import 'whatwg-fetch' |
19 | 19 | ||
20 | import * as videojs from 'video.js' | 20 | import * as vjs from 'video.js' |
21 | import * as Channel from 'jschannel' | ||
21 | 22 | ||
22 | import { VideoDetails } from '../../../../shared' | 23 | import { VideoDetails } from '../../../../shared' |
23 | import { addContextMenu, getVideojsOptions, loadLocale } from '../../assets/player/peertube-player' | 24 | import { addContextMenu, getVideojsOptions, loadLocale } from '../../assets/player/peertube-player' |
25 | import { PeerTubeResolution } from '../player/definitions'; | ||
24 | 26 | ||
25 | function 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 | */ | ||
31 | class PeerTubeEmbedApi { | ||
32 | constructor( | ||
33 | private embed : PeerTubeEmbed | ||
34 | ) { | ||
35 | } | ||
28 | 36 | ||
29 | function 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 | ||
33 | function removeElement (element: HTMLElement) { | 41 | initialize() { |
34 | element.parentElement.removeChild(element) | 42 | this.constructChannel() |
35 | } | 43 | this.setupStateTracking() |
36 | 44 | ||
37 | function 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 | ||
50 | function 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 | ||
55 | function 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 | ||
60 | const urlParts = window.location.href.split('/') | 154 | class PeerTubeEmbed { |
61 | const lastPart = urlParts[urlParts.length - 1] | 155 | constructor( |
62 | const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[0] | 156 | private videoContainerId : string |
157 | ) { | ||
158 | this.videoElement = document.getElementById(videoContainerId) as HTMLVideoElement | ||
159 | } | ||
63 | 160 | ||
64 | loadLocale(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 | |||
312 | PeerTubeEmbed.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 | |||
6 | html { | ||
7 | width: 100%; | ||
8 | overflow-x: hidden; | ||
9 | overflow-y: auto; | ||
10 | } | ||
11 | |||
12 | body { | ||
13 | margin: 0; | ||
14 | padding: 0; | ||
15 | } | ||
16 | |||
17 | iframe { | ||
18 | border: none; | ||
19 | border-radius: 8px; | ||
20 | min-width: 200px; | ||
21 | width: 100%; | ||
22 | height: 100%; | ||
23 | pointer-events: none; | ||
24 | } | ||
25 | |||
26 | aside { | ||
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 | |||
51 | main { | ||
52 | padding: 0 1em; | ||
53 | display: flex; | ||
54 | align-items: flex-start; | ||
55 | } | ||
56 | |||
57 | .spacer { | ||
58 | flex: 1; | ||
59 | } | ||
60 | |||
61 | header { | ||
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 | |||
95 | fieldset { | ||
96 | border: none; | ||
97 | min-width: 8em; | ||
98 | legend { | ||
99 | border-bottom: 1px solid #ccc; | ||
100 | width: 100%; | ||
101 | } | ||
102 | } | ||
103 | |||
104 | button { | ||
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 | |||
114 | a { | ||
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 @@ | |||
1 | import './test-embed.scss' | ||
2 | import { PeerTubePlayer } from '../player/player'; | ||
3 | import { PlayerEventType } from '../player/definitions'; | ||
4 | |||
5 | window.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 | /** |