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,363 @@
(function(window) {
'use strict';
const HIDE_OVERLAY_TIMEOUT = 5000;
const REPEAT_VALUES = ['off', 'list', 'song'];
const SHUFFLE_VALUES = ['off', 'on'];
var proto = Object.create(HTMLElement.prototype);
proto.createdCallback = function() {
var shadowRoot = this.createShadowRoot();
shadowRoot.innerHTML =
`<style>
[data-icon]:before { /* Copied from /components/gaia-icons/gaia-icons.css */
font-family: "gaia-icons";
content: attr(data-icon);
display: inline-block;
font-weight: 500;
font-style: normal;
text-decoration: inherit;
text-transform: none;
text-rendering: optimizeLegibility;
font-size: 30px;
-webkit-font-smoothing: antialiased;
}
#container {
background-color: #000;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
-moz-user-select: none;
}
#container > img {
background-color: #000;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
#container > img[data-layer="front"] {
opacity: 0;
visibility: hidden;
transition: opacity 150ms linear, visibility 0s linear 150ms;
}
#container > img[data-layer="front"].active {
opacity: 1;
visibility: visible;
transition-delay: 0s, 0s;
}
#container button {
background: none;
border: none;
border-radius: 0;
color: #fff;
flex: 0 0 auto;
margin: 0 1rem;
width: 6rem;
transition: background 200ms ease;
}
#container button:hover {
background: transparent;
}
#container button:active {
background: #00caf2;
transition-duration: 0s;
}
#container button:disabled,
#container button[data-value="off"] {
opacity: 0.3;
}
#container button:disabled:active {
background: transparent;
}
#container button[data-icon="repeat"][data-value="song"]:before {
content: 'repeat-once'
}
#caption,
#controls {
background-color: rgba(0, 0, 0, 0.5);
display: flex;
flex-flow: row nowrap;
position: absolute;
left: 0;
width: 100%;
height: 5rem;
visibility: hidden;
transition: transform 300ms ease-in-out, visibility 0s linear 300ms;
}
#caption {
top: 0;
transform: translateY(-5rem);
}
#controls {
bottom: 0;
transform: translateY(5rem);
}
.show-overlay > #caption,
.show-overlay > #controls {
transform: translateY(0);
visibility: visible;
transition-delay: 0s, 0s;
}
#caption-text {
position: relative;
flex: 0 0 auto;
width: calc(100% - 8rem);
}
#artist,
#album {
color: #fff;
font-weight: normal;
text-shadow: 0 0.1rem rgba(0, 0, 0, 0.5);
text-indent: 3rem;
position: relative;
margin: 0;
width: 100%;
height: 2.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#artist {
font-size: 1.9rem;
line-height: 3rem;
}
#album {
font-size: 1.7rem;
line-height: 2.5rem;
}
#rating {
flex: 1 0 auto;
}
</style>
<div id="container">
<img data-layer="back">
<img data-layer="front" class="active">
<div id="caption">
<div id="caption-text">
<h1 id="artist"></h1>
<h2 id="album"></h2>
</div>
<button type="button"
data-action="share"
data-icon="share"
data-l10n-id="share-song">
</button>
</div>
<div id="controls">
<button type="button"
data-action="repeat"
data-icon="repeat"
data-l10n-id="repeat-off">
</button>
<music-rating id="rating"></music-rating>
<button type="button"
data-action="shuffle"
data-icon="shuffle"
data-l10n-id="shuffle-toggle">
</button>
</div>
</div>`;
var $ = shadowRoot.querySelector.bind(shadowRoot);
this.els = {
container: $('#container'),
backImage: $('img[data-layer="back"]'),
frontImage: $('img[data-layer="front"]'),
caption: $('#caption'),
controls: $('#controls'),
artist: $('#artist'),
album: $('#album'),
share: $('[data-action="share"]'),
repeat: $('[data-action="repeat"]'),
shuffle: $('[data-action="shuffle"]'),
rating: $('#rating')
};
var onImageLoad = (evt) => {
const image = evt.target;
window.requestAnimationFrame(() => {
var newActiveImage = image.closest('img:not(.active)');
if (!newActiveImage) {
return;
}
var oldActiveImage = this.els.container.querySelector('img.active');
newActiveImage.classList.add('active');
oldActiveImage.classList.remove('active');
});
};
[].forEach.call(this.els.container.querySelectorAll('img'), (img) => {
img.addEventListener('load', onImageLoad);
});
this.els.container.addEventListener('transitionend', (evt) => {
if (!evt.target.matches('img:not(.active)')) {
return;
}
var oldActiveImage = evt.target;
oldActiveImage.src = null;
});
this.els.container.addEventListener('click', (evt) => {
var button = evt.target.closest('button');
if (!button) {
if (evt.target.closest('#rating')) {
this.overlayVisible = true;
return;
}
this.overlayVisible = !this.overlayVisible;
return;
}
var action = button.dataset.action;
switch (action) {
case 'repeat':
this.nextRepeat();
break;
case 'shuffle':
this.nextShuffle();
break;
}
this.overlayVisible = true;
this.dispatchEvent(new CustomEvent(action));
});
this.els.rating.addEventListener('change', (evt) => {
this.dispatchEvent(new CustomEvent('ratingchange', {
detail: evt.detail
}));
});
this.onDOMRetranslated = () => {
document.l10n.translateFragment(shadowRoot);
};
this.repeat = this.getAttribute('repeat');
this.shuffle = this.getAttribute('shuffle');
this.overlayVisible = true;
};
proto.attachedCallback = function() {
document.addEventListener('DOMRetranslated', this.onDOMRetranslated);
this.onDOMRetranslated();
};
proto.detachedCallback = function() {
document.removeEventListener('DOMRetranslated', this.onDOMRetranslated);
};
proto.attributeChangedCallback = function(attr, oldVal, newVal) {
switch (attr) {
case 'artist':
this.els.artist.textContent = newVal;
break;
case 'album':
this.els.album.textContent = newVal;
break;
case 'repeat':
this.els.repeat.dataset.value =
REPEAT_VALUES.indexOf(newVal) !== -1 ? newVal : REPEAT_VALUES[0];
break;
case 'shuffle':
this.els.shuffle.dataset.value =
SHUFFLE_VALUES.indexOf(newVal) !== -1 ? newVal : SHUFFLE_VALUES[0];
break;
case 'src':
this.els.container.querySelector('img:not(.active)').src = newVal;
break;
}
};
proto.nextRepeat = function() {
this.repeat = REPEAT_VALUES[REPEAT_VALUES.indexOf(this.repeat) + 1];
};
proto.nextShuffle = function() {
this.shuffle = SHUFFLE_VALUES[SHUFFLE_VALUES.indexOf(this.shuffle) + 1];
};
['artist', 'album', 'src'].forEach(function(prop) {
Object.defineProperty(proto, prop, {
get: function() {
return this.getAttribute(prop);
},
set: function(value) {
this.setAttribute(prop, value || '');
}
});
});
Object.defineProperty(proto, 'repeat', {
get: function() {
return this.getAttribute('repeat') || REPEAT_VALUES[0];
},
set: function(value) {
value = REPEAT_VALUES.indexOf(value) !== -1 ? value : REPEAT_VALUES[0];
this.setAttribute('repeat', value);
this.els.repeat.dataset.l10nId = 'repeat-' + value;
this.onDOMRetranslated();
}
});
Object.defineProperty(proto, 'shuffle', {
get: function() {
return this.getAttribute('shuffle') || SHUFFLE_VALUES[0];
},
set: function(value) {
value = SHUFFLE_VALUES.indexOf(value) !== -1 ? value : SHUFFLE_VALUES[0];
this.setAttribute('shuffle', value);
}
});
Object.defineProperty(proto, 'overlayVisible', {
get: function() {
return this.els.container.classList.contains('show-overlay');
},
set: function(value) {
var overlayVisible = !!value;
clearTimeout(this._hideOverlayTimeout);
if (overlayVisible) {
this.els.container.classList.add('show-overlay');
this._hideOverlayTimeout = setTimeout(() => {
this.overlayVisible = false;
}, HIDE_OVERLAY_TIMEOUT);
}
else {
this.els.container.classList.remove('show-overlay');
}
}
});
try {
window.MusicArtwork = document.registerElement('music-artwork', {
prototype: proto
});
} catch (e) {
if (e.name !== 'NotSupportedError') {
throw e;
}
}
})(window);

