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.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html class="theme-media">
<head>
<meta charset="utf-8">
<title data-l10n-id="albums">Albums</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/music.{locale}.properties">
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
<!-- Stylesheets -->
<link rel="stylesheet" href="/components/gaia-icons/gaia-icons.css">
<link rel="stylesheet" href="/components/gaia-theme/gaia-theme.css">
<link rel="stylesheet" href="/css/view.css">
<!-- Deferred Scripts -->
<script defer src="/components/bridge/client.js"></script>
<script defer src="/shared/js/intl/l20n-client.js"></script>
<script defer src="/shared/js/intl_helper.js"></script>
<script defer src="/components/dom-scheduler/lib/dom-scheduler.js"></script>
<script defer src="/components/fast-list/fast-list.js"></script>
<script defer src="/components/poplar/poplar.js"></script>
<script defer src="/components/gaia-component/gaia-component.js"></script>
<script defer src="/components/gaia-fast-list/gaia-fast-list.js"></script>
<script defer src="/js/config.js"></script>
<script defer src="/js/view.js"></script>
<script defer src="view.js"></script>
</head>
<body role="application">
<gaia-fast-list id="list" top="50" bottom="0">
<template>
<a href="/player" data-file-path="${name}" data-number="${index}">
<h3 dir="auto">${title}</h3>
</a>
</template>
</gaia-fast-list>
</body>
</html>

View file

@ -0,0 +1,85 @@
/* global View, IntlHelper */
'use strict';
var AlbumDetailView = View.extend(function AlbumDetailView() {
View.call(this); // super();
this.list = document.getElementById('list');
// Triggers player service to begin playing the track.
// This works for now, but we might have the PlayerView
// take care of this task as it's a big more webby :)
this.list.addEventListener('click', (evt) => {
var link = evt.target.closest('a[data-file-path]');
if (link) {
this.queueAlbum(link.dataset.filePath);
}
});
this.client.on('databaseChange', () => this.update());
this.update();
});
AlbumDetailView.prototype.update = function() {
return this.getAlbum().then((songs) => {
this.songs = songs;
return this.render();
});
};
AlbumDetailView.prototype.destroy = function() {
this.client.destroy();
View.prototype.destroy.call(this); // super(); // Always call *last*
};
AlbumDetailView.prototype.render = function() {
return this.list.setModel(this.songs)
.then(this.onRenderDone);
};
AlbumDetailView.prototype.getAlbum = function() {
var unpaddedIndex = IntlHelper.get('unpaddedIndex');
var paddedIndex = IntlHelper.get('paddedIndex');
return this.fetch('/api/albums/info/' + decodeURIComponent(this.params.id))
.then(response => response.json())
.then(songs => {
var maxDiscNumber = Math.max(
songs[songs.length - 1].metadata.disccount,
songs[songs.length - 1].metadata.discnum
);
return songs.map((song) => {
return {
index: maxDiscNumber > 1 &&
song.metadata.discnum && song.metadata.tracknum ?
unpaddedIndex.format(song.metadata.discnum) + '.' +
paddedIndex.format(song.metadata.tracknum) :
(song.metadata.tracknum ?
unpaddedIndex.format(song.metadata.tracknum) : ''),
name: song.name,
title: song.metadata.title
};
});
});
};
AlbumDetailView.prototype.queueAlbum = function(filePath) {
this.fetch('/api/queue/album/' + filePath);
};
IntlHelper.define('unpaddedIndex', 'number', {
style: 'decimal',
useGrouping: false,
minimumIntegerDigits: 1
});
IntlHelper.define('paddedIndex', 'number', {
style: 'decimal',
useGrouping: false,
minimumIntegerDigits: 2
});
window.view = new AlbumDetailView();

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html class="theme-media">
<head>
<meta charset="utf-8">
<title data-l10n-id="albums">Albums</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/music.{locale}.properties">
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
<!-- Stylesheets -->
<link rel="stylesheet" href="/components/gaia-icons/gaia-icons.css">
<link rel="stylesheet" href="/components/gaia-theme/gaia-theme.css">
<link rel="stylesheet" href="/css/view.css">
<!-- Deferred Scripts -->
<script defer src="/components/bridge/client.js"></script>
<script defer src="/shared/js/intl/l20n-client.js"></script>
<script defer src="/components/dom-scheduler/lib/dom-scheduler.js"></script>
<script defer src="/components/fast-list/fast-list.js"></script>
<script defer src="/components/poplar/poplar.js"></script>
<script defer src="/components/gaia-component/gaia-component.js"></script>
<script defer src="/components/gaia-fast-list/gaia-fast-list.js"></script>
<script defer src="/components/gaia-sub-header/gaia-sub-header.js"></script>
<script defer src="/components/gaia-text-input/gaia-text-input.js"></script>
<script defer src="/elements/music-search-box.js"></script>
<script defer src="/elements/music-search-results.js"></script>
<script defer src="/js/config.js"></script>
<script defer src="/js/view.js"></script>
<script defer src="view.js"></script>
</head>
<body role="application" hidden>
<gaia-fast-list id="list" top="50" bottom="0" caching>
<music-search-box id="search-box" results="#search-results"></music-search-box>
<template>
<a href="${url}">
<div class="image"><img/></div>
<h3 dir="auto">${album}</h3>
<p dir="auto">${artist}</p>
</a>
</template>
</gaia-fast-list>
<music-search-results id="search-results"></music-search-results>
</body>
</html>

