This commit is contained in:
+47
-50
@@ -3,7 +3,6 @@
|
||||
network round trip before it looks like it did anything feels broken. */
|
||||
|
||||
const STEP = Number(document.documentElement.dataset.step) || 2;
|
||||
const HOST_LABEL = document.documentElement.dataset.host || 'the AVR';
|
||||
// Where the app is mounted: "/" on its own port, "/heos/" behind a proxy.
|
||||
const BASE = document.documentElement.dataset.base || '/';
|
||||
const POLL_MS = 5000;
|
||||
@@ -26,7 +25,7 @@ const ICONS = {
|
||||
'lcr-fronts': SPEAKERS,
|
||||
'surround-sound-system': SPEAKERS,
|
||||
'subwoofer': SPEAKER,
|
||||
mediaplayer: { viewbox: '0 0 640 544', path: 'M192 64c0-35.3 28.7-64 64-64L544 0c35.3 0 64 28.7 64 64l0 120.4c-6.8-.8-13.8-.6-20.9 .6l-64.1 11.7-54.5-89.2C464.1 100.4 456.4 96 448 96s-16.1 4.4-20.5 11.5l-54 88.3-17.9-25.6c-4.5-6.4-11.8-10.2-19.7-10.2s-15.2 3.8-19.7 10.2l-56 80c-5.1 7.3-5.8 16.9-1.6 24.8S271.1 288 280 288l72 0 0 64-96 0c-35.3 0-64-28.7-64-64l0-224zM352 403.8c-15.3 3.8-29.7 10.5-42.1 19.7-8.7 6.5-16.8 14.8-23.3 24.5L64 448c-35.3 0-64-28.7-64-64L0 160c0-35.3 28.7-64 64-64l80 0 0 192c0 61.9 50.1 112 112 112l96 0 0 3.8zM320 96a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM52 164l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0c-8.8 0-16 7.2-16 16zm16 80c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm0 96c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm556-84.1l0 208c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-84.9-128 23.3 0 139.9c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-161.6c0-11.6 8.3-21.5 19.7-23.6l176-32c7-1.3 14.2 .6 19.7 5.2s8.6 11.3 8.6 18.4z' },
|
||||
mediaplayer: { viewbox: '0 -16 576 512', path: 'M0 112c0 70.7 57.3 128 128 128l224 0c70.7 0 128-57.3 128-128S422.7-16 352-16c-48.2 0-90.2 26.6-112 66-21.8-39.4-63.8-66-112-66-70.7 0-128 57.3-128 128zm304 0a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM128 64a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM64 352l0 64c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64l-224 0c-35.3 0-64 28.7-64 64zM537.5 490.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2l-73.5 58.8 0 160 73.5 58.8z' },
|
||||
game: { viewbox: '0 0 640 512', path: 'M448 64c106 0 192 86 192 192S554 448 448 448l-256 0C86 448 0 362 0 256S86 64 192 64l256 0zM192 176c-13.3 0-24 10.7-24 24l0 32-32 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l32 0 0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-32 32 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-32 0 0-32c0-13.3-10.7-24-24-24zm240 96a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm64-96a32 32 0 1 0 0 64 32 32 0 1 0 0-64z' },
|
||||
tv: TV,
|
||||
tvaudio: TV,
|
||||
@@ -53,29 +52,31 @@ const ui = {
|
||||
foot: el('[data-role="foot"]'),
|
||||
toast: el('[data-role="toast"]'),
|
||||
refresh: el('[data-role="refresh"]'),
|
||||
splitAll: el('[data-role="split-all"]'),
|
||||
avrStatus: el('[data-role="avr-status"]'),
|
||||
inputButton: el('[data-role="input-button"]'),
|
||||
inputName: el('[data-role="input-name"]'),
|
||||
inputIcon: el('[data-role="input-icon"]'),
|
||||
sheet: el('[data-role="sheet"]'),
|
||||
options: el('[data-role="options"]'),
|
||||
source: el('.card.source'),
|
||||
joined: el('[data-role="joined"]'),
|
||||
joinedRooms: el('[data-role="joined-rooms"]'),
|
||||
avrNowPlaying: el('[data-role="avr-now-playing"]'),
|
||||
avrCover: el('[data-role="avr-cover"]'),
|
||||
avrSong: el('[data-role="avr-song"]'),
|
||||
avrArtist: el('[data-role="avr-artist"]'),
|
||||
avrProgress: el('[data-role="avr-progress"]'),
|
||||
};
|
||||
|
||||
// Shares its shape with a room's {nowPlaying, cover, song, artist} refs, so
|
||||
// renderTrack() below works for both -- a device plugged into the AVR (a
|
||||
// Zidoo, say) is "now playing" the same way a room's own stream is.
|
||||
// Shares its shape with a room's {nowPlaying, cover, song, artist,
|
||||
// progress} refs, so renderTrack() below works for both -- a device plugged
|
||||
// into the AVR (a Zidoo, say) is "now playing" the same way a room's own
|
||||
// stream is.
|
||||
const avrTrack = {
|
||||
nowPlaying: ui.avrNowPlaying,
|
||||
cover: ui.avrCover,
|
||||
song: ui.avrSong,
|
||||
artist: ui.avrArtist,
|
||||
progress: ui.avrProgress,
|
||||
};
|
||||
// Same reasoning as a room's own cover: drop one that will not load rather
|
||||
// than leave a broken-image box.
|
||||
@@ -84,6 +85,7 @@ avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; }
|
||||
const rooms = {};
|
||||
let inputs = [];
|
||||
let currentInput = null;
|
||||
let avrNowPlaying = null;
|
||||
|
||||
/* --- transport -------------------------------------------------------- */
|
||||
async function api(path, body) {
|
||||
@@ -122,7 +124,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), // absent with no Spotify account set up
|
||||
spotify: el('.spotify-row', 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),
|
||||
@@ -169,7 +171,7 @@ function paintRoom(room) {
|
||||
room.meter.style.setProperty('--level', known ? room.volume : 0);
|
||||
room.node.classList.toggle('offline', !room.available);
|
||||
room.steps.forEach((button) => { button.disabled = !room.available; });
|
||||
paintTrack(room);
|
||||
renderTrack(room.available ? room.track : null, room);
|
||||
const playing = room.playState === 'play';
|
||||
room.node.classList.toggle('playing', playing);
|
||||
room.play.classList.toggle('playing', playing);
|
||||
@@ -187,27 +189,31 @@ function paintRoom(room) {
|
||||
room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped);
|
||||
room.prev.disabled = !room.available;
|
||||
room.next.disabled = !room.available;
|
||||
// Resuming stays on offer while grouped: only the speakers have Spotify
|
||||
// buttons, so hiding them here would leave none at all.
|
||||
if (room.spotify) {
|
||||
room.spotify.hidden = !room.available;
|
||||
els('.spotify', room.spotify).forEach((button) => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
|
||||
});
|
||||
}
|
||||
// Grouped, the room plays whatever the AVR does, so resuming Spotify on it
|
||||
// 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) => {
|
||||
button.hidden = !room.available || room.grouped;
|
||||
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
|
||||
});
|
||||
|
||||
room.toggle.disabled = !room.available;
|
||||
room.toggle.classList.toggle('busy', room.busy);
|
||||
room.toggle.setAttribute('aria-pressed', String(room.grouped));
|
||||
// Where the card sits already says whether it is grouped, so the button
|
||||
// says what tapping it does instead.
|
||||
room.toggleLabel.textContent = room.grouped ? 'Leave' : `Join ${HOST_LABEL}`;
|
||||
// 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);
|
||||
}
|
||||
|
||||
/* Song, artist and cover, each only when there is one. The cover's src is
|
||||
only touched when the track changes, so a poll never makes it flicker. */
|
||||
/* Song, artist, cover and progress, each only when there is one. The
|
||||
cover's src is only touched when the track changes, so a poll never makes
|
||||
it flicker. */
|
||||
function renderTrack(track, refs) {
|
||||
renderProgress(track, refs.progress);
|
||||
refs.nowPlaying.hidden = !track;
|
||||
if (!track) return;
|
||||
refs.song.textContent = track.song;
|
||||
@@ -222,44 +228,46 @@ function renderTrack(track, refs) {
|
||||
}
|
||||
}
|
||||
|
||||
function paintTrack(room) {
|
||||
renderTrack(room.available ? room.track : null, room);
|
||||
paintProgress(room);
|
||||
}
|
||||
|
||||
/* The discreet cursor on the line below the song, sized off the same
|
||||
{position_ms, duration_ms} the poll hands back -- absent for anything
|
||||
HEOS never sends a progress event for (an AVR input, an internet radio
|
||||
stream with no fixed length), in which case the line stays plain. */
|
||||
function paintProgress(room) {
|
||||
const track = room.available ? room.track : null;
|
||||
room.progress.hidden = !track;
|
||||
function renderProgress(track, progress) {
|
||||
progress.hidden = !track;
|
||||
if (!track) return;
|
||||
const { position_ms: position, duration_ms: duration } = track;
|
||||
const known = typeof duration === 'number' && duration > 0 && typeof position === 'number';
|
||||
const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0;
|
||||
room.progress.classList.toggle('known', known);
|
||||
room.progress.style.setProperty('--progress', percent);
|
||||
progress.classList.toggle('known', known);
|
||||
progress.style.setProperty('--progress', percent);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
function placeRoom(room) {
|
||||
const target = room.grouped ? ui.joinedRooms : room.slot;
|
||||
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;
|
||||
}
|
||||
|
||||
function paintGrouping() {
|
||||
const order = Object.keys(rooms);
|
||||
const inside = Array.from(ui.joinedRooms.children);
|
||||
const inside = Array.from(ui.joined.children);
|
||||
// Keep them in the order the cards are declared, not the order they joined.
|
||||
inside
|
||||
.slice()
|
||||
.sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room))
|
||||
.forEach((node) => ui.joinedRooms.appendChild(node));
|
||||
.forEach((node) => ui.joined.appendChild(node));
|
||||
ui.joined.hidden = inside.length === 0;
|
||||
ui.splitAll.hidden = inside.length === 0;
|
||||
// Grouped rooms share the host's transport, so one of them playing means
|
||||
// the whole group is -- and the tint goes on the group's card.
|
||||
const groupPlaying = inside.some((node) => node.classList.contains('playing'));
|
||||
ui.source.classList.toggle('playing', groupPlaying);
|
||||
// A film on the Zidoo also moves the AVR up. The Zidoo does not say whether
|
||||
// it is paused, so a loaded film counts, and it gets no tint.
|
||||
ui.source.classList.toggle('on-top', groupPlaying || Boolean(avrNowPlaying));
|
||||
}
|
||||
|
||||
/* Press and hold to keep moving, accelerating as you hold. */
|
||||
@@ -438,18 +446,6 @@ function applyJoined(joined) {
|
||||
paintGrouping();
|
||||
}
|
||||
|
||||
ui.splitAll.addEventListener('click', async () => {
|
||||
try {
|
||||
applyJoined([]);
|
||||
const data = await api('/api/group/none', {}); // {} so it is a POST, as the route requires
|
||||
applyJoined(data.joined || []);
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
} finally {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
/* --- Spotify: one button per account, asking the card's own Connect
|
||||
receiver to resume that account, instead of always connecting to it
|
||||
from the Spotify app -------------------------------------------------- */
|
||||
@@ -543,7 +539,6 @@ function render(state) {
|
||||
room.spotifyAccount = incoming.spotify_account || null;
|
||||
paintRoom(room);
|
||||
});
|
||||
paintGrouping();
|
||||
|
||||
const avr = state.avr || {};
|
||||
inputs = avr.inputs || [];
|
||||
@@ -552,7 +547,9 @@ function render(state) {
|
||||
setInputIcon(currentInput ? currentInput.code : null);
|
||||
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
|
||||
ui.avrStatus.classList.toggle('on', Boolean(avr.connected));
|
||||
renderTrack(avr.now_playing || null, avrTrack);
|
||||
avrNowPlaying = avr.now_playing || null;
|
||||
renderTrack(avrNowPlaying, avrTrack);
|
||||
paintGrouping(); // after avrNowPlaying, which decides where the AVR's card sits
|
||||
|
||||
const problems = state.errors || [];
|
||||
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
|
||||
|
||||
+42
-36
@@ -13,6 +13,7 @@
|
||||
--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; }
|
||||
@@ -51,15 +52,20 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
.app {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: max(12px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
|
||||
max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
|
||||
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: 14px;
|
||||
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 { display: flex; align-items: center; justify-content: space-between; padding: 6px 4px 0; }
|
||||
.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; }
|
||||
|
||||
@@ -84,8 +90,10 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
}
|
||||
.card.offline { opacity: .5; }
|
||||
/* A tint, not a repaint -- it should read at a glance without competing
|
||||
with the song title right above it. */
|
||||
.card.room.playing {
|
||||
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%);
|
||||
@@ -118,6 +126,9 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
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; }
|
||||
@@ -212,23 +223,27 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
.transport.playing .icon-play { display: none; }
|
||||
.transport.playing .icon-pause { display: block; }
|
||||
|
||||
/* --- join / leave the AVR, beside the room's name ---------------------- */
|
||||
/* --- 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;
|
||||
padding: 7px 14px; border-radius: 999px;
|
||||
height: 50px; padding: 0 14px; border-radius: 16px;
|
||||
background: var(--raised);
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
font-size: 14px; font-weight: 550; white-space: nowrap;
|
||||
color: var(--muted);
|
||||
display: flex; align-items: center; justify-content: center; gap: 9px;
|
||||
font-size: 15px; font-weight: 550; white-space: nowrap;
|
||||
}
|
||||
.toggle .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .6; }
|
||||
.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[aria-pressed="true"] .dot { background: #fff; opacity: 1; }
|
||||
.toggle:active { transform: scale(.985); }
|
||||
.toggle:disabled { opacity: .5; }
|
||||
.toggle.busy { opacity: .6; }
|
||||
|
||||
/* --- Spotify: one resume button per account ----------------------------- */
|
||||
/* --- Spotify: one resume button per account, around the host toggle ----- */
|
||||
.spotify-row { display: flex; gap: 10px; }
|
||||
.spotify {
|
||||
flex: 1; min-width: 0;
|
||||
@@ -260,33 +275,24 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
.source-button .chevron { color: var(--muted); }
|
||||
|
||||
/* --- rooms merged into the host's card --------------------------------- */
|
||||
.joined { display: flex; flex-direction: column; gap: 12px; }
|
||||
.joined-title { margin: 0 2px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
||||
.joined-rooms { display: flex; flex-direction: column; gap: 14px; }
|
||||
.joined { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
/* The card stops being a card in here: one border around the group, not
|
||||
one around every room in it. */
|
||||
.joined .card.room { background: none; border: 0; border-radius: 0; padding: 0; gap: 12px; }
|
||||
.joined .card.room + .card.room { border-top: 1px solid var(--edge); padding-top: 14px; }
|
||||
/* Flush against the group's own card, so no radius here -- .playing has
|
||||
to out-specificity the plain "background: none" above to show at all. */
|
||||
.joined .card.room.playing {
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--live) 25%, transparent) 0%,
|
||||
transparent 70%);
|
||||
}
|
||||
/* 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; }
|
||||
.joined .toggle,
|
||||
.joined .toggle[aria-pressed="true"] {
|
||||
height: 36px; font-size: 13px; font-weight: 500;
|
||||
background: none; border: 1px solid var(--edge); color: var(--muted);
|
||||
}
|
||||
.joined .toggle .dot { display: none; }
|
||||
.joined .spotify { height: 40px; font-size: 13px; font-weight: 500; }
|
||||
.joined .toggle:active { background: var(--raised); color: var(--ink); }
|
||||
/* 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; }
|
||||
|
||||
Reference in New Issue
Block a user