diff --git a/static/app.js b/static/app.js index d22ad1a..d19a54e 100644 --- a/static/app.js +++ b/static/app.js @@ -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. */ diff --git a/static/panel.css b/static/panel.css index e704c2a..4aec021 100644 --- a/static/panel.css +++ b/static/panel.css @@ -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 */ diff --git a/templates/index.html b/templates/index.html index 32f3bf3..983f5e1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,24 +1,25 @@ +
- - -