'use strict'; /* global Application, FilterManager, CardManager, Clock, Deck, Edit, Folder, KeyNavigationAdapter, MessageHandler, MozActivity, SearchBar, SharedUtils, SpatialNavigator, URL, XScrollable, Animations, Utils, FTEWizard, AppBookmark */ /* jshint nonew: false */ (function(exports) { const FULLSIZED_ICON = 336 * (window.devicePixelRatio || 1); const DEFAULT_ICON = 'url("/style/images/appic_developer.png")'; const DEFAULT_BGCOLOR = 'rgba(0, 0, 0, 0.5)'; const DEFAULT_BGCOLOR_ARRAY = [0, 0, 0, 0.5]; const CARDLIST_LEFT_MARGIN = 8.4; /** * Main class of smart-home * @class Home */ function Home() {} Home.prototype = { navigableIds: ['search-button', 'search-input', 'edit-button', 'settings-button', 'filter-tab-group'], topElementIds: ['search-button', 'search-input', 'edit-button', 'settings-button'], bottomElementIds: ['filter-tab-group', 'filter-all-button', 'filter-tv-button', 'filter-device-button', 'filter-app-button', 'filter-website-button'], isNavigable: true, navigableClasses: ['filter-tab', 'command-button'], navigableScrollable: [], cardScrollable: undefined, folderScrollable: undefined, _focus: undefined, _focusScrollable: undefined, _folderCard: undefined, filterElementIds: ['filter-all-button', 'filter-tv-button', 'filter-device-button', 'filter-app-button', 'filter-website-button'], filterManager: undefined, cardListElem: document.getElementById('card-list'), folderListElem: document.getElementById('folder-list'), cardManager: undefined, editButton: document.getElementById('edit-button'), settingsButton: document.getElementById('settings-button'), searchButton: document.getElementById('search-button'), timeElem: document.getElementById('time'), fteElem: document.getElementById('fte'), init: function() { var that = this; this.initClock(); this.cardManager = new CardManager(); this.cardManager.init(); this.searchBar = new SearchBar(); this.searchBar.init(document.getElementById('search-bar')); this.searchBar.on('shown', this.onSearchBarShown.bind(this)); this.searchBar.on('hidden', this.onSearchBarHidden.bind(this)); this.cardManager.getCardList().then(function(cardList) { that.messageHandler = new MessageHandler(); that.messageHandler.init(that); that._createCardList(cardList); that.cardScrollable = new XScrollable({ frameElem: 'card-list-frame', listElem: 'card-list', itemClassName: 'app-button', leftMargin: CARDLIST_LEFT_MARGIN}); that.folderScrollable = new XScrollable({ frameElem: 'folder-list-frame', listElem: 'folder-list', itemClassName: 'app-button', leftMargin: CARDLIST_LEFT_MARGIN, scale: 0.68, referenceElement: that.cardScrollable}); that.navigableScrollable = [that.cardScrollable, that.folderScrollable]; var collection = that._getNavigateElements(); that.spatialNavigator = new SpatialNavigator(collection); that.spatialNavigator.straightOnly = true; that.keyNavigatorAdapter = new KeyNavigationAdapter(); that.keyNavigatorAdapter.init(); that.keyNavigatorAdapter.on('move', that.onMove.bind(that)); // All behaviors which no need to have multple events while holding // the key should use keyup. that.keyNavigatorAdapter.on('enter-keyup', that.onEnter.bind(that)); that.cardListElem.addEventListener('transitionend', that.determineFolderExpand.bind(that)); that.cardManager.on('card-inserted', that.onCardInserted.bind(that, that.cardScrollable)); that.cardManager.on('card-removed', that.onCardRemoved.bind(that, that.cardScrollable)); that.cardManager.on('card-updated', that.onCardUpdated.bind(that)); that.spatialNavigator.on('focus', that.handleFocus.bind(that)); that.spatialNavigator.on('unfocus', that.handleUnfocus.bind(that)); var handleCardFocusBound = that.handleCardFocus.bind(that); var handleCardUnfocusBound = that.handleCardUnfocus.bind(that); var handleCardUnhoverBound = that.handleCardUnhover.bind(that); that.navigableScrollable.forEach(function(scrollable) { scrollable.on('focus', handleCardFocusBound); scrollable.on('unfocus', handleCardUnfocusBound); scrollable.on('unhover', handleCardUnhoverBound); if (scrollable.isEmpty()) { that.spatialNavigator.remove(scrollable); } }); that.edit = new Edit(); that.edit.init(that.spatialNavigator, that.cardManager, that.cardScrollable, that.folderScrollable); that.edit.on('arrange', that.onArrangeMode.bind(that)); that.filterManager = new FilterManager(); that.filterManager.init({ cardListElem: that.cardListElem, cardScrollable: that.cardScrollable, home: that, cardManager: that.cardManager, }); // In some case, we can do action at keydown which is translated as // onEnter in home.js. But in button click case, we need to listen // keyup. So, instead keydown/keyup, we just use click event to handle // it. The click event is translated at smart-button when use press // enter on smart-button. that.searchButton.addEventListener('click', function() { that.searchBar.show(); // hide the searchButton because searchBar has an element whose // appearance is the same as it. that.searchButton.classList.add('hidden'); }.bind(that)); // handle animation that.endBubble = null; document.addEventListener( 'visibilitychange', that.onVisibilityChange.bind(that)); // if this init function is executed after the document is set to // visible, the visibilitychange event may not be triggered. if (document.visibilityState === 'visible') { that.onVisibilityChange(); } cardList.forEach(function(card) { if (card instanceof Folder) { card.on('card-inserted', that.onCardInserted.bind(that, that.folderScrollable)); card.on('card-removed', that.onCardRemoved.bind(that, that.folderScrollable)); } }); that._fteWizard = new FTEWizard('homeFTE'); that._fteWizard.init({ container: that.fteElem, onfinish: () => { that.spatialNavigator.focus(that.cardScrollable); that.isNavigable = true; } }); }); }, onVisibilityChange: function() { if (document.visibilityState === 'visible') { Utils.holdFocusForAnimation(); this.cardListElem.classList.remove('hidden'); var that = this; Promise.all([new Promise(function(resolve) { that.skipBubble = Animations.doBubbleAnimation( that.cardListElem, '.app-button', 100, resolve); }), new Promise(function(resolve) { if (that._folderCard) { that.skipFolderBubble = Animations.doBubbleAnimation( that.folderListElem, '.app-button', 100, resolve); } else { resolve(); } })]).then(function() { // Catch focus back unless there is a pin activity since callback of // pinning would catch the focus for us. if (that._fteWizard.running) { that._fteWizard.focus(); } else if (!that.messageHandler.resumeActivity()) { that.spatialNavigator.focus(); } that.isNavigable = true; that.skipBubble = null; that.skipFolderBubble = null; }); } else { this.cardListElem.classList.add('hidden'); this.messageHandler.stopActivity(); this.isNavigable = false; // An user may close home app when bubbling or sliding animations are // still playing, and then open home app again right away. In this case, // the user will see the last unfinished animations. In order to solve // this, we have to force disable all the animations and trigger their // callbacks when home app is in hidden state. if (this.skipBubble) { this.skipBubble(); } if (this.skipFolderBubble) { this.skipFolderBubble(); } if (this.cardScrollable.isSliding) { this.cardScrollable.endSlide(); } this.filterManager.resetFilter(); } }, initClock: function() { var that = this; navigator.mozL10n.ready(function() { that.clock = new Clock(); that.clock.start(that.updateClock.bind(that)); // Listen to 'moztimechange' window.addEventListener('moztimechange', that.restartClock.bind(that)); // Listen to 'timeformatchange' window.addEventListener('timeformatchange', that.restartClock.bind(that)); }); }, _localizeCardName: function(elem, card) { if (!elem || !card) { return; } // We should use user given name first, otherwise we use localized // application/deck name. var lang = document.documentElement.lang; var name = this.cardManager.resolveCardName(card, lang); if (name && name.raw) { elem.removeAttribute('data-l10n-id'); elem.textContent = name.raw; } else if (name && name.id) { SharedUtils.localizeElement(elem, name); } }, onCardInserted: function(scrollable, card, idx, overFolder) { if (card instanceof Folder) { card.on('card-inserted', this.onCardInserted.bind(this, this.folderScrollable)); card.on('card-removed', this.onCardRemoved.bind(this, this.folderScrollable)); } var newCardElem = this.createCardNode(card); var newCardButtonElem = newCardElem.firstElementChild; // Initial transition for new card newCardButtonElem.classList.add('new-card'); newCardButtonElem.classList.add('new-card-transition'); newCardButtonElem.addEventListener('transitionend', function onPinned() { newCardButtonElem.classList.remove('new-card-transition'); newCardButtonElem.classList.remove('last-card'); newCardButtonElem.removeEventListener('transitionend', onPinned); }); // insert new card into cardScrollable this.isNavigable = false; scrollable.on('slideEnd', function() { newCardButtonElem.classList.remove('new-card'); if (scrollable.nodes.indexOf(newCardElem) === scrollable.nodes.length - 1) { newCardButtonElem.classList.add('last-card'); } this.isNavigable = true; }.bind(this)); if (!overFolder) { scrollable.insertNodeBefore(newCardElem, idx); } else { scrollable.insertNodeOver(newCardElem, idx); } }, onCardUpdated: function(card, idx) { var that = this; var cardButton = this.cardScrollable.getItemFromNode( this.cardScrollable.getNode(idx)); var spans = SharedUtils.nodeListToArray(cardButton.getElementsByTagName('span')); spans.forEach(function(span) { if (span.classList.contains('name')) { that._localizeCardName(span, card); } }); }, onCardRemoved: function(scrollable, indices) { indices.forEach(function(idx) { var elm = scrollable.getNode(idx); if (elm && elm.dataset.revokableURL) { URL.revokeObjectURL(elm.dataset.revokableURL); } }, this); scrollable.removeNodes(indices); }, _setCardIcon: function (cardButton, card, blob, bgColor) { try { var bgUrl = URL.createObjectURL(blob); if (bgColor) { cardButton.style.backgroundColor = bgColor; cardButton.classList.add('fitted'); card.backgroundType = 'fitted'; } else { cardButton.classList.add('fullsized'); card.backgroundType = 'fullsized'; } cardButton.dataset.revokableURL = bgUrl; cardButton.style.backgroundImage = 'url("' + bgUrl + '")'; } catch (e) { // If the blob is broken, we may get an exception while creating object // URL. cardButton.style.backgroundImage = DEFAULT_ICON; cardButton.style.backgroundColor = DEFAULT_BGCOLOR; } }, _createWave: function(cardButton, card) { // deck's icon using gaia font var deckIcon = document.createElement('span'); deckIcon.className = 'icon'; deckIcon.dataset.icon = card.deckClass; // front wave of a deck var waveFront = document.createElement('div'); waveFront.className = 'deck-wave'; waveFront.classList.add('wave-front'); waveFront.classList.add(card.deckClass + '-wave-front'); waveFront.classList.add('wave-paused'); // back wave of a deck var waveBack = document.createElement('div'); waveBack.className = 'deck-wave'; waveBack.classList.add('wave-back'); waveBack.classList.add(card.deckClass + '-wave-back'); cardButton.appendChild(waveBack); cardButton.appendChild(deckIcon); cardButton.appendChild(waveFront); cardButton.classList.add('deck-' + card.deckClass); }, _fillCardIcon: function(cardButton, card) { var manifestURL = card.nativeApp && card.nativeApp.manifestURL; var that = this; // We have thumbnail which is created by pin if (card.thumbnail) { this._setCardIcon(cardButton, card, card.thumbnail, card.backgroundColor); // TODO add backgroundColor??? How to do it??? } else if (!card.cachedIconBlob) { // We don't have cachedIconBlob, just get icon from app this.cardManager.getIconBlob({ manifestURL: manifestURL, entryPoint: card.entryPoint, // XXX: preferredSize should be determined by // real offsetWidth of cardThumbnailElem instead of hard-coded value preferredSize: FULLSIZED_ICON }).then(function(iconData) { var blob = iconData[0]; var size = iconData[1]; if (size >= FULLSIZED_ICON) { that._setCardIcon(cardButton, card, blob, null); } else { that._getIconColor(blob, function(color, err) { if (err) { that._setCardIcon(cardButton, card, blob, DEFAULT_BGCOLOR); } else { that._setCardIcon(cardButton, card, blob, 'rgba(' + color[0] + ', ' + color[1] + ', ' + color[2] + ', ' + color[3] + ')'); } }); } card.cachedIconBlob = blob; }); } else if (card.cachedIconBlob) { // We already have cacedIconBlob which is created by previous step. this._setCardIcon(cardButton, card, card.cachedIconBlob, card.backgroundColor); } }, _getIconColor: function(blob, callback) { var dy = 0; function checkColor(color, err) { if (err) { callback(null, err); } else if (color[3] < 255 && dy < 0.5) { dy += 0.25; SharedUtils.readColorCode(blob, 0.5, dy, checkColor); } else { callback(color[3] < 255 ? DEFAULT_BGCOLOR_ARRAY : color, err); } } SharedUtils.readColorCode(blob, 0.5, 0, checkColor); }, onArrangeMode: function() { if (this._focusScrollable !== this.folderScrollable) { this.cleanFolderScrollable(); } }, createCardNode: function(card) { // card element would be created like this: //
// /* Card button */ //
// /* Rename button */ // /* Delete button */ //
//
// and return DOM element var cardNode = document.createElement('div'); cardNode.classList.add('card'); var cardButton = document.createElement('smart-button'); cardButton.setAttribute('type', 'app-button'); cardButton.className = 'app-button'; cardButton.dataset.cardId = card.cardId; var cardPanel = document.createElement('section'); cardPanel.className = 'card-panel'; var renameButton = document.createElement('smart-button'); renameButton.dataset.icon = 'rename'; renameButton.classList.add('rename-btn'); var deleteButton = document.createElement('smart-button'); deleteButton.dataset.icon = 'delete'; deleteButton.classList.add('delete-btn'); cardPanel.appendChild(renameButton); cardPanel.appendChild(deleteButton); cardNode.appendChild(cardButton); cardNode.appendChild(cardPanel); // XXX: will support Folder and other type of Card in the future // for now, we only create card element for Application and Deck if (card instanceof Application) { if (card.group === 'tv') { cardButton.classList.add('tv-channel'); cardButton.dataset.icon = 'tv'; cardButton.setAttribute('app-type', 'tv'); } else { cardButton.setAttribute('app-type', 'app'); this._fillCardIcon(cardButton, card); } } else if (card instanceof Deck) { cardButton.setAttribute('app-type', 'deck'); if (card.group === 'website') { this._fillCardIcon(cardButton, card); } else { this._createWave(cardButton, card); } } else if (card instanceof AppBookmark) { cardButton.setAttribute('app-type', 'appbookmark'); this._fillCardIcon(cardButton, card); } else if (card instanceof Folder) { cardButton.setAttribute('app-type', 'folder'); cardButton.dataset.icon = 'folder'; } // For smart-button, we put card name in pseudo-element :after. However, // we need to localize card name and l10n library do not support // localizing element with children elements. // Instead of using :after, we create a 'span' element under smart-button // and put card name in it. var nameSpan = document.createElement('span'); nameSpan.classList.add('name'); this._localizeCardName(nameSpan, card); cardButton.appendChild(nameSpan); return cardNode; }, _createCardList: function(cardList) { cardList.forEach(function(card) { this.cardListElem.appendChild(this.createCardNode(card)); }.bind(this)); }, onMove: function(key) { if (!this.isNavigable || this.edit.onMove(key)) { return; } var focus = this.spatialNavigator.getFocusedElement(); if (!(focus.CLASS_NAME == 'XScrollable' && focus.move(key))) { this.spatialNavigator.move(key); } }, onEnter: function() { if (!this.isNavigable || this.edit.onEnter()) { return; } var focusElem = this.focusElem; if (focusElem === this.settingsButton) { this.openSettings(); } else if (focusElem === this.editButton) { this.cleanFolderScrollable(); this.edit.toggleEditMode(); // XXX: Reset card filter when entering edit mode this.filterManager.resetFilter(); } else if (focusElem && this.filterElementIds.indexOf(focusElem.id) > -1) { this.cleanFolderScrollable(); } else { // Current focus is on a card var cardId = focusElem.dataset.cardId; var card; if (this.focusScrollable === this.folderScrollable) { card = this._folderCard.findCard({cardId: cardId}); } else { card = this.cardManager.findCardFromCardList({cardId: cardId}); } if (card) { card.launch(); } } }, onSearchBarShown: function() { var hideSearchBar = function() { document.removeEventListener('visibilitychange', hideSearchBar); this.searchBar.hide(); }.bind(this); document.addEventListener('visibilitychange', hideSearchBar); var activity = new MozActivity({ name: 'search', data: { keyword: '' } }); activity.onerror = hideSearchBar; }, onSearchBarHidden: function() { this.searchButton.classList.remove('hidden'); }, _getNavigateElements: function() { var elements = []; this.navigableIds.forEach(function(id) { var elem = document.getElementById(id); if (elem) { elements.push(elem); } }); this.navigableClasses.forEach(function(className) { var elems = document.getElementsByClassName(className); if (elems.length) { // Change HTMLCollection to array before concatenating elements = elements.concat(Array.prototype.slice.call(elems)); } }); elements = elements.concat(this.navigableScrollable); return elements; }, handleFocus: function(elem) { if (elem.CLASS_NAME == 'XScrollable') { this._focusScrollable = elem; elem.focus(); this.checkFocusedGroup(); } else if (elem.nodeName) { if (this._focus) { this._focus.blur(); } switch(elem.nodeName.toLowerCase()) { case 'menu-group': this.handleFocusMenuGroup(elem); break; default: elem.focus(); this._focus = elem; this._focusScrollable = undefined; this.checkFocusedGroup(elem); break; } } else { this._focusScrollable = undefined; } }, handleUnfocus: function(elem, nodeElem) { if(elem.CLASS_NAME == 'XScrollable') { this.handleCardUnfocus( elem, elem.currentItem, elem.getNodeFromItem(elem.currentItem)); } }, checkFocusedGroup: function(elem) { if (!this._focusedGroup) { return; } // close the focused group when we move focus out of this group. if (!elem || !this._focusedGroup.contains(elem)) { this._focusedGroup.close(); this._focusedGroup = null; } }, handleFocusMenuGroup: function(menuGroup) { var self = this; menuGroup.once('opened', function() { self.spatialNavigator.remove(menuGroup); var childElement = menuGroup.firstElementChild; var firstFocusable = null; while(childElement) { switch(childElement.nodeName.toLowerCase()) { case 'style': case 'script': break; default: firstFocusable = firstFocusable || childElement; self.spatialNavigator.add(childElement); } childElement = childElement.nextElementSibling; } if (firstFocusable) { self.spatialNavigator.focus(firstFocusable); } }); menuGroup.once('will-close', function() { // Clear all opened event listener because we won't have it if opened is // not fired and the group is trying to close it self. menuGroup.off('opened'); self.spatialNavigator.add(menuGroup); var childElement = menuGroup.firstElementChild; while(childElement) { switch(childElement.nodeName.toLowerCase()) { case 'style': case 'script': break; default: self.spatialNavigator.remove(childElement); } childElement = childElement.nextElementSibling; } }); this.checkFocusedGroup(menuGroup); this._focusedGroup = menuGroup; menuGroup.open(); }, handleCardFocus: function(scrollable, itemElem, nodeElem) { this._focus = itemElem; if (this.edit.mode === 'edit') { this.edit.handleCardFocus(scrollable, itemElem, nodeElem); } itemElem.focus(); nodeElem.classList.add('focused'); if(scrollable === this.cardScrollable && this._folderCard && itemElem.dataset.cardId !== this._folderCard.cardId && !this.cardScrollable.isHovering) { this.cleanFolderScrollable(); } }, cleanFolderScrollable: function(doNotChangeFocus) { if (this._focusScrollable === this.folderScrollable && !doNotChangeFocus) { this.spatialNavigator.focus(this.cardScrollable); } this.spatialNavigator.remove(this.folderScrollable); this.folderScrollable.clean(); this._folderCard = undefined; this.edit.isFolderReady = false; this.cardScrollable.setColspanOnFocus(0); }, handleCardUnfocus: function(scrollable, itemElem, nodeElem) { // Fix null error when the last card in a folder is removed. if (nodeElem) { nodeElem.classList.remove('focused'); } }, handleCardUnhover: function(scrollable, itemElem, nodeElem) { this.cleanFolderScrollable(); }, determineFolderExpand: function(evt) { // Folder expansion is performed on only when user moves cursor onto a // folder or hover a folder in edit mode and it finished its focus // transition. if (this.focusScrollable === this.cardScrollable && evt.originalTarget.classList.contains('app-button') && (!this._folderCard || this._folderCard.cardId !== evt.originalTarget.dataset.cardId) && (evt.originalTarget.classList.contains('focused') && // Listen to 'outline-width' rather than 'transform' here since it // also applies to edit mode when user moves from panel button back // to card. evt.propertyName === 'outline-width' && document.getElementById('main-section').dataset.mode !== 'arrange' || // Folder needs to be expanded when hovered as well. evt.originalTarget.classList.contains('hovered'))) { this.buildFolderList(evt.originalTarget); } }, buildFolderList: function(target) { var cardId = target.dataset.cardId; var card = this.cardManager.findCardFromCardList({cardId: cardId}); if (!(card instanceof Folder)) { return; } this._folderCard = card; var folderList = this._folderCard.getCardList(); if (folderList.length === 0) { this.edit.isFolderReady = true; // Needs to disable the folder-list animation right away to // prevent it from affecting the new-card into folder animation. this.folderListElem.style.transition = 'none'; return; } // Build folder list folderList.forEach(function(card) { this.folderScrollable.addNode(this.createCardNode(card)); }, this); var isFirstFrame = true; var initFolderAnimation = function() { if (isFirstFrame) { isFirstFrame = false; // At first frame, we call setReferenceElement to move folder list // right under folder card. Transition should be replaced by 'none' // since we don't need to show this process as animation to user. this.folderListElem.style.transition = 'none'; this.folderScrollable.realignToReferenceElement(); this.skipFolderBubble = Animations.doBubbleAnimation( this.folderListElem, '.app-button', 100, function() { this.spatialNavigator.add(this.folderScrollable); this.edit.isFolderReady = true; this.skipFolderBubble = undefined; }.bind(this)); window.requestAnimationFrame(initFolderAnimation); } else { // 2nd frame, recover original transition. this.folderListElem.style.transition = ''; } }.bind(this); window.requestAnimationFrame(initFolderAnimation); }, openSettings: function() { /* jshint nonew: false */ new MozActivity({ name: 'configure', data: {} }); }, updateClock: function() { var now = new Date(); var _ = navigator.mozL10n.get; var use12Hour = window.navigator.mozHour12; var f = new navigator.mozL10n.DateTimeFormat(); var timeFormat = use12Hour ? _('shortTimeFormat12') : _('shortTimeFormat24'); // remove AM/PM and we use our owned style to show it. timeFormat = timeFormat.replace('%p', '').trim(); var formatted = f.localeFormat(now, timeFormat); this.timeElem.innerHTML = formatted; this.timeElem.dataset.ampm = use12Hour ? f.localeFormat(now, '%p') : ''; }, restartClock: function() { navigator.mozL10n.ready((function() { // restart clcok this.clock.stop(); this.clock.start(this.updateClock.bind(this)); }).bind(this)); }, get focusElem() { return this._focus; }, get focusScrollable() { return this._focusScrollable; } }; exports.Home = Home; }(window));