diff --git a/apps/music/css/tv.css b/apps/music/css/tv.css new file mode 100644 index 000000000000..1d80a7ba83fd --- /dev/null +++ b/apps/music/css/tv.css @@ -0,0 +1,7 @@ +#view-stack { + bottom: 0; +} + +#tab-bar { + display: none !important; +} diff --git a/apps/music/elements/music-view-stack.js b/apps/music/elements/music-view-stack.js index 197c4c49a1a4..5d6439d10166 100644 --- a/apps/music/elements/music-view-stack.js +++ b/apps/music/elements/music-view-stack.js @@ -217,6 +217,7 @@ proto.setRootView = function(url) { newActiveView.frame.contentWindow.dispatchEvent( new CustomEvent('viewvisible') ); + setTimeout(() => newActiveView.frame.contentWindow.focus(), 100); this.dispatchEvent(new CustomEvent('change', { detail: newActiveView })); }); @@ -245,6 +246,7 @@ proto.pushView = function(url) { newActiveView.frame.contentWindow.dispatchEvent( new CustomEvent('viewvisible') ); + setTimeout(() => newActiveView.frame.contentWindow.focus(), 100); this.dispatchEvent(new CustomEvent('change', { detail: newActiveView })); }); @@ -279,6 +281,7 @@ proto.popView = function(destroy) { newActiveView.frame.contentWindow.dispatchEvent( new CustomEvent('viewvisible') ); + setTimeout(() => newActiveView.frame.contentWindow.focus(), 100); this.dispatchEvent(new CustomEvent('change', { detail: newActiveView })); diff --git a/apps/music/index-tv.html b/apps/music/index-tv.html new file mode 100644 index 000000000000..232fbec98238 --- /dev/null +++ b/apps/music/index-tv.html @@ -0,0 +1,96 @@ + + + + + Music + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/music/js/app.js b/apps/music/js/app.js index 4d08706fc7c8..3b6b9e6a4c99 100644 --- a/apps/music/js/app.js +++ b/apps/music/js/app.js @@ -1,4 +1,4 @@ -/* exported onSearchOpen, onSearchClose */ +/* exported onSearchOpen, onSearchClose, navigateBack */ /* global SERVICE_WORKERS, bridge */ 'use strict'; @@ -28,8 +28,8 @@ const VIEWS = { ALBUMS: {TAB: 'albums', URL: '/views/albums/index.html'}, ARTIST_DETAIL: {TAB: 'artists', URL: '/views/artist-detail/index.html'}, ARTISTS: {TAB: 'artists', URL: '/views/artists/index.html'}, - HOME: {TAB: 'home', URL: '/views/home/index.html'}, - PLAYER: {TAB: 'home', URL: '/views/player/index.html'}, + HOME: {TAB: 'home', URL: '/views/home-tv/index.html'}, + PLAYER: {TAB: 'home', URL: '/views/player-tv/index.html'}, PLAYLIST_DETAIL: {TAB: 'playlists', URL: '/views/playlist-detail/index.html'}, PLAYLISTS: {TAB: 'playlists', URL: '/views/playlists/index.html'}, SONGS: {TAB: 'songs', URL: '/views/songs/index.html'} @@ -292,6 +292,18 @@ function navigateToURL(url, replaceRoot) { window.history.pushState(null, null, url); } +function navigateBack() { + var isPlayerView = viewStack.activeView && + viewStack.activeView.url === VIEWS.PLAYER.URL; + + if (viewStack.views.length > 1) { + // Don't destroy the popped view if it is the "Player" view. + viewStack.popView(!isPlayerView); + window.history.back(); + return; + } +} + function updateOverlays() { if (emptyOverlay) { diff --git a/apps/music/js/endpoint.js b/apps/music/js/endpoint.js index 22f4db4be202..0ed9964edd28 100644 --- a/apps/music/js/endpoint.js +++ b/apps/music/js/endpoint.js @@ -1,6 +1,6 @@ /* global AlbumArtCache, AudioMetadata, Database, LazyLoader, NFCShare, - PlaybackQueue, Remote, bridge, navigateToURL, onSearchOpen, - onSearchClose */ + PlaybackQueue, Remote, bridge, navigateToURL, navigateBack, + onSearchOpen, onSearchClose */ 'use strict'; var audio = null; @@ -466,7 +466,11 @@ function getDatabaseStatus() { } function navigate(url) { - navigateToURL(url); + if(url) { + navigateToURL(url); + } else { + navigateBack(); + } } function searchOpen() { diff --git a/apps/music/manifest.webapp b/apps/music/manifest.webapp index 1b3b901ec081..9ecf696f56e4 100644 --- a/apps/music/manifest.webapp +++ b/apps/music/manifest.webapp @@ -2,7 +2,7 @@ "version": "0.0.1", "name": "Music", "description": "Gaia Music", - "launch_path": "/index.html", + "launch_path": "/index-tv.html", "icons": { "84": "/img/icons/music_84.png", "126": "/img/icons/music_126.png", @@ -74,7 +74,7 @@ } }, "default_locale": "en", - "orientation": "default", + "orientation": "landscape", "messages": [ { "media-button": "/index.html" } ] diff --git a/apps/music/views/home-tv/index.html b/apps/music/views/home-tv/index.html new file mode 100644 index 000000000000..45693bb84355 --- /dev/null +++ b/apps/music/views/home-tv/index.html @@ -0,0 +1,29 @@ + + + + + Music + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/apps/music/views/home-tv/view.css b/apps/music/views/home-tv/view.css new file mode 100644 index 000000000000..ae811433e877 --- /dev/null +++ b/apps/music/views/home-tv/view.css @@ -0,0 +1,91 @@ +body { + overflow-x: hidden; + overflow-y: auto; + scroll-behavior: smooth; +} + +body[data-search="true"] { + overflow: hidden; +} + +music-search-box { + padding: 0 16px 10px; + border-bottom: solid 1px var(--border-color); + box-sizing: border-box; +} + +music-search-box[hidden] { + display: none; +} + +#tiles { + min-height: 100%; +} + +.search-open #tiles { + display: none; +} + +.tile { + border: solid 1px var(--background); + box-sizing: border-box; + display: block; + float: inline-start; + text-align: match-parent; + position: relative; + width: 33.3vw; + height: 33.3vw; + overflow: hidden; + text-decoration: none; + transition: border 0.2s ease; +} + +.tile.selected { + border: solid 10px var(--highlight-color); +} + +.tile:before, +.tile:after { + background-color: rgba(0, 0, 0, 0.5); + box-sizing: border-box; + color: #fff; + font-size: 1.9rem; + line-height: 2.8rem; + text-shadow: 0 0.1rem rgba(0, 0, 0, 0.5); + display: block; + position: absolute; + padding: 0.15rem 1rem; + top: 0; + left: 0; + width: 100%; + height: 2.8rem; + z-index: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tile:before { + content: attr(data-artist); +} + +.tile:after { + content: attr(data-album); + color: rgba(255, 255, 255, 0.65); + font-size: 1.4rem; + line-height: 2.2rem; + top: 2.8rem; +} + +.tile > img { + position: relative; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 250ms; +} + +.tile > img.loaded { + opacity: 1; +} diff --git a/apps/music/views/home-tv/view.js b/apps/music/views/home-tv/view.js new file mode 100644 index 000000000000..1e62d9b310d6 --- /dev/null +++ b/apps/music/views/home-tv/view.js @@ -0,0 +1,181 @@ +/* global View, Sanitizer */ +'use strict'; + +var HomeView = View.extend(function HomeView() { + View.call(this); // super(); + + this.thumbnailCache = {}; + + this.tiles = document.getElementById('tiles'); + + this.onScroll = debounce(this.loadVisibleImages.bind(this), 500); + window.addEventListener('scroll', () => this.onScroll()); + this.client.on('databaseChange', () => this.update()); + + window.addEventListener('keydown', (evt) => { + evt.preventDefault(); + + var selectedElement = this.tiles.querySelector('.selected'); + if (!selectedElement) { + return; + } + + var elements = this.tiles.querySelectorAll('.tile'); + var selectedIndex = [].indexOf.call(elements, selectedElement); + + switch (evt.key) { + case 'Escape': + this.client.method('navigate', '/player-tv'); + break; + case 'ArrowUp': + selectedIndex = clamp(0, elements.length - 1, selectedIndex - 3); + break; + case 'ArrowDown': + selectedIndex = clamp(0, elements.length - 1, selectedIndex + 3); + break; + case 'ArrowLeft': + selectedIndex = clamp(0, elements.length - 1, selectedIndex - 1); + break; + case 'ArrowRight': + selectedIndex = clamp(0, elements.length - 1, selectedIndex + 1); + break; + case 'Enter': + this.queueAlbum(selectedElement.dataset.filePath); + this.client.method('navigate', selectedElement.getAttribute('href')); + break; + } + selectedElement.classList.remove('selected'); + + selectedElement = elements[selectedIndex]; + selectedElement.classList.add('selected'); + + window.scrollTo(0, selectedElement.offsetTop); + }); + + this.update(); +}); + +HomeView.prototype.update = function() { + return this.getAlbums().then((albums) => { + this.albums = albums; + return this.render(); + }); +}; + +HomeView.prototype.loadVisibleImages = function() { + var scrollTop = window.scrollY; + var scrollBottom = scrollTop + window.innerHeight; + var promises = []; + + var tiles = this.tiles.querySelectorAll('.tile'); + var lastTileVisible = false; + var tile, tileOffset; + + for (var i = 0, length = tiles.length; i < length; i++) { + tile = tiles[i]; + tileOffset = tile.offsetTop; + + if (scrollTop <= tileOffset && tileOffset <= scrollBottom) { + lastTileVisible = true; + promises.push(this.loadTile(tile)); + } + + else if (lastTileVisible) { + break; + } + } + + return Promise.all(promises); +}; + +HomeView.prototype.loadTile = function(tile) { + return new Promise((resolve) => { + if (tile.dataset.loaded) { + return; + } + + this.getThumbnail(tile.dataset.filePath).then((url) => { + var img = tile.querySelector('img'); + img.src = url; + tile.dataset.loaded = true; + img.onload = () => { + setTimeout(() => { + requestAnimationFrame(() => { + img.classList.add('loaded'); + resolve(); + }); + }); + }; + }); + }); +}; + +HomeView.prototype.destroy = function() { + this.client.destroy(); + + View.prototype.destroy.call(this); // super(); // Always call *last* +}; + +HomeView.prototype.render = function() { + View.prototype.render.call(this); // super(); + + return document.l10n.formatValues( + 'unknownArtist', 'unknownAlbum' + ).then(([unknownArtist, unknownAlbum]) => { + var html = []; + + this.albums.forEach((album) => { + var template = +Sanitizer.createSafeHTML ` + +`; + + html.push(template); + }); + + this.tiles.innerHTML = Sanitizer.unwrapSafeHTML(...html); + this.tiles.firstElementChild.classList.add('selected'); + return this.loadVisibleImages(); + }); +}; + +HomeView.prototype.getAlbums = function() { + return this.fetch('/api/albums/list').then(response => response.json()); +}; + +HomeView.prototype.getThumbnail = function(filePath) { + if (!filePath) return; + + if (this.thumbnailCache[filePath]) { + return Promise.resolve(this.thumbnailCache[filePath]); + } + + return this.fetch('/api/artwork/url/thumbnail/' + filePath) + .then((response) => response.json()) + .then((url) => { + this.thumbnailCache[filePath] = url; + return url; + }); +}; + +HomeView.prototype.queueAlbum = function(filePath) { + this.fetch('/api/queue/album/' + filePath); +}; + +function clamp(min, max, value) { + return Math.min(Math.max(min, value), max); +} + +function debounce(fn, ms) { + var timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn.apply(this, args), ms); + }; +} + +window.view = new HomeView(); diff --git a/apps/music/views/player-tv/index.html b/apps/music/views/player-tv/index.html new file mode 100644 index 000000000000..8cb6d20bb336 --- /dev/null +++ b/apps/music/views/player-tv/index.html @@ -0,0 +1,36 @@ + + + + + Music + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/music/views/player-tv/view.css b/apps/music/views/player-tv/view.css new file mode 100644 index 000000000000..eeeaa5cab142 --- /dev/null +++ b/apps/music/views/player-tv/view.css @@ -0,0 +1,5 @@ +music-artwork { + display: block; + width: 100%; + height: calc(100% - 9.1rem); +} diff --git a/apps/music/views/player-tv/view.js b/apps/music/views/player-tv/view.js new file mode 100644 index 000000000000..e22ebbff28f1 --- /dev/null +++ b/apps/music/views/player-tv/view.js @@ -0,0 +1,174 @@ +/* global View */ +'use strict'; + +const REPEAT_VALUES = ['off', 'list', 'song']; +const SHUFFLE_VALUES = ['off', 'on']; + +var PlayerView = View.extend(function PlayerView() { + View.call(this); // super(); + + this.artwork = document.getElementById('artwork'); + this.controls = document.getElementById('controls'); + this.seekBar = document.getElementById('seek-bar'); + + this.artwork.addEventListener('share', () => this.share()); + this.artwork.addEventListener('repeat', () => { + this.setRepeatSetting(this.artwork.repeat); + }); + this.artwork.addEventListener('shuffle', () => { + this.setShuffleSetting(this.artwork.shuffle); + }); + this.artwork.addEventListener('ratingchange', (evt) => { + this.setSongRating(evt.detail); + }); + + this.controls.addEventListener('play', () => this.play()); + this.controls.addEventListener('pause', () => this.pause()); + this.controls.addEventListener('previous', () => this.previous()); + this.controls.addEventListener('next', () => this.next()); + this.controls.addEventListener('startseek', (evt) => { + this.startFastSeek(evt.detail.reverse); + }); + this.controls.addEventListener('stopseek', () => this.stopFastSeek()); + + this.seekBar.addEventListener('seek', (evt) => { + this.seek(evt.detail.elapsedTime); + }); + + this.client.on('play', () => this.controls.paused = false); + this.client.on('pause', () => this.controls.paused = true); + this.client.on('songChange', () => this.update()); + this.client.on('durationChange', (duration) => { + this.seekBar.duration = duration; + }); + this.client.on('elapsedTimeChange', (elapsedTime) => { + this.seekBar.elapsedTime = elapsedTime; + }); + + window.addEventListener('keydown', (evt) => { + switch (evt.key) { + case 'Escape': + // Goes back to music home screen + this.client.method('navigate'); + break; + case 'ArrowLeft': + this.previous(); + break; + case 'ArrowRight': + this.next(); + break; + case 'Enter': + this.controls.paused ? this.play() : this.pause(); + break; + } + }); + + this.update(); +}); + +PlayerView.prototype.update = function() { + this.getPlaybackStatus().then((status) => { + this.getSong(status.filePath).then((song) => { + if (!song) { + return; + } + + document.l10n.formatValues( + 'unknownTitle', 'unknownArtist', 'unknownAlbum' + ).then(([unknownTitle, unknownArtist, unknownAlbum]) => { + this.title = song.metadata.title || unknownTitle; + this.artwork.artist = song.metadata.artist || unknownArtist; + this.artwork.album = song.metadata.album || unknownAlbum; + }); + + this.artwork.els.rating.value = song.metadata.rated; + }); + + this.getSongArtwork(status.filePath) + .then((url) => this.artwork.src = url); + + this.artwork.repeat = REPEAT_VALUES[status.repeat]; + this.artwork.shuffle = SHUFFLE_VALUES[status.shuffle]; + this.controls.paused = status.paused; + this.seekBar.duration = status.duration; + this.seekBar.elapsedTime = status.elapsedTime; + this.render(); + }); +}; + +PlayerView.prototype.destroy = function() { + this.client.destroy(); + + View.prototype.destroy.call(this); // super(); // Always call *last* +}; + +PlayerView.prototype.render = function() { + View.prototype.render.call(this); // super(); +}; + +PlayerView.prototype.startFastSeek = function(reverse) { + this.fetch('/api/audio/fastseek/start/' + (reverse ? 'reverse' : 'forward')); +}; + +PlayerView.prototype.stopFastSeek = function() { + this.fetch('/api/audio/fastseek/stop'); +}; + +PlayerView.prototype.seek = function(time) { + this.fetch('/api/audio/seek/' + time); +}; + +PlayerView.prototype.play = function() { + this.fetch('/api/audio/play'); +}; + +PlayerView.prototype.pause = function() { + this.fetch('/api/audio/pause'); +}; + +PlayerView.prototype.previous = function() { + this.fetch('/api/queue/previous'); +}; + +PlayerView.prototype.next = function() { + this.fetch('/api/queue/next'); +}; + +PlayerView.prototype.share = function() { + this.getPlaybackStatus().then((status) => { + this.fetch('/api/activities/share/' + status.filePath); + }); +}; + +PlayerView.prototype.getPlaybackStatus = function() { + return this.fetch('/api/audio/status').then(response => response.json()); +}; + +PlayerView.prototype.setRepeatSetting = function(repeat) { + this.fetch('/api/queue/repeat/' + REPEAT_VALUES.indexOf(repeat)); +}; + +PlayerView.prototype.setShuffleSetting = function(shuffle) { + this.fetch('/api/queue/shuffle/' + SHUFFLE_VALUES.indexOf(shuffle)); +}; + +PlayerView.prototype.setSongRating = function(rating) { + this.getPlaybackStatus().then((status) => { + this.fetch('/api/songs/rating/' + rating + '/' + status.filePath); + }); +}; + +PlayerView.prototype.getSong = function(filePath) { + return this.fetch('/api/songs/info/' + filePath).then((response) => { + return response.json(); + }); +}; + +PlayerView.prototype.getSongArtwork = function(filePath) { + return this.fetch('/api/artwork/url/original/' + filePath) + .then((response) => { + return response.json(); + }); +}; + +window.view = new PlayerView(); diff --git a/build/csslint/xfail.list b/build/csslint/xfail.list index e9e8bf001230..858598f7cfc7 100644 --- a/build/csslint/xfail.list +++ b/build/csslint/xfail.list @@ -70,8 +70,10 @@ apps/music/components/dom-scheduler/demo-app/css/ul.css 1 1 apps/music/components/gaia-theme/gaia-theme.css 5 0 apps/music/css/view.css 1 1 apps/music/css/app.css 0 2 +apps/music/css/tv.css 0 1 apps/music/node_modules/nws/node_modules/connect/lib/public/style.css 4 10 apps/music/views/home/view.css 3 0 +apps/music/views/home-tv/view.css 2 0 apps/pdfjs/content/web/viewer.css 0 1 apps/ringtones/style/pick.css 0 1 apps/search/style/newtab.css 3 0