Animate room rank changes
Deploy HEOS panel / deploy (push) Successful in 27s

This commit is contained in:
franzz committed 2026-09-20 13:58:57 +02:00
1 parent 3565465f34
commit 0560079b16
3 files changed
+81 -30

No files matched your search

+55 -10
View File
@@ -8,6 +8,8 @@ const BASE = document.documentElement.dataset.base || '/';
const POLL_MS = 5000;
// How long a dropped cursor waits for the player to report it got there.
const SEEK_HOLD_MS = 10000;
// Motion turned down in the OS means cards change places without sliding.
const CALM = matchMedia('(prefers-reduced-motion: reduce)');
const el = (sel, root = document) => root.querySelector(sel);
const els = (sel, root = document) => Array.from(root.querySelectorAll(sel));
@@ -58,6 +60,7 @@ const ui = {
inputIcon: el('[data-role="input-icon"]'),
sheet: el('[data-role="sheet"]'),
options: el('[data-role="options"]'),
app: el('.app'),
avr: el('.card.avr'),
joined: el('[data-role="joined"]'),
avrMedia: el('[data-role="avr-media"]'),
@@ -91,6 +94,10 @@ const avrTrack = {
avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; });
seekable(avrTrack);
// Same as a room's card: named, so a view transition can follow it when it
// moves to the top and back.
ui.avr.style.viewTransitionName = 'avr';
const rooms = {};
let inputs = [];
let currentInput = null;
@@ -124,7 +131,6 @@ els('.room').forEach((node) => {
const room = {
key,
node,
slot: el(`.room-slot[data-slot="${key}"]`),
level: el('[data-role="level"]', node),
meter: el('[data-role="meter"]', node),
actions: el('[data-role="actions"]', node),
@@ -225,9 +231,8 @@ function paintRoom(room) {
// 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);
placeCards();
}
/* Song, artist, cover and progress, each only when there is one. The
@@ -427,13 +432,24 @@ function clock(seconds) {
}
/* 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. */
means: one group, playing one thing. Leaving puts it back in the column,
ahead of the rooms declared after it, so the cards keep the order they are
declared in however they were grouped. */
function placeRoom(room) {
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;
if (room.grouped) {
if (room.node.parentElement !== ui.joined) ui.joined.appendChild(room.node);
} else if (room.node.parentElement !== ui.app) {
const order = Object.keys(rooms);
const next = order.slice(order.indexOf(room.key) + 1)
.map((key) => rooms[key].node)
.find((node) => node.parentElement === ui.app);
// Nothing left in the column below it means it goes last, before the host.
ui.app.insertBefore(room.node, next || ui.avr);
}
// A view transition only follows an element it can name, and the name has
// to be one of a kind on the page (see placeCards). Inside the host's card
// the card travels with it, so it takes no name of its own there.
room.node.style.viewTransitionName = room.grouped ? 'none' : `room-${room.key}`;
}
function paintGrouping() {
@@ -451,7 +467,36 @@ function paintGrouping() {
const playing = inside.some((node) => node.classList.contains('playing'))
|| avrNowPlaying?.play_state === 'play';
ui.avr.classList.toggle('playing', playing);
ui.avr.classList.toggle('on-top', playing);
placeCards();
}
/* Which card sits at the top of the column is CSS order (see .on-top), and
order is not a property that transitions: the cards change places between
one frame and the next. A view transition does the sliding instead -- the
browser snapshots the cards where they are, runs the swap, and animates
them to where they landed. One turn can paint several cards (a poll
touches every room and then the group), so the move is collected and run
once, and only started when a card really does change places. Without
support, or with motion turned down, the cards just change places the way
they always did. */
let placing = false;
function placeCards() {
if (placing) return;
placing = true;
queueMicrotask(() => {
placing = false;
// Grouped, a room's card has left the column and the AVR's is the one
// that moves up. The AVR's tint and its position answer the same
// question, and paintGrouping() has just settled it.
const wanted = Object.values(rooms)
.map((room) => [room.node, room.playing() && !room.grouped]);
wanted.push([ui.avr, ui.avr.classList.contains('playing')]);
const move = () => wanted.forEach(([node, top]) => node.classList.toggle('on-top', top));
const moves = wanted.some(([node, top]) => node.classList.contains('on-top') !== top);
if (!moves || CALM.matches || !document.startViewTransition) move();
else document.startViewTransition(move);
});
}
/* Press and hold to keep moving, accelerating as you hold. */
+10 -3
View File
@@ -89,7 +89,14 @@ svg { width: 22px; height: 22px; fill: currentColor; }
/* Card */
.app > .on-top { order: -1; } /* A playing card goes to the top */
/* A playing card goes to the top. order does not transition, so the swap
rides on a view transition instead -- see placeCards() in app.js. */
.app > .on-top { order: -1; }
::view-transition-group(*) {
animation-duration: .4s;
animation-timing-function: cubic-bezier(.22, 1, .36, 1);
}
.card {
background: var(--card);
@@ -321,8 +328,8 @@ button.sources-btn.loading .icon { display: none; }
/* Footer */
.foot { margin: var(--space-xs) var(--space-xs) 0; min-height: 18px; font-size: 12px; color: var(--muted); text-align: center; }
.foot.bad { color: var(--warn); }
footer { margin: var(--space-xs) var(--space-xs) 0; min-height: 18px; font-size: 12px; color: var(--muted); text-align: center; }
footer.bad { color: var(--warn); }
/* Toast */
+16 -17
View File
@@ -1,24 +1,25 @@
<!doctype html>
<html lang="en" data-step="{{ step }}" data-host="{{ host.label }}" data-base="{{ url_for('index') }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<title>{{ app_name }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<title>{{ app_name }}</title>
<!-- Added to the iOS home screen, this opens full-screen with no browser chrome. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ app_name }}">
<meta name="theme-color" content="#0a0d14">
<!-- Added to the iOS home screen, this opens full-screen with no browser chrome. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ app_name }}">
<meta name="theme-color" content="#0a0d14">
<link rel="manifest" href="{{ url_for('manifest') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icon-180.png') }}">
<link rel="icon" href="{{ url_for('static', filename='logo.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='panel.css') }}">
<link rel="manifest" href="{{ url_for('manifest') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icon-180.png') }}">
<link rel="icon" href="{{ url_for('static', filename='logo.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='panel.css') }}">
</head>
<body>
<body>
{% macro host_toggle() -%}
<button class="sources-btn toggle" data-role="group" aria-pressed="false">
<svg class="cinema icon" viewBox="0 0 512 512" aria-hidden="true"><path d="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM128 192c0-17.7 14.3-32 32-32l128 0c17.7 0 32 14.3 32 32l0 38.4 61-36.6c1.9-1.2 4.2-1.8 6.4-1.8 6.9 0 12.5 5.6 12.5 12.5l0 102.9c0 6.9-5.6 12.5-12.5 12.5-2.3 0-4.5-.6-6.4-1.8l-61-36.6 0 38.4c0 17.7-14.3 32-32 32l-128 0c-17.7 0-32-14.3-32-32l0-128z"/></svg>
@@ -52,7 +53,6 @@
</header>
{% for room in rooms %}
<div class="room-slot" data-slot="{{ room.key }}">
<section class="card room" data-room="{{ room.key }}">
<div class="card-head">
<h2><svg class="room-icon" data-icon="{{ room.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ room.label }}</span></h2>
@@ -105,7 +105,6 @@
{{ control_row(room.key, spotify_accounts) }}
</section>
</div>
{% endfor %}
<section class="card avr">
@@ -142,7 +141,7 @@
<div class="joined" data-role="joined" hidden></div>
</section>
<p class="foot" data-role="foot"></p>
<footer data-role="foot"></footer>
</div>
<div class="sheet-wrap" data-role="sheet" hidden>