View file

@ -0,0 +1,86 @@
/* global View */
'use strict';
var AlbumsView = View.extend(function AlbumsView() {
View.call(this); // super();
this.searchBox = document.getElementById('search-box');
this.searchResults = document.getElementById('search-results');
this.list = document.getElementById('list');
this.client.on('databaseChange', () => this.update());
this.setupSearch();
this.setupList();
this.update();
});
AlbumsView.prototype.update = function() {
return this.getAlbums().then((albums) => {
this.albums = albums;
return this.render();
});
};
AlbumsView.prototype.destroy = function() {
this.client.destroy();
View.prototype.destroy.call(this); // super(); // Always call *last*
};
AlbumsView.prototype.render = function() {
return this.list.setModel(this.albums)
.then(() => this.list.cache())
.then(this.onRenderDone);
};
AlbumsView.prototype.getAlbums = function() {
return document.l10n.formatValues('unknownAlbum', 'unknownArtist')
.then(([unknownAlbum, unknownArtist]) => {
return this.fetch('/api/albums/list')
.then(response => response.json())
.then((albums) => {
return albums.map((album) => {
return {
name: album.name,
url: '/album-detail?id=' + encodeURIComponent(album.name),
album: album.metadata.album || unknownAlbum,
artist: album.metadata.artist || unknownArtist
};
});
});
});
};
AlbumsView.prototype.getThumbnail = function(filePath) {
return this.fetch('/api/artwork/url/thumbnail/' + filePath)
.then(response => response.json());
};
AlbumsView.prototype.search = function(query) {
if (!query) {
return Promise.resolve(this.searchResults.clearResults());
}
return document.l10n.formatValues(
'unknownAlbum', 'unknownArtist'
).then(([unknownAlbum, unknownArtist]) => {
return this.fetch('/api/search/album/' + query)
.then(response => response.json())
.then((albums) => {
var results = albums.map((album) => {
return {
name: album.name,
title: album.metadata.album || unknownAlbum,
subtitle: album.metadata.artist || unknownArtist,
section: 'albums',
url: '/album-detail?id=' + encodeURIComponent(album.name)
};
});
return this.searchResults.setResults(results);
});
});
};
window.view = new AlbumsView();

View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html class="theme-media">
<head>
<meta charset="utf-8">
<title data-l10n-id="artists">Artists</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/music.{locale}.properties">
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
<!-- Stylesheets -->
<link rel="stylesheet" href="/components/gaia-icons/gaia-icons.css">
<link rel="stylesheet" href="/components/gaia-theme/gaia-theme.css">
<link rel="stylesheet" href="/css/view.css">
<!-- Deferred Scripts -->
<script defer src="/components/bridge/client.js"></script>
<script defer src="/shared/js/intl/l20n-client.js"></script>
<script defer src="/shared/js/intl_helper.js"></script>
<script defer src="/components/dom-scheduler/lib/dom-scheduler.js"></script>
<script defer src="/components/fast-list/fast-list.js"></script>
<script defer src="/components/poplar/poplar.js"></script>
<script defer src="/components/gaia-component/gaia-component.js"></script>
<script defer src="/components/gaia-fast-list/gaia-fast-list.js"></script>
<script defer src="/components/gaia-sub-header/gaia-sub-header.js"></script>
<script defer src="/js/config.js"></script>
<script defer src="/js/view.js"></script>
<script defer src="view.js"></script>
</head>
<body role="application">
<gaia-fast-list id="list" top="50" bottom="0">
<template>
<a href="/player" data-file-path="${name}" data-number="${index}">
<h3 dir="auto">${title}</h3>
</a>
</template>
</gaia-fast-list>
</body>
</html>

View file

