diff --git a/static/app.js b/static/app.js index 396a954..304e553 100644 --- a/static/app.js +++ b/static/app.js @@ -59,7 +59,7 @@ const ui = { inputIcon: el('[data-role="input-icon"]'), sheet: el('[data-role="sheet"]'), options: el('[data-role="options"]'), - source: el('.card.source'), + avr: el('.card.avr'), joined: el('[data-role="joined"]'), avrNowPlaying: el('[data-role="avr-now-playing"]'), avrCover: el('[data-role="avr-cover"]'), @@ -129,7 +129,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), // the resume buttons and the host toggle + sources: el('.sources', 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), @@ -203,7 +203,7 @@ function paintRoom(room) { // 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) => { + els('.spotify', room.sources).forEach((button) => { const holding = button.dataset.account === room.spotifyAccount; button.hidden = !room.available || room.grouped; button.setAttribute('aria-pressed', String(holding)); @@ -214,8 +214,8 @@ function paintRoom(room) { `${holding ? 'Disconnect' : 'Resume'} ${button.querySelector('span').textContent}'s Spotify`); }); - room.toggle.disabled = !room.available; - room.toggle.classList.toggle('busy', room.busy); + room.toggle.disabled = !room.available || room.busy; + room.toggle.classList.toggle('loading', room.busy); room.toggle.setAttribute('aria-pressed', String(room.grouped)); // 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. @@ -414,8 +414,8 @@ function paintGrouping() { // A film running on the Zidoo counts as the AVR playing too, grouped rooms or not. const playing = inside.some((node) => node.classList.contains('playing')) || avrNowPlaying?.play_state === 'play'; - ui.source.classList.toggle('playing', playing); - ui.source.classList.toggle('on-top', playing); + ui.avr.classList.toggle('playing', playing); + ui.avr.classList.toggle('on-top', playing); } /* Press and hold to keep moving, accelerating as you hold. */ diff --git a/static/panel.css b/static/panel.css index b6d94df..827acb1 100644 --- a/static/panel.css +++ b/static/panel.css @@ -3,17 +3,33 @@ :root { color-scheme: dark; + --accent: #5b8def; --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 */ + --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-2xs: 4px; + --space-lg: 24px; + --space-md: 12px; + --space-sm: 8px; + --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; } @@ -52,8 +68,8 @@ 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)); + padding: max(var(--space-md), env(safe-area-inset-top)) max(var(--gutter), env(safe-area-inset-right)) + max(var(--space-lg), env(safe-area-inset-bottom)) max(var(--gutter), env(safe-area-inset-left)); display: flex; flex-direction: column; gap: var(--gutter); @@ -65,7 +81,7 @@ svg { width: 22px; height: 22px; fill: currentColor; } .app > .on-top { order: -1; } /* --- header ---------------------------------------------------------- */ -.top { order: -2; display: flex; align-items: center; justify-content: center; padding: 6px 4px 0; } +.top { order: -2; display: flex; align-items: center; justify-content: center; padding: var(--space-2xs) var(--space-2xs) 0; } .top h1 { margin: 0; line-height: 0; } .top .logo { height: 34px; width: auto; display: block; } @@ -74,25 +90,25 @@ svg { width: 22px; height: 22px; fill: currentColor; } background: var(--card); border: 1px solid var(--edge); border-radius: var(--radius); - padding: 16px; + padding: var(--gutter); display: flex; flex-direction: column; - gap: 14px; + gap: var(--gutter); } .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. */ + AVR's card that lights up, not the rooms merged into it. */ .card.room.playing, -.card.source.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: space-between; gap: 12px; } +.card-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); } .card-head h2 { - display: flex; align-items: center; gap: 10px; + display: flex; align-items: center; gap: var(--space-md); margin: 0; min-width: 0; font-size: 17px; font-weight: 600; } @@ -102,13 +118,13 @@ svg { width: 22px; height: 22px; fill: currentColor; } .pill { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); background: var(--raised); - padding: 4px 9px; border-radius: 999px; + padding: var(--space-2xs) var(--space-sm); 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; + display: flex; align-items: center; gap: var(--gutter); min-width: 0; } .cover { flex: 0 0 auto; @@ -129,8 +145,8 @@ svg { width: 22px; height: 22px; fill: currentColor; } 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: 10px; } -.progress-line { position: relative; flex: 1; height: 3px; border-radius: 999px; background: #0c111b; } +.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); @@ -163,7 +179,7 @@ svg { width: 22px; height: 22px; fill: currentColor; } } .progress-time.elapsed { bottom: 9px; } .progress-time.remaining { top: 9px; } -.progress.known { padding: 16px 0; } /* room for the labels above and below the cursor */ +.progress.known { padding: var(--gutter) 0; } /* room for the labels above and below the cursor */ .progress.known .progress-cursor, .progress.known .progress-time, .progress.known .progress-total { display: block; } @@ -174,27 +190,27 @@ svg { width: 22px; height: 22px; fill: currentColor; } 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: -16px -14px; } +.progress.seekable .progress-cursor::before { content: ''; position: absolute; inset: calc(var(--gutter) * -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: 14px; } +.volume { display: flex; align-items: center; gap: var(--gutter); } -.step { +.control-btn { flex: 0 0 auto; - width: 78px; height: 62px; - border-radius: 18px; + width: 72px; height: 62px; + border-radius: 16px; 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; } +.control-btn svg { width: 20px; height: 20px; } +.control-btn:active { background: var(--accent); transform: scale(.96); } +.control-btn:disabled { opacity: .5; } -.meter { position: relative; flex: 1; height: 8px; border-radius: 999px; background: #0c111b; } +.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); @@ -211,7 +227,7 @@ svg { width: 22px; height: 22px; fill: currentColor; } transform: translateY(-50%); display: grid; place-items: center; border-radius: 999px; - background: var(--accent); color: #fff; + 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; @@ -220,108 +236,77 @@ svg { width: 22px; height: 22px; fill: currentColor; } } /* 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; } +.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, centred ----------------------------- */ -.actions { display: flex; align-items: stretch; justify-content: center; gap: 10px; } +/* --- previous / play-pause / next -------------------------------------- */ +.actions { display: flex; align-items: stretch; justify-content: center; gap: var(--space-md); } -.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; } +.control-btn .icon-pause { display: none; } +.control-btn.playing .icon-play { display: none; } +.control-btn.playing .icon-pause { display: block; } -/* 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; 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 { +/* --- 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: 9px; + display: flex; align-items: center; justify-content: center; gap: var(--space-sm); 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); } -/* While a resume or disconnect is on its way, the logo turns into a - spinner. Swapped by a class for the same reason as the transport icons. - It ticks round in eight steps, like Font Awesome's own fa-spin-pulse. */ -.spotify .spinner { display: none; } -.spotify.loading .brand { display: none; } -.spotify.loading .spinner { display: block; animation: spin 1s steps(8) infinite; } +.sources-btn:active { transform: scale(.985); } +.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); } } -/* --- source card ------------------------------------------------------ */ -.source-button { - display: flex; align-items: center; gap: 12px; +/* 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: 10px 14px; + padding: var(--space-md) var(--gutter); 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); } +.input-select:active { transform: scale(.985); } +.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 host's card --------------------------------- */ -.joined { display: flex; flex-direction: column; gap: 14px; } +/* --- rooms merged into the AVR's card --------------------------------- */ +.joined { display: flex; flex-direction: column; gap: var(--gutter); } /* 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.room { background: none; border-color: var(--ink); border-radius: 18px; padding: var(--gutter); 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 host's would only muddle. */ -.joined .toggle { flex: 1; min-width: 0; } + 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; } @@ -331,54 +316,54 @@ svg { width: 22px; height: 22px; fill: currentColor; } .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 { margin: var(--space-2xs) var(--space-2xs) 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); } +.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: 10px 16px calc(16px + env(safe-area-inset-bottom)); + padding: var(--space-md) var(--gutter) calc(var(--gutter) + env(safe-area-inset-bottom)); max-height: 82vh; - display: flex; flex-direction: column; gap: 12px; + 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: 2px auto 4px; } -.sheet h3 { margin: 0 4px; font-size: 14px; font-weight: 600; color: var(--muted); } +.grabber { width: 38px; height: 4px; border-radius: 999px; background: var(--edge); margin: var(--space-2xs) auto var(--space-2xs); } +.sheet h3 { margin: 0 var(--space-2xs); font-size: 14px; font-weight: 600; color: var(--muted); } -.options { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; -webkit-overflow-scrolling: touch; } +.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: 10px; - min-height: 58px; padding: 0 16px; + display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); + min-height: 58px; padding: 0 var(--gutter); 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[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: rgba(255, 255, 255, .8); } +.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: rgba(255, 255, 255, .8); } +.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: 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); + padding: var(--space-md) var(--gutter); 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: #172a21; border-color: #2a5540; color: #c9f7df; } +.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; } diff --git a/templates/index.html b/templates/index.html index 82dd7af..491f0fd 100644 --- a/templates/index.html +++ b/templates/index.html @@ -20,9 +20,10 @@ {% macro host_toggle() -%} - {%- endmacro %} @@ -30,12 +31,12 @@ {# The Spotify resume buttons with the host toggle in the middle of them. #} {% macro control_row(target, accounts) -%} {% set split = (accounts | length + 1) // 2 %} -
+
{% for account in accounts %} {% if loop.index0 == split %}{{ host_toggle() }}{% endif %} - @@ -76,27 +77,27 @@
-
—
-
@@ -106,7 +107,7 @@
{% endfor %} -
+

{{ host.label }}

offline @@ -129,7 +130,7 @@
-