View file

@ -0,0 +1,170 @@
(function(window) {
'use strict';
var proto = Object.create(HTMLElement.prototype);
var isTouch = 'ontouchstart' in window;
proto.createdCallback = function() {
var shadowRoot = this.createShadowRoot();
shadowRoot.innerHTML =
`<style>
[data-icon]:before { /* Copied from /components/gaia-icons/gaia-icons.css */
font-family: "gaia-icons";
content: attr(data-icon);
display: inline-block;
font-weight: 500;
font-style: normal;
text-decoration: inherit;
text-transform: none;
text-rendering: optimizeLegibility;
font-size: 30px;
-webkit-font-smoothing: antialiased;
}
#container {
background-color: #000;
border-top: 0.1rem solid rgba(255, 255, 255, 0.1);
direction: ltr;
display: flex;
flex-flow: row nowrap;
position: relative;
width: 100%;
height: 4.8rem;
-moz-user-select: none;
}
#container > button {
background: transparent;
border: none;
border-radius: 0;
color: #fff;
flex: 1 0 auto;
position: relative;
padding: 0;
height: 100%;
transition: background 0.2s ease;
}
#container > button:hover {
background: transparent;
}
#container > button:active {
background: #00caf2;
transition-duration: 0s;
}
#container > button:disabled {
opacity: 0.3;
}
#container > button:disabled:active {
background: transparent;
}
</style>
<div id="container">
<button type="button" id="previous"
data-icon="skip-back"
data-l10n-id="playbackPrevious">
</button>
<button type="button" id="toggle"
data-icon="play"
data-l10n-id="playbackPlay">
</button>
<button type="button" id="next"
data-icon="skip-forward"
data-l10n-id="playbackNext">
</button>
</div>`;
var $id = shadowRoot.getElementById.bind(shadowRoot);
this.els = {
container: $id('container'),
previous: $id('previous'),
toggle: $id('toggle'),
next: $id('next')
};
var seeking = false;
this.els.container.addEventListener('contextmenu', (evt) => {
evt.preventDefault();
if (seeking) {
return;
}
var button = evt.target.closest('button');
if (button.id === 'previous' || button.id === 'next') {
seeking = true;
this.dispatchEvent(new CustomEvent('startseek', {
detail: { reverse: button.id === 'previous' }
}));
}
});
this.els.container.addEventListener(isTouch ? 'touchend' : 'mouseup',
(evt) => {
if (seeking) {
evt.preventDefault();
this.dispatchEvent(new CustomEvent('stopseek'));
seeking = false;
}
}
);
this.els.container.addEventListener('click', (evt) => {
var button = evt.target.closest('button');
switch (button.id) {
case 'previous':
case 'next':
this.dispatchEvent(new CustomEvent(button.id));
break;
case 'toggle':
this.paused = !this.paused;
this.dispatchEvent(new CustomEvent(this.paused ? 'pause' : 'play'));
break;
}
});
this.onDOMRetranslated = () => {
document.l10n.translateFragment(shadowRoot);
};
};
proto.attachedCallback = function() {
document.addEventListener('DOMRetranslated', this.onDOMRetranslated);
this.onDOMRetranslated();
};
proto.detachedCallback = function() {
document.removeEventListener('DOMRetranslated', this.onDOMRetranslated);
};
Object.defineProperty(proto, 'paused', {
get: function() {
return this.els.toggle.dataset.icon !== 'pause';
},
set: function(value) {
var paused = !!value;
if (paused === this.paused) {
return;
}
this.els.toggle.dataset.icon = paused ? 'play' : 'pause';
this.els.toggle.dataset.l10nId = paused ? 'playbackPlay' : 'playbackPause';
this.onDOMRetranslated();
}
});
try {
window.MusicControls = document.registerElement('music-controls', {
prototype: proto
});
} catch (e) {
if (e.name !== 'NotSupportedError') {
throw e;
}
}
})(window);