@ -0,0 +1,72 @@
/* global View, IntlHelper */
'use strict';
var ArtistDetailView = View.extend(function ArtistDetailView() {
View.call(this); // super();
this.list = document.getElementById('list');
// Triggers player service to begin playing the track.
// This works for now, but we might have the PlayerView
// take care of this task as it's a big more webby :)
this.list.addEventListener('click', evt => {
var link = evt.target.closest('a[data-file-path]');
if (link) {
this.queueArtist(link.dataset.filePath);
}
});
this.list.configure({
getSectionName: item => item.section
});
this.client.on('databaseChange', () => this.update());
this.update();
});
ArtistDetailView.prototype.update = function() {
return this.getArtist().then(songs => {
this.songs = songs;
return this.render();
});
};
ArtistDetailView.prototype.destroy = function() {
this.client.destroy();
View.prototype.destroy.call(this); // super(); // Always call *last*
};
ArtistDetailView.prototype.render = function() {
return this.list.setModel(this.songs)
.then(this.onRenderDone);
};
ArtistDetailView.prototype.getArtist = function() {
var unpaddedIndex = IntlHelper.get('unpaddedIndex');
return this.fetch('/api/artists/info/' + decodeURIComponent(this.params.id))
.then(response => response.json())
.then(songs => {
return songs.map(song => {
return {
index: song.metadata.tracknum ?
unpaddedIndex.format(song.metadata.tracknum) : '',
name: song.name,
title: song.metadata.title,
section: song.metadata.album
};
});
});
};
ArtistDetailView.prototype.queueArtist = function(filePath) {
this.fetch('/api/queue/artist/' + filePath);
};
IntlHelper.define('unpaddedIndex', 'number', {
style: 'decimal',
useGrouping: false,
minimumIntegerDigits: 1
});
window.view = new ArtistDetailView();

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html class="theme-media">
<head>
<meta charset="utf-8">
<title data-l10n-id="artists">Artists</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/music.{locale}.properties">
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
<!-- Stylesheets -->
<link rel="stylesheet" href="/components/gaia-icons/gaia-icons.css">
<link rel="stylesheet" href="/components/gaia-theme/gaia-theme.css">
<link rel="stylesheet" href="/css/view.css">
<!-- Deferred Scripts -->
<script defer src="/components/bridge/client.js"></script>
<script defer src="/shared/js/intl/l20n-client.js"></script>
<script defer src="/components/dom-scheduler/lib/dom-scheduler.js"></script>
<script defer src="/components/fast-list/fast-list.js"></script>
<script defer src="/components/poplar/poplar.js"></script>
<script defer src="/components/gaia-component/gaia-component.js"></script>
<script defer src="/components/gaia-fast-list/gaia-fast-list.js"></script>
<script defer src="/components/gaia-sub-header/gaia-sub-header.js"></script>
<script defer src="/components/gaia-text-input/gaia-text-input.js"></script>
<script defer src="/elements/music-search-box.js"></script>
<script defer src="/elements/music-search-results.js"></script>
<script defer src="/js/config.js"></script>
<script defer src="/js/view.js"></script>
<script defer src="view.js"></script>
</head>
<body role="application" hidden>
<gaia-fast-list id="list" top="50" bottom="0" caching>
<music-search-box id="search-box" results="#search-results"></music-search-box>
<template>
<a href="${url}">
<div class="image"><img/></div>
<h3 dir="auto">${artist}</h3>
</a>
</template>
</gaia-fast-list>
<music-search-results id="search-results"></music-search-results>
</body>
</html>

View file

@ -0,0 +1,82 @@
/* global View */
'use strict';
var ArtistsView = View.extend(function ArtistsView() {
View.call(this); // super();
this.searchBox = document.getElementById('search-box');
this.searchResults = document.getElementById('search-results');
this.list = document.getElementById('list');
this.client.on('databaseChange', () => this.update());
this.setupSearch();
this.setupList();
this.update();
});
ArtistsView.prototype.update = function() {
return this.getArtists().then((artists) => {
this.artists = artists;
return this.render();
});
};
ArtistsView.prototype.destroy = function() {
this.client.destroy();
View.prototype.destroy.call(this); // super(); // Always call *last*
};
ArtistsView.prototype.render = function() {
return this.list.setModel(this.artists)
.then(() => this.list.cache())
.then(this.onRenderDone);
};
ArtistsView.prototype.getArtists = function() {
return document.l10n.formatValue('unknownArtist')
.then((unknownArtist) => {
return this.fetch('/api/artists/list')
.then(response => response.json())
.then((artists) => {
return artists.map((artist) => {
return {
name: artist.name,
url: '/artist-detail?id=' + encodeURIComponent(artist.name),
artist: artist.metadata.artist || unknownArtist
};
});
});
});
};
ArtistsView.prototype.getThumbnail = function(filePath) {
return this.fetch('/api/artwork/url/thumbnail/' + filePath)
.then(response => response.json());
};
ArtistsView.prototype.search = function(query) {
if (!query) {
return Promise.resolve(this.searchResults.clearResults());
}
return document.l10n.formatValue('unknownArtist').then((unknownArtist) => {
return this.fetch('/api/search/artist/' + query)
.then(response => response.json())
.then((artists) => {
var results = artists.map((artist) => {
return {
name: artist.name,
title: artist.metadata.artist || unknownArtist,
subtitle: '',
section: 'artists',
url: '/artist-detail?id=' + encodeURIComponent(artist.name)
};
});
return this.searchResults.setResults(results);
});
});
};
window.view = new ArtistsView();

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html class="theme-media">
<head>
<meta charset="utf-8">
<title data-l10n-id="music">Music</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/music.{locale}.properties">
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
<!-- Stylesheets -->
<link rel="stylesheet" href="/components/gaia-icons/gaia-icons.css">
<link rel="stylesheet" href="/components/gaia-theme/gaia-theme.css">
<link rel="stylesheet" href="/css/view.css">
<link rel="stylesheet" href="view.css">
<!-- Deferred Scripts -->
<script defer src="/components/bridge/client.js"></script>
<script defer src="/shared/js/intl/l20n-client.js"></script>
<script defer src="/shared/js/sanitizer.js"></script>
<script defer src="/shared/js/lazy_loader.js"></script>
<script defer src="/js/config.js"></script>
<script defer src="/js/view.js"></script>
<script defer src="view.js"></script>
</head>
<body role="application">
<music-search-box id="search-box" results="#search-results" hidden></music-search-box>
<section id="tiles"></section>
<music-search-results id="search-results"></music-search-results>
</body>
</html>

