This commit is contained in:
+47
-50
@@ -3,7 +3,6 @@
|
||||
network round trip before it looks like it did anything feels broken. */
|
||||
|
||||
const STEP = Number(document.documentElement.dataset.step) || 2;
|
||||
const HOST_LABEL = document.documentElement.dataset.host || 'the AVR';
|
||||
// Where the app is mounted: "/" on its own port, "/heos/" behind a proxy.
|
||||
const BASE = document.documentElement.dataset.base || '/';
|
||||
const POLL_MS = 5000;
|
||||
@@ -26,7 +25,7 @@ const ICONS = {
|
||||
'lcr-fronts': SPEAKERS,
|
||||
'surround-sound-system': SPEAKERS,
|
||||
'subwoofer': SPEAKER,
|
||||
mediaplayer: { viewbox: '0 0 640 544', path: 'M192 64c0-35.3 28.7-64 64-64L544 0c35.3 0 64 28.7 64 64l0 120.4c-6.8-.8-13.8-.6-20.9 .6l-64.1 11.7-54.5-89.2C464.1 100.4 456.4 96 448 96s-16.1 4.4-20.5 11.5l-54 88.3-17.9-25.6c-4.5-6.4-11.8-10.2-19.7-10.2s-15.2 3.8-19.7 10.2l-56 80c-5.1 7.3-5.8 16.9-1.6 24.8S271.1 288 280 288l72 0 0 64-96 0c-35.3 0-64-28.7-64-64l0-224zM352 403.8c-15.3 3.8-29.7 10.5-42.1 19.7-8.7 6.5-16.8 14.8-23.3 24.5L64 448c-35.3 0-64-28.7-64-64L0 160c0-35.3 28.7-64 64-64l80 0 0 192c0 61.9 50.1 112 112 112l96 0 0 3.8zM320 96a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM52 164l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0c-8.8 0-16 7.2-16 16zm16 80c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm0 96c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm556-84.1l0 208c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-84.9-128 23.3 0 139.9c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-161.6c0-11.6 8.3-21.5 19.7-23.6l176-32c7-1.3 14.2 .6 19.7 5.2s8.6 11.3 8.6 18.4z' },
|
||||
mediaplayer: { viewbox: '0 -16 576 512', path: 'M0 112c0 70.7 57.3 128 128 128l224 0c70.7 0 128-57.3 128-128S422.7-16 352-16c-48.2 0-90.2 26.6-112 66-21.8-39.4-63.8-66-112-66-70.7 0-128 57.3-128 128zm304 0a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM128 64a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM64 352l0 64c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64l-224 0c-35.3 0-64 28.7-64 64zM537.5 490.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2l-73.5 58.8 0 160 73.5 58.8z' },
|
||||
game: { viewbox: '0 0 640 512', path: 'M448 64c106 0 192 86 192 192S554 448 448 448l-256 0C86 448 0 362 0 256S86 64 192 64l256 0zM192 176c-13.3 0-24 10.7-24 24l0 32-32 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l32 0 0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-32 32 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-32 0 0-32c0-13.3-10.7-24-24-24zm240 96a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm64-96a32 32 0 1 0 0 64 32 32 0 1 0 0-64z' },
|
||||
tv: TV,
|
||||
tvaudio: TV,
|
||||
@@ -53,29 +52,31 @@ const ui = {
|
||||
foot: el('[data-role="foot"]'),
|
||||
toast: el('[data-role="toast"]'),
|
||||
refresh: el('[data-role="refresh"]'),
|
||||
splitAll: el('[data-role="split-all"]'),
|
||||
avrStatus: el('[data-role="avr-status"]'),
|
||||
inputButton: el('[data-role="input-button"]'),
|
||||
inputName: el('[data-role="input-name"]'),
|
||||
inputIcon: el('[data-role="input-icon"]'),
|
||||
sheet: el('[data-role="sheet"]'),
|
||||
options: el('[data-role="options"]'),
|
||||
source: el('.card.source'),
|
||||
joined: el('[data-role="joined"]'),
|
||||
joinedRooms: el('[data-role="joined-rooms"]'),
|
||||
avrNowPlaying: el('[data-role="avr-now-playing"]'),
|
||||
avrCover: el('[data-role="avr-cover"]'),
|
||||
avrSong: el('[data-role="avr-song"]'),
|
||||
avrArtist: el('[data-role="avr-artist"]'),
|
||||
avrProgress: el('[data-role="avr-progress"]'),
|
||||
};
|
||||
|
||||
// Shares its shape with a room's {nowPlaying, cover, song, artist} refs, so
|
||||
// renderTrack() below works for both -- a device plugged into the AVR (a
|
||||
// Zidoo, say) is "now playing" the same way a room's own stream is.
|
||||
// Shares its shape with a room's {nowPlaying, cover, song, artist,
|
||||
// progress} refs, so renderTrack() below works for both -- a device plugged
|
||||
// into the AVR (a Zidoo, say) is "now playing" the same way a room's own
|
||||
// stream is.
|
||||
const avrTrack = {
|
||||
nowPlaying: ui.avrNowPlaying,
|
||||
cover: ui.avrCover,
|
||||
song: ui.avrSong,
|
||||
artist: ui.avrArtist,
|
||||
progress: ui.avrProgress,
|
||||
};
|
||||
// Same reasoning as a room's own cover: drop one that will not load rather
|
||||
// than leave a broken-image box.
|
||||
@@ -84,6 +85,7 @@ avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; }
|
||||
const rooms = {};
|
||||
let inputs = [];
|
||||
let currentInput = null;
|
||||
let avrNowPlaying = null;
|
||||
|
||||
/* --- transport -------------------------------------------------------- */
|
||||
async function api(path, body) {
|
||||
@@ -122,7 +124,7 @@ els('.room').forEach((node) => {
|
||||
prev: el('[data-role="prev"]', node),
|
||||
play: el('[data-role="play"]', node),
|
||||
next: el('[data-role="next"]', node),
|
||||
spotify: el('.spotify-row', node), // absent with no Spotify account set up
|
||||
spotify: el('.spotify-row', node), // the resume buttons and the host toggle
|
||||
nowPlaying: el('[data-role="now-playing"]', node),
|
||||
cover: el('[data-role="cover"]', node),
|
||||
song: el('[data-role="song"]', node),
|
||||
@@ -169,7 +171,7 @@ function paintRoom(room) {
|
||||
room.meter.style.setProperty('--level', known ? room.volume : 0);
|
||||
room.node.classList.toggle('offline', !room.available);
|
||||
room.steps.forEach((button) => { button.disabled = !room.available; });
|
||||
paintTrack(room);
|
||||
renderTrack(room.available ? room.track : null, room);
|
||||
const playing = room.playState === 'play';
|
||||
room.node.classList.toggle('playing', playing);
|
||||
room.play.classList.toggle('playing', playing);
|
||||
@@ -187,27 +189,31 @@ function paintRoom(room) {
|
||||
room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped);
|
||||
room.prev.disabled = !room.available;
|
||||
room.next.disabled = !room.available;
|
||||
// Resuming stays on offer while grouped: only the speakers have Spotify
|
||||
// buttons, so hiding them here would leave none at all.
|
||||
if (room.spotify) {
|
||||
room.spotify.hidden = !room.available;
|
||||
els('.spotify', room.spotify).forEach((button) => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
|
||||
});
|
||||
}
|
||||
// Grouped, the room plays whatever the AVR does, so resuming Spotify on it
|
||||
// is not on offer. They hide one by one rather than the row hiding with
|
||||
// them, because the row also holds the host toggle, which stays put and
|
||||
// merely disables.
|
||||
els('.spotify', room.spotify).forEach((button) => {
|
||||
button.hidden = !room.available || room.grouped;
|
||||
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
|
||||
});
|
||||
|
||||
room.toggle.disabled = !room.available;
|
||||
room.toggle.classList.toggle('busy', room.busy);
|
||||
room.toggle.setAttribute('aria-pressed', String(room.grouped));
|
||||
// Where the card sits already says whether it is grouped, so the button
|
||||
// says what tapping it does instead.
|
||||
room.toggleLabel.textContent = room.grouped ? 'Leave' : `Join ${HOST_LABEL}`;
|
||||
// Out of the group the button names where tapping it sends the room; in it,
|
||||
// where the card sits already says so, so the button says what it does.
|
||||
room.toggleLabel.textContent = room.grouped ? 'Ungroup' : 'Cinema';
|
||||
// Grouped, its slot is empty and the AVR's card is the one that moves up.
|
||||
room.slot.classList.toggle('on-top', playing && !room.grouped);
|
||||
placeRoom(room);
|
||||
}
|
||||
|
||||
/* Song, artist and cover, each only when there is one. The cover's src is
|
||||
only touched when the track changes, so a poll never makes it flicker. */
|
||||
/* Song, artist, cover and progress, each only when there is one. The
|
||||
cover's src is only touched when the track changes, so a poll never makes
|
||||
it flicker. */
|
||||
function renderTrack(track, refs) {
|
||||
renderProgress(track, refs.progress);
|
||||
refs.nowPlaying.hidden = !track;
|
||||
if (!track) return;
|
||||
refs.song.textContent = track.song;
|
||||
@@ -222,44 +228,46 @@ function renderTrack(track, refs) {
|
||||
}
|
||||
}
|
||||
|
||||
function paintTrack(room) {
|
||||
renderTrack(room.available ? room.track : null, room);
|
||||
paintProgress(room);
|
||||
}
|
||||
|
||||
/* The discreet cursor on the line below the song, sized off the same
|
||||
{position_ms, duration_ms} the poll hands back -- absent for anything
|
||||
HEOS never sends a progress event for (an AVR input, an internet radio
|
||||
stream with no fixed length), in which case the line stays plain. */
|
||||
function paintProgress(room) {
|
||||
const track = room.available ? room.track : null;
|
||||
room.progress.hidden = !track;
|
||||
function renderProgress(track, progress) {
|
||||
progress.hidden = !track;
|
||||
if (!track) return;
|
||||
const { position_ms: position, duration_ms: duration } = track;
|
||||
const known = typeof duration === 'number' && duration > 0 && typeof position === 'number';
|
||||
const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0;
|
||||
room.progress.classList.toggle('known', known);
|
||||
room.progress.style.setProperty('--progress', percent);
|
||||
progress.classList.toggle('known', known);
|
||||
progress.style.setProperty('--progress', percent);
|
||||
}
|
||||
|
||||
/* A merged room moves into the host's card, because that is what merging
|
||||
means: one group, playing one thing. Leaving puts the card back in its
|
||||
own slot, which is why the slots exist. */
|
||||
function placeRoom(room) {
|
||||
const target = room.grouped ? ui.joinedRooms : room.slot;
|
||||
const target = room.grouped ? ui.joined : room.slot;
|
||||
if (room.node.parentElement !== target) target.appendChild(room.node);
|
||||
// Left empty, a slot would still take a gap of its own and double the space.
|
||||
room.slot.hidden = room.grouped;
|
||||
}
|
||||
|
||||
function paintGrouping() {
|
||||
const order = Object.keys(rooms);
|
||||
const inside = Array.from(ui.joinedRooms.children);
|
||||
const inside = Array.from(ui.joined.children);
|
||||
// Keep them in the order the cards are declared, not the order they joined.
|
||||
inside
|
||||
.slice()
|
||||
.sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room))
|
||||
.forEach((node) => ui.joinedRooms.appendChild(node));
|
||||
.forEach((node) => ui.joined.appendChild(node));
|
||||
ui.joined.hidden = inside.length === 0;
|
||||
ui.splitAll.hidden = inside.length === 0;
|
||||
// Grouped rooms share the host's transport, so one of them playing means
|
||||
// the whole group is -- and the tint goes on the group's card.
|
||||
const groupPlaying = inside.some((node) => node.classList.contains('playing'));
|
||||
ui.source.classList.toggle('playing', groupPlaying);
|
||||
// A film on the Zidoo also moves the AVR up. The Zidoo does not say whether
|
||||
// it is paused, so a loaded film counts, and it gets no tint.
|
||||
ui.source.classList.toggle('on-top', groupPlaying || Boolean(avrNowPlaying));
|
||||
}
|
||||
|
||||
/* Press and hold to keep moving, accelerating as you hold. */
|
||||
@@ -438,18 +446,6 @@ function applyJoined(joined) {
|
||||
paintGrouping();
|
||||
}
|
||||
|
||||
ui.splitAll.addEventListener('click', async () => {
|
||||
try {
|
||||
applyJoined([]);
|
||||
const data = await api('/api/group/none', {}); // {} so it is a POST, as the route requires
|
||||
applyJoined(data.joined || []);
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
} finally {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
/* --- Spotify: one button per account, asking the card's own Connect
|
||||
receiver to resume that account, instead of always connecting to it
|
||||
from the Spotify app -------------------------------------------------- */
|
||||
@@ -543,7 +539,6 @@ function render(state) {
|
||||
room.spotifyAccount = incoming.spotify_account || null;
|
||||
paintRoom(room);
|
||||
});
|
||||
paintGrouping();
|
||||
|
||||
const avr = state.avr || {};
|
||||
inputs = avr.inputs || [];
|
||||
@@ -552,7 +547,9 @@ function render(state) {
|
||||
setInputIcon(currentInput ? currentInput.code : null);
|
||||
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
|
||||
ui.avrStatus.classList.toggle('on', Boolean(avr.connected));
|
||||
renderTrack(avr.now_playing || null, avrTrack);
|
||||
avrNowPlaying = avr.now_playing || null;
|
||||
renderTrack(avrNowPlaying, avrTrack);
|
||||
paintGrouping(); // after avrNowPlaying, which decides where the AVR's card sits
|
||||
|
||||
const problems = state.errors || [];
|
||||
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
|
||||
|
||||
Reference in New Issue
Block a user