View file

@ -0,0 +1,236 @@
(function(window) {
'use strict';
var proto = Object.create(HTMLElement.prototype);
proto.createdCallback = function() {
var buttons = document.createDocumentFragment();
[].forEach.call(this.querySelectorAll('button'), (button) => {
buttons.appendChild(button);
});
var shadowRoot = this.createShadowRoot();
shadowRoot.innerHTML =
`<style>
#heading:empty,
#message:empty,
#menu:empty {
display: none;
}
#container {
background-color: #2d2d2d;
display: flex;
flex-flow: column nowrap;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
overflow: hidden;
-moz-user-select: none;
}
#content {
box-sizing: border-box;
display: flex;
flex-flow: column nowrap;
justify-content: center;
flex: 1 0 auto;
padding: 1rem 1.5rem;
width: 100%;
max-height: 100%;
overflow-y: auto;
vertical-align: middle;
word-wrap: break-word;
}
#heading {
color: #fff;
font-size: 1.6rem;
line-height: 1.6rem;
margin: 0;
padding: 0;
}
#message {
border-top: 0.1rem solid #686868;
color: #fafafa;
font-size: 2.2rem;
font-weight: 300;
line-height: 3rem;
margin: 1rem 0 0;
padding: 1rem 0 0;
}
#heading:empty + #message {
border: none;
padding: 0;
}
#menu {
box-sizing: border-box;
display: flex;
flex-flow: row nowrap;
flex: 0 0 auto;
margin: 0;
padding: 1.5rem;
width: 100%;
}
#menu > button {
background: #d8d8d8;
border: none;
border-radius: 2rem;
color: #333;
outline: none;
font-family: sans-serif;
font-size: 1.6rem;
font-style: italic;
line-height: 4rem;
box-sizing: border-box;
flex: 1 1 auto;
padding: 0 0.2rem;
height: 4rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background 200ms 280ms, color 200ms 280ms;
}
#menu > button:active {
background: #00aacc;
color: #fff;
transition: none;
}
#menu > button.primary {
background: #00caf2;
color: #fff;
}
#menu > button.danger {
background: #e51e1e;
color: #fff;
}
#menu > button.primary:active {
background: #006579;
color: #c8c8c8;
}
#menu > button.danger:active {
background: #730f0f;
color: #c8c8c8;
}
#menu > button[disabled] {
background: #565656;
color: rgba(255, 255, 255, 0.4);
pointer-events: none;
}
#menu > button.primary[disabled] {
background: #006579;
}
#menu > button.danger[disabled] {
background: #730f0f;
}
#menu > button + button {
margin-left: 1rem;
}
</style>
<form id="container" role="dialog">
<section id="content">
<h1 id="heading"></h1>
<p id="message"></p>
</section>
<menu id="menu"></menu>
</form>`;
var $id = shadowRoot.getElementById.bind(shadowRoot);
this.els = {
container: $id('container'),
content: $id('content'),
heading: $id('heading'),
message: $id('message'),
menu: $id('menu')
};
this.els.menu.appendChild(buttons);
this.els.menu.addEventListener('click', (evt) => {
var button = evt.target.closest('button[data-action]');
if (button) {
this.dispatchEvent(new CustomEvent('action', {
detail: button.dataset.action
}));
}
});
this.els.heading.textContent = this.getAttribute('heading');
this.els.message.textContent = this.getAttribute('message');
this.els.heading.dataset.l10nId = this.getAttribute('heading-l10n-id');
this.els.message.dataset.l10nId = this.getAttribute('message-l10n-id');
this.onDOMRetranslated = () => {
document.l10n.translateFragment(shadowRoot);
};
};
proto.attachedCallback = function() {
document.addEventListener('DOMRetranslated', this.onDOMRetranslated);
this.onDOMRetranslated();
};
proto.detachedCallback = function() {
document.removeEventListener('DOMRetranslated', this.onDOMRetranslated);
};
proto.attributeChangedCallback = function(attr, oldVal, newVal) {
switch (attr) {
case 'heading':
this.els.heading.textContent = newVal;
break;
case 'message':
this.els.message.textContent = newVal;
break;
case 'heading-l10n-id':
this.els.heading.dataset.l10nId = newVal;
this.onDOMRetranslated();
break;
case 'message-l10n-id':
this.els.message.dataset.l10nId = newVal;
this.onDOMRetranslated();
break;
}
};
proto.addActionButton = function(title, action, className = '') {
var button = document.createElement('button');
button.type = 'button';
button.textContent = title;
button.dataset.action = action;
button.className = className;
this.els.menu.appendChild(button);
};
proto.removeActionButton = function(action) {
var buttons = this.els.menu.querySelectorAll(`[data-action="${action}"]`);
[].forEach.call(buttons, button => this.els.menu.removeChild(button));
};
['heading', 'message'].forEach((prop) => {
Object.defineProperty(proto, prop, {
get: function() {
return this.getAttribute(prop);
},
set: function(value) {
this.setAttribute(prop, value || '');
}
});
});
try {
window.MusicOverlay = document.registerElement('music-overlay', {
prototype: proto
});
} catch (e) {
if (e.name !== 'NotSupportedError') {
throw e;
}
}
})(window);