View file

@ -0,0 +1,116 @@
body {
overflow-x: hidden;
overflow-y: auto;
}
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-bottom: solid 0.1rem var(--background);
box-sizing: border-box;
display: block;
float: inline-start;
text-align: match-parent;
position: relative;
width: 33.333vw;
height: 33.333vw;
overflow: hidden;
text-decoration: none;
}
.tile:nth-child(3n+1) {
width: 66.666vw;
height: 66.666vw;
}
.tile:nth-child(6n+4),
.tile:nth-child(6n+5),
.tile:nth-child(6n+6) {
float: inline-end;
}
.tile:nth-child(1),
.tile:nth-child(2) {
border-top: solid 0.1rem var(--background);
}
.tile:nth-child(6n+1) {
border-right: solid 0.1rem var(--background);
}
.tile:nth-child(6n+4) {
border-left: solid 0.1rem var(--background);
}
.tile:before,
.tile:after {
background-color: rgba(0, 0, 0, 0.5);
text-shadow: 0 0.1rem rgba(0, 0, 0, 0.5);
position: absolute;
display: block;
box-sizing: border-box;
padding: 0.15rem 1rem;
left: 0;
width: 100%;
height: 2.8rem;
z-index: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 1;
transition: opacity 250ms;
}
.tile.loaded:not(.show-title):before,
.tile.loaded:not(.show-title):after {
opacity: 0;
}
.tile:before {
content: attr(data-artist);
color: #fff;
font-size: 1.9rem;
line-height: 2.8rem;
top: 0;
}
.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.loaded > img {
opacity: 1;
}

View file

