[iOS] Optimize media controls AirPlay discovery
[WebKit-https.git] / Source / WebCore / Modules / mediacontrols / mediaControlsiOS.js
1 function createControls(root, video, host)
2 {
3     return new ControllerIOS(root, video, host);
4 };
5
6 function ControllerIOS(root, video, host)
7 {
8     this.hasWirelessPlaybackTargets = false;
9     this._pageScaleFactor = 1;
10     this.isListeningForPlaybackTargetAvailabilityEvent = false;
11     Controller.call(this, root, video, host);
12
13     this.updateWirelessTargetAvailable();
14     this.updateWirelessPlaybackStatus();
15     this.setNeedsTimelineMetricsUpdate();
16
17     host.controlsDependOnPageScaleFactor = true;
18 };
19
20 /* Enums */
21 ControllerIOS.StartPlaybackControls = 2;
22
23 /* Globals */
24 ControllerIOS.gWirelessImage = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 245"><g fill="#1060FE"><path d="M193.6,6.3v121.6H6.4V6.3H193.6 M199.1,0.7H0.9v132.7h198.2V0.7L199.1,0.7z"/><path d="M43.5,139.3c15.8,8,35.3,12.7,56.5,12.7s40.7-4.7,56.5-12.7H43.5z"/></g><g text-anchor="middle" font-family="Helvetica Neue"><text x="100" y="204" fill="white" font-size="24">##DEVICE_TYPE##</text><text x="100" y="234" fill="#5C5C5C" font-size="21">##DEVICE_NAME##</text></g></svg>';
25 ControllerIOS.gSimulateWirelessPlaybackTarget = false; // Used for testing when there are no wireless targets.
26
27 ControllerIOS.prototype = {
28     addVideoListeners: function() {
29         Controller.prototype.addVideoListeners.call(this);
30
31         this.listenFor(this.video, 'webkitbeginfullscreen', this.handleFullscreenChange);
32         this.listenFor(this.video, 'webkitendfullscreen', this.handleFullscreenChange);
33     },
34
35     removeVideoListeners: function() {
36         Controller.prototype.removeVideoListeners.call(this);
37
38         this.stopListeningFor(this.video, 'webkitbeginfullscreen', this.handleFullscreenChange);
39         this.stopListeningFor(this.video, 'webkitendfullscreen', this.handleFullscreenChange);
40
41         this.setShouldListenForPlaybackTargetAvailabilityEvent(false);
42     },
43
44     createBase: function() {
45         Controller.prototype.createBase.call(this);
46
47         var startPlaybackButton = this.controls.startPlaybackButton = document.createElement('button');
48         startPlaybackButton.setAttribute('pseudo', '-webkit-media-controls-start-playback-button');
49         startPlaybackButton.setAttribute('aria-label', this.UIString('Start Playback'));
50
51         this.listenFor(this.base, 'gesturestart', this.handleBaseGestureStart);
52         this.listenFor(this.base, 'gesturechange', this.handleBaseGestureChange);
53         this.listenFor(this.base, 'gestureend', this.handleBaseGestureEnd);
54         this.listenFor(this.base, 'touchstart', this.handleWrapperTouchStart);
55         this.stopListeningFor(this.base, 'mousemove', this.handleWrapperMouseMove);
56         this.stopListeningFor(this.base, 'mouseout', this.handleWrapperMouseOut);
57     },
58
59     shouldHaveStartPlaybackButton: function() {
60         var allowsInline = this.host.mediaPlaybackAllowsInline;
61
62         if (this.isAudio() && allowsInline)
63             return false;
64
65         if (this.isFullScreen())
66             return false;
67
68         if (!this.video.currentSrc && this.video.error)
69             return false;
70
71         if (!this.video.controls && allowsInline)
72             return false;
73
74         if (this.video.currentSrc && this.video.error)
75             return true;
76
77         if (!this.host.userGestureRequired && allowsInline)
78             return false;
79
80         return true;
81     },
82
83     shouldHaveControls: function() {
84         if (this.shouldHaveStartPlaybackButton())
85             return false;
86
87         return Controller.prototype.shouldHaveControls.call(this);
88     },
89
90     shouldHaveAnyUI: function() {
91         return this.shouldHaveStartPlaybackButton() || Controller.prototype.shouldHaveAnyUI.call(this) || this.currentPlaybackTargetIsWireless();
92     },
93
94     currentPlaybackTargetIsWireless: function() {
95         return ControllerIOS.gSimulateWirelessPlaybackTarget || (('webkitCurrentPlaybackTargetIsWireless' in this.video) && this.video.webkitCurrentPlaybackTargetIsWireless);
96     },
97
98     updateWirelessPlaybackStatus: function() {
99         if (this.currentPlaybackTargetIsWireless()) {
100             var backgroundImageSVG = "url('" + ControllerIOS.gWirelessImage + "')";
101
102             var deviceName = "";
103             var deviceType = "";
104             var type = this.host.externalDeviceType;
105             if (type == "airplay") {
106                 deviceType = this.UIString('##WIRELESS_PLAYBACK_DEVICE_TYPE##');
107                 deviceName = this.UIString('##WIRELESS_PLAYBACK_DEVICE_NAME##', '##DEVICE_NAME##', this.host.externalDeviceDisplayName || "Apple TV");
108             } else if (type == "tvout") {
109                 deviceType = this.UIString('##TVOUT_DEVICE_TYPE##');
110                 deviceName = this.UIString('##TVOUT_DEVICE_NAME##');
111             }
112
113             backgroundImageSVG = backgroundImageSVG.replace('##DEVICE_TYPE##', deviceType);
114             backgroundImageSVG = backgroundImageSVG.replace('##DEVICE_NAME##', deviceName);
115
116             this.controls.wirelessPlaybackStatus.style.backgroundImage = backgroundImageSVG;
117             this.controls.wirelessPlaybackStatus.setAttribute('aria-label', deviceType + ", " + deviceName);
118
119             this.controls.wirelessPlaybackStatus.classList.remove(this.ClassNames.hidden);
120             this.controls.wirelessTargetPicker.classList.add(this.ClassNames.active);
121         } else {
122             this.controls.wirelessPlaybackStatus.classList.add(this.ClassNames.hidden);
123             this.controls.wirelessTargetPicker.classList.remove(this.ClassNames.active);
124         }
125     },
126
127     updateWirelessTargetAvailable: function() {
128         if (ControllerIOS.gSimulateWirelessPlaybackTarget || this.hasWirelessPlaybackTargets)
129             this.controls.wirelessTargetPicker.classList.remove(this.ClassNames.hidden);
130         else
131             this.controls.wirelessTargetPicker.classList.add(this.ClassNames.hidden);
132     },
133
134     createControls: function() {
135         Controller.prototype.createControls.call(this);
136
137         var wirelessPlaybackStatus = this.controls.wirelessPlaybackStatus = document.createElement('div');
138         wirelessPlaybackStatus.setAttribute('pseudo', '-webkit-media-controls-wireless-playback-status');
139         wirelessPlaybackStatus.classList.add(this.ClassNames.hidden);
140
141         var wirelessTargetPicker = this.controls.wirelessTargetPicker = document.createElement('button');
142         wirelessTargetPicker.setAttribute('pseudo', '-webkit-media-controls-wireless-playback-picker-button');
143         wirelessTargetPicker.setAttribute('aria-label', this.UIString('Choose Wireless Display'));
144         this.listenFor(wirelessTargetPicker, 'touchstart', this.handleWirelessPickerButtonTouchStart);
145         this.listenFor(wirelessTargetPicker, 'touchend', this.handleWirelessPickerButtonTouchEnd);
146         this.listenFor(wirelessTargetPicker, 'touchcancel', this.handleWirelessPickerButtonTouchCancel);
147
148         if (!ControllerIOS.gSimulateWirelessPlaybackTarget)
149             wirelessTargetPicker.classList.add(this.ClassNames.hidden);
150
151         this.listenFor(this.controls.startPlaybackButton, 'touchstart', this.handleStartPlaybackButtonTouchStart);
152         this.listenFor(this.controls.startPlaybackButton, 'touchend', this.handleStartPlaybackButtonTouchEnd);
153         this.listenFor(this.controls.startPlaybackButton, 'touchcancel', this.handleStartPlaybackButtonTouchCancel);
154
155         this.listenFor(this.controls.panel, 'touchstart', this.handlePanelTouchStart);
156         this.listenFor(this.controls.panel, 'touchend', this.handlePanelTouchEnd);
157         this.listenFor(this.controls.panel, 'touchcancel', this.handlePanelTouchCancel);
158         this.listenFor(this.controls.playButton, 'touchstart', this.handlePlayButtonTouchStart);
159         this.listenFor(this.controls.playButton, 'touchend', this.handlePlayButtonTouchEnd);
160         this.listenFor(this.controls.playButton, 'touchcancel', this.handlePlayButtonTouchCancel);
161         this.listenFor(this.controls.fullscreenButton, 'touchstart', this.handleFullscreenTouchStart);
162         this.listenFor(this.controls.fullscreenButton, 'touchend', this.handleFullscreenTouchEnd);
163         this.listenFor(this.controls.fullscreenButton, 'touchcancel', this.handleFullscreenTouchCancel);
164         this.stopListeningFor(this.controls.playButton, 'click', this.handlePlayButtonClicked);
165     },
166
167     setControlsType: function(type) {
168         if (type === this.controlsType)
169             return;
170         Controller.prototype.setControlsType.call(this, type);
171
172         if (type === ControllerIOS.StartPlaybackControls)
173             this.addStartPlaybackControls();
174         else
175             this.removeStartPlaybackControls();
176
177         this.setShouldListenForPlaybackTargetAvailabilityEvent(type !== ControllerIOS.StartPlaybackControls);
178     },
179
180     addStartPlaybackControls: function() {
181         this.base.appendChild(this.controls.startPlaybackButton);
182     },
183
184     removeStartPlaybackControls: function() {
185         if (this.controls.startPlaybackButton.parentNode)
186             this.controls.startPlaybackButton.parentNode.removeChild(this.controls.startPlaybackButton);
187     },
188
189     configureInlineControls: function() {
190         this.base.appendChild(this.controls.wirelessPlaybackStatus);
191
192         this.controls.panel.appendChild(this.controls.playButton);
193         this.controls.panel.appendChild(this.controls.statusDisplay);
194         this.controls.panel.appendChild(this.controls.timelineBox);
195         this.controls.panel.appendChild(this.controls.wirelessTargetPicker);
196         if (!this.isLive) {
197             this.controls.timelineBox.appendChild(this.controls.currentTime);
198             this.controls.timelineBox.appendChild(this.controls.timeline);
199             this.controls.timelineBox.appendChild(this.controls.remainingTime);
200         }
201         if (!this.isAudio())
202             this.controls.panel.appendChild(this.controls.fullscreenButton);
203     },
204
205     configureFullScreenControls: function() {
206         // Do nothing
207     },
208
209     updateControls: function() {
210         if (this.shouldHaveStartPlaybackButton())
211             this.setControlsType(ControllerIOS.StartPlaybackControls);
212         else if (this.isFullScreen())
213             this.setControlsType(Controller.FullScreenControls);
214         else
215             this.setControlsType(Controller.InlineControls);
216
217         this.setNeedsTimelineMetricsUpdate();
218     },
219
220     updateTime: function() {
221         Controller.prototype.updateTime.call(this);
222         this.updateProgress();
223     },
224
225     progressFillStyle: function() {
226         return 'rgba(0, 0, 0, 0.5)';
227     },
228
229     updateProgress: function() {
230         Controller.prototype.updateProgress.call(this);
231
232         var width = this.timelineWidth;
233         var height = this.timelineHeight;
234
235         // Magic number, matching the value for ::-webkit-media-controls-timeline::-webkit-slider-thumb
236         // in mediaControlsiOS.css. Since we cannot ask the thumb for its offsetWidth as it's in its own
237         // shadow dom, just hard-code the value.
238         var thumbWidth = 16;
239         var endX = thumbWidth / 2 + (width - thumbWidth) * this.video.currentTime / this.video.duration;
240
241         var context = document.getCSSCanvasContext('2d', 'timeline-' + this.timelineID, width, height);
242         context.fillStyle = 'white';
243         context.fillRect(0, 0, endX, height);
244     },
245
246     formatTime: function(time) {
247         if (isNaN(time))
248             time = 0;
249         var absTime = Math.abs(time);
250         var intSeconds = Math.floor(absTime % 60).toFixed(0);
251         var intMinutes = Math.floor((absTime / 60) % 60).toFixed(0);
252         var intHours = Math.floor(absTime / (60 * 60)).toFixed(0);
253         var sign = time < 0 ? '-' : String();
254
255         if (intHours > 0)
256             return sign + intHours + ':' + String('00' + intMinutes).slice(-2) + ":" + String('00' + intSeconds).slice(-2);
257
258         return sign + String('00' + intMinutes).slice(intMinutes >= 10 ? -2 : -1) + ":" + String('00' + intSeconds).slice(-2);
259     },
260
261     handleTimelineChange: function(event) {
262         Controller.prototype.handleTimelineChange.call(this);
263         this.updateProgress();
264     },
265
266     handlePlayButtonTouchStart: function() {
267         this.controls.playButton.classList.add('active');
268     },
269
270     handlePlayButtonTouchEnd: function(event) {
271         this.controls.playButton.classList.remove('active');
272
273         if (this.canPlay())
274             this.video.play();
275         else
276             this.video.pause();
277
278         return true;
279     },
280
281     handlePlayButtonTouchCancel: function(event) {
282         this.controls.playButton.classList.remove('active');
283         return true;
284     },
285
286     handleBaseGestureStart: function(event) {
287         this.gestureStartTime = new Date();
288         // If this gesture started with two fingers inside the video, then
289         // don't treat it as a potential zoom, unless we're still waiting
290         // to play.
291         if (this.mostRecentNumberOfTargettedTouches == 2 && this.controlsType != ControllerIOS.StartPlaybackControls)
292             event.preventDefault();
293     },
294
295     handleBaseGestureChange: function(event) {
296         if (!this.video.controls || this.isAudio() || this.isFullScreen() || this.gestureStartTime === undefined || this.controlsType == ControllerIOS.StartPlaybackControls)
297             return;
298
299         if (this.mostRecentNumberOfTargettedTouches == 2 && event.scale >= 1.0)
300             event.preventDefault();
301
302         var currentGestureTime = new Date();
303         var duration = (currentGestureTime - this.gestureStartTime) / 1000;
304         if (!duration)
305             return;
306
307         var velocity = Math.abs(event.scale - 1) / duration;
308
309         if (event.scale < 1.25 || velocity < 2)
310             return;
311
312         delete this.gestureStartTime;
313         this.video.webkitEnterFullscreen();
314     },
315
316     handleBaseGestureEnd: function(event) {
317         delete this.gestureStartTime;
318     },
319
320     handleWrapperTouchStart: function(event) {
321         if (event.target != this.base && event.target != this.controls.wirelessPlaybackStatus)
322             return;
323
324         this.mostRecentNumberOfTargettedTouches = event.targetTouches.length;
325
326         if (this.controlsAreHidden()) {
327             this.showControls();
328             if (this.hideTimer)
329                 clearTimeout(this.hideTimer);
330             this.hideTimer = setTimeout(this.hideControls.bind(this), this.HideControlsDelay);
331         } else if (!this.canPlay())
332             this.hideControls();
333     },
334
335     handlePanelTouchStart: function(event) {
336         this.video.style.webkitUserSelect = 'none';
337     },
338
339     handlePanelTouchEnd: function(event) {
340         this.video.style.removeProperty('-webkit-user-select');
341     },
342
343     handlePanelTouchCancel: function(event) {
344         this.video.style.removeProperty('-webkit-user-select');
345     },
346
347     isFullScreen: function()
348     {
349         return this.video.webkitDisplayingFullscreen;
350     },
351
352     handleFullscreenButtonClicked: function(event) {
353         if (this.isFullScreen())
354             this.video.webkitExitFullscreen();
355         else
356             this.video.webkitEnterFullscreen();
357     },
358
359     handleFullscreenTouchStart: function() {
360         this.controls.fullscreenButton.classList.add('active');
361     },
362
363     handleFullscreenTouchEnd: function(event) {
364         this.controls.fullscreenButton.classList.remove('active');
365
366         this.handleFullscreenButtonClicked();
367
368         return true;
369     },
370
371     handleFullscreenTouchCancel: function(event) {
372         this.controls.fullscreenButton.classList.remove('active');
373         return true;
374     },
375
376     handleStartPlaybackButtonTouchStart: function(event) {
377         this.controls.fullscreenButton.classList.add('active');
378     },
379
380     handleStartPlaybackButtonTouchEnd: function(event) {
381         this.controls.fullscreenButton.classList.remove('active');
382         if (this.video.error)
383             return true;
384
385         this.video.play();
386
387         return true;
388     },
389
390     handleStartPlaybackButtonTouchCancel: function(event) {
391         this.controls.fullscreenButton.classList.remove('active');
392         return true;
393     },
394
395     handleReadyStateChange: function(event) {
396         Controller.prototype.handleReadyStateChange.call(this, event);
397         this.updateControls();
398     },
399
400     handleWirelessPlaybackChange: function(event) {
401         this.updateWirelessPlaybackStatus();
402         this.setNeedsTimelineMetricsUpdate();
403     },
404
405     handleWirelessTargetAvailableChange: function(event) {
406         var wirelessPlaybackTargetsAvailable = event.availability == "available";
407         if (this.hasWirelessPlaybackTargets === wirelessPlaybackTargetsAvailable)
408             return;
409
410         this.hasWirelessPlaybackTargets = wirelessPlaybackTargetsAvailable;
411         this.updateWirelessTargetAvailable();
412         this.setNeedsTimelineMetricsUpdate();
413     },
414
415     handleWirelessPickerButtonTouchStart: function() {
416         if (!this.video.error)
417             this.controls.wirelessTargetPicker.classList.add('active');
418     },
419
420     handleWirelessPickerButtonTouchEnd: function(event) {
421         this.controls.wirelessTargetPicker.classList.remove('active');
422         this.video.webkitShowPlaybackTargetPicker();
423         return true;
424     },
425
426     handleWirelessPickerButtonTouchCancel: function(event) {
427         this.controls.wirelessTargetPicker.classList.remove('active');
428         return true;
429     },
430
431     updateStatusDisplay: function(event)
432     {
433         if (this.video.error)
434             setShouldListenForPlaybackTargetAvailabilityEvent(false);
435         this.controls.startPlaybackButton.classList.toggle(this.ClassNames.failed, this.video.error !== null);
436         Controller.prototype.updateStatusDisplay.call(this, event);
437     },
438
439     setPlaying: function(isPlaying)
440     {
441         this.updateControls();
442         Controller.prototype.setPlaying.call(this, isPlaying);
443     },
444
445     setShouldListenForPlaybackTargetAvailabilityEvent: function(shouldListen)
446     {
447         if (!window.WebKitPlaybackTargetAvailabilityEvent || this.isListeningForPlaybackTargetAvailabilityEvent == shouldListen)
448             return;
449
450         if (shouldListen && (this.shouldHaveStartPlaybackButton() || this.video.error))
451             return;
452
453         this.isListeningForPlaybackTargetAvailabilityEvent = shouldListen;
454         if (shouldListen) {
455             this.listenFor(this.video, 'webkitcurrentplaybacktargetiswirelesschanged', this.handleWirelessPlaybackChange);
456             this.listenFor(this.video, 'webkitplaybacktargetavailabilitychanged', this.handleWirelessTargetAvailableChange);
457         } else {
458             this.stopListeningFor(this.video, 'webkitcurrentplaybacktargetiswirelesschanged', this.handleWirelessPlaybackChange);
459             this.stopListeningFor(this.video, 'webkitplaybacktargetavailabilitychanged', this.handleWirelessTargetAvailableChange);
460         }
461     },
462
463     get pageScaleFactor() {
464         return this._pageScaleFactor;
465     },
466
467     set pageScaleFactor(newScaleFactor) {
468         if (this._pageScaleFactor === newScaleFactor)
469             return;
470
471         this._pageScaleFactor = newScaleFactor;
472
473         if (newScaleFactor) {
474             var scaleValue = 1 / newScaleFactor;
475             var scaleTransform = "scale(" + scaleValue + ")";
476             if (this.controls.startPlaybackButton)
477                 this.controls.startPlaybackButton.style.webkitTransform = scaleTransform;
478             if (this.controls.panel) {
479                 var bottomAligment = -2 * scaleValue;
480                 this.controls.panel.style.bottom = bottomAligment + "px";
481                 this.controls.panel.style.paddingBottom = -(newScaleFactor * bottomAligment) + "px";
482                 this.controls.panel.style.width = Math.ceil(newScaleFactor * 100) + "%";
483                 this.controls.panel.style.webkitTransform = scaleTransform;
484                 this.setNeedsTimelineMetricsUpdate();
485                 this.updateProgress();
486             }
487         }
488     }
489 };
490
491 Object.create(Controller.prototype).extend(ControllerIOS.prototype);
492 Object.defineProperty(ControllerIOS.prototype, 'constructor', { enumerable: false, value: ControllerIOS });