View file

@ -0,0 +1,115 @@
(function(window) {
/*jshint maxlen:false*/
'use strict';
var proto = Object.create(HTMLElement.prototype);
proto.createdCallback = function() {
var shadowRoot = this.createShadowRoot();
shadowRoot.innerHTML =
`<style>
#container {
display: flex;
flex-flow: row nowrap;
width: 100%;
height: 100%;
-moz-user-select: none;
}
#container > button {
background-color: transparent;
background-image: url(/img/ui/music-rating-off.png);
background-position: center center;
background-repeat: no-repeat;
background-size: 2.2rem auto;
border: none;
flex: 1 0 auto;
width: 2.2rem;
height: 100%;
}
#container[data-value="1"] > :-moz-any([value="1"]),
#container[data-value="2"] > :-moz-any([value="1"],[value="2"]),
#container[data-value="3"] > :-moz-any([value="1"],[value="2"],[value="3"]),
#container[data-value="4"] > :-moz-any([value="1"],[value="2"],[value="3"],[value="4"]),
#container[data-value="5"] > * {
background-image: url(/img/ui/music-rating-on.png);
}
</style>
<div id="container">
<button type="button" value="1" data-l10n-id="rating-star" data-l10n-args='{"n":1}'></button>
<button type="button" value="2" data-l10n-id="rating-star" data-l10n-args='{"n":2}'></button>
<button type="button" value="3" data-l10n-id="rating-star" data-l10n-args='{"n":3}'></button>
<button type="button" value="4" data-l10n-id="rating-star" data-l10n-args='{"n":4}'></button>
<button type="button" value="5" data-l10n-id="rating-star" data-l10n-args='{"n":5}'></button>
</div>`;
var $id = shadowRoot.getElementById.bind(shadowRoot);
this.els = {
container: $id('container')
};
this.els.container.addEventListener('click', (evt) => {
var button = evt.target.closest('button');
if (!button) {
return;
}
var value = parseInt(button.value, 10) || 0;
if (value === this.value) {
value--;
}
this.value = value;
this.dispatchEvent(new CustomEvent('change', {
detail: value
}));
});
this.onDOMRetranslated = () => {
document.l10n.translateFragment(shadowRoot);
};
};
proto.attachedCallback = function() {
document.addEventListener('DOMRetranslated', this.onDOMRetranslated);
this.onDOMRetranslated();
};
proto.detachedCallback = function() {
document.removeEventListener('DOMRetranslated', this.onDOMRetranslated);
};
proto.attributeChangedCallback = function(attr, oldVal, newVal) {
switch (attr) {
case 'value':
this.els.container.dataset.value = newVal;
break;
}
};
Object.defineProperty(proto, 'value', {
get: function() {
return parseInt(this.getAttribute('value'), 10) || 0;
},
set: function(value) {
this.setAttribute('value', clamp(0, 5, parseInt(value, 10) || 0));
}
});
function clamp(min, max, value) {
return Math.min(Math.max(min, value), max);
}
try {
window.MusicRating = document.registerElement('music-rating', {
prototype: proto
});
} catch (e) {
if (e.name !== 'NotSupportedError') {
throw e;
}
}
})(window);

View file

@ -0,0 +1,184 @@
(function(window) {
/*jshint maxlen:false*/
'use strict';
var proto = Object.create(HTMLElement.prototype);
injectGlobalStyles();
proto.createdCallback = function() {
var shadowRoot = this.createShadowRoot();
shadowRoot.innerHTML =
`<style scoped>
[hidden] {
display: none;
}
#container {
background-color: #333;
color: #fff;
position: relative;
width: 100%;
height: 5rem;
overflow: hidden;
-moz-user-select: none;
}
#spinner {
background: url('/img/ui/music-scan-progress.png') no-repeat center center / 100% auto;
border: none;
display: block;
position: absolute;
top: 1rem;
left: 1rem;
width: 2.9rem;
height: 2.9rem;
animation: 0.9s music-scan-progress-spinner infinite linear;
}
#spinner::-moz-progress-bar {
background: none;
}
#value {
font-size: 1.2rem;
font-weight: 700;
line-height: 5rem;
text-align: center;
display: block;
position: absolute;
top: 0;
left: 0;
width: 5rem;
height: 5rem;
}
#heading,
#subheading {
font-weight: normal;
box-sizing: border-box;
position: absolute;
margin: 0;
padding: 0 0.5rem;
left: 5rem;
width: calc(100% - 5rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#heading {
font-size: 1.9rem;
line-height: 3rem;
top: 0;
}
#subheading {
font-size: 1.4rem;
top: 3rem;
}
</style>
<div id="container">
<progress id="spinner"></progress>
<span id="value"></span>
<h1 id="heading"></h1>
<h2 id="subheading"></h2>
</div>`;
var $id = shadowRoot.getElementById.bind(shadowRoot);
this.els = {
container: $id('container'),
spinner: $id('spinner'),
value: $id('value'),
heading: $id('heading'),
subheading: $id('subheading')
};
if (!this.hasAttribute('value') && !this.hasAttribute('heading') &&
!this.hasAttribute('subheading')) {
this.els.container.hidden = true;
}
this.value = this.getAttribute('value');
this.heading = this.getAttribute('heading');
this.subheading = this.getAttribute('subheading');
this.onDOMRetranslated = () => {
document.l10n.translateFragment(shadowRoot);
};
};
proto.attachedCallback = function() {
document.addEventListener('DOMRetranslated', this.onDOMRetranslated);
this.onDOMRetranslated();
};
proto.detachedCallback = function() {
document.removeEventListener('DOMRetranslated', this.onDOMRetranslated);
};
proto.attributeChangedCallback = function(attr, oldVal, newVal) {
var promise;
var l10nId = this.getAttribute(attr + '-l10n-id');
if (!newVal && l10nId) {
promise = document.l10n.formatValues(l10nId);
} else {
promise = Promise.resolve();
}
promise.then(() => {
switch (attr) {
case 'value':
this.els.value.textContent = newVal;
break;
case 'heading':
this.els.heading.textContent = newVal;
break;
case 'subheading':
this.els.subheading.textContent = newVal;
break;
}
});
};
proto.update = function(properties = {}) {
this.value = properties.value || this.value;
this.heading = properties.heading || this.heading;
this.subheading = properties.subheading || this.subheading;
this.els.container.hidden = false;
};
proto.clear = function() {
this.els.container.hidden = true;
};
['value', 'heading', 'subheading'].forEach((prop) => {
Object.defineProperty(proto, prop, {
get: function() {
return this.getAttribute(prop);
},
set: function(value) {
this.setAttribute(prop, value || '');
}
});
});
function injectGlobalStyles() {
var style = document.createElement('style');
style.innerHTML =
`@keyframes music-scan-progress-spinner {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}`;
document.head.appendChild(style);
}
try {
window.MusicScanProgress = document.registerElement('music-scan-progress', {
prototype: proto
});
} catch (e) {
if (e.name !== 'NotSupportedError') {
throw e;
}
}
})(window);