@ -0,0 +1,297 @@
/* global View, Sanitizer, LazyLoader */
'use strict';
var HomeView = View.extend(function HomeView() {
View.call(this); // super();
this.thumbnailCache = {};
this.tiles = document.getElementById('tiles');
this.tiles.addEventListener('click', (evt) => {
var link = evt.target.closest('a[data-file-path]');
if (link) {
this.queueAlbum(link.dataset.filePath);
}
});
this.onScroll = debounce(this.loadVisibleImages.bind(this), 500);
window.addEventListener('scroll', () => this.onScroll());
this.client.on('databaseChange', () => this.update());
// Two phase setup prevents non-critical
// work blocking getting content on the screen
this.update()
.then(() => this.lazyLoadScripts());
});
/**
* List of scripts that can be
* lazy-loaded after the critical
* path has completed.
*
* @type {Array}
*/
HomeView.prototype.nonCriticalScripts = [
'/components/dom-scheduler/lib/dom-scheduler.js',
'/components/fast-list/fast-list.js',
'/components/poplar/poplar.js',
'/components/gaia-component/gaia-component.js',
'/components/gaia-text-input/gaia-text-input.js',
'/components/gaia-fast-list/gaia-fast-list.js',
'/components/gaia-sub-header/gaia-sub-header.js',
'/elements/music-search-box.js',
'/elements/music-search-results.js'
];
/**
* Setup everything that is not
* critical for first-paint.
*
* @return {Promise}
*/
HomeView.prototype.lazyLoadScripts = function() {
return new Promise((resolve) => {
setTimeout(() => {
LazyLoader.load(this.nonCriticalScripts).then(() => {
this.setupSearch();
resolve();
});
}, 500);
});
};
HomeView.prototype.setupSearch = function() {
this.searchBox = document.getElementById('search-box');
this.searchResults = document.getElementById('search-results');
this.searchBoxHeight = this.searchBox.HEIGHT + 11;// 10px padding + 1px border
this.searchBox.addEventListener('search', (evt) => this.search(evt.detail));
this.searchResults.addEventListener('open', () => {
this.client.method('searchOpen');
document.body.dataset.search = true;
document.body.classList.add('search-open');
});
this.searchResults.addEventListener('close', () => {
this.client.method('searchClose');
document.body.dataset.search = false;
document.body.classList.remove('search-open');
window.scrollTo(0, this.searchBoxHeight);
});
this.searchResults.addEventListener('resultclick', (evt) => {
var link = evt.detail;
if (link) {
if (link.dataset.section === 'songs') {
this.queueSong(link.dataset.filePath);
}
this.client.method('navigate', link.getAttribute('href'));
}
});
this.searchResults.getItemImageSrc = (item) => this.getThumbnail(item.name);
this.searchBox.hidden = false;
window.scrollTo(0, this.searchBoxHeight);
};
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(() => {
tile.classList.add('loaded');
resolve();
});
});
};
});
});
};
HomeView.prototype.destroy = function() {
this.client.destroy();
View.prototype.destroy.call(this); // super(); // Always call *last*
};
HomeView.prototype.render = function() {
return document.l10n.formatValues(
'unknownArtist', 'unknownAlbum'
).then(([unknownArtist, unknownAlbum]) => {
var html = [];
this.albums.forEach((album) => {
var template =
Sanitizer.createSafeHTML `
<a class="${album.metadata.picture ? 'tile' : 'tile show-title'}" dir="auto"
href="/player"
data-artist="${album.metadata.artist || unknownArtist}"
data-album="${album.metadata.album || unknownAlbum}"
data-file-path="${album.name}">
<img>
</a>`;
html.push(template);
});
this.tiles.innerHTML = Sanitizer.unwrapSafeHTML(...html);
return Promise.all([
this.loadVisibleImages(),
document.l10n.ready
]);
}).then(this.onRenderDone);
};
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);
};
HomeView.prototype.queueSong = function(filePath) {
this.fetch('/api/queue/song/' + filePath);
};
HomeView.prototype.search = function(query) {
if (!query) {
return Promise.resolve(this.searchResults.clearResults());
}
var results = [];
return document.l10n.formatValues(
'unknownTitle', 'unknownArtist', 'unknownAlbum'
).then(([unknownTitle, unknownArtist, unknownAlbum]) => {
var albumResults = this.fetch('/api/search/album/' + query)
.then(response => response.json())
.then((albums) => {
var albumResults = albums.map((album) => {
return {
name: album.name,
title: album.metadata.album || unknownAlbum,
subtitle: album.metadata.artist || unknownArtist,
section: 'albums',
url: '/album-detail?id=' + encodeURIComponent(album.name)
};
});
results = results.concat(albumResults);
this.searchResults.setResults(results);
return albumResults;
});
var artistResults = this.fetch('/api/search/artist/' + query)
.then(response => response.json())
.then((artists) => {
var artistResults = artists.map((artist) => {
return {
name: artist.name,
title: artist.metadata.artist || unknownArtist,
subtitle: '',
section: 'artists',
url: '/artist-detail?id=' + encodeURIComponent(artist.name)
};
});
results = results.concat(artistResults);
this.searchResults.setResults(results);
return artistResults;
});
var songResults = this.fetch('/api/search/title/' + query)
.then(response => response.json())
.then((songs) => {
var songResults = songs.map((song) => {
return {
name: song.name,
title: song.metadata.title || unknownTitle,
subtitle: song.metadata.artist || unknownArtist,
section: 'songs',
url: '/player'
};
});
results = results.concat(songResults);
this.searchResults.setResults(results);
return songResults;
});
return Promise.all([albumResults, artistResults, songResults]).then(() => {
return results;
});
});
};
function debounce(fn, ms) {
var timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), ms);
};
}
window.view = new HomeView();

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html class="theme-media">
<head>
<meta charset="utf-8">
<title data-l10n-id="music">Music</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/music.{locale}.properties">
<link rel="localization" href="/shared/locales/date/date.{locale}.properties">
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
<!-- Stylesheets -->
<link rel="stylesheet" href="/components/gaia-icons/gaia-icons.css">
<link rel="stylesheet" href="/components/gaia-theme/gaia-theme.css">
<link rel="stylesheet" href="/css/view.css">
<link rel="stylesheet" href="view.css">
<!-- Deferred Scripts -->
<script defer src="/components/bridge/client.js"></script>
<script defer src="/shared/js/intl/l20n-client.js"></script>
<script defer src="/shared/js/moz_intl.js"></script>
<script defer src="/shared/js/intl_helper.js"></script>
<script defer src="/elements/music-artwork.js"></script>
<script defer src="/elements/music-controls.js"></script>
<script defer src="/elements/music-rating.js"></script>
<script defer src="/elements/music-seek-bar.js"></script>
<script defer src="/js/config.js"></script>
<script defer src="/js/view.js"></script>
<script defer src="view.js"></script>
</head>
<body role="application">
<music-artwork id="artwork"></music-artwork>
<music-seek-bar id="seek-bar"></music-seek-bar>
<music-controls id="controls"></music-controls>
</body>
</html>

