diff options
Diffstat (limited to 'client/src/assets/player')
21 files changed, 1396 insertions, 692 deletions
diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts new file mode 100644 index 000000000..022a9c16f --- /dev/null +++ b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts | |||
@@ -0,0 +1,143 @@ | |||
1 | // FIXME: something weird with our path definition in tsconfig and typings | ||
2 | // @ts-ignore | ||
3 | import * as videojs from 'video.js' | ||
4 | import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings' | ||
5 | import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' | ||
6 | import { Events } from 'p2p-media-loader-core' | ||
7 | |||
8 | // videojs-hlsjs-plugin needs videojs in window | ||
9 | window['videojs'] = videojs | ||
10 | require('@streamroot/videojs-hlsjs-plugin') | ||
11 | |||
12 | const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') | ||
13 | class P2pMediaLoaderPlugin extends Plugin { | ||
14 | |||
15 | private readonly CONSTANTS = { | ||
16 | INFO_SCHEDULER: 1000 // Don't change this | ||
17 | } | ||
18 | private readonly options: P2PMediaLoaderPluginOptions | ||
19 | |||
20 | private hlsjs: any // Don't type hlsjs to not bundle the module | ||
21 | private p2pEngine: Engine | ||
22 | private statsP2PBytes = { | ||
23 | pendingDownload: [] as number[], | ||
24 | pendingUpload: [] as number[], | ||
25 | numPeers: 0, | ||
26 | totalDownload: 0, | ||
27 | totalUpload: 0 | ||
28 | } | ||
29 | private statsHTTPBytes = { | ||
30 | pendingDownload: [] as number[], | ||
31 | pendingUpload: [] as number[], | ||
32 | totalDownload: 0, | ||
33 | totalUpload: 0 | ||
34 | } | ||
35 | |||
36 | private networkInfoInterval: any | ||
37 | |||
38 | constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) { | ||
39 | super(player, options) | ||
40 | |||
41 | this.options = options | ||
42 | |||
43 | videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => { | ||
44 | this.hlsjs = hlsjs | ||
45 | }) | ||
46 | |||
47 | initVideoJsContribHlsJsPlayer(player) | ||
48 | |||
49 | player.src({ | ||
50 | type: options.type, | ||
51 | src: options.src | ||
52 | }) | ||
53 | |||
54 | player.on('play', () => { | ||
55 | player.addClass('vjs-has-big-play-button-clicked') | ||
56 | }) | ||
57 | |||
58 | player.ready(() => this.initialize()) | ||
59 | } | ||
60 | |||
61 | dispose () { | ||
62 | if (this.hlsjs) this.hlsjs.destroy() | ||
63 | if (this.p2pEngine) this.p2pEngine.destroy() | ||
64 | |||
65 | clearInterval(this.networkInfoInterval) | ||
66 | } | ||
67 | |||
68 | private initialize () { | ||
69 | initHlsJsPlayer(this.hlsjs) | ||
70 | |||
71 | const tech = this.player.tech_ | ||
72 | this.p2pEngine = tech.options_.hlsjsConfig.loader.getEngine() | ||
73 | |||
74 | // Avoid using constants to not import hls.hs | ||
75 | // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37 | ||
76 | this.hlsjs.on('hlsLevelSwitching', (_: any, data: any) => { | ||
77 | this.trigger('resolutionChange', { auto: this.hlsjs.autoLevelEnabled, resolutionId: data.height }) | ||
78 | }) | ||
79 | |||
80 | this.p2pEngine.on(Events.SegmentError, (segment, err) => { | ||
81 | console.error('Segment error.', segment, err) | ||
82 | }) | ||
83 | |||
84 | this.statsP2PBytes.numPeers = 1 + this.options.redundancyBaseUrls.length | ||
85 | |||
86 | this.runStats() | ||
87 | } | ||
88 | |||
89 | private runStats () { | ||
90 | this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, size: number) => { | ||
91 | const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes | ||
92 | |||
93 | elem.pendingDownload.push(size) | ||
94 | elem.totalDownload += size | ||
95 | }) | ||
96 | |||
97 | this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, size: number) => { | ||
98 | const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes | ||
99 | |||
100 | elem.pendingUpload.push(size) | ||
101 | elem.totalUpload += size | ||
102 | }) | ||
103 | |||
104 | this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++) | ||
105 | this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--) | ||
106 | |||
107 | this.networkInfoInterval = setInterval(() => { | ||
108 | const p2pDownloadSpeed = this.arraySum(this.statsP2PBytes.pendingDownload) | ||
109 | const p2pUploadSpeed = this.arraySum(this.statsP2PBytes.pendingUpload) | ||
110 | |||
111 | const httpDownloadSpeed = this.arraySum(this.statsHTTPBytes.pendingDownload) | ||
112 | const httpUploadSpeed = this.arraySum(this.statsHTTPBytes.pendingUpload) | ||
113 | |||
114 | this.statsP2PBytes.pendingDownload = [] | ||
115 | this.statsP2PBytes.pendingUpload = [] | ||
116 | this.statsHTTPBytes.pendingDownload = [] | ||
117 | this.statsHTTPBytes.pendingUpload = [] | ||
118 | |||
119 | return this.player.trigger('p2pInfo', { | ||
120 | http: { | ||
121 | downloadSpeed: httpDownloadSpeed, | ||
122 | uploadSpeed: httpUploadSpeed, | ||
123 | downloaded: this.statsHTTPBytes.totalDownload, | ||
124 | uploaded: this.statsHTTPBytes.totalUpload | ||
125 | }, | ||
126 | p2p: { | ||
127 | downloadSpeed: p2pDownloadSpeed, | ||
128 | uploadSpeed: p2pUploadSpeed, | ||
129 | numPeers: this.statsP2PBytes.numPeers, | ||
130 | downloaded: this.statsP2PBytes.totalDownload, | ||
131 | uploaded: this.statsP2PBytes.totalUpload | ||
132 | } | ||
133 | } as PlayerNetworkInfo) | ||
134 | }, this.CONSTANTS.INFO_SCHEDULER) | ||
135 | } | ||
136 | |||
137 | private arraySum (data: number[]) { | ||
138 | return data.reduce((a: number, b: number) => a + b, 0) | ||
139 | } | ||
140 | } | ||
141 | |||
142 | videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin) | ||
143 | export { P2pMediaLoaderPlugin } | ||
diff --git a/client/src/assets/player/p2p-media-loader/segment-url-builder.ts b/client/src/assets/player/p2p-media-loader/segment-url-builder.ts new file mode 100644 index 000000000..32e7ce4f2 --- /dev/null +++ b/client/src/assets/player/p2p-media-loader/segment-url-builder.ts | |||
@@ -0,0 +1,28 @@ | |||
1 | import { basename } from 'path' | ||
2 | import { Segment } from 'p2p-media-loader-core' | ||
3 | |||
4 | function segmentUrlBuilderFactory (baseUrls: string[]) { | ||
5 | return function segmentBuilder (segment: Segment) { | ||
6 | const max = baseUrls.length + 1 | ||
7 | const i = getRandomInt(max) | ||
8 | |||
9 | if (i === max - 1) return segment.url | ||
10 | |||
11 | let newBaseUrl = baseUrls[i] | ||
12 | let middlePart = newBaseUrl.endsWith('/') ? '' : '/' | ||
13 | |||
14 | return newBaseUrl + middlePart + basename(segment.url) | ||
15 | } | ||
16 | } | ||
17 | |||
18 | // --------------------------------------------------------------------------- | ||
19 | |||
20 | export { | ||
21 | segmentUrlBuilderFactory | ||
22 | } | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | function getRandomInt (max: number) { | ||
27 | return Math.floor(Math.random() * Math.floor(max)) | ||
28 | } | ||
diff --git a/client/src/assets/player/p2p-media-loader/segment-validator.ts b/client/src/assets/player/p2p-media-loader/segment-validator.ts new file mode 100644 index 000000000..72c32f9e0 --- /dev/null +++ b/client/src/assets/player/p2p-media-loader/segment-validator.ts | |||
@@ -0,0 +1,63 @@ | |||
1 | import { Segment } from 'p2p-media-loader-core' | ||
2 | import { basename } from 'path' | ||
3 | |||
4 | function segmentValidatorFactory (segmentsSha256Url: string) { | ||
5 | const segmentsJSON = fetchSha256Segments(segmentsSha256Url) | ||
6 | const regex = /bytes=(\d+)-(\d+)/ | ||
7 | |||
8 | return async function segmentValidator (segment: Segment) { | ||
9 | const filename = basename(segment.url) | ||
10 | const captured = regex.exec(segment.range) | ||
11 | |||
12 | const range = captured[1] + '-' + captured[2] | ||
13 | |||
14 | const hashShouldBe = (await segmentsJSON)[filename][range] | ||
15 | if (hashShouldBe === undefined) { | ||
16 | throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) | ||
17 | } | ||
18 | |||
19 | const calculatedSha = bufferToEx(await sha256(segment.data)) | ||
20 | if (calculatedSha !== hashShouldBe) { | ||
21 | throw new Error( | ||
22 | `Hashes does not correspond for segment ${filename}/${range}` + | ||
23 | `(expected: ${hashShouldBe} instead of ${calculatedSha})` | ||
24 | ) | ||
25 | } | ||
26 | } | ||
27 | } | ||
28 | |||
29 | // --------------------------------------------------------------------------- | ||
30 | |||
31 | export { | ||
32 | segmentValidatorFactory | ||
33 | } | ||
34 | |||
35 | // --------------------------------------------------------------------------- | ||
36 | |||
37 | function fetchSha256Segments (url: string) { | ||
38 | return fetch(url) | ||
39 | .then(res => res.json()) | ||
40 | .catch(err => { | ||
41 | console.error('Cannot get sha256 segments', err) | ||
42 | return {} | ||
43 | }) | ||
44 | } | ||
45 | |||
46 | function sha256 (data?: ArrayBuffer) { | ||
47 | if (!data) return undefined | ||
48 | |||
49 | return window.crypto.subtle.digest('SHA-256', data) | ||
50 | } | ||
51 | |||
52 | // Thanks: https://stackoverflow.com/a/53307879 | ||
53 | function bufferToEx (buffer?: ArrayBuffer) { | ||
54 | if (!buffer) return '' | ||
55 | |||
56 | let s = '' | ||
57 | const h = '0123456789abcdef' | ||
58 | const o = new Uint8Array(buffer) | ||
59 | |||
60 | o.forEach((v: any) => s += h[ v >> 4 ] + h[ v & 15 ]) | ||
61 | |||
62 | return s | ||
63 | } | ||
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts new file mode 100644 index 000000000..0ba9bcb11 --- /dev/null +++ b/client/src/assets/player/peertube-player-manager.ts | |||
@@ -0,0 +1,466 @@ | |||
1 | import { VideoFile } from '../../../../shared/models/videos' | ||
2 | // @ts-ignore | ||
3 | import * as videojs from 'video.js' | ||
4 | import 'videojs-hotkeys' | ||
5 | import 'videojs-dock' | ||
6 | import 'videojs-contextmenu-ui' | ||
7 | import 'videojs-contrib-quality-levels' | ||
8 | import './peertube-plugin' | ||
9 | import './videojs-components/peertube-link-button' | ||
10 | import './videojs-components/resolution-menu-button' | ||
11 | import './videojs-components/settings-menu-button' | ||
12 | import './videojs-components/p2p-info-button' | ||
13 | import './videojs-components/peertube-load-progress-bar' | ||
14 | import './videojs-components/theater-button' | ||
15 | import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings' | ||
16 | import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig } from './utils' | ||
17 | import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' | ||
18 | import { segmentValidatorFactory } from './p2p-media-loader/segment-validator' | ||
19 | import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder' | ||
20 | |||
21 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) | ||
22 | videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' | ||
23 | // Change Captions to Subtitles/CC | ||
24 | videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC' | ||
25 | // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) | ||
26 | videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' ' | ||
27 | |||
28 | export type PlayerMode = 'webtorrent' | 'p2p-media-loader' | ||
29 | |||
30 | export type WebtorrentOptions = { | ||
31 | videoFiles: VideoFile[] | ||
32 | } | ||
33 | |||
34 | export type P2PMediaLoaderOptions = { | ||
35 | playlistUrl: string | ||
36 | segmentsSha256Url: string | ||
37 | trackerAnnounce: string[] | ||
38 | redundancyBaseUrls: string[] | ||
39 | videoFiles: VideoFile[] | ||
40 | } | ||
41 | |||
42 | export type CommonOptions = { | ||
43 | playerElement: HTMLVideoElement | ||
44 | onPlayerElementChange: (element: HTMLVideoElement) => void | ||
45 | |||
46 | autoplay: boolean | ||
47 | videoDuration: number | ||
48 | enableHotkeys: boolean | ||
49 | inactivityTimeout: number | ||
50 | poster: string | ||
51 | startTime: number | string | ||
52 | |||
53 | theaterMode: boolean | ||
54 | captions: boolean | ||
55 | peertubeLink: boolean | ||
56 | |||
57 | videoViewUrl: string | ||
58 | embedUrl: string | ||
59 | |||
60 | language?: string | ||
61 | controls?: boolean | ||
62 | muted?: boolean | ||
63 | loop?: boolean | ||
64 | subtitle?: string | ||
65 | |||
66 | videoCaptions: VideoJSCaption[] | ||
67 | |||
68 | userWatching?: UserWatching | ||
69 | |||
70 | serverUrl: string | ||
71 | } | ||
72 | |||
73 | export type PeertubePlayerManagerOptions = { | ||
74 | common: CommonOptions, | ||
75 | webtorrent: WebtorrentOptions, | ||
76 | p2pMediaLoader?: P2PMediaLoaderOptions | ||
77 | } | ||
78 | |||
79 | export class PeertubePlayerManager { | ||
80 | |||
81 | private static videojsLocaleCache: { [ path: string ]: any } = {} | ||
82 | private static playerElementClassName: string | ||
83 | |||
84 | static getServerTranslations (serverUrl: string, locale: string) { | ||
85 | const path = PeertubePlayerManager.getLocalePath(serverUrl, locale) | ||
86 | // It is the default locale, nothing to translate | ||
87 | if (!path) return Promise.resolve(undefined) | ||
88 | |||
89 | return fetch(path + '/server.json') | ||
90 | .then(res => res.json()) | ||
91 | .catch(err => { | ||
92 | console.error('Cannot get server translations', err) | ||
93 | return undefined | ||
94 | }) | ||
95 | } | ||
96 | |||
97 | static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) { | ||
98 | let p2pMediaLoader: any | ||
99 | |||
100 | this.playerElementClassName = options.common.playerElement.className | ||
101 | |||
102 | if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin') | ||
103 | if (mode === 'p2p-media-loader') { | ||
104 | [ p2pMediaLoader ] = await Promise.all([ | ||
105 | import('p2p-media-loader-hlsjs'), | ||
106 | import('./p2p-media-loader/p2p-media-loader-plugin') | ||
107 | ]) | ||
108 | } | ||
109 | |||
110 | const videojsOptions = this.getVideojsOptions(mode, options, p2pMediaLoader) | ||
111 | |||
112 | await this.loadLocaleInVideoJS(options.common.serverUrl, options.common.language) | ||
113 | |||
114 | const self = this | ||
115 | return new Promise(res => { | ||
116 | videojs(options.common.playerElement, videojsOptions, function (this: any) { | ||
117 | const player = this | ||
118 | |||
119 | player.tech_.on('error', () => { | ||
120 | // Fallback to webtorrent? | ||
121 | if (mode === 'p2p-media-loader') { | ||
122 | self.fallbackToWebTorrent(player, options) | ||
123 | } | ||
124 | }) | ||
125 | |||
126 | self.addContextMenu(mode, player, options.common.embedUrl) | ||
127 | |||
128 | return res(player) | ||
129 | }) | ||
130 | }) | ||
131 | } | ||
132 | |||
133 | private static async fallbackToWebTorrent (player: any, options: PeertubePlayerManagerOptions) { | ||
134 | const newVideoElement = document.createElement('video') | ||
135 | newVideoElement.className = this.playerElementClassName | ||
136 | |||
137 | // VideoJS wraps our video element inside a div | ||
138 | const currentParentPlayerElement = options.common.playerElement.parentNode | ||
139 | currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement) | ||
140 | |||
141 | options.common.playerElement = newVideoElement | ||
142 | options.common.onPlayerElementChange(newVideoElement) | ||
143 | |||
144 | player.dispose() | ||
145 | |||
146 | await import('./webtorrent/webtorrent-plugin') | ||
147 | |||
148 | const mode = 'webtorrent' | ||
149 | const videojsOptions = this.getVideojsOptions(mode, options) | ||
150 | |||
151 | const self = this | ||
152 | videojs(newVideoElement, videojsOptions, function (this: any) { | ||
153 | const player = this | ||
154 | |||
155 | self.addContextMenu(mode, player, options.common.embedUrl) | ||
156 | }) | ||
157 | } | ||
158 | |||
159 | private static loadLocaleInVideoJS (serverUrl: string, locale: string) { | ||
160 | const path = PeertubePlayerManager.getLocalePath(serverUrl, locale) | ||
161 | // It is the default locale, nothing to translate | ||
162 | if (!path) return Promise.resolve(undefined) | ||
163 | |||
164 | let p: Promise<any> | ||
165 | |||
166 | if (PeertubePlayerManager.videojsLocaleCache[path]) { | ||
167 | p = Promise.resolve(PeertubePlayerManager.videojsLocaleCache[path]) | ||
168 | } else { | ||
169 | p = fetch(path + '/player.json') | ||
170 | .then(res => res.json()) | ||
171 | .then(json => { | ||
172 | PeertubePlayerManager.videojsLocaleCache[path] = json | ||
173 | return json | ||
174 | }) | ||
175 | .catch(err => { | ||
176 | console.error('Cannot get player translations', err) | ||
177 | return undefined | ||
178 | }) | ||
179 | } | ||
180 | |||
181 | const completeLocale = getCompleteLocale(locale) | ||
182 | return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json)) | ||
183 | } | ||
184 | |||
185 | private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions, p2pMediaLoaderModule?: any) { | ||
186 | const commonOptions = options.common | ||
187 | const webtorrentOptions = options.webtorrent | ||
188 | const p2pMediaLoaderOptions = options.p2pMediaLoader | ||
189 | |||
190 | let autoplay = options.common.autoplay | ||
191 | let html5 = {} | ||
192 | |||
193 | const plugins: VideoJSPluginOptions = { | ||
194 | peertube: { | ||
195 | mode, | ||
196 | autoplay, // Use peertube plugin autoplay because we get the file by webtorrent | ||
197 | videoViewUrl: commonOptions.videoViewUrl, | ||
198 | videoDuration: commonOptions.videoDuration, | ||
199 | startTime: commonOptions.startTime, | ||
200 | userWatching: commonOptions.userWatching, | ||
201 | subtitle: commonOptions.subtitle, | ||
202 | videoCaptions: commonOptions.videoCaptions | ||
203 | } | ||
204 | } | ||
205 | |||
206 | if (mode === 'p2p-media-loader') { | ||
207 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { | ||
208 | redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls, | ||
209 | type: 'application/x-mpegURL', | ||
210 | src: p2pMediaLoaderOptions.playlistUrl | ||
211 | } | ||
212 | |||
213 | const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce | ||
214 | .filter(t => t.startsWith('ws')) | ||
215 | |||
216 | const p2pMediaLoaderConfig = { | ||
217 | loader: { | ||
218 | trackerAnnounce, | ||
219 | segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url), | ||
220 | rtcConfig: getRtcConfig(), | ||
221 | requiredSegmentsPriority: 5, | ||
222 | segmentUrlBuilder: segmentUrlBuilderFactory(options.p2pMediaLoader.redundancyBaseUrls) | ||
223 | }, | ||
224 | segments: { | ||
225 | swarmId: p2pMediaLoaderOptions.playlistUrl | ||
226 | } | ||
227 | } | ||
228 | const streamrootHls = { | ||
229 | levelLabelHandler: (level: { height: number, width: number }) => { | ||
230 | const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === level.height) | ||
231 | |||
232 | let label = file.resolution.label | ||
233 | if (file.fps >= 50) label += file.fps | ||
234 | |||
235 | return label | ||
236 | }, | ||
237 | html5: { | ||
238 | hlsjsConfig: { | ||
239 | liveSyncDurationCount: 7, | ||
240 | loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() | ||
241 | } | ||
242 | } | ||
243 | } | ||
244 | |||
245 | Object.assign(plugins, { p2pMediaLoader, streamrootHls }) | ||
246 | html5 = streamrootHls.html5 | ||
247 | } | ||
248 | |||
249 | if (mode === 'webtorrent') { | ||
250 | const webtorrent = { | ||
251 | autoplay, | ||
252 | videoDuration: commonOptions.videoDuration, | ||
253 | playerElement: commonOptions.playerElement, | ||
254 | videoFiles: webtorrentOptions.videoFiles | ||
255 | } | ||
256 | Object.assign(plugins, { webtorrent }) | ||
257 | |||
258 | // WebTorrent plugin handles autoplay, because we do some hackish stuff in there | ||
259 | autoplay = false | ||
260 | } | ||
261 | |||
262 | const videojsOptions = { | ||
263 | html5, | ||
264 | |||
265 | // We don't use text track settings for now | ||
266 | textTrackSettings: false, | ||
267 | controls: commonOptions.controls !== undefined ? commonOptions.controls : true, | ||
268 | loop: commonOptions.loop !== undefined ? commonOptions.loop : false, | ||
269 | |||
270 | muted: commonOptions.muted !== undefined | ||
271 | ? commonOptions.muted | ||
272 | : undefined, // Undefined so the player knows it has to check the local storage | ||
273 | |||
274 | poster: commonOptions.poster, | ||
275 | autoplay: autoplay === true ? 'any' : autoplay, // Use 'any' instead of true to get notifier by videojs if autoplay fails | ||
276 | inactivityTimeout: commonOptions.inactivityTimeout, | ||
277 | playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ], | ||
278 | plugins, | ||
279 | controlBar: { | ||
280 | children: this.getControlBarChildren(mode, { | ||
281 | captions: commonOptions.captions, | ||
282 | peertubeLink: commonOptions.peertubeLink, | ||
283 | theaterMode: commonOptions.theaterMode | ||
284 | }) | ||
285 | } | ||
286 | } | ||
287 | |||
288 | if (commonOptions.enableHotkeys === true) { | ||
289 | Object.assign(videojsOptions.plugins, { | ||
290 | hotkeys: { | ||
291 | enableVolumeScroll: false, | ||
292 | enableModifiersForNumbers: false, | ||
293 | |||
294 | fullscreenKey: function (event: KeyboardEvent) { | ||
295 | // fullscreen with the f key or Ctrl+Enter | ||
296 | return event.key === 'f' || (event.ctrlKey && event.key === 'Enter') | ||
297 | }, | ||
298 | |||
299 | seekStep: function (event: KeyboardEvent) { | ||
300 | // mimic VLC seek behavior, and default to 5 (original value is 5). | ||
301 | if (event.ctrlKey && event.altKey) { | ||
302 | return 5 * 60 | ||
303 | } else if (event.ctrlKey) { | ||
304 | return 60 | ||
305 | } else if (event.altKey) { | ||
306 | return 10 | ||
307 | } else { | ||
308 | return 5 | ||
309 | } | ||
310 | }, | ||
311 | |||
312 | customKeys: { | ||
313 | increasePlaybackRateKey: { | ||
314 | key: function (event: KeyboardEvent) { | ||
315 | return event.key === '>' | ||
316 | }, | ||
317 | handler: function (player: videojs.Player) { | ||
318 | player.playbackRate((player.playbackRate() + 0.1).toFixed(2)) | ||
319 | } | ||
320 | }, | ||
321 | decreasePlaybackRateKey: { | ||
322 | key: function (event: KeyboardEvent) { | ||
323 | return event.key === '<' | ||
324 | }, | ||
325 | handler: function (player: videojs.Player) { | ||
326 | player.playbackRate((player.playbackRate() - 0.1).toFixed(2)) | ||
327 | } | ||
328 | }, | ||
329 | frameByFrame: { | ||
330 | key: function (event: KeyboardEvent) { | ||
331 | return event.key === '.' | ||
332 | }, | ||
333 | handler: function (player: videojs.Player) { | ||
334 | player.pause() | ||
335 | // Calculate movement distance (assuming 30 fps) | ||
336 | const dist = 1 / 30 | ||
337 | player.currentTime(player.currentTime() + dist) | ||
338 | } | ||
339 | } | ||
340 | } | ||
341 | } | ||
342 | }) | ||
343 | } | ||
344 | |||
345 | if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { | ||
346 | Object.assign(videojsOptions, { language: commonOptions.language }) | ||
347 | } | ||
348 | |||
349 | return videojsOptions | ||
350 | } | ||
351 | |||
352 | private static getControlBarChildren (mode: PlayerMode, options: { | ||
353 | peertubeLink: boolean | ||
354 | theaterMode: boolean, | ||
355 | captions: boolean | ||
356 | }) { | ||
357 | const settingEntries = [] | ||
358 | const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar' | ||
359 | |||
360 | // Keep an order | ||
361 | settingEntries.push('playbackRateMenuButton') | ||
362 | if (options.captions === true) settingEntries.push('captionsButton') | ||
363 | settingEntries.push('resolutionMenuButton') | ||
364 | |||
365 | const children = { | ||
366 | 'playToggle': {}, | ||
367 | 'currentTimeDisplay': {}, | ||
368 | 'timeDivider': {}, | ||
369 | 'durationDisplay': {}, | ||
370 | 'liveDisplay': {}, | ||
371 | |||
372 | 'flexibleWidthSpacer': {}, | ||
373 | 'progressControl': { | ||
374 | children: { | ||
375 | 'seekBar': { | ||
376 | children: { | ||
377 | [loadProgressBar]: {}, | ||
378 | 'mouseTimeDisplay': {}, | ||
379 | 'playProgressBar': {} | ||
380 | } | ||
381 | } | ||
382 | } | ||
383 | }, | ||
384 | |||
385 | 'p2PInfoButton': {}, | ||
386 | |||
387 | 'muteToggle': {}, | ||
388 | 'volumeControl': {}, | ||
389 | |||
390 | 'settingsButton': { | ||
391 | setup: { | ||
392 | maxHeightOffset: 40 | ||
393 | }, | ||
394 | entries: settingEntries | ||
395 | } | ||
396 | } | ||
397 | |||
398 | if (options.peertubeLink === true) { | ||
399 | Object.assign(children, { | ||
400 | 'peerTubeLinkButton': {} | ||
401 | }) | ||
402 | } | ||
403 | |||
404 | if (options.theaterMode === true) { | ||
405 | Object.assign(children, { | ||
406 | 'theaterButton': {} | ||
407 | }) | ||
408 | } | ||
409 | |||
410 | Object.assign(children, { | ||
411 | 'fullscreenToggle': {} | ||
412 | }) | ||
413 | |||
414 | return children | ||
415 | } | ||
416 | |||
417 | private static addContextMenu (mode: PlayerMode, player: any, videoEmbedUrl: string) { | ||
418 | const content = [ | ||
419 | { | ||
420 | label: player.localize('Copy the video URL'), | ||
421 | listener: function () { | ||
422 | copyToClipboard(buildVideoLink()) | ||
423 | } | ||
424 | }, | ||
425 | { | ||
426 | label: player.localize('Copy the video URL at the current time'), | ||
427 | listener: function () { | ||
428 | const player = this as videojs.Player | ||
429 | copyToClipboard(buildVideoLink(player.currentTime())) | ||
430 | } | ||
431 | }, | ||
432 | { | ||
433 | label: player.localize('Copy embed code'), | ||
434 | listener: () => { | ||
435 | copyToClipboard(buildVideoEmbed(videoEmbedUrl)) | ||
436 | } | ||
437 | } | ||
438 | ] | ||
439 | |||
440 | if (mode === 'webtorrent') { | ||
441 | content.push({ | ||
442 | label: player.localize('Copy magnet URI'), | ||
443 | listener: function () { | ||
444 | const player = this as videojs.Player | ||
445 | copyToClipboard(player.webtorrent().getCurrentVideoFile().magnetUri) | ||
446 | } | ||
447 | }) | ||
448 | } | ||
449 | |||
450 | player.contextmenuUI({ content }) | ||
451 | } | ||
452 | |||
453 | private static getLocalePath (serverUrl: string, locale: string) { | ||
454 | const completeLocale = getCompleteLocale(locale) | ||
455 | |||
456 | if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined | ||
457 | |||
458 | return serverUrl + '/client/locales/' + completeLocale | ||
459 | } | ||
460 | } | ||
461 | |||
462 | // ############################################################################ | ||
463 | |||
464 | export { | ||
465 | videojs | ||
466 | } | ||
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts deleted file mode 100644 index e0e063838..000000000 --- a/client/src/assets/player/peertube-player.ts +++ /dev/null | |||
@@ -1,292 +0,0 @@ | |||
1 | import { VideoFile } from '../../../../shared/models/videos' | ||
2 | |||
3 | import 'videojs-hotkeys' | ||
4 | import 'videojs-dock' | ||
5 | import 'videojs-contextmenu-ui' | ||
6 | import './peertube-link-button' | ||
7 | import './resolution-menu-button' | ||
8 | import './settings-menu-button' | ||
9 | import './webtorrent-info-button' | ||
10 | import './peertube-videojs-plugin' | ||
11 | import './peertube-load-progress-bar' | ||
12 | import './theater-button' | ||
13 | import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings' | ||
14 | import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' | ||
15 | import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' | ||
16 | |||
17 | // FIXME: something weird with our path definition in tsconfig and typings | ||
18 | // @ts-ignore | ||
19 | import { Player } from 'video.js' | ||
20 | |||
21 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) | ||
22 | videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' | ||
23 | // Change Captions to Subtitles/CC | ||
24 | videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC' | ||
25 | // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) | ||
26 | videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' ' | ||
27 | |||
28 | function getVideojsOptions (options: { | ||
29 | autoplay: boolean | ||
30 | playerElement: HTMLVideoElement | ||
31 | videoViewUrl: string | ||
32 | videoDuration: number | ||
33 | videoFiles: VideoFile[] | ||
34 | enableHotkeys: boolean | ||
35 | inactivityTimeout: number | ||
36 | peertubeLink: boolean | ||
37 | poster: string | ||
38 | startTime: number | string | ||
39 | theaterMode: boolean | ||
40 | videoCaptions: VideoJSCaption[] | ||
41 | |||
42 | language?: string | ||
43 | controls?: boolean | ||
44 | muted?: boolean | ||
45 | loop?: boolean | ||
46 | subtitle?: string | ||
47 | |||
48 | userWatching?: UserWatching | ||
49 | }) { | ||
50 | const videojsOptions = { | ||
51 | // We don't use text track settings for now | ||
52 | textTrackSettings: false, | ||
53 | controls: options.controls !== undefined ? options.controls : true, | ||
54 | loop: options.loop !== undefined ? options.loop : false, | ||
55 | |||
56 | muted: options.muted !== undefined ? options.muted : undefined, // Undefined so the player knows it has to check the local storage | ||
57 | |||
58 | poster: options.poster, | ||
59 | autoplay: false, | ||
60 | inactivityTimeout: options.inactivityTimeout, | ||
61 | playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ], | ||
62 | plugins: { | ||
63 | peertube: { | ||
64 | autoplay: options.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent | ||
65 | videoCaptions: options.videoCaptions, | ||
66 | videoFiles: options.videoFiles, | ||
67 | playerElement: options.playerElement, | ||
68 | videoViewUrl: options.videoViewUrl, | ||
69 | videoDuration: options.videoDuration, | ||
70 | startTime: options.startTime, | ||
71 | userWatching: options.userWatching, | ||
72 | subtitle: options.subtitle | ||
73 | } | ||
74 | }, | ||
75 | controlBar: { | ||
76 | children: getControlBarChildren(options) | ||
77 | } | ||
78 | } | ||
79 | |||
80 | if (options.enableHotkeys === true) { | ||
81 | Object.assign(videojsOptions.plugins, { | ||
82 | hotkeys: { | ||
83 | enableVolumeScroll: false, | ||
84 | enableModifiersForNumbers: false, | ||
85 | |||
86 | fullscreenKey: function (event: KeyboardEvent) { | ||
87 | // fullscreen with the f key or Ctrl+Enter | ||
88 | return event.key === 'f' || (event.ctrlKey && event.key === 'Enter') | ||
89 | }, | ||
90 | |||
91 | seekStep: function (event: KeyboardEvent) { | ||
92 | // mimic VLC seek behavior, and default to 5 (original value is 5). | ||
93 | if (event.ctrlKey && event.altKey) { | ||
94 | return 5 * 60 | ||
95 | } else if (event.ctrlKey) { | ||
96 | return 60 | ||
97 | } else if (event.altKey) { | ||
98 | return 10 | ||
99 | } else { | ||
100 | return 5 | ||
101 | } | ||
102 | }, | ||
103 | |||
104 | customKeys: { | ||
105 | increasePlaybackRateKey: { | ||
106 | key: function (event: KeyboardEvent) { | ||
107 | return event.key === '>' | ||
108 | }, | ||
109 | handler: function (player: Player) { | ||
110 | player.playbackRate((player.playbackRate() + 0.1).toFixed(2)) | ||
111 | } | ||
112 | }, | ||
113 | decreasePlaybackRateKey: { | ||
114 | key: function (event: KeyboardEvent) { | ||
115 | return event.key === '<' | ||
116 | }, | ||
117 | handler: function (player: Player) { | ||
118 | player.playbackRate((player.playbackRate() - 0.1).toFixed(2)) | ||
119 | } | ||
120 | }, | ||
121 | frameByFrame: { | ||
122 | key: function (event: KeyboardEvent) { | ||
123 | return event.key === '.' | ||
124 | }, | ||
125 | handler: function (player: Player) { | ||
126 | player.pause() | ||
127 | // Calculate movement distance (assuming 30 fps) | ||
128 | const dist = 1 / 30 | ||
129 | player.currentTime(player.currentTime() + dist) | ||
130 | } | ||
131 | } | ||
132 | } | ||
133 | } | ||
134 | }) | ||
135 | } | ||
136 | |||
137 | if (options.language && !isDefaultLocale(options.language)) { | ||
138 | Object.assign(videojsOptions, { language: options.language }) | ||
139 | } | ||
140 | |||
141 | return videojsOptions | ||
142 | } | ||
143 | |||
144 | function getControlBarChildren (options: { | ||
145 | peertubeLink: boolean | ||
146 | theaterMode: boolean, | ||
147 | videoCaptions: VideoJSCaption[] | ||
148 | }) { | ||
149 | const settingEntries = [] | ||
150 | |||
151 | // Keep an order | ||
152 | settingEntries.push('playbackRateMenuButton') | ||
153 | if (options.videoCaptions.length !== 0) settingEntries.push('captionsButton') | ||
154 | settingEntries.push('resolutionMenuButton') | ||
155 | |||
156 | const children = { | ||
157 | 'playToggle': {}, | ||
158 | 'currentTimeDisplay': {}, | ||
159 | 'timeDivider': {}, | ||
160 | 'durationDisplay': {}, | ||
161 | 'liveDisplay': {}, | ||
162 | |||
163 | 'flexibleWidthSpacer': {}, | ||
164 | 'progressControl': { | ||
165 | children: { | ||
166 | 'seekBar': { | ||
167 | children: { | ||
168 | 'peerTubeLoadProgressBar': {}, | ||
169 | 'mouseTimeDisplay': {}, | ||
170 | 'playProgressBar': {} | ||
171 | } | ||
172 | } | ||
173 | } | ||
174 | }, | ||
175 | |||
176 | 'webTorrentButton': {}, | ||
177 | |||
178 | 'muteToggle': {}, | ||
179 | 'volumeControl': {}, | ||
180 | |||
181 | 'settingsButton': { | ||
182 | setup: { | ||
183 | maxHeightOffset: 40 | ||
184 | }, | ||
185 | entries: settingEntries | ||
186 | } | ||
187 | } | ||
188 | |||
189 | if (options.peertubeLink === true) { | ||
190 | Object.assign(children, { | ||
191 | 'peerTubeLinkButton': {} | ||
192 | }) | ||
193 | } | ||
194 | |||
195 | if (options.theaterMode === true) { | ||
196 | Object.assign(children, { | ||
197 | 'theaterButton': {} | ||
198 | }) | ||
199 | } | ||
200 | |||
201 | Object.assign(children, { | ||
202 | 'fullscreenToggle': {} | ||
203 | }) | ||
204 | |||
205 | return children | ||
206 | } | ||
207 | |||
208 | function addContextMenu (player: any, videoEmbedUrl: string) { | ||
209 | player.contextmenuUI({ | ||
210 | content: [ | ||
211 | { | ||
212 | label: player.localize('Copy the video URL'), | ||
213 | listener: function () { | ||
214 | copyToClipboard(buildVideoLink()) | ||
215 | } | ||
216 | }, | ||
217 | { | ||
218 | label: player.localize('Copy the video URL at the current time'), | ||
219 | listener: function () { | ||
220 | const player = this as Player | ||
221 | copyToClipboard(buildVideoLink(player.currentTime())) | ||
222 | } | ||
223 | }, | ||
224 | { | ||
225 | label: player.localize('Copy embed code'), | ||
226 | listener: () => { | ||
227 | copyToClipboard(buildVideoEmbed(videoEmbedUrl)) | ||
228 | } | ||
229 | }, | ||
230 | { | ||
231 | label: player.localize('Copy magnet URI'), | ||
232 | listener: function () { | ||
233 | const player = this as Player | ||
234 | copyToClipboard(player.peertube().getCurrentVideoFile().magnetUri) | ||
235 | } | ||
236 | } | ||
237 | ] | ||
238 | }) | ||
239 | } | ||
240 | |||
241 | function loadLocaleInVideoJS (serverUrl: string, videojs: any, locale: string) { | ||
242 | const path = getLocalePath(serverUrl, locale) | ||
243 | // It is the default locale, nothing to translate | ||
244 | if (!path) return Promise.resolve(undefined) | ||
245 | |||
246 | let p: Promise<any> | ||
247 | |||
248 | if (loadLocaleInVideoJS.cache[path]) { | ||
249 | p = Promise.resolve(loadLocaleInVideoJS.cache[path]) | ||
250 | } else { | ||
251 | p = fetch(path + '/player.json') | ||
252 | .then(res => res.json()) | ||
253 | .then(json => { | ||
254 | loadLocaleInVideoJS.cache[path] = json | ||
255 | return json | ||
256 | }) | ||
257 | } | ||
258 | |||
259 | const completeLocale = getCompleteLocale(locale) | ||
260 | return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json)) | ||
261 | } | ||
262 | namespace loadLocaleInVideoJS { | ||
263 | export const cache: { [ path: string ]: any } = {} | ||
264 | } | ||
265 | |||
266 | function getServerTranslations (serverUrl: string, locale: string) { | ||
267 | const path = getLocalePath(serverUrl, locale) | ||
268 | // It is the default locale, nothing to translate | ||
269 | if (!path) return Promise.resolve(undefined) | ||
270 | |||
271 | return fetch(path + '/server.json') | ||
272 | .then(res => res.json()) | ||
273 | } | ||
274 | |||
275 | // ############################################################################ | ||
276 | |||
277 | export { | ||
278 | getServerTranslations, | ||
279 | loadLocaleInVideoJS, | ||
280 | getVideojsOptions, | ||
281 | addContextMenu | ||
282 | } | ||
283 | |||
284 | // ############################################################################ | ||
285 | |||
286 | function getLocalePath (serverUrl: string, locale: string) { | ||
287 | const completeLocale = getCompleteLocale(locale) | ||
288 | |||
289 | if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined | ||
290 | |||
291 | return serverUrl + '/client/locales/' + completeLocale | ||
292 | } | ||
diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts new file mode 100644 index 000000000..7ea4a06d4 --- /dev/null +++ b/client/src/assets/player/peertube-plugin.ts | |||
@@ -0,0 +1,262 @@ | |||
1 | // FIXME: something weird with our path definition in tsconfig and typings | ||
2 | // @ts-ignore | ||
3 | import * as videojs from 'video.js' | ||
4 | import './videojs-components/settings-menu-button' | ||
5 | import { | ||
6 | PeerTubePluginOptions, | ||
7 | ResolutionUpdateData, | ||
8 | UserWatching, | ||
9 | VideoJSCaption, | ||
10 | VideoJSComponentInterface, | ||
11 | videojsUntyped | ||
12 | } from './peertube-videojs-typings' | ||
13 | import { isMobile, timeToInt } from './utils' | ||
14 | import { | ||
15 | getStoredLastSubtitle, | ||
16 | getStoredMute, | ||
17 | getStoredVolume, | ||
18 | saveLastSubtitle, | ||
19 | saveMuteInStore, | ||
20 | saveVolumeInStore | ||
21 | } from './peertube-player-local-storage' | ||
22 | |||
23 | const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') | ||
24 | class PeerTubePlugin extends Plugin { | ||
25 | private readonly autoplay: boolean = false | ||
26 | private readonly startTime: number = 0 | ||
27 | private readonly videoViewUrl: string | ||
28 | private readonly videoDuration: number | ||
29 | private readonly CONSTANTS = { | ||
30 | USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video | ||
31 | } | ||
32 | |||
33 | private player: any | ||
34 | private videoCaptions: VideoJSCaption[] | ||
35 | private defaultSubtitle: string | ||
36 | |||
37 | private videoViewInterval: any | ||
38 | private userWatchingVideoInterval: any | ||
39 | private qualityObservationTimer: any | ||
40 | private lastResolutionChange: ResolutionUpdateData | ||
41 | |||
42 | constructor (player: videojs.Player, options: PeerTubePluginOptions) { | ||
43 | super(player, options) | ||
44 | |||
45 | this.startTime = timeToInt(options.startTime) | ||
46 | this.videoViewUrl = options.videoViewUrl | ||
47 | this.videoDuration = options.videoDuration | ||
48 | this.videoCaptions = options.videoCaptions | ||
49 | |||
50 | if (options.autoplay === true) this.player.addClass('vjs-has-autoplay') | ||
51 | |||
52 | this.player.on('autoplay-failure', () => { | ||
53 | this.player.removeClass('vjs-has-autoplay') | ||
54 | }) | ||
55 | |||
56 | this.player.ready(() => { | ||
57 | const playerOptions = this.player.options_ | ||
58 | |||
59 | if (options.mode === 'webtorrent') { | ||
60 | this.player.webtorrent().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d)) | ||
61 | this.player.webtorrent().on('autoResolutionChange', (_: any, d: any) => this.trigger('autoResolutionChange', d)) | ||
62 | } | ||
63 | |||
64 | if (options.mode === 'p2p-media-loader') { | ||
65 | this.player.p2pMediaLoader().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d)) | ||
66 | } | ||
67 | |||
68 | this.player.tech_.on('loadedqualitydata', () => { | ||
69 | setTimeout(() => { | ||
70 | // Replay a resolution change, now we loaded all quality data | ||
71 | if (this.lastResolutionChange) this.handleResolutionChange(this.lastResolutionChange) | ||
72 | }, 0) | ||
73 | }) | ||
74 | |||
75 | const volume = getStoredVolume() | ||
76 | if (volume !== undefined) this.player.volume(volume) | ||
77 | |||
78 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() | ||
79 | if (muted !== undefined) this.player.muted(muted) | ||
80 | |||
81 | this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() | ||
82 | |||
83 | this.player.on('volumechange', () => { | ||
84 | saveVolumeInStore(this.player.volume()) | ||
85 | saveMuteInStore(this.player.muted()) | ||
86 | }) | ||
87 | |||
88 | this.player.textTracks().on('change', () => { | ||
89 | const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { | ||
90 | return t.kind === 'captions' && t.mode === 'showing' | ||
91 | }) | ||
92 | |||
93 | if (!showing) { | ||
94 | saveLastSubtitle('off') | ||
95 | return | ||
96 | } | ||
97 | |||
98 | saveLastSubtitle(showing.language) | ||
99 | }) | ||
100 | |||
101 | this.player.on('sourcechange', () => this.initCaptions()) | ||
102 | |||
103 | this.player.duration(options.videoDuration) | ||
104 | |||
105 | this.initializePlayer() | ||
106 | this.runViewAdd() | ||
107 | |||
108 | if (options.userWatching) this.runUserWatchVideo(options.userWatching) | ||
109 | }) | ||
110 | } | ||
111 | |||
112 | dispose () { | ||
113 | clearTimeout(this.qualityObservationTimer) | ||
114 | |||
115 | clearInterval(this.videoViewInterval) | ||
116 | |||
117 | if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) | ||
118 | } | ||
119 | |||
120 | private initializePlayer () { | ||
121 | if (isMobile()) this.player.addClass('vjs-is-mobile') | ||
122 | |||
123 | this.initSmoothProgressBar() | ||
124 | |||
125 | this.initCaptions() | ||
126 | |||
127 | this.alterInactivity() | ||
128 | } | ||
129 | |||
130 | private runViewAdd () { | ||
131 | this.clearVideoViewInterval() | ||
132 | |||
133 | // After 30 seconds (or 3/4 of the video), add a view to the video | ||
134 | let minSecondsToView = 30 | ||
135 | |||
136 | if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4 | ||
137 | |||
138 | let secondsViewed = 0 | ||
139 | this.videoViewInterval = setInterval(() => { | ||
140 | if (this.player && !this.player.paused()) { | ||
141 | secondsViewed += 1 | ||
142 | |||
143 | if (secondsViewed > minSecondsToView) { | ||
144 | this.clearVideoViewInterval() | ||
145 | |||
146 | this.addViewToVideo().catch(err => console.error(err)) | ||
147 | } | ||
148 | } | ||
149 | }, 1000) | ||
150 | } | ||
151 | |||
152 | private runUserWatchVideo (options: UserWatching) { | ||
153 | let lastCurrentTime = 0 | ||
154 | |||
155 | this.userWatchingVideoInterval = setInterval(() => { | ||
156 | const currentTime = Math.floor(this.player.currentTime()) | ||
157 | |||
158 | if (currentTime - lastCurrentTime >= 1) { | ||
159 | lastCurrentTime = currentTime | ||
160 | |||
161 | this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader) | ||
162 | .catch(err => console.error('Cannot notify user is watching.', err)) | ||
163 | } | ||
164 | }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL) | ||
165 | } | ||
166 | |||
167 | private clearVideoViewInterval () { | ||
168 | if (this.videoViewInterval !== undefined) { | ||
169 | clearInterval(this.videoViewInterval) | ||
170 | this.videoViewInterval = undefined | ||
171 | } | ||
172 | } | ||
173 | |||
174 | private addViewToVideo () { | ||
175 | if (!this.videoViewUrl) return Promise.resolve(undefined) | ||
176 | |||
177 | return fetch(this.videoViewUrl, { method: 'POST' }) | ||
178 | } | ||
179 | |||
180 | private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) { | ||
181 | const body = new URLSearchParams() | ||
182 | body.append('currentTime', currentTime.toString()) | ||
183 | |||
184 | const headers = new Headers({ 'Authorization': authorizationHeader }) | ||
185 | |||
186 | return fetch(url, { method: 'PUT', body, headers }) | ||
187 | } | ||
188 | |||
189 | private handleResolutionChange (data: ResolutionUpdateData) { | ||
190 | this.lastResolutionChange = data | ||
191 | |||
192 | const qualityLevels = this.player.qualityLevels() | ||
193 | |||
194 | for (let i = 0; i < qualityLevels.length; i++) { | ||
195 | if (qualityLevels[i].height === data.resolutionId) { | ||
196 | data.id = qualityLevels[i].id | ||
197 | break | ||
198 | } | ||
199 | } | ||
200 | |||
201 | this.trigger('resolutionChange', data) | ||
202 | } | ||
203 | |||
204 | private alterInactivity () { | ||
205 | let saveInactivityTimeout: number | ||
206 | |||
207 | const disableInactivity = () => { | ||
208 | saveInactivityTimeout = this.player.options_.inactivityTimeout | ||
209 | this.player.options_.inactivityTimeout = 0 | ||
210 | } | ||
211 | const enableInactivity = () => { | ||
212 | this.player.options_.inactivityTimeout = saveInactivityTimeout | ||
213 | } | ||
214 | |||
215 | const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog') | ||
216 | |||
217 | this.player.controlBar.on('mouseenter', () => disableInactivity()) | ||
218 | settingsDialog.on('mouseenter', () => disableInactivity()) | ||
219 | this.player.controlBar.on('mouseleave', () => enableInactivity()) | ||
220 | settingsDialog.on('mouseleave', () => enableInactivity()) | ||
221 | } | ||
222 | |||
223 | private initCaptions () { | ||
224 | for (const caption of this.videoCaptions) { | ||
225 | this.player.addRemoteTextTrack({ | ||
226 | kind: 'captions', | ||
227 | label: caption.label, | ||
228 | language: caption.language, | ||
229 | id: caption.language, | ||
230 | src: caption.src, | ||
231 | default: this.defaultSubtitle === caption.language | ||
232 | }, false) | ||
233 | } | ||
234 | |||
235 | this.player.trigger('captionsChanged') | ||
236 | } | ||
237 | |||
238 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 | ||
239 | private initSmoothProgressBar () { | ||
240 | const SeekBar = videojsUntyped.getComponent('SeekBar') | ||
241 | SeekBar.prototype.getPercent = function getPercent () { | ||
242 | // Allows for smooth scrubbing, when player can't keep up. | ||
243 | // const time = (this.player_.scrubbing()) ? | ||
244 | // this.player_.getCache().currentTime : | ||
245 | // this.player_.currentTime() | ||
246 | const time = this.player_.currentTime() | ||
247 | const percent = time / this.player_.duration() | ||
248 | return percent >= 1 ? 1 : percent | ||
249 | } | ||
250 | SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) { | ||
251 | let newTime = this.calculateDistance(event) * this.player_.duration() | ||
252 | if (newTime === this.player_.duration()) { | ||
253 | newTime = newTime - 0.1 | ||
254 | } | ||
255 | this.player_.currentTime(newTime) | ||
256 | this.update() | ||
257 | } | ||
258 | } | ||
259 | } | ||
260 | |||
261 | videojs.registerPlugin('peertube', PeerTubePlugin) | ||
262 | export { PeerTubePlugin } | ||
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 634c7fdc9..79a5a6c4d 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts | |||
@@ -3,11 +3,16 @@ | |||
3 | import * as videojs from 'video.js' | 3 | import * as videojs from 'video.js' |
4 | 4 | ||
5 | import { VideoFile } from '../../../../shared/models/videos/video.model' | 5 | import { VideoFile } from '../../../../shared/models/videos/video.model' |
6 | import { PeerTubePlugin } from './peertube-videojs-plugin' | 6 | import { PeerTubePlugin } from './peertube-plugin' |
7 | import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin' | ||
8 | import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin' | ||
9 | import { PlayerMode } from './peertube-player-manager' | ||
7 | 10 | ||
8 | declare namespace videojs { | 11 | declare namespace videojs { |
9 | interface Player { | 12 | interface Player { |
10 | peertube (): PeerTubePlugin | 13 | peertube (): PeerTubePlugin |
14 | webtorrent (): WebTorrentPlugin | ||
15 | p2pMediaLoader (): P2pMediaLoaderPlugin | ||
11 | } | 16 | } |
12 | } | 17 | } |
13 | 18 | ||
@@ -30,26 +35,95 @@ type UserWatching = { | |||
30 | authorizationHeader: string | 35 | authorizationHeader: string |
31 | } | 36 | } |
32 | 37 | ||
33 | type PeertubePluginOptions = { | 38 | type PeerTubePluginOptions = { |
34 | videoFiles: VideoFile[] | 39 | mode: PlayerMode |
35 | playerElement: HTMLVideoElement | 40 | |
41 | autoplay: boolean | ||
36 | videoViewUrl: string | 42 | videoViewUrl: string |
37 | videoDuration: number | 43 | videoDuration: number |
38 | startTime: number | string | 44 | startTime: number | string |
39 | autoplay: boolean, | ||
40 | videoCaptions: VideoJSCaption[] | ||
41 | 45 | ||
42 | subtitle?: string | ||
43 | userWatching?: UserWatching | 46 | userWatching?: UserWatching |
47 | subtitle?: string | ||
48 | |||
49 | videoCaptions: VideoJSCaption[] | ||
50 | } | ||
51 | |||
52 | type WebtorrentPluginOptions = { | ||
53 | playerElement: HTMLVideoElement | ||
54 | |||
55 | autoplay: boolean | ||
56 | videoDuration: number | ||
57 | |||
58 | videoFiles: VideoFile[] | ||
59 | } | ||
60 | |||
61 | type P2PMediaLoaderPluginOptions = { | ||
62 | redundancyBaseUrls: string[] | ||
63 | type: string | ||
64 | src: string | ||
65 | } | ||
66 | |||
67 | type VideoJSPluginOptions = { | ||
68 | peertube: PeerTubePluginOptions | ||
69 | |||
70 | webtorrent?: WebtorrentPluginOptions | ||
71 | |||
72 | p2pMediaLoader?: P2PMediaLoaderPluginOptions | ||
44 | } | 73 | } |
45 | 74 | ||
46 | // videojs typings don't have some method we need | 75 | // videojs typings don't have some method we need |
47 | const videojsUntyped = videojs as any | 76 | const videojsUntyped = videojs as any |
48 | 77 | ||
78 | type LoadedQualityData = { | ||
79 | qualitySwitchCallback: Function, | ||
80 | qualityData: { | ||
81 | video: { | ||
82 | id: number | ||
83 | label: string | ||
84 | selected: boolean | ||
85 | }[] | ||
86 | } | ||
87 | } | ||
88 | |||
89 | type ResolutionUpdateData = { | ||
90 | auto: boolean, | ||
91 | resolutionId: number | ||
92 | id?: number | ||
93 | } | ||
94 | |||
95 | type AutoResolutionUpdateData = { | ||
96 | possible: boolean | ||
97 | } | ||
98 | |||
99 | type PlayerNetworkInfo = { | ||
100 | http: { | ||
101 | downloadSpeed: number | ||
102 | uploadSpeed: number | ||
103 | downloaded: number | ||
104 | uploaded: number | ||
105 | } | ||
106 | |||
107 | p2p: { | ||
108 | downloadSpeed: number | ||
109 | uploadSpeed: number | ||
110 | downloaded: number | ||
111 | uploaded: number | ||
112 | numPeers: number | ||
113 | } | ||
114 | } | ||
115 | |||
49 | export { | 116 | export { |
117 | PlayerNetworkInfo, | ||
118 | ResolutionUpdateData, | ||
119 | AutoResolutionUpdateData, | ||
50 | VideoJSComponentInterface, | 120 | VideoJSComponentInterface, |
51 | PeertubePluginOptions, | ||
52 | videojsUntyped, | 121 | videojsUntyped, |
53 | VideoJSCaption, | 122 | VideoJSCaption, |
54 | UserWatching | 123 | UserWatching, |
124 | PeerTubePluginOptions, | ||
125 | WebtorrentPluginOptions, | ||
126 | P2PMediaLoaderPluginOptions, | ||
127 | VideoJSPluginOptions, | ||
128 | LoadedQualityData | ||
55 | } | 129 | } |
diff --git a/client/src/assets/player/resolution-menu-button.ts b/client/src/assets/player/resolution-menu-button.ts deleted file mode 100644 index a3c1108ca..000000000 --- a/client/src/assets/player/resolution-menu-button.ts +++ /dev/null | |||
@@ -1,88 +0,0 @@ | |||
1 | // FIXME: something weird with our path definition in tsconfig and typings | ||
2 | // @ts-ignore | ||
3 | import { Player } from 'video.js' | ||
4 | |||
5 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | ||
6 | import { ResolutionMenuItem } from './resolution-menu-item' | ||
7 | |||
8 | const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') | ||
9 | const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') | ||
10 | class ResolutionMenuButton extends MenuButton { | ||
11 | label: HTMLElement | ||
12 | |||
13 | constructor (player: Player, options: any) { | ||
14 | super(player, options) | ||
15 | this.player = player | ||
16 | |||
17 | player.peertube().on('videoFileUpdate', () => this.updateLabel()) | ||
18 | player.peertube().on('autoResolutionUpdate', () => this.updateLabel()) | ||
19 | } | ||
20 | |||
21 | createEl () { | ||
22 | const el = super.createEl() | ||
23 | |||
24 | this.labelEl_ = videojsUntyped.dom.createEl('div', { | ||
25 | className: 'vjs-resolution-value', | ||
26 | innerHTML: this.buildLabelHTML() | ||
27 | }) | ||
28 | |||
29 | el.appendChild(this.labelEl_) | ||
30 | |||
31 | return el | ||
32 | } | ||
33 | |||
34 | updateARIAAttributes () { | ||
35 | this.el().setAttribute('aria-label', 'Quality') | ||
36 | } | ||
37 | |||
38 | createMenu () { | ||
39 | const menu = new Menu(this.player_) | ||
40 | for (const videoFile of this.player_.peertube().videoFiles) { | ||
41 | let label = videoFile.resolution.label | ||
42 | if (videoFile.fps && videoFile.fps >= 50) { | ||
43 | label += videoFile.fps | ||
44 | } | ||
45 | |||
46 | menu.addChild(new ResolutionMenuItem( | ||
47 | this.player_, | ||
48 | { | ||
49 | id: videoFile.resolution.id, | ||
50 | label, | ||
51 | src: videoFile.magnetUri | ||
52 | }) | ||
53 | ) | ||
54 | } | ||
55 | |||
56 | menu.addChild(new ResolutionMenuItem( | ||
57 | this.player_, | ||
58 | { | ||
59 | id: -1, | ||
60 | label: this.player_.localize('Auto'), | ||
61 | src: null | ||
62 | } | ||
63 | )) | ||
64 | |||
65 | return menu | ||
66 | } | ||
67 | |||
68 | updateLabel () { | ||
69 | if (!this.labelEl_) return | ||
70 | |||
71 | this.labelEl_.innerHTML = this.buildLabelHTML() | ||
72 | } | ||
73 | |||
74 | buildCSSClass () { | ||
75 | return super.buildCSSClass() + ' vjs-resolution-button' | ||
76 | } | ||
77 | |||
78 | buildWrapperCSSClass () { | ||
79 | return 'vjs-resolution-control ' + super.buildWrapperCSSClass() | ||
80 | } | ||
81 | |||
82 | private buildLabelHTML () { | ||
83 | return this.player_.peertube().getCurrentResolutionLabel() | ||
84 | } | ||
85 | } | ||
86 | ResolutionMenuButton.prototype.controlText_ = 'Quality' | ||
87 | |||
88 | MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) | ||
diff --git a/client/src/assets/player/resolution-menu-item.ts b/client/src/assets/player/resolution-menu-item.ts deleted file mode 100644 index b54fd91ef..000000000 --- a/client/src/assets/player/resolution-menu-item.ts +++ /dev/null | |||
@@ -1,67 +0,0 @@ | |||
1 | // FIXME: something weird with our path definition in tsconfig and typings | ||
2 | // @ts-ignore | ||
3 | import { Player } from 'video.js' | ||
4 | |||
5 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | ||
6 | |||
7 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') | ||
8 | class ResolutionMenuItem extends MenuItem { | ||
9 | |||
10 | constructor (player: Player, options: any) { | ||
11 | const currentResolutionId = player.peertube().getCurrentResolutionId() | ||
12 | options.selectable = true | ||
13 | options.selected = options.id === currentResolutionId | ||
14 | |||
15 | super(player, options) | ||
16 | |||
17 | this.label = options.label | ||
18 | this.id = options.id | ||
19 | |||
20 | player.peertube().on('videoFileUpdate', () => this.updateSelection()) | ||
21 | player.peertube().on('autoResolutionUpdate', () => this.updateSelection()) | ||
22 | } | ||
23 | |||
24 | handleClick (event: any) { | ||
25 | if (this.id === -1 && this.player_.peertube().isAutoResolutionForbidden()) return | ||
26 | |||
27 | super.handleClick(event) | ||
28 | |||
29 | // Auto resolution | ||
30 | if (this.id === -1) { | ||
31 | this.player_.peertube().enableAutoResolution() | ||
32 | return | ||
33 | } | ||
34 | |||
35 | this.player_.peertube().disableAutoResolution() | ||
36 | this.player_.peertube().updateResolution(this.id) | ||
37 | } | ||
38 | |||
39 | updateSelection () { | ||
40 | // Check if auto resolution is forbidden or not | ||
41 | if (this.id === -1) { | ||
42 | if (this.player_.peertube().isAutoResolutionForbidden()) { | ||
43 | this.addClass('disabled') | ||
44 | } else { | ||
45 | this.removeClass('disabled') | ||
46 | } | ||
47 | } | ||
48 | |||
49 | if (this.player_.peertube().isAutoResolutionOn()) { | ||
50 | this.selected(this.id === -1) | ||
51 | return | ||
52 | } | ||
53 | |||
54 | this.selected(this.player_.peertube().getCurrentResolutionId() === this.id) | ||
55 | } | ||
56 | |||
57 | getLabel () { | ||
58 | if (this.id === -1) { | ||
59 | return this.label + ' <small>' + this.player_.peertube().getCurrentResolutionLabel() + '</small>' | ||
60 | } | ||
61 | |||
62 | return this.label | ||
63 | } | ||
64 | } | ||
65 | MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) | ||
66 | |||
67 | export { ResolutionMenuItem } | ||
diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index 8b9f34b99..8d87567c2 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts | |||
@@ -112,9 +112,23 @@ function videoFileMinByResolution (files: VideoFile[]) { | |||
112 | return min | 112 | return min |
113 | } | 113 | } |
114 | 114 | ||
115 | function getRtcConfig () { | ||
116 | return { | ||
117 | iceServers: [ | ||
118 | { | ||
119 | urls: 'stun:stun.stunprotocol.org' | ||
120 | }, | ||
121 | { | ||
122 | urls: 'stun:stun.framasoft.org' | ||
123 | } | ||
124 | ] | ||
125 | } | ||
126 | } | ||
127 | |||
115 | // --------------------------------------------------------------------------- | 128 | // --------------------------------------------------------------------------- |
116 | 129 | ||
117 | export { | 130 | export { |
131 | getRtcConfig, | ||
118 | toTitleCase, | 132 | toTitleCase, |
119 | timeToInt, | 133 | timeToInt, |
120 | buildVideoLink, | 134 | buildVideoLink, |
diff --git a/client/src/assets/player/webtorrent-info-button.ts b/client/src/assets/player/videojs-components/p2p-info-button.ts index c3c1af951..6424787b2 100644 --- a/client/src/assets/player/webtorrent-info-button.ts +++ b/client/src/assets/player/videojs-components/p2p-info-button.ts | |||
@@ -1,8 +1,8 @@ | |||
1 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | 1 | import { PlayerNetworkInfo, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' |
2 | import { bytes } from './utils' | 2 | import { bytes } from '../utils' |
3 | 3 | ||
4 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | 4 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') |
5 | class WebtorrentInfoButton extends Button { | 5 | class P2pInfoButton extends Button { |
6 | 6 | ||
7 | createEl () { | 7 | createEl () { |
8 | const div = videojsUntyped.dom.createEl('div', { | 8 | const div = videojsUntyped.dom.createEl('div', { |
@@ -65,7 +65,7 @@ class WebtorrentInfoButton extends Button { | |||
65 | subDivHttp.appendChild(subDivHttpText) | 65 | subDivHttp.appendChild(subDivHttpText) |
66 | div.appendChild(subDivHttp) | 66 | div.appendChild(subDivHttp) |
67 | 67 | ||
68 | this.player_.peertube().on('torrentInfo', (event: any, data: any) => { | 68 | this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { |
69 | // We are in HTTP fallback | 69 | // We are in HTTP fallback |
70 | if (!data) { | 70 | if (!data) { |
71 | subDivHttp.className = 'vjs-peertube-displayed' | 71 | subDivHttp.className = 'vjs-peertube-displayed' |
@@ -74,11 +74,14 @@ class WebtorrentInfoButton extends Button { | |||
74 | return | 74 | return |
75 | } | 75 | } |
76 | 76 | ||
77 | const downloadSpeed = bytes(data.downloadSpeed) | 77 | const p2pStats = data.p2p |
78 | const uploadSpeed = bytes(data.uploadSpeed) | 78 | const httpStats = data.http |
79 | const totalDownloaded = bytes(data.downloaded) | 79 | |
80 | const totalUploaded = bytes(data.uploaded) | 80 | const downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed) |
81 | const numPeers = data.numPeers | 81 | const uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed) |
82 | const totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded) | ||
83 | const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded) | ||
84 | const numPeers = p2pStats.numPeers | ||
82 | 85 | ||
83 | subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + | 86 | subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + |
84 | this.player_.localize('Total uploaded: ' + totalUploaded.join(' ')) | 87 | this.player_.localize('Total uploaded: ' + totalUploaded.join(' ')) |
@@ -90,7 +93,7 @@ class WebtorrentInfoButton extends Button { | |||
90 | uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] | 93 | uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] |
91 | 94 | ||
92 | peersNumber.textContent = numPeers | 95 | peersNumber.textContent = numPeers |
93 | peersText.textContent = ' ' + this.player_.localize('peers') | 96 | peersText.textContent = ' ' + (numPeers > 1 ? this.player_.localize('peers') : this.player_.localize('peer')) |
94 | 97 | ||
95 | subDivHttp.className = 'vjs-peertube-hidden' | 98 | subDivHttp.className = 'vjs-peertube-hidden' |
96 | subDivWebtorrent.className = 'vjs-peertube-displayed' | 99 | subDivWebtorrent.className = 'vjs-peertube-displayed' |
@@ -99,4 +102,4 @@ class WebtorrentInfoButton extends Button { | |||
99 | return div | 102 | return div |
100 | } | 103 | } |
101 | } | 104 | } |
102 | Button.registerComponent('WebTorrentButton', WebtorrentInfoButton) | 105 | Button.registerComponent('P2PInfoButton', P2pInfoButton) |
diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/videojs-components/peertube-link-button.ts index de9a49de9..fed8ea33e 100644 --- a/client/src/assets/player/peertube-link-button.ts +++ b/client/src/assets/player/videojs-components/peertube-link-button.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | 1 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' |
2 | import { buildVideoLink } from './utils' | 2 | import { buildVideoLink } from '../utils' |
3 | // FIXME: something weird with our path definition in tsconfig and typings | 3 | // FIXME: something weird with our path definition in tsconfig and typings |
4 | // @ts-ignore | 4 | // @ts-ignore |
5 | import { Player } from 'video.js' | 5 | import { Player } from 'video.js' |
diff --git a/client/src/assets/player/peertube-load-progress-bar.ts b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts index af276d1b2..9a0e3b550 100644 --- a/client/src/assets/player/peertube-load-progress-bar.ts +++ b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | 1 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' |
2 | // FIXME: something weird with our path definition in tsconfig and typings | 2 | // FIXME: something weird with our path definition in tsconfig and typings |
3 | // @ts-ignore | 3 | // @ts-ignore |
4 | import { Player } from 'video.js' | 4 | import { Player } from 'video.js' |
@@ -27,7 +27,7 @@ class PeerTubeLoadProgressBar extends Component { | |||
27 | } | 27 | } |
28 | 28 | ||
29 | update () { | 29 | update () { |
30 | const torrent = this.player().peertube().getTorrent() | 30 | const torrent = this.player().webtorrent().getTorrent() |
31 | if (!torrent) return | 31 | if (!torrent) return |
32 | 32 | ||
33 | this.el_.style.width = (torrent.progress * 100) + '%' | 33 | this.el_.style.width = (torrent.progress * 100) + '%' |
diff --git a/client/src/assets/player/videojs-components/resolution-menu-button.ts b/client/src/assets/player/videojs-components/resolution-menu-button.ts new file mode 100644 index 000000000..abcc16411 --- /dev/null +++ b/client/src/assets/player/videojs-components/resolution-menu-button.ts | |||
@@ -0,0 +1,109 @@ | |||
1 | // FIXME: something weird with our path definition in tsconfig and typings | ||
2 | // @ts-ignore | ||
3 | import { Player } from 'video.js' | ||
4 | |||
5 | import { LoadedQualityData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
6 | import { ResolutionMenuItem } from './resolution-menu-item' | ||
7 | |||
8 | const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') | ||
9 | const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') | ||
10 | class ResolutionMenuButton extends MenuButton { | ||
11 | label: HTMLElement | ||
12 | |||
13 | constructor (player: Player, options: any) { | ||
14 | super(player, options) | ||
15 | this.player = player | ||
16 | |||
17 | player.tech_.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data)) | ||
18 | |||
19 | player.peertube().on('resolutionChange', () => setTimeout(() => this.trigger('updateLabel'), 0)) | ||
20 | } | ||
21 | |||
22 | createEl () { | ||
23 | const el = super.createEl() | ||
24 | |||
25 | this.labelEl_ = videojsUntyped.dom.createEl('div', { | ||
26 | className: 'vjs-resolution-value' | ||
27 | }) | ||
28 | |||
29 | el.appendChild(this.labelEl_) | ||
30 | |||
31 | return el | ||
32 | } | ||
33 | |||
34 | updateARIAAttributes () { | ||
35 | this.el().setAttribute('aria-label', 'Quality') | ||
36 | } | ||
37 | |||
38 | createMenu () { | ||
39 | return new Menu(this.player_) | ||
40 | } | ||
41 | |||
42 | buildCSSClass () { | ||
43 | return super.buildCSSClass() + ' vjs-resolution-button' | ||
44 | } | ||
45 | |||
46 | buildWrapperCSSClass () { | ||
47 | return 'vjs-resolution-control ' + super.buildWrapperCSSClass() | ||
48 | } | ||
49 | |||
50 | private addClickListener (component: any) { | ||
51 | component.on('click', () => { | ||
52 | let children = this.menu.children() | ||
53 | |||
54 | for (const child of children) { | ||
55 | if (component !== child) { | ||
56 | child.selected(false) | ||
57 | } | ||
58 | } | ||
59 | }) | ||
60 | } | ||
61 | |||
62 | private buildQualities (data: LoadedQualityData) { | ||
63 | // The automatic resolution item will need other labels | ||
64 | const labels: { [ id: number ]: string } = {} | ||
65 | |||
66 | data.qualityData.video.sort((a, b) => { | ||
67 | if (a.id > b.id) return -1 | ||
68 | if (a.id === b.id) return 0 | ||
69 | return 1 | ||
70 | }) | ||
71 | |||
72 | for (const d of data.qualityData.video) { | ||
73 | // Skip auto resolution, we'll add it ourselves | ||
74 | if (d.id === -1) continue | ||
75 | |||
76 | this.menu.addChild(new ResolutionMenuItem( | ||
77 | this.player_, | ||
78 | { | ||
79 | id: d.id, | ||
80 | label: d.label, | ||
81 | selected: d.selected, | ||
82 | callback: data.qualitySwitchCallback | ||
83 | }) | ||
84 | ) | ||
85 | |||
86 | labels[d.id] = d.label | ||
87 | } | ||
88 | |||
89 | this.menu.addChild(new ResolutionMenuItem( | ||
90 | this.player_, | ||
91 | { | ||
92 | id: -1, | ||
93 | label: this.player_.localize('Auto'), | ||
94 | labels, | ||
95 | callback: data.qualitySwitchCallback, | ||
96 | selected: true // By default, in auto mode | ||
97 | } | ||
98 | )) | ||
99 | |||
100 | for (const m of this.menu.children()) { | ||
101 | this.addClickListener(m) | ||
102 | } | ||
103 | |||
104 | this.trigger('menuChanged') | ||
105 | } | ||
106 | } | ||
107 | ResolutionMenuButton.prototype.controlText_ = 'Quality' | ||
108 | |||
109 | MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) | ||
diff --git a/client/src/assets/player/videojs-components/resolution-menu-item.ts b/client/src/assets/player/videojs-components/resolution-menu-item.ts new file mode 100644 index 000000000..6c42fefd2 --- /dev/null +++ b/client/src/assets/player/videojs-components/resolution-menu-item.ts | |||
@@ -0,0 +1,83 @@ | |||
1 | // FIXME: something weird with our path definition in tsconfig and typings | ||
2 | // @ts-ignore | ||
3 | import { Player } from 'video.js' | ||
4 | |||
5 | import { AutoResolutionUpdateData, ResolutionUpdateData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
6 | |||
7 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') | ||
8 | class ResolutionMenuItem extends MenuItem { | ||
9 | private readonly id: number | ||
10 | private readonly label: string | ||
11 | // Only used for the automatic item | ||
12 | private readonly labels: { [id: number]: string } | ||
13 | private readonly callback: Function | ||
14 | |||
15 | private autoResolutionPossible: boolean | ||
16 | private currentResolutionLabel: string | ||
17 | |||
18 | constructor (player: Player, options: any) { | ||
19 | options.selectable = true | ||
20 | |||
21 | super(player, options) | ||
22 | |||
23 | this.autoResolutionPossible = true | ||
24 | this.currentResolutionLabel = '' | ||
25 | |||
26 | this.label = options.label | ||
27 | this.labels = options.labels | ||
28 | this.id = options.id | ||
29 | this.callback = options.callback | ||
30 | |||
31 | player.peertube().on('resolutionChange', (_: any, data: ResolutionUpdateData) => this.updateSelection(data)) | ||
32 | |||
33 | // We only want to disable the "Auto" item | ||
34 | if (this.id === -1) { | ||
35 | player.peertube().on('autoResolutionChange', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data)) | ||
36 | } | ||
37 | } | ||
38 | |||
39 | handleClick (event: any) { | ||
40 | // Auto button disabled? | ||
41 | if (this.autoResolutionPossible === false && this.id === -1) return | ||
42 | |||
43 | super.handleClick(event) | ||
44 | |||
45 | this.callback(this.id, 'video') | ||
46 | } | ||
47 | |||
48 | updateSelection (data: ResolutionUpdateData) { | ||
49 | if (this.id === -1) { | ||
50 | this.currentResolutionLabel = this.labels[data.id] | ||
51 | } | ||
52 | |||
53 | // Automatic resolution only | ||
54 | if (data.auto === true) { | ||
55 | this.selected(this.id === -1) | ||
56 | return | ||
57 | } | ||
58 | |||
59 | this.selected(this.id === data.id) | ||
60 | } | ||
61 | |||
62 | updateAutoResolution (data: AutoResolutionUpdateData) { | ||
63 | // Check if the auto resolution is enabled or not | ||
64 | if (data.possible === false) { | ||
65 | this.addClass('disabled') | ||
66 | } else { | ||
67 | this.removeClass('disabled') | ||
68 | } | ||
69 | |||
70 | this.autoResolutionPossible = data.possible | ||
71 | } | ||
72 | |||
73 | getLabel () { | ||
74 | if (this.id === -1) { | ||
75 | return this.label + ' <small>' + this.currentResolutionLabel + '</small>' | ||
76 | } | ||
77 | |||
78 | return this.label | ||
79 | } | ||
80 | } | ||
81 | MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) | ||
82 | |||
83 | export { ResolutionMenuItem } | ||
diff --git a/client/src/assets/player/settings-menu-button.ts b/client/src/assets/player/videojs-components/settings-menu-button.ts index a7aefdcc3..14cb8ba43 100644 --- a/client/src/assets/player/settings-menu-button.ts +++ b/client/src/assets/player/videojs-components/settings-menu-button.ts | |||
@@ -6,8 +6,8 @@ | |||
6 | import * as videojs from 'video.js' | 6 | import * as videojs from 'video.js' |
7 | 7 | ||
8 | import { SettingsMenuItem } from './settings-menu-item' | 8 | import { SettingsMenuItem } from './settings-menu-item' |
9 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | 9 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' |
10 | import { toTitleCase } from './utils' | 10 | import { toTitleCase } from '../utils' |
11 | 11 | ||
12 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | 12 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') |
13 | const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') | 13 | const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') |
diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/videojs-components/settings-menu-item.ts index 698f4627a..f14959f9c 100644 --- a/client/src/assets/player/settings-menu-item.ts +++ b/client/src/assets/player/videojs-components/settings-menu-item.ts | |||
@@ -5,8 +5,8 @@ | |||
5 | // @ts-ignore | 5 | // @ts-ignore |
6 | import * as videojs from 'video.js' | 6 | import * as videojs from 'video.js' |
7 | 7 | ||
8 | import { toTitleCase } from './utils' | 8 | import { toTitleCase } from '../utils' |
9 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | 9 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' |
10 | 10 | ||
11 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') | 11 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') |
12 | const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') | 12 | const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') |
@@ -48,6 +48,19 @@ class SettingsMenuItem extends MenuItem { | |||
48 | // Update on rate change | 48 | // Update on rate change |
49 | player.on('ratechange', this.submenuClickHandler) | 49 | player.on('ratechange', this.submenuClickHandler) |
50 | 50 | ||
51 | if (subMenuName === 'CaptionsButton') { | ||
52 | // Hack to regenerate captions on HTTP fallback | ||
53 | player.on('captionsChanged', () => { | ||
54 | setTimeout(() => { | ||
55 | this.settingsSubMenuEl_.innerHTML = '' | ||
56 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) | ||
57 | this.update() | ||
58 | this.bindClickEvents() | ||
59 | |||
60 | }, 0) | ||
61 | }) | ||
62 | } | ||
63 | |||
51 | this.reset() | 64 | this.reset() |
52 | }, 0) | 65 | }, 0) |
53 | }) | 66 | }) |
@@ -207,12 +220,14 @@ class SettingsMenuItem extends MenuItem { | |||
207 | } | 220 | } |
208 | 221 | ||
209 | build () { | 222 | build () { |
210 | const saveUpdateLabel = this.subMenu.updateLabel | 223 | this.subMenu.on('updateLabel', () => { |
211 | this.subMenu.updateLabel = () => { | ||
212 | this.update() | 224 | this.update() |
213 | 225 | }) | |
214 | saveUpdateLabel.call(this.subMenu) | 226 | this.subMenu.on('menuChanged', () => { |
215 | } | 227 | this.bindClickEvents() |
228 | this.setSize() | ||
229 | this.update() | ||
230 | }) | ||
216 | 231 | ||
217 | this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_) | 232 | this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_) |
218 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) | 233 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) |
@@ -220,7 +235,7 @@ class SettingsMenuItem extends MenuItem { | |||
220 | this.update() | 235 | this.update() |
221 | 236 | ||
222 | this.createBackButton() | 237 | this.createBackButton() |
223 | this.getSize() | 238 | this.setSize() |
224 | this.bindClickEvents() | 239 | this.bindClickEvents() |
225 | 240 | ||
226 | // prefixed event listeners for CSS TransitionEnd | 241 | // prefixed event listeners for CSS TransitionEnd |
@@ -282,8 +297,9 @@ class SettingsMenuItem extends MenuItem { | |||
282 | 297 | ||
283 | // save size of submenus on first init | 298 | // save size of submenus on first init |
284 | // if number of submenu items change dynamically more logic will be needed | 299 | // if number of submenu items change dynamically more logic will be needed |
285 | getSize () { | 300 | setSize () { |
286 | this.dialog.removeClass('vjs-hidden') | 301 | this.dialog.removeClass('vjs-hidden') |
302 | videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
287 | this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) | 303 | this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) |
288 | this.setMargin() | 304 | this.setMargin() |
289 | this.dialog.addClass('vjs-hidden') | 305 | this.dialog.addClass('vjs-hidden') |
diff --git a/client/src/assets/player/theater-button.ts b/client/src/assets/player/videojs-components/theater-button.ts index 4f8fede3d..1e11a9546 100644 --- a/client/src/assets/player/theater-button.ts +++ b/client/src/assets/player/videojs-components/theater-button.ts | |||
@@ -2,8 +2,8 @@ | |||
2 | // @ts-ignore | 2 | // @ts-ignore |
3 | import * as videojs from 'video.js' | 3 | import * as videojs from 'video.js' |
4 | 4 | ||
5 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | 5 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' |
6 | import { saveTheaterInStore, getStoredTheater } from './peertube-player-local-storage' | 6 | import { saveTheaterInStore, getStoredTheater } from '../peertube-player-local-storage' |
7 | 7 | ||
8 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | 8 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') |
9 | class TheaterButton extends Button { | 9 | class TheaterButton extends Button { |
diff --git a/client/src/assets/player/peertube-chunk-store.ts b/client/src/assets/player/webtorrent/peertube-chunk-store.ts index 54cc0ea64..54cc0ea64 100644 --- a/client/src/assets/player/peertube-chunk-store.ts +++ b/client/src/assets/player/webtorrent/peertube-chunk-store.ts | |||
diff --git a/client/src/assets/player/video-renderer.ts b/client/src/assets/player/webtorrent/video-renderer.ts index a3415937b..a3415937b 100644 --- a/client/src/assets/player/video-renderer.ts +++ b/client/src/assets/player/webtorrent/video-renderer.ts | |||
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts index 4a280b7ef..c69bf31fa 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts | |||
@@ -3,23 +3,18 @@ | |||
3 | import * as videojs from 'video.js' | 3 | import * as videojs from 'video.js' |
4 | 4 | ||
5 | import * as WebTorrent from 'webtorrent' | 5 | import * as WebTorrent from 'webtorrent' |
6 | import { VideoFile } from '../../../../shared/models/videos/video.model' | 6 | import { VideoFile } from '../../../../../shared/models/videos/video.model' |
7 | import { renderVideo } from './video-renderer' | 7 | import { renderVideo } from './video-renderer' |
8 | import './settings-menu-button' | 8 | import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings' |
9 | import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | 9 | import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../utils' |
10 | import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' | ||
11 | import { PeertubeChunkStore } from './peertube-chunk-store' | 10 | import { PeertubeChunkStore } from './peertube-chunk-store' |
12 | import { | 11 | import { |
13 | getAverageBandwidthInStore, | 12 | getAverageBandwidthInStore, |
14 | getStoredLastSubtitle, | ||
15 | getStoredMute, | 13 | getStoredMute, |
16 | getStoredVolume, | 14 | getStoredVolume, |
17 | getStoredWebTorrentEnabled, | 15 | getStoredWebTorrentEnabled, |
18 | saveAverageBandwidth, | 16 | saveAverageBandwidth |
19 | saveLastSubtitle, | 17 | } from '../peertube-player-local-storage' |
20 | saveMuteInStore, | ||
21 | saveVolumeInStore | ||
22 | } from './peertube-player-local-storage' | ||
23 | 18 | ||
24 | const CacheChunkStore = require('cache-chunk-store') | 19 | const CacheChunkStore = require('cache-chunk-store') |
25 | 20 | ||
@@ -30,14 +25,13 @@ type PlayOptions = { | |||
30 | } | 25 | } |
31 | 26 | ||
32 | const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') | 27 | const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') |
33 | class PeerTubePlugin extends Plugin { | 28 | class WebTorrentPlugin extends Plugin { |
34 | private readonly playerElement: HTMLVideoElement | 29 | private readonly playerElement: HTMLVideoElement |
35 | 30 | ||
36 | private readonly autoplay: boolean = false | 31 | private readonly autoplay: boolean = false |
37 | private readonly startTime: number = 0 | 32 | private readonly startTime: number = 0 |
38 | private readonly savePlayerSrcFunction: Function | 33 | private readonly savePlayerSrcFunction: Function |
39 | private readonly videoFiles: VideoFile[] | 34 | private readonly videoFiles: VideoFile[] |
40 | private readonly videoViewUrl: string | ||
41 | private readonly videoDuration: number | 35 | private readonly videoDuration: number |
42 | private readonly CONSTANTS = { | 36 | private readonly CONSTANTS = { |
43 | INFO_SCHEDULER: 1000, // Don't change this | 37 | INFO_SCHEDULER: 1000, // Don't change this |
@@ -45,22 +39,12 @@ class PeerTubePlugin extends Plugin { | |||
45 | AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it | 39 | AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it |
46 | AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check | 40 | AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check |
47 | AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds | 41 | AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds |
48 | BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth | 42 | BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth |
49 | USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video | ||
50 | } | 43 | } |
51 | 44 | ||
52 | private readonly webtorrent = new WebTorrent({ | 45 | private readonly webtorrent = new WebTorrent({ |
53 | tracker: { | 46 | tracker: { |
54 | rtcConfig: { | 47 | rtcConfig: getRtcConfig() |
55 | iceServers: [ | ||
56 | { | ||
57 | urls: 'stun:stun.stunprotocol.org' | ||
58 | }, | ||
59 | { | ||
60 | urls: 'stun:stun.framasoft.org' | ||
61 | } | ||
62 | ] | ||
63 | } | ||
64 | }, | 48 | }, |
65 | dht: false | 49 | dht: false |
66 | }) | 50 | }) |
@@ -68,46 +52,37 @@ class PeerTubePlugin extends Plugin { | |||
68 | private player: any | 52 | private player: any |
69 | private currentVideoFile: VideoFile | 53 | private currentVideoFile: VideoFile |
70 | private torrent: WebTorrent.Torrent | 54 | private torrent: WebTorrent.Torrent |
71 | private videoCaptions: VideoJSCaption[] | ||
72 | private defaultSubtitle: string | ||
73 | 55 | ||
74 | private renderer: any | 56 | private renderer: any |
75 | private fakeRenderer: any | 57 | private fakeRenderer: any |
76 | private destroyingFakeRenderer = false | 58 | private destroyingFakeRenderer = false |
77 | 59 | ||
78 | private autoResolution = true | 60 | private autoResolution = true |
79 | private forbidAutoResolution = false | 61 | private autoResolutionPossible = true |
80 | private isAutoResolutionObservation = false | 62 | private isAutoResolutionObservation = false |
81 | private playerRefusedP2P = false | 63 | private playerRefusedP2P = false |
82 | 64 | ||
83 | private videoViewInterval: any | ||
84 | private torrentInfoInterval: any | 65 | private torrentInfoInterval: any |
85 | private autoQualityInterval: any | 66 | private autoQualityInterval: any |
86 | private userWatchingVideoInterval: any | ||
87 | private addTorrentDelay: any | 67 | private addTorrentDelay: any |
88 | private qualityObservationTimer: any | 68 | private qualityObservationTimer: any |
89 | private runAutoQualitySchedulerTimer: any | 69 | private runAutoQualitySchedulerTimer: any |
90 | 70 | ||
91 | private downloadSpeeds: number[] = [] | 71 | private downloadSpeeds: number[] = [] |
92 | 72 | ||
93 | constructor (player: videojs.Player, options: PeertubePluginOptions) { | 73 | constructor (player: videojs.Player, options: WebtorrentPluginOptions) { |
94 | super(player, options) | 74 | super(player, options) |
95 | 75 | ||
96 | // Disable auto play on iOS | 76 | // Disable auto play on iOS |
97 | this.autoplay = options.autoplay && this.isIOS() === false | 77 | this.autoplay = options.autoplay && this.isIOS() === false |
98 | this.playerRefusedP2P = !getStoredWebTorrentEnabled() | 78 | this.playerRefusedP2P = !getStoredWebTorrentEnabled() |
99 | 79 | ||
100 | this.startTime = timeToInt(options.startTime) | ||
101 | this.videoFiles = options.videoFiles | 80 | this.videoFiles = options.videoFiles |
102 | this.videoViewUrl = options.videoViewUrl | ||
103 | this.videoDuration = options.videoDuration | 81 | this.videoDuration = options.videoDuration |
104 | this.videoCaptions = options.videoCaptions | ||
105 | 82 | ||
106 | this.savePlayerSrcFunction = this.player.src | 83 | this.savePlayerSrcFunction = this.player.src |
107 | this.playerElement = options.playerElement | 84 | this.playerElement = options.playerElement |
108 | 85 | ||
109 | if (this.autoplay === true) this.player.addClass('vjs-has-autoplay') | ||
110 | |||
111 | this.player.ready(() => { | 86 | this.player.ready(() => { |
112 | const playerOptions = this.player.options_ | 87 | const playerOptions = this.player.options_ |
113 | 88 | ||
@@ -117,33 +92,10 @@ class PeerTubePlugin extends Plugin { | |||
117 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() | 92 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() |
118 | if (muted !== undefined) this.player.muted(muted) | 93 | if (muted !== undefined) this.player.muted(muted) |
119 | 94 | ||
120 | this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() | ||
121 | |||
122 | this.player.on('volumechange', () => { | ||
123 | saveVolumeInStore(this.player.volume()) | ||
124 | saveMuteInStore(this.player.muted()) | ||
125 | }) | ||
126 | |||
127 | this.player.textTracks().on('change', () => { | ||
128 | const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { | ||
129 | return t.kind === 'captions' && t.mode === 'showing' | ||
130 | }) | ||
131 | |||
132 | if (!showing) { | ||
133 | saveLastSubtitle('off') | ||
134 | return | ||
135 | } | ||
136 | |||
137 | saveLastSubtitle(showing.language) | ||
138 | }) | ||
139 | |||
140 | this.player.duration(options.videoDuration) | 95 | this.player.duration(options.videoDuration) |
141 | 96 | ||
142 | this.initializePlayer() | 97 | this.initializePlayer() |
143 | this.runTorrentInfoScheduler() | 98 | this.runTorrentInfoScheduler() |
144 | this.runViewAdd() | ||
145 | |||
146 | if (options.userWatching) this.runUserWatchVideo(options.userWatching) | ||
147 | 99 | ||
148 | this.player.one('play', () => { | 100 | this.player.one('play', () => { |
149 | // Don't run immediately scheduler, wait some seconds the TCP connections are made | 101 | // Don't run immediately scheduler, wait some seconds the TCP connections are made |
@@ -157,12 +109,9 @@ class PeerTubePlugin extends Plugin { | |||
157 | clearTimeout(this.qualityObservationTimer) | 109 | clearTimeout(this.qualityObservationTimer) |
158 | clearTimeout(this.runAutoQualitySchedulerTimer) | 110 | clearTimeout(this.runAutoQualitySchedulerTimer) |
159 | 111 | ||
160 | clearInterval(this.videoViewInterval) | ||
161 | clearInterval(this.torrentInfoInterval) | 112 | clearInterval(this.torrentInfoInterval) |
162 | clearInterval(this.autoQualityInterval) | 113 | clearInterval(this.autoQualityInterval) |
163 | 114 | ||
164 | if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) | ||
165 | |||
166 | // Don't need to destroy renderer, video player will be destroyed | 115 | // Don't need to destroy renderer, video player will be destroyed |
167 | this.flushVideoFile(this.currentVideoFile, false) | 116 | this.flushVideoFile(this.currentVideoFile, false) |
168 | 117 | ||
@@ -173,13 +122,6 @@ class PeerTubePlugin extends Plugin { | |||
173 | return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 | 122 | return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 |
174 | } | 123 | } |
175 | 124 | ||
176 | getCurrentResolutionLabel () { | ||
177 | if (!this.currentVideoFile) return '' | ||
178 | |||
179 | const fps = this.currentVideoFile.fps >= 50 ? this.currentVideoFile.fps : '' | ||
180 | return this.currentVideoFile.resolution.label + fps | ||
181 | } | ||
182 | |||
183 | updateVideoFile ( | 125 | updateVideoFile ( |
184 | videoFile?: VideoFile, | 126 | videoFile?: VideoFile, |
185 | options: { | 127 | options: { |
@@ -228,7 +170,8 @@ class PeerTubePlugin extends Plugin { | |||
228 | return done() | 170 | return done() |
229 | }) | 171 | }) |
230 | 172 | ||
231 | this.trigger('videoFileUpdate') | 173 | this.changeQuality() |
174 | this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id }) | ||
232 | } | 175 | } |
233 | 176 | ||
234 | updateResolution (resolutionId: number, delay = 0) { | 177 | updateResolution (resolutionId: number, delay = 0) { |
@@ -262,28 +205,17 @@ class PeerTubePlugin extends Plugin { | |||
262 | } | 205 | } |
263 | } | 206 | } |
264 | 207 | ||
265 | isAutoResolutionOn () { | ||
266 | return this.autoResolution | ||
267 | } | ||
268 | |||
269 | enableAutoResolution () { | 208 | enableAutoResolution () { |
270 | this.autoResolution = true | 209 | this.autoResolution = true |
271 | this.trigger('autoResolutionUpdate') | 210 | this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) |
272 | } | 211 | } |
273 | 212 | ||
274 | disableAutoResolution (forbid = false) { | 213 | disableAutoResolution (forbid = false) { |
275 | if (forbid === true) this.forbidAutoResolution = true | 214 | if (forbid === true) this.autoResolutionPossible = false |
276 | 215 | ||
277 | this.autoResolution = false | 216 | this.autoResolution = false |
278 | this.trigger('autoResolutionUpdate') | 217 | this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible }) |
279 | } | 218 | this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) |
280 | |||
281 | isAutoResolutionForbidden () { | ||
282 | return this.forbidAutoResolution === true | ||
283 | } | ||
284 | |||
285 | getCurrentVideoFile () { | ||
286 | return this.currentVideoFile | ||
287 | } | 219 | } |
288 | 220 | ||
289 | getTorrent () { | 221 | getTorrent () { |
@@ -462,13 +394,7 @@ class PeerTubePlugin extends Plugin { | |||
462 | } | 394 | } |
463 | 395 | ||
464 | private initializePlayer () { | 396 | private initializePlayer () { |
465 | if (isMobile()) this.player.addClass('vjs-is-mobile') | 397 | this.buildQualities() |
466 | |||
467 | this.initSmoothProgressBar() | ||
468 | |||
469 | this.initCaptions() | ||
470 | |||
471 | this.alterInactivity() | ||
472 | 398 | ||
473 | if (this.autoplay === true) { | 399 | if (this.autoplay === true) { |
474 | this.player.posterImage.hide() | 400 | this.player.posterImage.hide() |
@@ -491,7 +417,7 @@ class PeerTubePlugin extends Plugin { | |||
491 | 417 | ||
492 | // Not initialized or in HTTP fallback | 418 | // Not initialized or in HTTP fallback |
493 | if (this.torrent === undefined || this.torrent === null) return | 419 | if (this.torrent === undefined || this.torrent === null) return |
494 | if (this.isAutoResolutionOn() === false) return | 420 | if (this.autoResolution === false) return |
495 | if (this.isAutoResolutionObservation === true) return | 421 | if (this.isAutoResolutionObservation === true) return |
496 | 422 | ||
497 | const file = this.getAppropriateFile() | 423 | const file = this.getAppropriateFile() |
@@ -531,78 +457,27 @@ class PeerTubePlugin extends Plugin { | |||
531 | if (this.torrent === undefined) return | 457 | if (this.torrent === undefined) return |
532 | 458 | ||
533 | // Http fallback | 459 | // Http fallback |
534 | if (this.torrent === null) return this.trigger('torrentInfo', false) | 460 | if (this.torrent === null) return this.player.trigger('p2pInfo', false) |
535 | 461 | ||
536 | // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too | 462 | // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too |
537 | if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) | 463 | if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) |
538 | 464 | ||
539 | return this.trigger('torrentInfo', { | 465 | return this.player.trigger('p2pInfo', { |
540 | downloadSpeed: this.torrent.downloadSpeed, | 466 | http: { |
541 | numPeers: this.torrent.numPeers, | 467 | downloadSpeed: 0, |
542 | uploadSpeed: this.torrent.uploadSpeed, | 468 | uploadSpeed: 0, |
543 | downloaded: this.torrent.downloaded, | 469 | downloaded: 0, |
544 | uploaded: this.torrent.uploaded | 470 | uploaded: 0 |
545 | }) | 471 | }, |
546 | }, this.CONSTANTS.INFO_SCHEDULER) | 472 | p2p: { |
547 | } | 473 | downloadSpeed: this.torrent.downloadSpeed, |
548 | 474 | numPeers: this.torrent.numPeers, | |
549 | private runViewAdd () { | 475 | uploadSpeed: this.torrent.uploadSpeed, |
550 | this.clearVideoViewInterval() | 476 | downloaded: this.torrent.downloaded, |
551 | 477 | uploaded: this.torrent.uploaded | |
552 | // After 30 seconds (or 3/4 of the video), add a view to the video | ||
553 | let minSecondsToView = 30 | ||
554 | |||
555 | if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4 | ||
556 | |||
557 | let secondsViewed = 0 | ||
558 | this.videoViewInterval = setInterval(() => { | ||
559 | if (this.player && !this.player.paused()) { | ||
560 | secondsViewed += 1 | ||
561 | |||
562 | if (secondsViewed > minSecondsToView) { | ||
563 | this.clearVideoViewInterval() | ||
564 | |||
565 | this.addViewToVideo().catch(err => console.error(err)) | ||
566 | } | 478 | } |
567 | } | 479 | } as PlayerNetworkInfo) |
568 | }, 1000) | 480 | }, this.CONSTANTS.INFO_SCHEDULER) |
569 | } | ||
570 | |||
571 | private runUserWatchVideo (options: UserWatching) { | ||
572 | let lastCurrentTime = 0 | ||
573 | |||
574 | this.userWatchingVideoInterval = setInterval(() => { | ||
575 | const currentTime = Math.floor(this.player.currentTime()) | ||
576 | |||
577 | if (currentTime - lastCurrentTime >= 1) { | ||
578 | lastCurrentTime = currentTime | ||
579 | |||
580 | this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader) | ||
581 | .catch(err => console.error('Cannot notify user is watching.', err)) | ||
582 | } | ||
583 | }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL) | ||
584 | } | ||
585 | |||
586 | private clearVideoViewInterval () { | ||
587 | if (this.videoViewInterval !== undefined) { | ||
588 | clearInterval(this.videoViewInterval) | ||
589 | this.videoViewInterval = undefined | ||
590 | } | ||
591 | } | ||
592 | |||
593 | private addViewToVideo () { | ||
594 | if (!this.videoViewUrl) return Promise.resolve(undefined) | ||
595 | |||
596 | return fetch(this.videoViewUrl, { method: 'POST' }) | ||
597 | } | ||
598 | |||
599 | private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) { | ||
600 | const body = new URLSearchParams() | ||
601 | body.append('currentTime', currentTime.toString()) | ||
602 | |||
603 | const headers = new Headers({ 'Authorization': authorizationHeader }) | ||
604 | |||
605 | return fetch(url, { method: 'PUT', body, headers }) | ||
606 | } | 481 | } |
607 | 482 | ||
608 | private fallbackToHttp (options: PlayOptions, done?: Function) { | 483 | private fallbackToHttp (options: PlayOptions, done?: Function) { |
@@ -620,6 +495,11 @@ class PeerTubePlugin extends Plugin { | |||
620 | this.player.src = this.savePlayerSrcFunction | 495 | this.player.src = this.savePlayerSrcFunction |
621 | this.player.src(httpUrl) | 496 | this.player.src(httpUrl) |
622 | 497 | ||
498 | this.changeQuality() | ||
499 | |||
500 | // We changed the source, so reinit captions | ||
501 | this.player.trigger('sourcechange') | ||
502 | |||
623 | return this.tryToPlay(err => { | 503 | return this.tryToPlay(err => { |
624 | if (err && done) return done(err) | 504 | if (err && done) return done(err) |
625 | 505 | ||
@@ -646,25 +526,6 @@ class PeerTubePlugin extends Plugin { | |||
646 | return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform) | 526 | return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform) |
647 | } | 527 | } |
648 | 528 | ||
649 | private alterInactivity () { | ||
650 | let saveInactivityTimeout: number | ||
651 | |||
652 | const disableInactivity = () => { | ||
653 | saveInactivityTimeout = this.player.options_.inactivityTimeout | ||
654 | this.player.options_.inactivityTimeout = 0 | ||
655 | } | ||
656 | const enableInactivity = () => { | ||
657 | this.player.options_.inactivityTimeout = saveInactivityTimeout | ||
658 | } | ||
659 | |||
660 | const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog') | ||
661 | |||
662 | this.player.controlBar.on('mouseenter', () => disableInactivity()) | ||
663 | settingsDialog.on('mouseenter', () => disableInactivity()) | ||
664 | this.player.controlBar.on('mouseleave', () => enableInactivity()) | ||
665 | settingsDialog.on('mouseleave', () => enableInactivity()) | ||
666 | } | ||
667 | |||
668 | private pickAverageVideoFile () { | 529 | private pickAverageVideoFile () { |
669 | if (this.videoFiles.length === 1) return this.videoFiles[0] | 530 | if (this.videoFiles.length === 1) return this.videoFiles[0] |
670 | 531 | ||
@@ -709,41 +570,70 @@ class PeerTubePlugin extends Plugin { | |||
709 | } | 570 | } |
710 | } | 571 | } |
711 | 572 | ||
712 | private initCaptions () { | 573 | private buildQualities () { |
713 | for (const caption of this.videoCaptions) { | 574 | const qualityLevelsPayload = [] |
714 | this.player.addRemoteTextTrack({ | 575 | |
715 | kind: 'captions', | 576 | for (const file of this.videoFiles) { |
716 | label: caption.label, | 577 | const representation = { |
717 | language: caption.language, | 578 | id: file.resolution.id, |
718 | id: caption.language, | 579 | label: this.buildQualityLabel(file), |
719 | src: caption.src, | 580 | height: file.resolution.id, |
720 | default: this.defaultSubtitle === caption.language | 581 | _enabled: true |
721 | }, false) | 582 | } |
583 | |||
584 | this.player.qualityLevels().addQualityLevel(representation) | ||
585 | |||
586 | qualityLevelsPayload.push({ | ||
587 | id: representation.id, | ||
588 | label: representation.label, | ||
589 | selected: false | ||
590 | }) | ||
591 | } | ||
592 | |||
593 | const payload: LoadedQualityData = { | ||
594 | qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d), | ||
595 | qualityData: { | ||
596 | video: qualityLevelsPayload | ||
597 | } | ||
722 | } | 598 | } |
599 | this.player.tech_.trigger('loadedqualitydata', payload) | ||
723 | } | 600 | } |
724 | 601 | ||
725 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 | 602 | private buildQualityLabel (file: VideoFile) { |
726 | private initSmoothProgressBar () { | 603 | let label = file.resolution.label |
727 | const SeekBar = videojsUntyped.getComponent('SeekBar') | 604 | |
728 | SeekBar.prototype.getPercent = function getPercent () { | 605 | if (file.fps && file.fps >= 50) { |
729 | // Allows for smooth scrubbing, when player can't keep up. | 606 | label += file.fps |
730 | // const time = (this.player_.scrubbing()) ? | ||
731 | // this.player_.getCache().currentTime : | ||
732 | // this.player_.currentTime() | ||
733 | const time = this.player_.currentTime() | ||
734 | const percent = time / this.player_.duration() | ||
735 | return percent >= 1 ? 1 : percent | ||
736 | } | 607 | } |
737 | SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) { | 608 | |
738 | let newTime = this.calculateDistance(event) * this.player_.duration() | 609 | return label |
739 | if (newTime === this.player_.duration()) { | 610 | } |
740 | newTime = newTime - 0.1 | 611 | |
741 | } | 612 | private qualitySwitchCallback (id: number) { |
742 | this.player_.currentTime(newTime) | 613 | if (id === -1) { |
743 | this.update() | 614 | if (this.autoResolutionPossible === true) this.enableAutoResolution() |
615 | return | ||
616 | } | ||
617 | |||
618 | this.disableAutoResolution() | ||
619 | this.updateResolution(id) | ||
620 | } | ||
621 | |||
622 | private changeQuality () { | ||
623 | const resolutionId = this.currentVideoFile.resolution.id | ||
624 | const qualityLevels = this.player.qualityLevels() | ||
625 | |||
626 | if (resolutionId === -1) { | ||
627 | qualityLevels.selectedIndex = -1 | ||
628 | return | ||
629 | } | ||
630 | |||
631 | for (let i = 0; i < qualityLevels; i++) { | ||
632 | const q = this.player.qualityLevels[i] | ||
633 | if (q.height === resolutionId) qualityLevels.selectedIndex = i | ||
744 | } | 634 | } |
745 | } | 635 | } |
746 | } | 636 | } |
747 | 637 | ||
748 | videojs.registerPlugin('peertube', PeerTubePlugin) | 638 | videojs.registerPlugin('webtorrent', WebTorrentPlugin) |
749 | export { PeerTubePlugin } | 639 | export { WebTorrentPlugin } |