View file

@ -0,0 +1,190 @@
(function(window) {
/*jshint maxlen:false*/
'use strict';
var proto = Object.create(HTMLElement.prototype);
proto.HEIGHT = 52; // 12px margin + 40px height
// #form height attribute is the value of proto.HEIGHT
proto.createdCallback = function() {
var shadowRoot = this.createShadowRoot();
shadowRoot.innerHTML = `
<style>
#inner {
display: flex;
}
gaia-text-input {
margin: 6px 0 !important;
flex: 1;
}
#close {
display: none;
padding: 0 14px;
-moz-margin-end: -14px;
border: 0;
background: none;
color: var(--highlight-color);
font-style: italic;
font-size: 17px;
}
.open #close {
display: block;
}
</style>
<div id="inner">
<gaia-text-input type="search" clearable id="input"></gaia-text-input>
<button id="close" data-l10n-id="search-close">Close</button>
</div>`;
var $id = shadowRoot.getElementById.bind(shadowRoot);
this.els = {
inner: $id('inner'),
input: $id('input'),
close: $id('close')
};
this.isOpen = false;
this.els.results = document.querySelector(this.getAttribute('results'));
var onSearch = debounce(() => {
this.emit('search', this.els.input.value);
}, 500);
this.els.input.addEventListener('focus', () => this.open());
this.els.close.addEventListener('click', () => this.close());
this.els.input.addEventListener('input', onSearch);
this.addEventListener('touchmove', e => e.preventDefault());
/**
* Localizes the component's DOM.
*
* We can't use data-l10n-id attributes
* here as l10n.js doesn't support putting
* `placeholder` attribute on any element
* other than `<input>`.
*
* Because we have to use `.formatValues()` it
* makes sense to localize `search-close` in
* the same way.
*
* @private
*/
this.localize = () => {
document.l10n.formatValues('search', 'search-close')
.then(([search, close]) => {
this.els.input.placeholder = search;
this.els.close.textContent = close;
});
};
};
proto.attachedCallback = function() {
document.addEventListener('DOMRetranslated', this.localize);
this.localize();
};
proto.detachedCallback = function() {
document.removeEventListener('DOMRetranslated', this.localize);
};
proto.attributeChangedCallback = function(attr, oldVal, newVal) {
switch (attr) {
case 'results':
this.els.results = document.querySelector(newVal);
break;
}
};
proto.clear = function() {
requestAnimationFrame(() => {
this.els.input.clear();
});
};
proto.focus = function() {
requestAnimationFrame(() => {
this.els.input.focus();
});
};
proto.open = function() {
if (this.isOpen) {
return;
}
this.isOpen = true;
this.els.inner.classList.add('open');
this.focus();
this.emit('open');
if (this.els.results) {
this.els.results.open();
}
};
proto.close = function() {
if (!this.isOpen) {
return;
}
this.isOpen = false;
this.els.inner.classList.remove('open');
this.clear();
this.emit('close');
if (this.els.results) {
this.els.results.close();
}
};
proto.emit = function(name, detail) {
var evt = new CustomEvent(name, { detail: detail });
this.dispatchEvent(evt);
};
Object.defineProperty(proto, 'value', {
get: function() {
return this.els.input.value;
},
set: function(value) {
this.els.input.value = value;
}
});
Object.defineProperty(proto, 'results', {
get: function() {
return this.els.results;
},
set: function(value) {
this.els.results = value;
}
});
window.MusicSearchBox = document.registerElement('music-search-box', {
prototype: proto
});
/**
* Utils
*/
function debounce(fn, ms) {
var timeout;
return () => {
var args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), ms);
};
}
})(window);

View file