View file

@ -0,0 +1,5 @@
music-artwork {
display: block;
width: 100%;
height: calc(100% - 9.1rem);
}

View file

@ -0,0 +1,156 @@
/* 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;
});
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() {
this.onRenderDone();
};
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();

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html class="theme-media">
<head>
<meta charset="utf-8">
<title data-l10n-id="playlists2">Playlists</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/music.{locale}.properties">
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
<!-- Stylesheets -->
<link rel="stylesheet" href="/components/gaia-icons/gaia-icons.css">
<link rel="stylesheet" href="/components/gaia-theme/gaia-theme.css">
<link rel="stylesheet" href="/css/view.css">
<link rel="stylesheet" href="view.css">
<!-- Deferred Scripts -->
<script defer src="/components/bridge/client.js"></script>
<script defer src="/shared/js/intl/l20n-client.js"></script>
<script defer src="/shared/js/intl_helper.js"></script>
<script defer src="/components/dom-scheduler/lib/dom-scheduler.js"></script>
<script defer src="/components/fast-list/fast-list.js"></script>
<script defer src="/components/poplar/poplar.js"></script>
<script defer src="/components/gaia-component/gaia-component.js"></script>
<script defer src="/components/gaia-fast-list/gaia-fast-list.js"></script>
<script defer src="/js/config.js"></script>
<script defer src="/js/view.js"></script>
<script defer src="view.js"></script>
</head>
<body role="application">
<gaia-fast-list id="list" top="50" bottom="0">
<template>
<a href="/player" data-file-path="${name}" data-number="${index}">
<h3 dir="auto">${title}</h3>
<p dir="auto">${artist}</p>
</a>
</template>
</gaia-fast-list>
</body>
</html>

View file

@ -0,0 +1,3 @@
gaia-fast-list em {
line-height: 0;
}

View file

@ -0,0 +1,65 @@
/* global View, IntlHelper */
'use strict';
var PlaylistDetailView = View.extend(function PlaylistDetailView() {
View.call(this); // super();
this.list = document.getElementById('list');
this.list.addEventListener('click', (evt) => {
var link = evt.target.closest('a[data-file-path]');
if (link) {
this.queuePlaylist(link.dataset.filePath);
}
});
this.client.on('databaseChange', () => this.update());
this.update();
});
PlaylistDetailView.prototype.update = function() {
return this.getPlaylist().then((songs) => {
this.songs = songs;
return this.render();
});
};
PlaylistDetailView.prototype.destroy = function() {
this.client.destroy();
View.prototype.destroy.call(this); // super(); // Always call *last*
};
PlaylistDetailView.prototype.render = function() {
return this.list.setModel(this.songs)
.then(this.onRenderDone);
};
PlaylistDetailView.prototype.getPlaylist = function() {
var unpaddedIndex = IntlHelper.get('unpaddedIndex');
return this.fetch('/api/playlists/info/' + this.params.id)
.then(response => response.json())
.then(songs => {
return songs.map((song, index) => {
return {
index: unpaddedIndex.format(index + 1),
name: song.name,
title: song.metadata.title,
artist: song.metadata.artist
};
});
});
};
PlaylistDetailView.prototype.queuePlaylist = function(filePath) {
this.fetch('/api/queue/playlist/' + this.params.id + '/song/' + filePath);
};
IntlHelper.define('unpaddedIndex', 'number', {
style: 'decimal',
useGrouping: false,
minimumIntegerDigits: 1
});
window.view = new PlaylistDetailView();

View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html class="theme-media">
<head>
<meta charset="utf-8">
<title data-l10n-id="playlists2">Playlists</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/music.{locale}.properties">
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
<!-- Stylesheets -->
<link rel="stylesheet" href="/components/gaia-icons/gaia-icons.css">
<link rel="stylesheet" href="/components/gaia-theme/gaia-theme.css">
<link rel="stylesheet" href="/css/view.css">
<!-- Deferred Scripts -->
<script defer src="/components/bridge/client.js"></script>
<script defer src="/shared/js/intl/l20n-client.js"></script>
<script defer src="/components/dom-scheduler/lib/dom-scheduler.js"></script>
<script defer src="/components/fast-list/fast-list.js"></script>
<script defer src="/components/poplar/poplar.js"></script>
<script defer src="/components/gaia-component/gaia-component.js"></script>
<script defer src="/components/gaia-fast-list/gaia-fast-list.js"></script>
<script defer src="/components/gaia-sub-header/gaia-sub-header.js"></script>
<script defer src="/components/gaia-text-input/gaia-text-input.js"></script>
<script defer src="/elements/music-search-box.js"></script>
<script defer src="/elements/music-search-results.js"></script>
<script defer src="/js/config.js"></script>
<script defer src="/js/view.js"></script>
<script defer src="view.js"></script>
</head>
<body role="application">
<gaia-fast-list id="list" top="50" bottom="0">
<music-search-box id="search-box" results="#search-results"></music-search-box>
<template>
<a href="/playlist-detail?id=${id}" data-id="${id}" data-shuffle="${shuffle}">
<h3 dir="auto">${title}</h3>
</a>
</template>
</gaia-fast-list>
<music-search-results id="search-results"></music-search-results>
</body>
</html>

