Add liquid glass button options

This commit is contained in:
iamdoubz committed 2026-07-30 10:32:30 -05:00
1 parent 5e98b9fca6
commit 006b4b4aa3
5 files changed
+133 -9

No files matched your search

+5
View File
@@ -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
# -----------------------------------------------------------------------------
+33
View File
@@ -303,6 +303,39 @@ 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). Placed
after .playpause/.icon-btn/.lyrics-toggle/.menu-toggle on purpose so it
wins the cascade at equal specificity and overrides their THEME_ACCENT
fill without needing !important. */
.glass-btn,
.glass-btn:hover,
.glass-btn:focus,
.glass-btn:active {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.35);
}
.glass-btn {
position: relative;
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);
backdrop-filter: blur(8px) saturate(160%) brightness(1.1);
-webkit-backdrop-filter: blur(8px) saturate(160%) brightness(1.1);
}
/* ------------------------------------------------------------ listeners */
.listeners {
position: fixed; bottom: 12px; left: 12px;
+83 -5
View File
@@ -113,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,
@@ -141,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');
});
@@ -158,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;
});
}
@@ -924,6 +928,80 @@
.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(
'<svg height="' + h + '" width="' + w + '" viewBox="0 0 ' + w + ' ' + h + '" xmlns="http://www.w3.org/2000/svg">' +
'<style>.mix{mix-blend-mode:screen}</style>' +
'<defs>' +
'<linearGradient id="Y" x1="0" x2="0" y1="' + Math.ceil(r / h * 15) + '%" y2="' + Math.floor(100 - r / h * 15) + '%">' +
'<stop offset="0%" stop-color="#0F0"/><stop offset="100%" stop-color="#000"/></linearGradient>' +
'<linearGradient id="X" x1="' + Math.ceil(r / w * 15) + '%" x2="' + Math.floor(100 - r / w * 15) + '%" y1="0" y2="0">' +
'<stop offset="0%" stop-color="#F00"/><stop offset="100%" stop-color="#000"/></linearGradient>' +
'</defs>' +
'<rect x="0" y="0" height="' + h + '" width="' + w + '" fill="#808080"/>' +
'<g filter="blur(2px)">' +
'<rect x="0" y="0" height="' + h + '" width="' + w + '" fill="#000080"/>' +
'<rect x="0" y="0" height="' + h + '" width="' + w + '" fill="url(#Y)" class="mix"/>' +
'<rect x="0" y="0" height="' + h + '" width="' + w + '" fill="url(#X)" class="mix"/>' +
'<rect x="' + depth + '" y="' + depth + '" height="' + (h - 2 * depth) + '" width="' + (w - 2 * depth) +
'" fill="#808080" rx="' + r + '" ry="' + r + '" filter="blur(' + depth + 'px)"/>' +
'</g></svg>'
);
}
function displacementFilter(w, h, r, depth, strength, cab) {
return 'data:image/svg+xml;utf8,' + encodeURIComponent(
'<svg height="' + h + '" width="' + w + '" viewBox="0 0 ' + w + ' ' + h + '" xmlns="http://www.w3.org/2000/svg">' +
'<defs><filter id="displace" color-interpolation-filters="sRGB">' +
'<feImage x="0" y="0" height="' + h + '" width="' + w + '" href="' + displacementMap(w, h, r, depth) + '" result="dm"/>' +
'<feDisplacementMap in="SourceGraphic" in2="dm" scale="' + (strength + cab * 2) + '" xChannelSelector="R" yChannelSelector="G"/>' +
'<feColorMatrix type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="dR"/>' +
'<feDisplacementMap in="SourceGraphic" in2="dm" scale="' + (strength + cab) + '" xChannelSelector="R" yChannelSelector="G"/>' +
'<feColorMatrix type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="dG"/>' +
'<feDisplacementMap in="SourceGraphic" in2="dm" scale="' + strength + '" xChannelSelector="R" yChannelSelector="G"/>' +
'<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="dB"/>' +
'<feBlend in="dR" in2="dG" mode="screen"/><feBlend in2="dB" mode="screen"/>' +
'</filter></defs></svg>'
) + '#displace';
}
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 filter = 'blur(1px) url(\'' + displacementFilter(w, h, r, 6, 70, 4) + '\') blur(4px) brightness(1.1) saturate(1.6)';
fx.style.backdropFilter = filter;
fx.style.setProperty('-webkit-backdrop-filter', filter);
}
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();
})();
+5
View File
@@ -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'),
+7 -4
View File
@@ -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 ? '<span class="glass-fx" aria-hidden="true"></span>' : '';
?>
<!DOCTYPE html>
<html lang="<?= e(cfg('site.lang')) ?>" dir="ltr" prefix="og: http://ogp.me/ns#">
@@ -30,7 +33,7 @@ $showArtRing = $waveActive && in_array('circular', $waveTypes, true) && $rich;
<img id="art" class="album-art" src="<?= e($fallbackArt) ?>"
alt="Album art" title="<?= e(cfg('site.name')) ?>"/>
<?php if ($lyricsEnabled): ?>
<button id="lyrics-toggle" class="lyrics-toggle" type="button" hidden aria-pressed="false">🎶</button>
<button id="lyrics-toggle" class="lyrics-toggle<?= $glassBtn ?>" type="button" hidden aria-pressed="false">🎶<?= $glassFx ?></button>
<div id="lyrics-sync" class="lyrics-sync" hidden>
<button type="button" data-adj="-0.5" aria-label="Lyrics earlier">&minus;</button>
<span id="lyrics-offset">0.0s</span>
@@ -64,7 +67,7 @@ $showArtRing = $waveActive && in_array('circular', $waveTypes, true) && $rich;
<?php if ($rich): ?>
<div class="controls">
<div class="playpause-wrap">
<button id="playpause" class="playpause" type="button" aria-label="Play"></button>
<button id="playpause" class="playpause<?= $glassBtn ?>" type="button" aria-label="Play"><span class="btn-icon"></span><?= $glassFx ?></button>
<?php if ($showArtRing): ?>
<canvas id="art-ring" class="art-ring" hidden aria-hidden="true"></canvas>
<?php endif; ?>
@@ -77,7 +80,7 @@ $showArtRing = $waveActive && in_array('circular', $waveTypes, true) && $rich;
<span class="live-badge" id="live-badge" hidden><span class="live-dot"></span><span class="live-text">LIVE</span></span>
</div>
<div class="vol-row">
<button id="mute" class="icon-btn" type="button" aria-label="Mute"></button>
<button id="mute" class="icon-btn<?= $glassBtn ?>" type="button" aria-label="Mute"><span class="btn-icon"></span><?= $glassFx ?></button>
<input type="range" id="volume" class="range volume" min="0" max="100" value="100" aria-label="Volume"/>
</div>
<?php endif; ?>
@@ -87,7 +90,7 @@ $showArtRing = $waveActive && in_array('circular', $waveTypes, true) && $rich;
<?php if ($showHistory || $showWavePicker): ?>
<div class="menu-wrap" id="menu-wrap">
<button id="menu-toggle" class="menu-toggle" type="button" aria-haspopup="true" aria-expanded="false" aria-label="Menu">&#9776;</button>
<button id="menu-toggle" class="menu-toggle<?= $glassBtn ?>" type="button" aria-haspopup="true" aria-expanded="false" aria-label="Menu">&#9776;<?= $glassFx ?></button>
<ul id="menu-list" class="menu-list" hidden>
<?php if ($showHistory): ?>
<li><button type="button" id="menu-history" class="menu-item">Song List</button></li>