@ -0,0 +1,124 @@
(function(window) {
/*jshint maxlen:false*/
'use strict';
var proto = Object.create(HTMLElement.prototype);
proto.createdCallback = function() {
this.innerHTML = `
<style scoped>
music-search-results {
opacity: 0;
visibility: hidden;
pointer-events: none;
background: var(--background);
}
music-search-results.transitions-on {
transition:
opacity 0.2s linear,
visibility 0s linear 0.2s;
}
music-search-results[active] {
opacity: 1;
visibility: visible;
transition-delay: 0s, 0s;
pointer-events: auto;
}
</style>
<gaia-fast-list id="list">
<template>
<a href="\${url}" data-file-path="\${name}" data-section="\${section}">
<div class="image"><img></div>
<h3 dir="auto">\${title}</h3>
<p dir="auto">\${subtitle}</p>
</a>
</template>
</gaia-fast-list>`;
this.els = {
list: this.querySelector('#list')
};
this.els.list.configure({
getSectionName: (item) => item.section,
getItemImageSrc: (item) => this.getItemImageSrc(item)
});
this.els.list.addEventListener('click', (e) => this.onLinkClick(e));
};
proto.attachedCallback = function() {
setTimeout(() => this.enableTransitions());
};
proto.enableTransitions = function() {
this.classList.add('transitions-on');
};
proto.onLinkClick = function(evt) {
var link = evt.target.closest('a');
if (!link) return;
evt.preventDefault();
this.close().then(() => {
this.emit('resultclick', link);
});
};
// To be overriden by view
// if images are required
proto.getItemImageSrc = function() {};
proto.open = function() {
return new Promise((resolve) => {
requestAnimationFrame(() => {
this.setAttribute('active', true);
resolve();
});
this.emit('open');
});
};
proto.close = function() {
return new Promise((resolve) => {
requestAnimationFrame(() => {
this.removeAttribute('active');
this.clearResults();
resolve();
});
this.emit('close');
});
};
proto.setResults = function(results) {
if (results.length === 0) {
return document.l10n.formatValue('search-no-result')
.then((noResult) => {
return this.els.list.setModel([{
title: noResult,
subtitle: ''
}]);
});
}
return this.els.list.setModel(results);
};
proto.clearResults = function() {
return this.els.list.setModel([]);
};
proto.emit = function(name, detail) {
var evt = new CustomEvent(name, { detail: detail });
this.dispatchEvent(evt);
};
window.MusicSearchResults = document.registerElement('music-search-results', {
prototype: proto
});
})(window);

View file

@ -0,0 +1,270 @@
/* global IntlHelper */
(function(window) {
'use strict';
var proto = Object.create(HTMLElement.prototype);
var isTouch = 'ontouchstart' in window;
proto.createdCallback = function() {
var shadowRoot = this.createShadowRoot();
shadowRoot.innerHTML =
`<style>
#container {
background-color: rgba(0, 0, 0, 0.85);
display: flex;
flex-flow: row nowrap;
position: relative;
width: 100%;
height: 4.2rem;
-moz-user-select: none;
}
#container > span {
display: inline-block;
position: relative;
height: 100%;
}
#elapsed-time,
#remaining-time {
color: #e7e7e7;
font-size: 1.4rem;
font-weight: 400;
line-height: 4.2rem;
direction: ltr;
vertical-align: top;
flex: 0 0 auto;
width: 5.3rem;
}
#elapsed-time {
padding-left: 1.5rem;
}
#remaining-time {
padding-right: 1.5rem;
text-align: right;
}
#seek-bar {
flex: 1 0 auto;
z-index: 1;
}
#seek-bar-progress {
background-color: #a6b4b7;
border: none;
border-radius: 0;
position: absolute;
top: calc(50% - 0.1rem);
left: 0;
width: 100%;
height: 0.1rem;
pointer-events: none;
-moz-appearance: none;
}
#seek-bar-progress::-moz-progress-bar {
background-color: #01c5ed;
}
#seek-bar-indicator {
background-color: transparent;
border-radius: 50%;
position: absolute;
top: calc(50% - 3rem);
left: -3rem;
width: 6rem;
height: 6rem;
pointer-events: none;
transition: transform 20ms linear;
will-change: transform;
}
#seek-bar-indicator:after {
content: '';
background-color: #fff;
border: 1px solid #fff;
border-radius: 50%;
position: absolute;
top: 1.85rem;
left: 1.85rem;
width: 2.1rem;
height: 2.1rem;
}
#seek-bar-indicator.highlight {
background-color: #00caf2;
}
</style>
<div id="container">
<span id="elapsed-time"></span>
<span id="seek-bar">
<progress id="seek-bar-progress"></progress>
<div id="seek-bar-indicator"></div>
</span>
<span id="remaining-time"></span>
</div>`;
var $id = shadowRoot.getElementById.bind(shadowRoot);
this.els = {
container: $id('container'),
elapsedTime: $id('elapsed-time'),
seekBar: $id('seek-bar'),
seekBarProgress: $id('seek-bar-progress'),
seekBarIndicator: $id('seek-bar-indicator'),
remainingTime: $id('remaining-time')
};
var container = this.els.container;
var seekBar = this.els.seekBar;
var seekTimeout = null;
container.addEventListener(isTouch ? 'touchstart' : 'mousedown', (evt) => {
clearTimeout(seekTimeout);
container.addEventListener(isTouch ? 'touchmove' : 'mousemove',
pointerMoveHandler);
container.addEventListener(isTouch ? 'touchend' : 'mouseup',
pointerEndHandler);
this.els.seekBarIndicator.classList.add('highlight');
pointerMoveHandler(evt);
});
var pointerMoveHandler = (evt) => {
var pointer = isTouch ? evt.targetTouches[0] : evt;
var percent = clamp(0, 1,
(pointer.clientX - seekBar.offsetLeft) / seekBar.offsetWidth);
if (document.documentElement.dir === 'rtl') {
this.remainingTime = this._overrideRemainingTime =
percent * this.duration;
}
else {
this.elapsedTime = this._overrideElapsedTime =
percent * this.duration;
}
};
var pointerEndHandler = (evt) => {
this.dispatchEvent(new CustomEvent('seek', {
detail: { elapsedTime: this.elapsedTime }
}));
container.removeEventListener(isTouch ? 'touchmove' : 'mousemove',
pointerMoveHandler);
container.removeEventListener(isTouch ? 'touchend' : 'mouseup',
pointerEndHandler);
seekTimeout = setTimeout(() => {
this._overrideRemainingTime = null;
this._overrideElapsedTime = null;
this.els.seekBarIndicator.classList.remove('highlight');
}, 100);
};
this._overrideRemainingTime = null;
this._overrideElapsedTime = null;
this.duration = null;
this.elapsedTime = null;
this.remainingTime = null;
};
Object.defineProperty(proto, 'duration', {
get: function() {
return this._duration;
},
set: function(value) {
if (isNaN(value)) {
this._duration = null;
this.remainingTime = null;
return;
}
this._duration = value;
this.remainingTime = this._duration - this._elapsedTime;
}
});
Object.defineProperty(proto, 'elapsedTime', {
get: function() {
return this._elapsedTime;
},
set: function(value) {
if (isNaN(value)) {
this._elapsedTime = null;
this.remainingTime = null;
return;
}
this._elapsedTime = this._overrideElapsedTime !== null ?
this._overrideElapsedTime : value;
this.remainingTime = this._duration - this._elapsedTime;
}
});
Object.defineProperty(proto, 'remainingTime', {
get: function() {
return this._remainingTime;
},
set: function(value) {
var indeterminate = isNaN(value) || isNaN(this._duration);
if (indeterminate) {
this._remainingTime = null;
this._elapsedTime = null;
}
else {
this._remainingTime = this._overrideRemainingTime !== null ?
this._overrideRemainingTime : value;
this._elapsedTime = this._duration - this._remainingTime;
}
window.requestAnimationFrame(() => {
if (indeterminate) {
this.els.remainingTime.textContent = '---:--';
this.els.elapsedTime.textContent = '--:--';
return;
}
var percent = this._duration ? this._elapsedTime / this._duration : 0;
var x = this.els.seekBar.offsetWidth * percent;
if (document.documentElement.dir === 'rtl') {
x = this.els.seekBar.offsetWidth - x;
}
this.els.seekBarIndicator.style.transform = 'translateX(' + x + 'px)';
IntlHelper.get('duration').then((duration) => {
this.els.remainingTime.textContent =
duration.format(-this._remainingTime * 1000);
this.els.elapsedTime.textContent =
duration.format(this._elapsedTime * 1000);
});
});
}
});
function clamp(min, max, value) {
return Math.min(Math.max(min, value), max);
}
IntlHelper.define('duration', 'mozduration', {
minUnit: 'second',
maxUnit: 'minute'
});
try {
window.MusicSeekBar = document.registerElement('music-seek-bar', {
prototype: proto
});
} catch (e) {
if (e.name !== 'NotSupportedError') {
throw e;
}
}
})(window);

