diff --git a/.env.example b/.env.example
index 5fc6450..32d31c4 100644
--- a/.env.example
+++ b/.env.example
@@ -164,6 +164,11 @@ THEME_FONT="Helvetica, Calibri, Tahoma, Verdana, Arial, sans-serif"
# Recolor the page background to match the current album art (auto light/dark
# text). Needs CORS-accessible art to sample; falls back gracefully otherwise.
THEME_DYNAMIC_BG=true
+# "Liquid Glass" refraction effect on the menu/lyrics/mute/playpause buttons,
+# replacing their flat THEME_ACCENT fill with a frosted, distorted glass look
+# (technique from https://github.com/nikdelvin/liquid-glass). Falls back to a
+# plain blur on browsers without backdrop-filter SVG-filter support.
+THEME_LIQUID_GLASS=false
# -----------------------------------------------------------------------------
diff --git a/assets/css/radio.css b/assets/css/radio.css
index 7b7c9d5..04d347a 100644
--- a/assets/css/radio.css
+++ b/assets/css/radio.css
@@ -87,6 +87,7 @@ a:hover, a:focus { color: var(--text); text-decoration: underline; }
.art-ring[hidden] { display: none; }
.playpause {
+ position: relative;
width: 64px; height: 64px;
display: flex; align-items: center; justify-content: center;
border: none; border-radius: 50%;
@@ -160,22 +161,11 @@ audio[controls] { width: 100%; outline: 0; }
.waveform { width: 100%; display: block; border-radius: 8px; }
.waveform[hidden] { display: none; } /* [hidden] must beat display:block */
-.waveform-picker { position: absolute; top: 6px; right: 6px; z-index: 2; }
-.waveform-picker summary {
- list-style: none; padding: 4px 12px;
- background: rgba(0, 0, 0, 0.55); color: #fff;
- font-variant: small-caps; font-size: 0.8rem;
- border: 1px solid rgba(255, 255, 255, 0.35); border-radius: 999px;
- cursor: pointer; backdrop-filter: blur(4px);
- transition: background .15s ease;
-}
-.waveform-picker summary::-webkit-details-marker { display: none; }
-.waveform-picker summary:hover, .waveform-picker summary:focus { background: rgba(0, 0, 0, 0.8); outline: 0; }
-.waveform-picker[open] summary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
-
-.waveform-picker[open] {
+.waveform-picker {
+ position: fixed; top: 64px; right: 16px; z-index: 30;
display: flex; flex-direction: column; align-items: flex-end; gap: 6px;
}
+.waveform-picker[hidden] { display: none; }
.waveform-picker fieldset {
margin: 0; padding: 8px 10px;
background: rgba(0, 0, 0, 0.85); color: #fff;
@@ -266,15 +256,36 @@ audio[controls] { width: 100%; outline: 0; }
width: 0; height: 0; display: none;
}
-/* ----------------------------------------------------- history panel */
-.history-toggle {
- position: fixed; top: 16px; right: 16px; z-index: 30;
- padding: 8px 14px;
+/* ----------------------------------------------------------------- menu */
+.menu-wrap { position: fixed; top: 16px; right: 16px; z-index: 30; }
+.menu-toggle {
+ position: relative;
+ width: 40px; height: 40px;
+ display: flex; align-items: center; justify-content: center;
background: var(--accent); color: var(--accent-text);
- font-variant: small-caps; border: none; border-radius: 8px; cursor: pointer;
+ font-size: 1.1rem; line-height: 1;
+ border: none; border-radius: 8px; cursor: pointer;
}
-.history-toggle:hover, .history-toggle:focus { background: var(--accent-hover); outline: 0; }
+.menu-toggle:hover, .menu-toggle:focus { background: var(--accent-hover); outline: 0; }
+.menu-list {
+ position: absolute; top: calc(100% + 6px); right: 0; z-index: 30;
+ min-width: 170px; margin: 0; padding: 6px;
+ list-style: none;
+ background: rgba(0, 0, 0, 0.85); color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.35); border-radius: 10px;
+ backdrop-filter: blur(4px);
+}
+.menu-list[hidden] { display: none; }
+.menu-item {
+ display: block; width: 100%; text-align: left;
+ padding: 8px 10px; margin: 0;
+ background: none; border: none; border-radius: 6px;
+ color: inherit; font: inherit; font-size: 0.85rem; cursor: pointer;
+}
+.menu-item:hover, .menu-item:focus-visible { background: rgba(255, 255, 255, 0.15); outline: 0; }
+
+/* ----------------------------------------------------- history panel */
.history {
position: fixed; top: 0; right: 0; height: 100%;
width: min(90vw, 420px); padding: 64px 16px 16px;
@@ -294,6 +305,52 @@ audio[controls] { width: 100%; outline: 0; }
}
.history-table th { opacity: 0.7; font-weight: 600; }
+/* ------------------------------------------------------- liquid glass btns */
+/* THEME_LIQUID_GLASS=true: refraction technique ported from
+ https://github.com/nikdelvin/liquid-glass (MIT) — see the liquid-glass
+ block in player.js for the SVG feDisplacementMap filter it builds.
+ .glass-fx gets its real backdrop-filter set there; the rule below is
+ just the pre-JS / unsupported-browser fallback (plain blur). The
+ [aria-pressed="true"] variant is needed to beat .lyrics-toggle's own
+ higher-specificity active-state accent fill (class+attribute selector).
+ position/z-index are NOT set here — .lyrics-toggle already needs its own
+ position: absolute for corner placement, so each button supplies its own
+ (see .playpause / .menu-toggle), keeping this class layout-neutral. */
+.glass-btn,
+.glass-btn:hover,
+.glass-btn:focus,
+.glass-btn:active,
+.glass-btn[aria-pressed="true"] {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.35);
+}
+.glass-btn {
+ isolation: isolate; /* keeps .glass-fx's negative z-index scoped to this button */
+ overflow: hidden;
+ transition: transform .25s ease;
+}
+.glass-btn:hover, .glass-btn:focus { outline: 0; transform: scale(1.05) rotate(-1deg); }
+.glass-fx {
+ position: absolute; inset: 0; z-index: -1; /* behind the icon/text, above the page */
+ border-radius: inherit;
+ pointer-events: none;
+ background: rgba(255, 255, 255, 0.14);
+ box-shadow: inset 0 0 4px rgba(255, 255, 255, 0.55);
+ /* player.js sets --glass-filter/--glass-filter-hover per button (sized to
+ fit); the value here is only the pre-JS / unsupported-browser fallback. */
+ backdrop-filter: var(--glass-filter, blur(8px) saturate(160%) brightness(1.1));
+ -webkit-backdrop-filter: var(--glass-filter, blur(8px) saturate(160%) brightness(1.1));
+ transition: backdrop-filter .2s ease, -webkit-backdrop-filter .2s ease,
+ background .2s ease, box-shadow .2s ease;
+}
+/* Hover: weaker refraction + lighter tint so more of what's behind shows through. */
+.glass-btn:hover .glass-fx, .glass-btn:focus .glass-fx {
+ background: rgba(255, 255, 255, 0.06);
+ box-shadow: inset 0 0 3px rgba(255, 255, 255, 0.35);
+ backdrop-filter: var(--glass-filter-hover, blur(4px) saturate(130%) brightness(1.05));
+ -webkit-backdrop-filter: var(--glass-filter-hover, blur(4px) saturate(130%) brightness(1.05));
+}
+
/* ------------------------------------------------------------ listeners */
.listeners {
position: fixed; bottom: 12px; left: 12px;
@@ -308,11 +365,11 @@ audio[controls] { width: 100%; outline: 0; }
.history { width: min(96vw, 420px); }
/* -webkit-touch-callout is a no-op everywhere except iOS WebKit, so this
- @supports check targets iOS phones without any JS/UA sniffing. Picker is
- hidden there, so give the art a fake ambient glow (no audio analysis)
- in its place. */
+ @supports check targets iOS phones without any JS/UA sniffing. The menu
+ item is hidden there, so give the art a fake ambient glow (no audio
+ analysis) in its place. */
@supports (-webkit-touch-callout: none) {
- .waveform-picker { display: none; }
+ #menu-waveform { display: none; }
.album-art { animation: art-glow-pulse 3.2s ease-in-out infinite; }
}
}
diff --git a/assets/js/player.js b/assets/js/player.js
index 1dd68f0..b96c968 100644
--- a/assets/js/player.js
+++ b/assets/js/player.js
@@ -39,14 +39,17 @@
lyricsLines: $('lyrics-lines'),
lyricsSync: $('lyrics-sync'),
lyricsOffset: $('lyrics-offset'),
- historyToggle:$('history-toggle'),
historyBody: $('history-body'),
history: $('history'),
listeners: $('listeners'),
canvas: $('tint-canvas'),
waveform: $('waveform'),
artRing: $('art-ring'),
- wavePicker: $('waveform-picker')
+ wavePicker: $('waveform-picker'),
+ menuToggle: $('menu-toggle'),
+ menuList: $('menu-list'),
+ menuHistory: $('menu-history'),
+ menuWaveform: $('menu-waveform')
};
var ICON = {
@@ -110,7 +113,10 @@
}
if (audio && el.playpause) {
- el.playpause.innerHTML = ICON.play;
+ // Icon lives in its own child so it can be swapped without touching the
+ // glass-fx overlay span that sits alongside it (see liquid-glass block).
+ var playIcon = el.playpause.querySelector('.btn-icon') || el.playpause;
+ playIcon.innerHTML = ICON.play;
// iOS Safari: play() must be called directly inside the tap. Calling
// audio.load() first drops the user-gesture flag and iOS rejects playback,
@@ -138,11 +144,11 @@
else audio.pause();
});
audio.addEventListener('play', function () {
- el.playpause.innerHTML = ICON.pause;
+ playIcon.innerHTML = ICON.pause;
el.playpause.setAttribute('aria-label', 'Pause');
});
audio.addEventListener('pause', function () {
- el.playpause.innerHTML = ICON.play;
+ playIcon.innerHTML = ICON.play;
el.playpause.setAttribute('aria-label', 'Play');
});
@@ -155,10 +161,11 @@
});
}
if (el.mute) {
- el.mute.innerHTML = ICON.vol;
+ var muteIcon = el.mute.querySelector('.btn-icon') || el.mute;
+ muteIcon.innerHTML = ICON.vol;
el.mute.addEventListener('click', function () {
audio.muted = !audio.muted;
- el.mute.innerHTML = audio.muted ? ICON.mute : ICON.vol;
+ muteIcon.innerHTML = audio.muted ? ICON.mute : ICON.vol;
});
}
@@ -768,13 +775,47 @@
while (el.historyBody.children.length > 100) el.historyBody.removeChild(el.historyBody.lastChild);
}
- if (el.historyToggle && el.history) {
- el.historyToggle.addEventListener('click', function () {
- var open = el.history.classList.toggle('open');
- el.historyToggle.classList.toggle('open', open);
- el.historyToggle.textContent = open ? 'Close' : 'Song list';
+ /* --------------------------------------------------------- menu / popovers */
+ function closeMenu() {
+ if (!el.menuList) return;
+ el.menuList.hidden = true;
+ if (el.menuToggle) el.menuToggle.setAttribute('aria-expanded', 'false');
+ }
+
+ if (el.menuToggle && el.menuList) {
+ el.menuToggle.addEventListener('click', function (e) {
+ e.stopPropagation();
+ var open = el.menuList.hidden;
+ el.menuList.hidden = !open;
+ el.menuToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
+ if (el.menuHistory && el.history) {
+ el.menuHistory.addEventListener('click', function (e) {
+ e.stopPropagation();
+ el.history.classList.toggle('open');
+ closeMenu();
+ });
+ }
+ if (el.menuWaveform && el.wavePicker) {
+ el.menuWaveform.addEventListener('click', function (e) {
+ e.stopPropagation();
+ el.wavePicker.hidden = !el.wavePicker.hidden;
+ closeMenu();
+ });
+ }
+ // Clicking anywhere outside the menu / history pane / waveform picker
+ // closes whichever of them is open (the triggers above stop propagation
+ // so this only fires for genuine outside clicks).
+ document.addEventListener('click', function (e) {
+ closeMenu();
+ if (el.history && el.history.classList.contains('open') && !el.history.contains(e.target)) {
+ el.history.classList.remove('open');
+ }
+ if (el.wavePicker && !el.wavePicker.hidden && !el.wavePicker.contains(e.target)) {
+ el.wavePicker.hidden = true;
+ }
+ });
/* ----------------------------------------------------------- listeners */
function updateListeners(data) {
@@ -887,6 +928,84 @@
.then(function () { setTimeout(poll, POLL_MS); });
}
+ /* --------------------------------------------------- liquid glass buttons */
+ // THEME_LIQUID_GLASS=true: replaces each .glass-btn's flat THEME_ACCENT fill
+ // with a real glass refraction. Technique ported from
+ // https://github.com/nikdelvin/liquid-glass (MIT) — an SVG filter chain
+ // (feImage + feDisplacementMap, split per RGB channel for chromatic
+ // aberration) applied via backdrop-filter: url(#filter). CSS already gives
+ // every .glass-fx a plain blur as a static fallback for browsers that don't
+ // support backdrop-filter url() at all (e.g. Safari).
+ (function () {
+ if (!CFG.liquidGlass) return;
+ var buttons = document.querySelectorAll('.glass-btn');
+ if (!buttons.length) return;
+
+ var supportsBackdropUrl = (function () {
+ var t = document.createElement('div');
+ try { t.style.cssText = 'backdrop-filter:url(#t)'; } catch (e) { return false; }
+ return t.style.backdropFilter === 'url(#t)' || t.style.backdropFilter === 'url("#t")';
+ })();
+ if (!supportsBackdropUrl) return; // static CSS blur fallback is already in place
+
+ function displacementMap(w, h, r, depth) {
+ return 'data:image/svg+xml;utf8,' + encodeURIComponent(
+ ''
+ );
+ }
+
+ function displacementFilter(w, h, r, depth, strength, cab) {
+ return 'data:image/svg+xml;utf8,' + encodeURIComponent(
+ ''
+ ) + '#displace';
+ }
+
+ // Two filter strengths per button: full refraction at rest, a weaker/
+ // clearer one on hover (see .glass-btn:hover .glass-fx in radio.css) so
+ // more of what's behind the glass shows through.
+ function redraw(btn, fx) {
+ var w = Math.round(btn.offsetWidth), h = Math.round(btn.offsetHeight);
+ if (!w || !h) return;
+ var r = parseFloat(getComputedStyle(btn).borderRadius) || 0;
+ var normal = 'blur(1px) url(\'' + displacementFilter(w, h, r, 6, 70, 4) + '\') blur(4px) brightness(1.1) saturate(1.6)';
+ var hover = 'blur(1px) url(\'' + displacementFilter(w, h, r, 6, 32, 2) + '\') blur(2px) brightness(1.05) saturate(1.3)';
+ fx.style.setProperty('--glass-filter', normal);
+ fx.style.setProperty('--glass-filter-hover', hover);
+ }
+
+ buttons.forEach(function (btn) {
+ var fx = btn.querySelector('.glass-fx');
+ if (!fx) return;
+ redraw(btn, fx);
+ if (window.ResizeObserver) new ResizeObserver(function () { redraw(btn, fx); }).observe(btn);
+ });
+ })();
+
setupMediaSession();
poll();
})();
diff --git a/config.php b/config.php
index 9ff3c25..07871a1 100644
--- a/config.php
+++ b/config.php
@@ -213,6 +213,10 @@ if (!defined('RADIO_CONFIG_LOADED')) {
// Recolor the page background to the album art's average color,
// switching text light/dark for contrast.
'dynamic_bg' => radio_env_bool('THEME_DYNAMIC_BG', true),
+ // "Liquid Glass" refraction effect (menu/lyrics/mute/playpause
+ // buttons) in place of a flat THEME_ACCENT fill. See
+ // https://github.com/nikdelvin/liquid-glass
+ 'liquid_glass' => radio_env_bool('THEME_LIQUID_GLASS', false),
],
// ---- Player ---------------------------------------------------
@@ -356,6 +360,7 @@ function radio_client_config() {
'playerStyle' => cfg('player.style'),
'autoplay' => cfg('player.autoplay'),
'dynamicBg' => cfg('theme.dynamic_bg'),
+ 'liquidGlass' => cfg('theme.liquid_glass'),
'mediaSession' => cfg('player.mediasession'),
'dynamicMeta' => cfg('player.dynamic_meta'),
'siteName' => cfg('site.name'),
diff --git a/index.php b/index.php
index ea78d4c..0c3e37a 100644
--- a/index.php
+++ b/index.php
@@ -15,6 +15,9 @@ $waveActive = radio_waveform_active();
$waveTypes = cfg('waveform.types');
$showWavePicker = $waveActive && count($waveTypes) > 1;
$showArtRing = $waveActive && in_array('circular', $waveTypes, true) && $rich;
+$liquidGlass = cfg('theme.liquid_glass');
+$glassBtn = $liquidGlass ? ' glass-btn' : '';
+$glassFx = $liquidGlass ? '' : '';
?>
@@ -30,7 +33,7 @@ $showArtRing = $waveActive && in_array('circular', $waveTypes, true) && $rich;
-
+