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; Album art - +