View file

@ -0,0 +1,104 @@
(function(window) {
'use strict';
var GaiaToolbar = window['gaia-toolbar'];
var proto = Object.create(GaiaToolbar.prototype);
proto.createdCallback = function() {
GaiaToolbar.prototype.createdCallback.apply(this, arguments);
this.querySelector('style').innerHTML +=
`music-tab-bar {
background-color: var(--background-minus);
position: absolute;
bottom: 0;
left: 0;
width: 100%;
-moz-user-select: none;
}
.-content {
direction: ltr;
}
.-content > *,
.more-button {
transition: none;
}
.-content > .selected {
color: var(--highlight-color);
}`;
this.addEventListener('click', (evt) => {
var tab = evt.target.closest('button');
if (!tab || tab === this.selectedElement) {
return;
}
this.selectedElement = tab;
this.dispatchEvent(new CustomEvent('change', {
detail: {
selectedElement: this.selectedElement,
selectedIndex: this.selectedIndex
}
}));
});
this.selectedIndex = 0;
// Fixes issue where rAF-wrapped `reflow()` doesn't get
// called in the inherited <gaia-toolbar> `createdCallback()`.
this.reflow();
};
Object.defineProperty(proto, 'selectedElement', {
get: function() {
return this.querySelectorAll('button')[this.selectedIndex];
},
set: function(value) {
var index = [].indexOf.call(this.querySelectorAll('button'), value);
if (index !== -1) {
this.selectedIndex = index;
}
}
});
Object.defineProperty(proto, 'selectedIndex', {
get: function() {
return this._selectedIndex || 0;
},
set: function(value) {
var tabs = this.querySelectorAll('button');
if (this._selectedIndex === value || value < 0 || value >= tabs.length) {
return;
}
this._selectedIndex = value;
[].forEach.call(tabs, (tab, index) => {
if (index === this._selectedIndex) {
tab.classList.add('selected');
return;
}
tab.classList.remove('selected');
});
}
});
try {
window.MusicTabBar = document.registerElement('music-tab-bar', {
prototype: proto
});
} catch (e) {
if (e.name !== 'NotSupportedError') {
throw e;
}
}
})(window);

View file

