|
@@ -24,9 +24,15 @@ import Speedy from 'speedy-vision';
|
24
|
24
|
import { SpeedyMedia } from 'speedy-vision/types/core/speedy-media';
|
25
|
25
|
import { SpeedyPromise } from 'speedy-vision/types/core/speedy-promise';
|
26
|
26
|
import { Utils, Nullable } from '../utils/utils';
|
27
|
|
-import { IllegalOperationError, NotSupportedError } from '../utils/errors';
|
|
27
|
+import { IllegalOperationError, NotSupportedError, TimeoutError } from '../utils/errors';
|
28
|
28
|
import { Source } from './source';
|
29
|
29
|
|
|
30
|
+/** A message to be displayed if a video can't autoplay and user interaction is required */
|
|
31
|
+const ALERT_MESSAGE = 'Tap on the screen to start';
|
|
32
|
+
|
|
33
|
+/** Whether or not we have displayed the ALERT_MESSAGE */
|
|
34
|
+let displayedAlertMessage = false;
|
|
35
|
+
|
30
|
36
|
|
31
|
37
|
/**
|
32
|
38
|
* HTMLVideoElement-based source of data
|
|
@@ -94,11 +100,10 @@ export class VideoSource implements Source
|
94
|
100
|
*/
|
95
|
101
|
_init(): SpeedyPromise<void>
|
96
|
102
|
{
|
97
|
|
- this._handleBrowserQuirks(this._video);
|
98
|
|
-
|
99
|
103
|
return Speedy.load(this._video).then(media => {
|
100
|
104
|
Utils.log(`Source of data is a ${media.width}x${media.height} ${this._type}`);
|
101
|
105
|
this._media = media;
|
|
106
|
+ return this._handleBrowserQuirks(this._video).then(() => void(0));
|
102
|
107
|
});
|
103
|
108
|
}
|
104
|
109
|
|
|
@@ -118,10 +123,11 @@ export class VideoSource implements Source
|
118
|
123
|
|
119
|
124
|
/**
|
120
|
125
|
* Handle browser-specific quirks for <video> elements
|
121
|
|
- * @param video
|
|
126
|
+ * @param video a video element
|
|
127
|
+ * @returns a promise that resolves to the input video
|
122
|
128
|
* @internal
|
123
|
129
|
*/
|
124
|
|
- _handleBrowserQuirks(video: HTMLVideoElement): void
|
|
130
|
+ _handleBrowserQuirks(video: HTMLVideoElement): SpeedyPromise<HTMLVideoElement>
|
125
|
131
|
{
|
126
|
132
|
// WebKit <video> policies for iOS:
|
127
|
133
|
// https://webkit.org/blog/6784/new-video-policies-for-ios/
|
|
@@ -130,80 +136,133 @@ export class VideoSource implements Source
|
130
|
136
|
video.setAttribute('playsinline', '');
|
131
|
137
|
|
132
|
138
|
// handle autoplay
|
133
|
|
- if(video.autoplay)
|
134
|
|
- this._handleAutoPlay(video);
|
135
|
|
-
|
136
|
|
- // Handle WebKit quirks
|
137
|
|
- // note: navigator.vendor is deprecated. Alternatively, test GL_RENDERER == "Apple GPU"
|
138
|
|
- if(Utils.isIOS() || /Apple/.test(navigator.vendor)) {
|
139
|
|
-
|
140
|
|
- // on Epiphany, a hidden <video> shows up as a black screen when copied to a canvas
|
141
|
|
- if(video.hidden) {
|
142
|
|
- video.hidden = false;
|
143
|
|
- video.style.setProperty('opacity', '0');
|
144
|
|
- video.style.setProperty('position', 'absolute');
|
|
139
|
+ return this._handleAutoPlay(video).then(video => {
|
|
140
|
+
|
|
141
|
+ // Handle WebKit quirks
|
|
142
|
+ // note: navigator.vendor is deprecated. Alternatively, test GL_RENDERER == "Apple GPU"
|
|
143
|
+ if(Utils.isIOS() || /Apple/.test(navigator.vendor)) {
|
|
144
|
+
|
|
145
|
+ // on Epiphany, a hidden <video> shows up as a black screen when copied to a canvas
|
|
146
|
+ if(video.hidden) {
|
|
147
|
+ video.hidden = false;
|
|
148
|
+ video.style.setProperty('opacity', '0');
|
|
149
|
+ video.style.setProperty('position', 'absolute');
|
|
150
|
+ }
|
|
151
|
+
|
145
|
152
|
}
|
146
|
153
|
|
147
|
|
- }
|
|
154
|
+ // done
|
|
155
|
+ return video;
|
|
156
|
+
|
|
157
|
+ });
|
148
|
158
|
}
|
149
|
159
|
|
150
|
160
|
/**
|
151
|
161
|
* Handle browser-specific quirks for videos marked with autoplay
|
152
|
162
|
* @param video a <video> marked with autoplay
|
|
163
|
+ * @returns a promise that resolves to the input video
|
153
|
164
|
* @internal
|
154
|
165
|
*/
|
155
|
|
- _handleAutoPlay(video: HTMLVideoElement): void
|
|
166
|
+ _handleAutoPlay(video: HTMLVideoElement): SpeedyPromise<HTMLVideoElement>
|
156
|
167
|
{
|
157
|
168
|
// Autoplay guide: https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
158
|
169
|
// Chrome policy: https://developer.chrome.com/blog/autoplay/
|
159
|
170
|
// WebKit policy: https://webkit.org/blog/7734/auto-play-policy-changes-for-macos/
|
160
|
|
- Utils.assert(video.autoplay);
|
|
171
|
+
|
|
172
|
+ // nothing to do?
|
|
173
|
+ if(!video.autoplay)
|
|
174
|
+ return Speedy.Promise.resolve(video);
|
161
|
175
|
|
162
|
176
|
// videos marked with autoplay should be muted
|
163
|
177
|
video.muted = true;
|
164
|
178
|
|
165
|
179
|
// the browser may not honor the autoplay attribute if the video is not
|
166
|
180
|
// visible on-screen. So, let's try to play the video in any case.
|
167
|
|
- video.addEventListener('canplay', () => {
|
|
181
|
+ return this._waitUntilPlayable(video).then(video => {
|
|
182
|
+
|
|
183
|
+ // try to play the video
|
168
|
184
|
const promise = video.play();
|
169
|
185
|
|
170
|
186
|
// handle older browsers
|
171
|
187
|
if(promise === undefined)
|
172
|
|
- return;
|
173
|
|
-
|
174
|
|
- // can't play the video
|
175
|
|
- promise.catch((error: DOMException) => {
|
176
|
|
- Utils.error(`Can't autoplay video!`, error, video);
|
177
|
|
-
|
178
|
|
- // autoplay is blocked for some reason
|
179
|
|
- if(error.name == 'NotAllowedError') {
|
180
|
|
- Utils.warning('Tip: allow manual playback');
|
181
|
|
-
|
182
|
|
- if(Utils.isIOS())
|
183
|
|
- Utils.warning('Is low power mode on?');
|
184
|
|
-
|
185
|
|
- // User interaction is required to play the video. We can
|
186
|
|
- // solve this here (easy and convenient to do) or at the
|
187
|
|
- // application layer (for a better user experience). If the
|
188
|
|
- // latter is preferred, just disable autoplay and play the
|
189
|
|
- // video programatically.
|
190
|
|
- if(video.hidden || !video.controls || video.parentNode === null) {
|
191
|
|
- // this is added for convenience
|
192
|
|
- document.body.addEventListener('pointerdown', () => video.play());
|
193
|
|
- alert('Tap on the screen to start');
|
|
188
|
+ return video;
|
|
189
|
+
|
|
190
|
+ // resolve if successful
|
|
191
|
+ return new Speedy.Promise<HTMLVideoElement>((resolve, reject) => {
|
|
192
|
+ promise.then(() => resolve(video), error => {
|
|
193
|
+ // can't play the video
|
|
194
|
+ Utils.error(`Can't autoplay video!`, error, video);
|
|
195
|
+
|
|
196
|
+ // autoplay is blocked for some reason
|
|
197
|
+ if(error.name == 'NotAllowedError') {
|
|
198
|
+ Utils.warning('Tip: allow manual playback');
|
|
199
|
+
|
|
200
|
+ if(Utils.isIOS())
|
|
201
|
+ Utils.warning('Is low power mode on?');
|
|
202
|
+
|
|
203
|
+ // User interaction is required to play the video. We can
|
|
204
|
+ // solve this here (easy and convenient to do) or at the
|
|
205
|
+ // application layer (for a better user experience). If the
|
|
206
|
+ // latter is preferred, just disable autoplay and play the
|
|
207
|
+ // video programatically.
|
|
208
|
+ if(video.hidden || !video.controls || video.parentNode === null) {
|
|
209
|
+
|
|
210
|
+ // this is added for convenience
|
|
211
|
+ document.body.addEventListener('pointerdown', () => video.play());
|
|
212
|
+
|
|
213
|
+ // display the interactive message only once
|
|
214
|
+ if(!displayedAlertMessage) {
|
|
215
|
+ alert(ALERT_MESSAGE);
|
|
216
|
+ displayedAlertMessage = true;
|
|
217
|
+ }
|
|
218
|
+
|
|
219
|
+ }
|
|
220
|
+ /*else {
|
|
221
|
+ // play the video after the first interaction with the page
|
|
222
|
+ const polling = setInterval(() => {
|
|
223
|
+ video.play().then(() => clearInterval(polling));
|
|
224
|
+ }, 500);
|
|
225
|
+ }*/
|
|
226
|
+ }
|
|
227
|
+
|
|
228
|
+ // unsupported media source
|
|
229
|
+ else if(error.name == 'NotSupportedError') {
|
|
230
|
+ reject(new NotSupportedError('Unsupported video format', error));
|
|
231
|
+ return;
|
194
|
232
|
}
|
195
|
|
- /*else {
|
196
|
|
- // play the video after the first interaction with the page
|
197
|
|
- const polling = setInterval(() => {
|
198
|
|
- video.play().then(() => clearInterval(polling));
|
199
|
|
- }, 500);
|
200
|
|
- }*/
|
201
|
|
- }
|
202
|
233
|
|
203
|
|
- // unsupported media source
|
204
|
|
- else if(error.name == 'NotSupportedError')
|
205
|
|
- throw new NotSupportedError('Unsupported video format', error);
|
|
234
|
+ // done
|
|
235
|
+ resolve(video);
|
|
236
|
+ });
|
206
|
237
|
});
|
207
|
238
|
});
|
208
|
239
|
}
|
|
240
|
+
|
|
241
|
+ /**
|
|
242
|
+ * Wait for the input video to be playable
|
|
243
|
+ * @param video
|
|
244
|
+ * @returns a promise that resolves to the input video when it can be played through to the end
|
|
245
|
+ * @internal
|
|
246
|
+ */
|
|
247
|
+ _waitUntilPlayable(video: HTMLVideoElement): SpeedyPromise<HTMLVideoElement>
|
|
248
|
+ {
|
|
249
|
+ const TIMEOUT = 15000, INTERVAL = 500;
|
|
250
|
+
|
|
251
|
+ if(video.readyState >= 4)
|
|
252
|
+ return Speedy.Promise.resolve(video);
|
|
253
|
+
|
|
254
|
+ return new Speedy.Promise<HTMLVideoElement>((resolve, reject) => {
|
|
255
|
+ let ms = 0, t = setInterval(() => {
|
|
256
|
+
|
|
257
|
+ if(video.readyState >= 4) { // canplaythrough
|
|
258
|
+ clearInterval(t);
|
|
259
|
+ resolve(video);
|
|
260
|
+ }
|
|
261
|
+ else if((ms += INTERVAL) > TIMEOUT) {
|
|
262
|
+ reject(new TimeoutError('The video took too long to load'));
|
|
263
|
+ }
|
|
264
|
+
|
|
265
|
+ }, INTERVAL);
|
|
266
|
+ });
|
|
267
|
+ }
|
209
|
268
|
}
|