View file

@ -0,0 +1,170 @@
/* global View */
'use strict';
var PlaylistsView = View.extend(function PlaylistsView() {
View.call(this); // super();
this.searchBox = document.getElementById('search-box');
this.searchResults = document.getElementById('search-results');
this.list = document.getElementById('list');
this.searchBox.addEventListener('search', (evt) => this.search(evt.detail));
this.searchResults.addEventListener('open', () => {
this.client.method('searchOpen');
});
this.searchResults.addEventListener('close', () => {
this.client.method('searchClose');
this.list.scrollTop = this.searchBox.HEIGHT;
});
this.searchResults.addEventListener('resultclick', (evt) => {
var link = evt.detail;
if (link) {
if (link.dataset.section === 'songs') {
this.queueSong(link.dataset.filePath);
}
this.client.method('navigate', link.getAttribute('href'));
}
});
this.searchResults.getItemImageSrc = (item) => this.getThumbnail(item.name);
this.list.minScrollHeight = `calc(100% + ${this.searchBox.HEIGHT}px)`;
this.list.offset = this.searchBox.HEIGHT;
this.list.addEventListener('click', (evt) => {
var link = evt.target.closest('a[data-shuffle="true"]');
if (link) {
evt.preventDefault();
evt.stopPropagation();
this.queuePlaylist(link.dataset.id);
this.client.method('navigate', '/player');
}
});
this.client.on('databaseChange', () => this.update());
this.update()
.then(() => this.list.scrollTo(this.searchBox.HEIGHT));
});
PlaylistsView.prototype.update = function() {
return this.getPlaylists().then((playlists) => {
return Promise.all(playlists.map((playlist) => {
return document.l10n.formatValue('playlists-' + playlist.id);
}))
.then((titles) => {
titles.forEach((title, index) => playlists[index].title = title);
this.playlists = playlists;
return this.render();
});
});
};
PlaylistsView.prototype.destroy = function() {
this.client.destroy();
View.prototype.destroy.call(this); // super(); // Always call *last*
};
PlaylistsView.prototype.render = function() {
return this.list.setModel(this.playlists)
.then(this.onRenderDone);
};
PlaylistsView.prototype.getPlaylists = function() {
return this.fetch('/api/playlists/list').then(response => response.json());
};
PlaylistsView.prototype.queuePlaylist = function(id) {
this.fetch('/api/queue/playlist/' + id + '/shuffle');
};
PlaylistsView.prototype.queueSong = function(filePath) {
this.fetch('/api/queue/song/' + filePath);
};
PlaylistsView.prototype.getThumbnail = function(filePath) {
return this.fetch('/api/artwork/url/thumbnail/' + filePath)
.then(response => response.json());
};
PlaylistsView.prototype.search = function(query) {
if (!query) {
return Promise.resolve(this.searchResults.clearResults());
}
var results = [];
return document.l10n.formatValues(
'unknownTitle', 'unknownArtist', 'unknownAlbum'
).then(([unknownTitle, unknownArtist, unknownAlbum]) => {
var albumResults = this.fetch('/api/search/album/' + query)
.then(response => response.json())
.then((albums) => {
var albumResults = albums.map((album) => {
return {
name: album.name,
title: album.metadata.album || unknownAlbum,
subtitle: album.metadata.artist || unknownArtist,
section: 'albums',
url: '/album-detail?id=' + encodeURIComponent(album.name)
};
});
results = results.concat(albumResults);
this.searchResults.setResults(results);
return albumResults;
});
var artistResults = this.fetch('/api/search/artist/' + query)
.then(response => response.json())
.then((artists) => {
var artistResults = artists.map((artist) => {
return {
name: artist.name,
title: artist.metadata.artist || unknownArtist,
subtitle: '',
section: 'artists',
url: '/artist-detail?id=' + encodeURIComponent(artist.name)
};
});
results = results.concat(artistResults);
this.searchResults.setResults(results);
return artistResults;
});
var songResults = this.fetch('/api/search/title/' + query)
.then(response => response.json())
.then((songs) => {
var songResults = songs.map((song) => {
return {
name: song.name,
title: song.metadata.title || unknownTitle,
subtitle: song.metadata.artist || unknownArtist,
section: 'songs',
url: '/player'
};
});
results = results.concat(songResults);
this.searchResults.setResults(results);
return songResults;
});
return Promise.all([albumResults, artistResults, songResults]).then(() => {
return results;
});
});
};
window.view = new PlaylistsView();

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html class="theme-media">
<head>
<meta charset="utf-8">
<title data-l10n-id="songs">Songs</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/locales/music.{locale}.properties">
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
<!-- Stylesheets -->
<link rel="stylesheet" href="/components/gaia-icons/gaia-icons.css">
<link rel="stylesheet" href="/components/gaia-theme/gaia-theme.css">
<link rel="stylesheet" href="/css/view.css">
<!-- Deferred Scripts -->
<script defer src="/components/bridge/client.js"></script>
<script defer src="/shared/js/intl/l20n-client.js"></script>
<script defer src="/components/dom-scheduler/lib/dom-scheduler.js"></script>
<script defer src="/components/fast-list/fast-list.js"></script>
<script defer src="/components/poplar/poplar.js"></script>
<script defer src="/components/gaia-component/gaia-component.js"></script>
<script defer src="/components/gaia-fast-list/gaia-fast-list.js"></script>
<script defer src="/components/gaia-sub-header/gaia-sub-header.js"></script>
<script defer src="/components/gaia-text-input/gaia-text-input.js"></script>
<script defer src="/elements/music-search-box.js"></script>
<script defer src="/elements/music-search-results.js"></script>
<script defer src="/js/config.js"></script>
<script defer src="/js/view.js"></script>
<script defer src="view.js"></script>
</head>
<body role="application" hidden>
<gaia-fast-list id="list" top="50" bottom="0" caching>
<music-search-box id="search-box" results="#search-results"></music-search-box>
<template>
<a href="/player" data-file-path="${name}">
<div class="image"><img/></div>
<h3 dir="auto">${title}</h3>
<p dir="auto">${artist}</p>
</a>
</template>
</gaia-fast-list>
<music-search-results id="search-results"></music-search-results>
</body>
</html>

