VulpesOS/gaia/tv_apps/fling-player/js/fling_player.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

600 lines
17 KiB
JavaScript
Executable file

/* global VideoPlayer, Connector, mDBG, KeyNavigationAdapter,
SimpleKeyNavigation
*/
(function(exports) {
'use strict';
// Helping variables, methods
var uiID = {
player : 'player',
loadingUI : 'loading-section',
initialMsgSection : 'initial-message-section',
controlPanel : 'video-control-panel',
backwardButton : 'backward-button',
playButton : 'play-button',
forwardButton : 'forward-button',
bufferedTimeBar : 'buffered-time-bar',
elapsedTimeBar : 'elapsed-time-bar',
elapsedTime : 'elapsed-time',
durationTime : 'duration-time'
};
function $(id) {
return document.getElementById(id);
}
// Helping variables, methods end
/**
* FlingPlayer could be controlled by 2 sources.
* The 1st source is remote control device, like Fennec,
* which sends command through the Presentation API.
* The 2nd source is TV remote controller which sends command through
* UI navigation by pressing TV remote controller's key
*/
function FlingPlayer(videoPlayer, connector) {
this._player = videoPlayer;
this._connector = connector;
}
var proto = FlingPlayer.prototype;
proto.MAX_DISPLAYED_VIDEO_TIME_SEC = 3600 * 99 + 60 * 59 + 59;
proto.CONTROL_PANEL_HIDE_DELAY_MS = 3000;
proto.SEEK_ON_KEY_PRESS_INTERVAL_MS = 150;
proto.SEEK_ON_LONG_KEY_PRESS_MS = 3000;
proto.SEEK_ON_EXTRA_LONG_KEY_PRESS_MS = 6000;
proto.SEEK_ON_KEY_PRESS_NORMAL_STEP_SEC = 5;
proto.SEEK_ON_KEY_PRESS_LARGE_STEP_SEC = 10;
proto.SEEK_ON_KEY_PRESS_EXTRA_LARGE_STEP_SEC = 30;
proto.INITIAL_MSG_MIN_DISPLAY_TIME_MS = 2000;
proto.REFOCUS_PLAY_BUTTON_AFTER_HIDDING_SEC = 60000;
proto.init = function () {
this._autoPlayOnSeeked = true;
this._seekOnKeyPressTimer = null;
this._seekOnKeyPressDirection = null; // 'backward' or 'forward'
this._seekOnKeyPressStartTime = null; // in ms
this._hideControlsTimer = null;
this._initialMsgStartTime = null; // in ms
this._loadingUI = $(uiID.loadingUI);
this._initialMsgSection = $(uiID.initialMsgSection);
this._controlPanel = $(uiID.controlPanel);
this._backwardButton = $(uiID.backwardButton);
this._playButton = $(uiID.playButton);
this._forwardButton = $(uiID.forwardButton);
this._bufferedTimeBar = $(uiID.bufferedTimeBar);
this._elapsedTimeBar = $(uiID.elapsedTimeBar);
this._elapsedTime = $(uiID.elapsedTime);
this._durationTime = $(uiID.durationTime);
this._keyNav = new SimpleKeyNavigation();
this._keyNav.start(
[this._backwardButton, this._playButton, this._forwardButton],
SimpleKeyNavigation.DIRECTION.HORIZONTAL
);
this._keyNav.focusOn(this._playButton);
this._keyNav.pause();
this._keyNavAdapter = new KeyNavigationAdapter();
this._keyNavAdapter.init();
this._keyNavAdapter.on('enter', this.onKeyEnterDown.bind(this));
this._keyNavAdapter.on('enter-keyup', this.onKeyEnterUp.bind(this));
this._keyNavAdapter.on('esc-keyup', this.onBackKeyUp.bind(this));
this._keyNavAdapter.on('move-keyup',
this.onDemandingControlPanel.bind(this)
);
this._keyNavAdapter.on('enter-keyup',
this.onDemandingControlPanel.bind(this)
);
document.addEventListener('visibilitychange', () => {
// We don't need to restore the video while visibilityState goes back
// because system app will kill the original one and relaunch a new one.
if (document.visibilityState === 'hidden') {
this._player.release();
}
});
this._initPlayer();
this._initSession();
};
proto._initSession = function () {
this._connector.init();
this._connector.on('loadRequest', this.onLoadRequest.bind(this));
this._connector.on('playRequest', this.onPlayRequest.bind(this));
this._connector.on('seekRequest', this.onSeekRequest.bind(this));
this._connector.on('pauseRequest', this.onPauseRequest.bind(this));
};
proto._initPlayer = function () {
var video = this._player.getVideo();
this._player.init();
video.addEventListener('loadedmetadata', this);
video.addEventListener('durationchange', this);
video.addEventListener('timeupdate', this);
video.addEventListener('waiting', this);
video.addEventListener('playing', this);
video.addEventListener('seeked', this);
video.addEventListener('pause', this);
video.addEventListener('ended', this);
video.addEventListener('error', this);
};
// UI handling
proto.resetUI = function () {
this.moveTimeBar('elapsed', 0);
this.moveTimeBar('buffered', 0);
this.writeTimeInfo('elapsed', 0);
this.writeTimeInfo('duration', 0);
this.setPlayButtonState('paused');
this._keyNav.focusOn(this._playButton);
};
proto.showLoading = function (loading) {
this._loadingUI.hidden = !loading;
};
/**
* @param {boolean} on True to toggle on and false to toggle off
*/
proto.toggleInitialMessage = function (on, opt = {}) {
if (on) {
this._initialMsgStartTime = (new Date()).getTime();
this._initialMsgSection.classList.remove('fade-out');
} else {
if (opt.delayHiding) {
// Make sure that the initial message has beed displayed
// for at least the min duration
var displayDuration = (new Date()).getTime() -
this._initialMsgStartTime;
var gap = this.INITIAL_MSG_MIN_DISPLAY_TIME_MS - displayDuration;
if (gap < 0) {
gap = 0;
}
setTimeout(() => this.toggleInitialMessage(false), gap);
} else {
this._initialMsgSection.classList.add('fade-out');
}
}
};
/**
* @param {String} state 'playing' or 'paused'
*/
proto.setPlayButtonState = function (state) {
switch (state) {
case 'playing':
this._playButton.setAttribute('data-icon', 'fling-player-pause');
break;
case 'paused':
this._playButton.setAttribute('data-icon', 'fling-player-play');
break;
}
};
proto.isControlPanelHiding = function () {
return this._controlPanel.classList.contains('fade-out');
};
/**
* @param {Boolean} autoHide? Auto hide the controls later. Default to false
*/
proto.showControlPanel = function (autoHide) {
if (this._hideControlsTimer) {
clearTimeout(this._hideControlsTimer);
this._hideControlsTimer = null;
}
this._keyNav.resume();
this._controlPanel.classList.remove('fade-out');
if (autoHide === true) {
this.hideControlPanel();
}
};
/**
* @param {Boolean} immediate? Hide immediately or later. Default to false.
*/
proto.hideControlPanel = function (immediate) {
if (this._hideControlsTimer) {
clearTimeout(this._hideControlsTimer);
this._hideControlsTimer = null;
}
if (immediate === true) {
if (!this.isControlPanelHiding()) {
this._keyNav.pause();
this._controlPanel.classList.add('fade-out');
setTimeout(() => {
if (this.isControlPanelHiding()) {
this._keyNav.focusOn(this._playButton);
}
}, this.REFOCUS_PLAY_BUTTON_AFTER_HIDDING_SEC);
}
} else {
this._hideControlsTimer = setTimeout(() => {
this.hideControlPanel(true);
}, this.CONTROL_PANEL_HIDE_DELAY_MS);
}
};
/**
* @param {string} type 'buffered' or 'elapsed'
* @param {number} sec the sec in video you want to move to
*/
proto.moveTimeBar = function (type, sec) {
mDBG.log('FlingPlayer#moveTimeBar');
mDBG.log('Move type ', type);
mDBG.log('Move to ', sec);
var timeBar = this[`_${type}TimeBar`];
var duration = this._player.getRoundedDuration();
sec = Math.round(sec);
if (!timeBar ||
typeof sec != 'number' ||
isNaN(sec) ||
sec < 0 ||
sec > duration
) {
mDBG.warn('Not moving due to corrupt type/sec', type, sec);
return;
}
requestAnimationFrame(() => {
mDBG.log('Move to sec / duration = %d / %d', sec, duration);
timeBar.style.width = (100 * sec / duration) + '%';
});
};
/**
* @param {string} type 'elapsed' or 'duration'
* @param {number} sec
*/
proto.writeTimeInfo = function (type, sec) {
var timeInfo = this[`_${type}Time`];
var duration = Math.min(this._player.getRoundedDuration(),
this.MAX_DISPLAYED_VIDEO_TIME_SEC);
sec = Math.min(Math.round(sec), this.MAX_DISPLAYED_VIDEO_TIME_SEC);
if (!timeInfo ||
(sec >= 0) === false ||
(sec <= duration) === false
) {
return;
}
var t = this._player.parseTime(sec);
t.hh = (t.hh <= 0) ? '' :
(t.hh < 10) ? '0' + t.hh + ':' : t.hh + ':';
t.mm = (t.mm < 10) ? '0' + t.mm + ':' : t.mm + ':';
t.ss = (t.ss < 10) ? '0' + t.ss : t.ss;
timeInfo.textContent = t.hh + t.mm + t.ss;
};
// UI handling end
// Video handling
proto.play = function () {
this._player.play();
this.setPlayButtonState('playing');
};
proto.pause = function () {
this._player.pause();
this.setPlayButtonState('paused');
};
proto._updateControlPanel = function () {
var buf = this._player.getVideo().buffered;
var current = this._player.getRoundedCurrentTime();
this.writeTimeInfo('elapsed', current);
this.moveTimeBar('elapsed', current);
for (var bufEnd, bufStart, i = 0; i < buf.length; ++i ) {
bufEnd = buf.end(i);
bufStart = buf.start(i);
if (bufStart <= current && current <= bufEnd) {
this.moveTimeBar('buffered', bufEnd);
break;
}
}
};
proto._startSeekOnKeyPress = function (dir) {
if (this._seekOnKeyPressStartTime == null) { // Do not double start
this._seekOnKeyPressStartTime = (new Date()).getTime();
this._seekOnKeyPressDirection = dir;
this._seekOnKeyPress();
}
};
proto._stopSeekOnKeyPress = function () {
clearTimeout(this._seekOnKeyPressTimer);
this._seekOnKeyPressDirection = null;
this._seekOnKeyPressStartTime = null;
this._seekOnKeyPressTimer = null;
};
/**
* This is to handle this case that user seeks on video by long pressing key.
* The seeking policy would go based on duration of pressing
*/
proto._seekOnKeyPress = function () {
if (this._seekOnKeyPressStartTime != null) {
var time = this._player.getRoundedCurrentTime();
var factor = (this._seekOnKeyPressDirection == 'backward') ? -1 : 1;
var seekDuration = (new Date()).getTime() - this._seekOnKeyPressStartTime;
var seekStep;
if (seekDuration <= this.SEEK_ON_LONG_KEY_PRESS_MS) {
seekStep = this.SEEK_ON_KEY_PRESS_NORMAL_STEP_SEC;
} else if (seekDuration > this.SEEK_ON_LONG_KEY_PRESS_MS &&
seekDuration <= this.SEEK_ON_EXTRA_LONG_KEY_PRESS_MS) {
seekStep = this.SEEK_ON_KEY_PRESS_LARGE_STEP_SEC;
} else {
seekStep = this.SEEK_ON_KEY_PRESS_EXTRA_LARGE_STEP_SEC;
}
time += factor * seekStep;
time = Math.min(Math.max(0, time), this._player.getRoundedDuration());
this.seek(time);
this._seekOnKeyPressTimer = setTimeout(
this._seekOnKeyPress.bind(this),
this.SEEK_ON_KEY_PRESS_INTERVAL_MS
);
}
};
proto.seek = function (sec, opt) {
if (!(opt instanceof Object)) {
opt = {
autoPlayOnSeeked: true,
autoHideControlPanel: true
};
}
this._player.seek(sec);
this.moveTimeBar('elapsed', sec);
this.writeTimeInfo('elapsed', sec);
this._autoPlayOnSeeked = !!opt.autoPlayOnSeeked;
this.showControlPanel(!!opt.autoHideControlPanel);
};
// Video handling end
// Event handling
proto.handleEvent = function handleVideoEvent(e) {
mDBG.log('FlingPlayer#handleEvent: e.type = ' + e.type);
var data = { 'time': this._player.getRoundedCurrentTime() };
switch (e.type) {
case 'loadedmetadata':
this.writeTimeInfo('elapsed', this._player.getRoundedCurrentTime());
this.writeTimeInfo('duration', this._player.getRoundedDuration());
this._connector.reportStatus('loaded', data);
break;
// XXX: Bug 1238862.
// On real TV, decoder would provide NaN duration at the loadedmetadata
// event, so duration of 00:00 would be displayed.
// However, valid duration would be available at the durationchange
// event afterwards.
// So we listen to the durationchange and display valid duration info
// to workaround this NaN duration issue,
case 'durationchange':
this.writeTimeInfo('duration', this._player.getRoundedDuration());
break;
case 'timeupdate':
this._updateControlPanel();
break;
case 'waiting':
this.showLoading(true);
this._connector.reportStatus('buffering', data);
break;
case 'playing':
this.showLoading(false);
this.hideControlPanel();
this.toggleInitialMessage(false, { delayHiding: true });
this._connector.reportStatus('buffered', data);
this._connector.reportStatus('playing', data);
break;
case 'seeked':
if (this._autoPlayOnSeeked) {
this.play();
}
this._connector.reportStatus('seeked', data);
break;
case 'pause':
this.showControlPanel();
this._connector.reportStatus('stopped', data);
break;
case 'ended':
// Stop updating the control panel and prevent from showing loading UI.
// Restore these actions once back to the very 1st frame.
var handle = () => {
this._player.getVideo().addEventListener('waiting', this);
this._player.getVideo().addEventListener('timeupdate', this);
this._player.getVideo().removeEventListener('seeked', handle);
};
this._player.getVideo().addEventListener('seeked', handle);
this._player.getVideo().removeEventListener('waiting', this);
this._player.getVideo().removeEventListener('timeupdate', this);
// Go back to the very 1st frame on ended and reset the control panel
this.resetUI();
this.writeTimeInfo('duration', this._player.getRoundedDuration());
this.seek(0, {
autoPlayOnSeeked: false,
autoHideControlPanel: false
});
this._connector.reportStatus('stopped', data);
break;
case 'error':
this.showLoading(false);
data.error = e.target.error.code;
this._connector.reportStatus('error', data);
break;
}
};
proto.onLoadRequest = function (e) {
this.resetUI();
this.showLoading(true);
this._player.load(e.url);
this.play();
this._connector.getControllingDeviceInfo().then(info => {
var msg = this._initialMsgSection.textContent;
var name = 'device';
if (info.displayName && typeof info.displayName == 'string') {
name = info.displayName;
}
this._initialMsgSection.textContent =
msg.replace('{{ device }}', name);
this.toggleInitialMessage(true);
if (this._player.isPlaying()) {
this.toggleInitialMessage(false, { delayHiding: true });
}
});
};
proto.onPlayRequest = function () {
this.play();
};
proto.onPauseRequest = function () {
this.pause();
};
proto.onSeekRequest = function (e) {
this.seek(e.time);
};
proto.onKeyEnterDown = function () {
mDBG.log('FlingPlayer#onKeyEnterDown');
if (this.isControlPanelHiding()) {
mDBG.log('The control panel is hiding so no action is taken.');
return;
}
var focused = this._keyNav.getFocusedElement();
if (focused) {
mDBG.log('control focused = ', focused);
switch (focused.id) {
case uiID.backwardButton:
case uiID.forwardButton:
if (focused === this._backwardButton) {
this._startSeekOnKeyPress('backward');
} else {
this._startSeekOnKeyPress('forward');
}
break;
}
}
};
proto.onKeyEnterUp = function () {
mDBG.log('FlingPlayer#onKeyEnterUp');
if (this.isControlPanelHiding()) {
mDBG.log('The control panel is hiding so no action is taken.');
return;
}
var focused = this._keyNav.getFocusedElement();
if (focused) {
mDBG.log('control focused = ', focused);
switch (focused.id) {
case uiID.playButton:
if (this._player.isPlaying()) {
this.pause();
} else {
this.play();
}
break;
case uiID.backwardButton:
case uiID.forwardButton:
this._stopSeekOnKeyPress();
break;
}
}
};
proto.onBackKeyUp = function () {
navigator.mozL10n.formatValue('want-to-end')
.then((txt) => {
var end = window.confirm(txt);
if (end) {
this._connector.reportStatus('stopped', {
'time': this._player.getRoundedCurrentTime() });
window.close();
}
});
};
proto.onDemandingControlPanel = function () {
mDBG.log('FlingPlayer#onDemandingControlPanel');
this.showControlPanel(true);
};
// Event handling end
window.addEventListener('load', function() {
window.fp = new FlingPlayer(
new VideoPlayer($(uiID.player)),
new Connector(navigator.presentation)
);
window.fp.init();
if (document.visibilityState === 'hidden') {
navigator.mozApps.getSelf().onsuccess = function(evt) {
var app = evt.target.result;
if (app) {
app.launch();
}
};
}
});
exports.FlingPlayer = FlingPlayer;
})(window);