Files
heos/static/panel.css
T
franzz 532b48ad37
Deploy HEOS panel / deploy (push) Successful in 25s
Fix grouped AVR / room card + add movie info
2026-09-16 20:36:29 +02:00

354 lines
14 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;
--bg: #0a0d14;
--card: #151a25;
--edge: #232b3b;
--raised: #29334a;
--ink: #eef2f9;
--muted: #8d98ad;
--accent: #5b8def;
--live: #3ddc97;
--warn: #ff7a6b;
--radius: 22px;
--gutter: 16px; /* from the screen's side to a card, and from one card to the next */
}
* { 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;
}
/* 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(12px, env(safe-area-inset-top)) max(var(--gutter), env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(var(--gutter), env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: var(--gutter);
}
/* 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: space-between; padding: 6px 4px 0; }
.top h1 { margin: 0; line-height: 0; }
.top .logo { height: 34px; width: auto; display: block; }
.icon-button {
width: 44px; height: 44px; border-radius: 50%;
display: grid; place-items: center;
color: var(--muted); background: var(--card);
}
.icon-button:active { background: var(--raised); color: var(--ink); }
.icon-button.spin svg { animation: spin .7s linear; }
@keyframes spin { to { transform: rotate(360deg); } }
/* --- cards ----------------------------------------------------------- */
.card {
background: var(--card);
border: 1px solid var(--edge);
border-radius: var(--radius);
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.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
host's card that lights up, not the rooms merged into it. */
.card.room.playing,
.card.source.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: space-between; gap: 12px; }
.card-head h2 {
display: flex; align-items: center; gap: 10px;
margin: 0; min-width: 0;
font-size: 17px; font-weight: 600;
}
.card-head h2 .room-icon { flex: 0 0 auto; height: 20px; width: auto; fill: currentColor; color: var(--muted); }
.card-head h2 .room-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill {
font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); background: var(--raised);
padding: 4px 9px; border-radius: 999px;
}
.pill.on { color: var(--live); }
/* --- now playing, between the room's name and its volume --------------- */
.now-playing {
display: flex; align-items: center; gap: 16px; 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: 20px; font-weight: 550; }
.artist { font-size: 17px; 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 { position: relative; height: 3px; border-radius: 999px; background: #0c111b; }
.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;
}
.progress.known .progress-cursor { display: block; }
/* --- volume ---------------------------------------------------------- */
.volume { display: flex; align-items: center; gap: 14px; }
.step {
flex: 0 0 auto;
width: 78px; height: 62px;
border-radius: 18px;
background: var(--raised);
display: grid; place-items: center;
}
.step svg { width: 20px; height: 20px; }
.step:active { background: var(--accent); transform: scale(.96); }
.step:disabled { opacity: .4; }
.meter { position: relative; flex: 1; height: 8px; border-radius: 999px; background: #0c111b; }
.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: #fff;
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: -12px -8px; }
/* 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, centred ----------------------------- */
.actions { display: flex; align-items: stretch; justify-content: center; gap: 10px; }
.transport {
flex: 0 0 auto;
width: 66px; height: 62px;
border-radius: 16px;
background: var(--raised);
display: grid; place-items: center;
}
.transport:active { background: var(--accent); transform: scale(.97); }
.transport:disabled { opacity: .5; }
.transport svg { width: 19px; height: 19px; }
/* Which icon shows is a class on the button, not `hidden` on the svg:
`hidden` is an HTMLElement property and SVGElement does not inherit it,
so svg.hidden = true sets a JS expando and styles nothing. */
.transport .icon-pause { display: none; }
.transport.playing .icon-play { display: none; }
.transport.playing .icon-pause { display: block; }
/* --- join / leave the AVR, between the Spotify buttons ----------------- */
/* Styled like the .spotify buttons it sits between -- same height, corner,
type and full-strength ink -- but sized to its own label instead of
sharing the row equally. Like them, it says "not the active one" with a
plain background rather than by dimming itself, so the three read as
equal choices. */
.toggle {
flex: 0 0 auto;
height: 50px; padding: 0 14px; border-radius: 16px;
background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 9px;
font-size: 15px; font-weight: 550; white-space: nowrap;
}
.toggle .cinema { flex: 0 0 auto; height: 18px; width: auto; color: #fff; }
.toggle .leave { display: none; flex: 0 0 auto; color: #fff; }
.toggle[aria-pressed="true"] { background: var(--accent); color: #fff; }
.toggle:active { transform: scale(.985); }
.toggle:disabled { opacity: .5; }
.toggle.busy { opacity: .6; }
/* --- Spotify: one resume button per account, around the host toggle ----- */
.spotify-row { display: flex; gap: 10px; }
.spotify {
flex: 1; min-width: 0;
height: 50px; border-radius: 16px;
background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 9px;
font-size: 15px; font-weight: 550;
}
.spotify svg { flex: 0 0 auto; color: #fff; }
.spotify span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spotify:active { transform: scale(.985); }
.spotify:disabled { opacity: .5; }
/* The account playing on this room right now. */
.spotify[aria-pressed="true"] { border: 2px solid var(--ink); }
/* --- source card ------------------------------------------------------ */
.source-button {
display: flex; align-items: center; gap: 12px;
width: 100%; min-height: 64px;
padding: 10px 14px;
border-radius: 16px;
background: var(--raised);
text-align: left;
}
.source-button:active { transform: scale(.985); }
.source-button .eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.source-button [data-role="input-icon"] { flex: 0 0 auto; height: 18px; width: 18px; fill: currentColor; color: var(--muted); }
.source-button .value { flex: 1; font-size: 19px; font-weight: 600; }
.source-button .chevron { color: var(--muted); }
/* --- rooms merged into the host's card --------------------------------- */
.joined { display: flex; flex-direction: column; gap: 14px; }
/* 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: 14px; gap: 12px; }
.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 host's would only muddle. */
.joined .toggle { flex: 1; min-width: 0; }
.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); }
.ghost:active { background: var(--card); color: var(--ink); }
.foot { margin: 2px 4px 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: rgba(4, 6, 11, .6); backdrop-filter: blur(3px); }
.sheet {
position: relative;
background: var(--card);
border: 1px solid var(--edge);
border-radius: 28px 28px 0 0;
padding: 10px 16px calc(16px + env(safe-area-inset-bottom));
max-height: 82vh;
display: flex; flex-direction: column; gap: 12px;
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: 2px auto 4px; }
.sheet h3 { margin: 0 4px; font-size: 14px; font-weight: 600; color: var(--muted); }
.options { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
.option {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
min-height: 58px; padding: 0 16px;
border-radius: 16px; background: var(--raised);
font-size: 17px; text-align: left;
}
.option:active { transform: scale(.985); }
.option[aria-current="true"] { background: var(--accent); color: #fff; }
.option-label { display: flex; align-items: center; gap: 10px; 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: rgba(255, 255, 255, .8); }
.option .code { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
.option[aria-current="true"] .code { color: rgba(255, 255, 255, .8); }
/* --- toast ------------------------------------------------------------ */
.toast {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: calc(22px + env(safe-area-inset-bottom));
z-index: 20; max-width: 90vw;
padding: 12px 16px; border-radius: 14px;
background: #2b1f24; border: 1px solid #52303a; color: #ffd9d4;
font-size: 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, .45);
}
.toast.ok { background: #172a21; border-color: #2a5540; color: #c9f7df; }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}