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 3fdc1b1..eaf91ff 100644 --- a/assets/css/radio.css +++ b/assets/css/radio.css @@ -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; diff --git a/assets/js/player.js b/assets/js/player.js index f3b9061..2711a93 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -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( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + ); + } + + function displacementFilter(w, h, r, depth, strength, cab) { + return 'data:image/svg+xml;utf8,' + encodeURIComponent( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + ) + '#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(); })(); 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 89253d4..5d26545 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; Album art - +