View file

@ -0,0 +1,125 @@
/* global View */
'use strict';
var SongsView = View.extend(function SongsView() {
View.call(this); // super();
this.searchBox = document.getElementById('search-box');
this.searchResults = document.getElementById('search-results');
this.list = document.getElementById('list');
this.client.on('databaseChange', () => this.update());
this.setupSearch();
this.setupList();
this.update();
});
SongsView.prototype.setupSearch = function() {
this.searchBox.addEventListener('search', (evt) => this.search(evt.detail));
this.searchResults.addEventListener('open', () => {
this.client.method('searchOpen');
});
this.searchResults.addEventListener('close', () => {
this.client.method('searchClose');
this.list.scrollTop = this.searchBox.HEIGHT;
});
this.searchResults.addEventListener('resultclick', (evt) => {
var link = evt.detail;
if (link) {
this.queueSong(link.dataset.filePath);
this.client.method('navigate', link.getAttribute('href'));
}
});
this.searchResults.getItemImageSrc = (item) => this.getThumbnail(item.name);
};
SongsView.prototype.setupList = function() {
View.prototype.setupList.call(this);
// Triggers player service to begin playing the track.
// This works for now, but we might have the PlayerView
// take care of this task as it's a big more webby :)
this.list.addEventListener('click', (evt) => {
var link = evt.target.closest('a[data-file-path]');
if (link) {
this.queueSong(link.dataset.filePath);
}
});
};
SongsView.prototype.update = function() {
return this.getSongs().then((songs) => {
this.songs = songs;
return this.render();
});
};
SongsView.prototype.destroy = function() {
this.client.destroy();
View.prototype.destroy.call(this); // super(); // Always call *last*
};
SongsView.prototype.render = function() {
return this.list.setModel(this.songs)
.then(() => this.list.cache())
.then(this.onRenderDone);
};
SongsView.prototype.getSongs = function() {
return document.l10n.formatValues('unknownTitle', 'unknownArtist')
.then(([unknownTitle, unknownArtist]) => {
return this.fetch('/api/songs/list')
.then(response => response.json())
.then((songs) => {
return songs.map((song) => {
return {
name: song.name,
title: song.metadata.title || unknownTitle,
artist: song.metadata.artist || unknownArtist,
};
});
});
});
};
SongsView.prototype.queueSong = function(filePath) {
this.fetch('/api/queue/song/' + filePath);
};
SongsView.prototype.getThumbnail = function(filePath) {
return this.fetch('/api/artwork/url/thumbnail/' + filePath)
.then(response => response.json());
};
SongsView.prototype.search = function(query) {
if (!query) {
return Promise.resolve(this.searchResults.clearResults());
}
return document.l10n.formatValues(
'unknownTitle', 'unknownArtist'
).then(([unknownTitle, unknownArtist]) => {
return this.fetch('/api/search/title/' + query)
.then(response => response.json())
.then((songs) => {
var results = songs.map((song) => {
return {
name: song.name,
title: song.metadata.title || unknownTitle,
subtitle: song.metadata.artist || unknownArtist,
section: 'songs',
url: '/player'
};
});
return this.searchResults.setResults(results);
});
});
};
window.view = new SongsView();