@ -0,0 +1,305 @@
(function(window) {
'use strict';
var proto = Object.create(HTMLElement.prototype);
proto.createdCallback = function() {
var style = document.createElement('style');
style.innerHTML =
`music-view-stack {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
-moz-user-select: none;
}
music-view-stack > iframe {
border: none;
visibility: hidden;
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
will-change: transform;
}
music-view-stack > iframe.active,
music-view-stack > iframe.fade,
music-view-stack > iframe.pop,
music-view-stack > iframe.push {
animation-duration: 0.2s;
animation-timing-function: linear;
visibility: visible;
}
music-view-stack > iframe.active {
pointer-events: auto;
}
music-view-stack > iframe.fade {
animation-duration: 0s;
}
music-view-stack > iframe.fade.in {
animation-name: fade-in;
}
music-view-stack > iframe.fade.out {
animation-name: fade-out;
}
music-view-stack > iframe.pop.in {
animation-name: pop-in;
}
music-view-stack > iframe.pop.out {
animation-name: pop-out;
}
music-view-stack > iframe.push.in {
animation-name: push-in;
}
music-view-stack > iframe.push.out {
animation-name: push-out;
}
[dir="rtl"] music-view-stack > iframe.pop.in {
animation-name: push-in;
}
[dir="rtl"] music-view-stack > iframe.pop.out {
animation-name: push-out;
}
[dir="rtl"] music-view-stack > iframe.push.in {
animation-name: pop-in;
}
[dir="rtl"] music-view-stack > iframe.push.out {
animation-name: pop-out;
}
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes pop-in {
0% { transform: translateX(-100%); }
100% { transform: translateX(0); }
}
@keyframes pop-out {
0% { transform: translateX(0); }
100% { transform: translateX(100%); }
}
@keyframes push-in {
0% { transform: translateX(100%); }
100% { transform: translateX(0); }
}
@keyframes push-out {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}`;
if (this.firstChild) {
this.insertBefore(style, this.firstChild);
} else {
this.appendChild(style);
}
this.cachedViews = {};
[].forEach.call(this.querySelectorAll('iframe'), (frame) => {
var location = new URL(frame.src);
var view = {
url: location.pathname + location.search,
frame: frame,
title: frame.contentDocument.title
};
this.initializeView(view).then((view) => {
if (this.activeView === view) {
this.dispatchEvent(new CustomEvent('titlechange', {
detail: view.title
}));
}
});
this.cachedViews[view.url] = view;
});
this.addEventListener('animationend', (evt) => {
var classList = evt.target.classList;
if (classList.contains('destroy')) {
this.destroyView(evt.target.src);
}
classList.remove('fade', 'pop', 'push', 'in', 'out');
});
this.views = [];
this.activeView = null;
};
proto.loadView = function(url) {
var view = this.cachedViews[url];
if (view) {
return Promise.resolve(view);
}
var frame = document.createElement('iframe');
this.appendChild(frame);
frame.src = url;
view = {
url: url,
frame: frame,
title: ''
};
return this.initializeView(view);
};
proto.initializeView = function(view) {
view.frame.addEventListener('rendered', () => {
this.dispatchEvent(new CustomEvent('rendered'));
});
view.frame.addEventListener('titlechange', (evt) => {
view.title = evt.detail;
if (this.activeView === view) {
this.dispatchEvent(new CustomEvent('titlechange', {
detail: evt.detail
}));
}
});
const onL10nReady = () => {
view.title = view.frame.contentDocument.title;
this.cachedViews[view.url] = view;
this.dispatchEvent(new CustomEvent('loaded'));
return view;
};
return loaded(view.frame)
.then(() => view.frame.contentDocument.l10n.ready)
.then(onL10nReady);
};
proto.destroyView = function(url) {
for (var key in this.cachedViews) {
var view = this.cachedViews[key];
if (key === url || view.frame.src === url) {
view.frame.contentWindow.dispatchEvent(new CustomEvent('viewdestroy'));
delete this.cachedViews[key];
setTimeout(() => this.removeChild(view.frame), 5000);
return;
}
}
};
proto.setRootView = function(url) {
return this.loadView(url).then((view) => {
var oldActiveView = this.activeView;
if (oldActiveView) {
oldActiveView.frame.classList.add('fade', 'out');
oldActiveView.frame.classList.remove('active');
oldActiveView.frame.contentWindow.dispatchEvent(
new CustomEvent('viewhidden')
);
}
this.views = [view];
this.activeView = view;
var newActiveView = this.activeView;
newActiveView.frame.classList.add('fade', 'in');
newActiveView.frame.classList.add('active');
newActiveView.frame.contentWindow.dispatchEvent(
new CustomEvent('viewvisible')
);
this.dispatchEvent(new CustomEvent('change', { detail: newActiveView }));
});
};
proto.pushView = function(url) {
if (!this.activeView) {
return this.setRootView(url);
}
return this.loadView(url).then((view) => {
requestAnimationFrame(() => {
var oldActiveView = this.activeView;
oldActiveView.frame.classList.add('push', 'out');
oldActiveView.frame.classList.remove('active');
oldActiveView.frame.contentWindow.dispatchEvent(
new CustomEvent('viewhidden')
);
this.views.push(view);
this.activeView = view;
var newActiveView = this.activeView;
newActiveView.frame.classList.add('push', 'in', 'active');
newActiveView.frame.contentWindow.dispatchEvent(
new CustomEvent('viewvisible')
);
this.dispatchEvent(new CustomEvent('change', { detail: newActiveView }));
});
});
};
proto.popView = function(destroy) {
if (this.views.length < 2) {
return Promise.reject();
}
var oldActiveView = this.activeView;
this.views.pop();
this.activeView = this.views[this.views.length - 1];
var newActiveView = this.activeView;
return new Promise((resolve) => {
requestAnimationFrame(() => {
oldActiveView.frame.classList.add('pop', 'out');
oldActiveView.frame.classList.remove('active');
oldActiveView.frame.contentWindow.dispatchEvent(
new CustomEvent('viewhidden')
);
if (destroy) {
oldActiveView.frame.classList.add('destroy');
}
newActiveView.frame.classList.add('pop', 'in', 'active');
newActiveView.frame.contentWindow.dispatchEvent(
new CustomEvent('viewvisible')
);
this.dispatchEvent(new CustomEvent('change', { detail: newActiveView }));
resolve();
});
});
};
try {
window.MusicViewStack = document.registerElement('music-view-stack', {
prototype: proto
});
} catch (e) {
if (e.name !== 'NotSupportedError') {
throw e;
}
}
function loaded(frame) {
return new Promise((resolve) => {
frame.addEventListener('load', function onLoad() {
frame.removeEventListener('load', onLoad);
resolve();
});
});
}
})(window);