Files
heos/static/panel.css
T
franzz d4959b4ac3
Deploy HEOS panel / deploy (push) Successful in 27s
Harmonize button active state
2026-09-18 17:23:03 +02:00

359 lines
15 KiB
CSS

/* A remote lives in the dark next to a TV, so the panel is dark too --
and every control is sized for a thumb, not a cursor. */
:root {
color-scheme: dark;
--accent: #5b8def;
--bg: #0a0d14;
--card: #151a25;
--edge: #232b3b;
--ink: #eef2f9;
--ink-bright: #fff;
--ink-bright-muted: rgba(255, 255, 255, .8);
--live: #3ddc97;
--muted: #8d98ad;
--radius: 22px;
--raised: #29334a;
--scrim: rgba(4, 6, 11, .6);
--shadow: rgba(0, 0, 0, .45);
/* spacing scale for every gap/padding/margin in the sheet, smallest to
largest: 2xs 4px, sm 8px, md 12px, gutter 16px, lg 24px. */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px; /* from the screen's side to a card, and from one card to the next */
--space-lg: 24px;
--toast-bad-bg: #2b1f24;
--toast-bad-edge: #52303a;
--toast-bad-ink: #ffd9d4;
--toast-ok-bg: #172a21;
--toast-ok-edge: #2a5540;
--toast-ok-ink: #c9f7df;
--warn: #ff7a6b;
}
* { box-sizing: border-box; }
/* Must beat every display rule below it. `hidden` is a plain UA style, so
any author rule -- .sheet-wrap's display:flex, say -- silently wins, and
an overlay that never hides sits over the whole panel eating taps. */
[hidden] { display: none !important; }
html { background: var(--bg); }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
overscroll-behavior-y: contain;
}
button {
font: inherit;
color: inherit;
border: 0;
background: none;
cursor: pointer;
touch-action: manipulation;
box-shadow: 1px 1px 1px 0px var(--shadow);
}
button:active { transform: scale(.96); }
/* Every icon is a Font Awesome glyph: a solid shape, filled, never stroked. */
svg { width: 22px; height: 22px; fill: currentColor; }
.app {
max-width: 520px;
margin: 0 auto;
padding: max(var(--space-md), env(safe-area-inset-top)) max(var(--space-md), env(safe-area-inset-right))
max(var(--space-lg), env(safe-area-inset-bottom)) max(var(--space-md), env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: var(--space-md);
}
/* A playing card goes to the top. The rest stay in markup order (rooms as
room_keys lists them, then the AVR), because items with the same order
keep their place, so playing cards keep that order among themselves too.
Only the order changes, not the DOM, so a knob held mid-drag stays put. */
.app > .on-top { order: -1; }
/* --- header ---------------------------------------------------------- */
.top { order: -2; display: flex; align-items: center; justify-content: center; }
.top .logo { height: 44px; width: auto; display: block; margin: var(--space-xs); }
/* --- cards ----------------------------------------------------------- */
.card {
background: var(--card);
border: 1px solid var(--edge);
border-radius: var(--radius);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.card.offline { opacity: .5; }
/* A tint, not a repaint -- it should read at a glance without competing
with the song title right above it. A group plays one thing, so it is the
AVR's card that lights up, not the rooms merged into it. */
.card.room.playing,
.card.avr.playing {
background: linear-gradient(135deg,
color-mix(in srgb, var(--live) 25%, var(--card)) 0%,
var(--card) 70%);
}
.card-head { display: flex; align-items: center; justify-content: center; gap: var(--space-md); }
.card-head h2 {
display: flex; align-items: center; gap: var(--space-md);
margin: var(--space-sm); min-width: 0;
font-size: 20px; font-weight: 600;
}
.card-head h2 .room-icon { flex: 0 0 auto; height: 20px; width: auto; fill: currentColor; }
.card-head h2 .room-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* --- now playing, between the room's name and its volume --------------- */
.now-playing {
display: flex; align-items: center; gap: var(--space-md); min-width: 0;
}
.cover {
flex: 0 0 auto;
width: 62px; height: 62px;
border-radius: 13px;
object-fit: cover;
background: var(--raised);
}
/* A film's jacket keeps its own portrait shape rather than being cropped
square like an album's. */
.cover.poster { width: 50px; height: 75px; border-radius: 9px; }
.track { display: flex; flex-direction: column; min-width: 0; }
.track span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.song { font-size: 17px; font-weight: 550; }
.artist { font-size: 15px; color: var(--muted); }
/* Doubles as the line between the song and the volume -- it goes with the
block, when nothing is playing. Muted throughout, so it never competes
with the volume meter below it; the cursor only shows once a position is
actually known (an AVR input or a stream with no duration never gets one). */
.progress { display: flex; align-items: center; gap: var(--space-md); }
.progress-line { position: relative; flex: 1; height: 3px; border-radius: 2px; background: var(--bg); }
.progress-fill {
display: block; height: 100%; width: calc(var(--progress, 0) * 1%);
border-radius: 999px; background: var(--muted);
transition: width .12s ease-out;
}
.progress-cursor {
display: none;
position: absolute; top: 50%;
left: calc(var(--progress, 0) * 1%);
width: 7px; height: 7px; margin-left: -3.5px;
border-radius: 50%; background: var(--muted);
transform: translateY(-50%);
transition: left .12s ease-out;
}
/* Elapsed rides above the cursor and remaining below it, all three centred
on one axis, and the total waits at the end of the line. Near either end a
label stops half its own width (--half, measured in app.js) short of it,
so it never hangs off the card. */
.progress-time,
.progress-total {
display: none;
font-size: 12px; line-height: 1; color: var(--muted);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.progress-time {
position: absolute;
left: clamp(var(--half, 0px), calc(var(--progress, 0) * 1%), calc(100% - var(--half, 0px)));
transform: translateX(-50%);
transition: left .12s ease-out;
}
.progress-time.elapsed { bottom: 9px; }
.progress-time.remaining { top: 9px; }
.progress.known { padding: var(--space-md) 0; } /* room for the labels above and below the cursor */
.progress.known .progress-cursor,
.progress.known .progress-time,
.progress.known .progress-total { display: block; }
/* Where the player can seek, the cursor and both times are one handle, with
a thumb-sized grip around a 7px dot. pan-y leaves a vertical swipe to the
page, so scrolling past never seeks; only a sideways drag is ours. Held,
it lights up and follows the finger without easing after it. */
.progress.seekable .progress-cursor,
.progress.seekable .progress-time { cursor: grab; touch-action: pan-y; }
.progress.seekable .progress-cursor::before { content: ''; position: absolute; inset: calc(var(--space-md) * -1); }
.progress.dragging .progress-cursor,
.progress.dragging .progress-time { transition: none; cursor: grabbing; }
.progress.dragging .progress-cursor { background: var(--ink); transform: translateY(-50%) scale(1.6); }
.progress.dragging .progress-time { color: var(--ink); }
/* --- volume ---------------------------------------------------------- */
.volume { display: flex; align-items: center; gap: var(--space-md); }
.control-btn {
flex: 0 0 auto;
width: 72px; height: 62px;
border-radius: 16px;
background: var(--raised);
display: grid; place-items: center;
}
.control-btn svg { width: 20px; height: 20px; }
.control-btn:disabled { opacity: .5; }
.meter { position: relative; flex: 1; height: 8px; border-radius: 4px; background: var(--bg); }
.meter i {
display: block; height: 100%; width: calc(var(--level, 0) * 1%);
border-radius: 999px; background: var(--accent);
transition: width .12s ease-out;
}
/* The level rides the bar. It travels the track less its own width, so it
never hangs off either end over the buttons -- and the fill's end is
always somewhere underneath it. */
.knob {
position: absolute; top: 50%;
left: calc((100% - 46px) * var(--level, 0) / 100);
width: 46px; height: 30px;
transform: translateY(-50%);
display: grid; place-items: center;
border-radius: 999px;
background: var(--accent); color: var(--ink-bright);
box-shadow: 0 0 0 3px var(--card);
font-size: 14px; font-weight: 640; font-variant-numeric: tabular-nums;
transition: left .12s ease-out;
cursor: grab;
touch-action: none; /* the drag is ours, not a page scroll */
}
/* A thumb-sized grip around a knob only 30px tall. A press on the
pseudo-element lands on the knob itself. */
.knob::before { content: ''; position: absolute; inset: calc(var(--space-md) * -1) calc(var(--space-sm) * -1); }
/* Under a finger the knob has to keep up, not ease after it. */
.meter.dragging i,
.meter.dragging .knob { transition: none; }
.meter.dragging .knob { cursor: grabbing; transform: translateY(-50%) scale(1.12); }
/* --- previous / play-pause / next -------------------------------------- */
.actions { display: flex; align-items: stretch; justify-content: center; gap: var(--space-md); }
.control-btn .icon-pause { display: none; }
.control-btn.playing .icon-play { display: none; }
.control-btn.playing .icon-pause { display: block; }
/* --- Sources : Spotify / Home cinema ----------------------------------- */
.sources { display: flex; gap: var(--space-md); }
.sources-btn {
flex: 1; min-width: 0;
height: 50px; border-radius: 16px;
background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: var(--space-sm);
font-size: 15px; font-weight: 550;
}
.sources-btn:disabled { opacity: .5; }
.sources-btn svg { flex: 0 0 auto; color: var(--ink-bright); }
.sources-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sources-btn .spinner { display: none; }
/* Bumped with the `button` type selector so this reliably beats
.joined .toggle .leave's display:block below, same specificity otherwise. */
button.sources-btn.loading .icon { display: none; }
.sources-btn.loading .spinner { display: block; animation: spin 1s steps(8) infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* The account playing on this room right now. */
.spotify[aria-pressed="true"] { border: 2px solid var(--ink); }
/* --- join / leave the AVR, between the Spotify buttons ----------------- */
.toggle .leave { display: none; }
.toggle[aria-pressed="true"] { background: var(--accent); color: var(--ink-bright); }
/* --- AVR card ------------------------------------------------------ */
.input-select {
display: flex; align-items: center; gap: var(--space-md);
width: 100%; min-height: 50px;
padding: var(--space-md) var(--space-md);
border-radius: 16px;
background: var(--raised);
text-align: left;
}
.input-select .eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.input-select [data-role="input-icon"] { flex: 0 0 auto; height: 18px; width: 18px; fill: currentColor; color: var(--muted); }
.input-select .value { flex: 1; font-size: 19px; font-weight: 600; }
.input-select .chevron { color: var(--muted); }
/* --- rooms merged into the AVR's card --------------------------------- */
.joined { display: flex; flex-direction: column; gap: var(--space-md); }
/* In here a room is an outline rather than a card: its border is what says
it is playing with the AVR, and the fill -- a tint too, when it plays --
belongs to the group's card instead. Same specificity as
.card.room.playing, so being later is what wins. */
.joined .card.room { background: none; border-color: var(--ink); border-radius: 18px; padding: var(--space-md); gap: var(--space-md); }
.joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); }
.joined .cover { width: 52px; height: 52px; border-radius: 10px; }
.joined .song { font-size: 18px; }
.joined .step { height: 54px; }
/* In here the Spotify buttons hide and the toggle says "Detach", so it takes
their place and their look: the whole row, a plain background rather than
the pressed accent, and an exit icon where the AVR's would only muddle. */
.joined .toggle[aria-pressed="true"] { background: var(--raised); color: inherit; }
.joined .toggle .cinema { display: none; }
.joined .toggle .leave { display: block; }
/* --- misc ------------------------------------------------------------- */
.wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; }
.ghost { background: transparent; border: 1px solid var(--edge); color: var(--muted); }
.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); }
/* --- input sheet ------------------------------------------------------ */
.sheet-wrap { position: fixed; inset: 0; z-index: 10; display: flex; flex-direction: column; justify-content: flex-end; }
.scrim { position: absolute; inset: 0; background: var(--scrim); backdrop-filter: blur(3px); }
.sheet {
position: relative;
background: var(--card);
border: 1px solid var(--edge);
border-radius: 28px 28px 0 0;
padding: var(--space-md) var(--space-md) calc(var(--space-md) + env(safe-area-inset-bottom));
max-height: 82vh;
display: flex; flex-direction: column; gap: var(--space-md);
animation: rise .22s cubic-bezier(.22, .68, .3, 1);
}
@keyframes rise { from { transform: translateY(14%); opacity: .4; } }
.grabber { width: 38px; height: 4px; border-radius: 999px; background: var(--edge); margin: var(--space-xs) auto var(--space-xs); }
.sheet h3 { margin: 0 var(--space-xs); font-size: 14px; font-weight: 600; color: var(--muted); }
.options { display: flex; flex-direction: column; gap: var(--space-sm); overflow-y: auto; -webkit-overflow-scrolling: touch; }
.option {
display: flex; align-items: center; justify-content: space-between; gap: var(--space-md);
min-height: 58px; padding: 0 var(--space-md);
border-radius: 16px; background: var(--raised);
font-size: 17px; text-align: left;
}
.option[aria-current="true"] { background: var(--accent); color: var(--ink-bright); }
.option-label { display: flex; align-items: center; gap: var(--space-md); min-width: 0; overflow: hidden; }
.option-label svg { flex: 0 0 auto; height: 18px; width: 18px; fill: currentColor; color: var(--muted); }
.option-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.option[aria-current="true"] .option-label svg { color: var(--ink-bright-muted); }
.option .code { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
.option[aria-current="true"] .code { color: var(--ink-bright-muted); }
/* --- toast ------------------------------------------------------------ */
.toast {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: calc(22px + env(safe-area-inset-bottom));
z-index: 20; max-width: 90vw;
padding: var(--space-md) var(--space-md); border-radius: 14px;
background: var(--toast-bad-bg); border: 1px solid var(--toast-bad-edge); color: var(--toast-bad-ink);
font-size: 14px; box-shadow: 0 10px 30px var(--shadow);
}
.toast.ok { background: var(--toast-ok-bg); border-color: var(--toast-ok-edge); color: var(--toast-ok-ink); }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}