diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-16 09:35:31 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-16 09:35:31 +0300 |
| commit | 1b24076788868f50812468b1bf4bb0ae86214e8c (patch) | |
| tree | 389fd715b5c27e0fe036f6c123b8d09e3872da1c /internal/generator/templates/shared | |
| parent | fbac3d13a575a410c2ee3f49418bf4b462544b4f (diff) | |
Add theme background musicv0.16.0
Diffstat (limited to 'internal/generator/templates/shared')
| -rw-r--r-- | internal/generator/templates/shared/nav.tmpl | 61 | ||||
| -rw-r--r-- | internal/generator/templates/shared/shared.css | 53 | ||||
| -rw-r--r-- | internal/generator/templates/shared/shared.js | 224 |
3 files changed, 317 insertions, 21 deletions
diff --git a/internal/generator/templates/shared/nav.tmpl b/internal/generator/templates/shared/nav.tmpl index b04b160..9759761 100644 --- a/internal/generator/templates/shared/nav.tmpl +++ b/internal/generator/templates/shared/nav.tmpl @@ -44,38 +44,65 @@ {{end}} {{define "navhints"}} -<div class="nav-hints" role="region" aria-label="Keyboard shortcuts"> +<div class="nav-hints" role="region" aria-label="Controls and shortcuts"> <div class="nav-hints-row nav-hints-nav"> <span class="nav-hints-label">nav</span> - <span><kbd>j</kbd><kbd>k</kbd><kbd>↑</kbd><kbd>↓</kbd> post</span> - <span><kbd>h</kbd><kbd>l</kbd><kbd>←</kbd><kbd>→</kbd> page</span> - <span><kbd>PgUp</kbd><kbd>PgDn</kbd> scroll</span> - <span><kbd>Enter</kbd> open</span> - <span><kbd>Esc</kbd> close</span> + <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="prev-post" aria-label="Previous post"> + <i class="fas fa-arrow-up" aria-hidden="true"></i><kbd>j</kbd><kbd>↑</kbd><span class="sno-btn-text">post</span> + </button> + <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="next-post" aria-label="Next post"> + <i class="fas fa-arrow-down" aria-hidden="true"></i><kbd>k</kbd><kbd>↓</kbd><span class="sno-btn-text">post</span> + </button> + <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="prev-page" aria-label="Previous page"> + <i class="fas fa-arrow-left" aria-hidden="true"></i><kbd>h</kbd><kbd>←</kbd><span class="sno-btn-text">page</span> + </button> + <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="next-page" aria-label="Next page"> + <i class="fas fa-arrow-right" aria-hidden="true"></i><kbd>l</kbd><kbd>→</kbd><span class="sno-btn-text">page</span> + </button> + <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="open" aria-label="Open selected post"> + <i class="fas fa-expand" aria-hidden="true"></i><kbd>Enter</kbd><span class="sno-btn-text">open</span> + </button> + <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="close" aria-label="Close modal"> + <i class="fas fa-compress" aria-hidden="true"></i><kbd>Esc</kbd><span class="sno-btn-text">close</span> + </button> </div> <div class="nav-hints-row nav-hints-fx"> <span class="nav-hints-label">fx</span> - <button type="button" class="nav-fx-button" data-sno-fx="wild" aria-pressed="false" aria-label="Toggle wild mode"><kbd>w</kbd> wild</button> - <button type="button" class="nav-fx-button" data-sno-fx="crt" aria-pressed="false" aria-label="Toggle CRT effect"><kbd>c</kbd> crt</button> - <button type="button" class="nav-fx-button" data-sno-fx="ghost" aria-pressed="false" aria-label="Toggle ghost mode"><kbd>g</kbd> ghost</button> - <button type="button" class="nav-fx-button" data-sno-fx="blank" aria-pressed="false" aria-label="Toggle blank mode"><kbd>b</kbd> blank</button> - <button type="button" class="nav-fx-button" data-sno-fx="ambient" aria-pressed="false" aria-label="Start or pause ambient music"><kbd>p</kbd> music</button> - <button type="button" class="nav-fx-button" data-sno-fx="flash" aria-label="Trigger flash effect"><kbd>f</kbd> flash</button> - <button type="button" class="nav-fx-button" data-sno-fx="scatter" aria-label="Trigger scatter effect"><kbd>x</kbd> scatter</button> - <span class="nav-hints-spacer"><kbd>t</kbd> theme</span> + <button type="button" class="nav-fx-button" data-sno-fx="wild" aria-pressed="false" aria-label="Toggle wild mode"> + <i class="fas fa-bolt" aria-hidden="true"></i><kbd>w</kbd><span class="sno-btn-text">wild</span> + </button> + <button type="button" class="nav-fx-button" data-sno-fx="crt" aria-pressed="false" aria-label="Toggle CRT effect"> + <i class="fas fa-tv" aria-hidden="true"></i><kbd>c</kbd><span class="sno-btn-text">crt</span> + </button> + <button type="button" class="nav-fx-button" data-sno-fx="ghost" aria-pressed="false" aria-label="Toggle ghost mode"> + <i class="fas fa-ghost" aria-hidden="true"></i><kbd>g</kbd><span class="sno-btn-text">ghost</span> + </button> + <button type="button" class="nav-fx-button" data-sno-fx="blank" aria-pressed="false" aria-label="Toggle blank mode"> + <i class="fas fa-eye-slash" aria-hidden="true"></i><kbd>b</kbd><span class="sno-btn-text">blank</span> + </button> + <button type="button" class="nav-fx-button" data-sno-fx="ambient" aria-pressed="false" aria-label="Start or pause ambient music"> + <i class="fas fa-music" aria-hidden="true"></i><kbd>p</kbd><span class="sno-btn-text">music</span> + </button> + <button type="button" class="nav-fx-button" data-sno-fx="flash" aria-label="Trigger flash effect"> + <i class="fas fa-camera" aria-hidden="true"></i><kbd>f</kbd><span class="sno-btn-text">flash</span> + </button> + <button type="button" class="nav-fx-button" data-sno-fx="scatter" aria-label="Trigger scatter effect"> + <i class="fas fa-wind" aria-hidden="true"></i><kbd>x</kbd><span class="sno-btn-text">scatter</span> + </button> + <button type="button" class="nav-fx-button" data-sno-fx="theme" aria-label="Random theme"> + <i class="fas fa-palette" aria-hidden="true"></i><kbd>t</kbd><span class="sno-btn-text">theme</span> + </button> <select id="sno-theme-select" class="nav-fx-button" aria-label="Theme"></select> </div> </div> {{end}} - {{define "navmodal"}} <div class="post-modal" id="post-modal" role="dialog" aria-modal="true" aria-label="Expanded post"> <div class="modal-inner"> - <button class="modal-close" onclick="closeModal()">[ ESC ] CLOSE</button> + <button class="modal-close" onclick="closeModal()" aria-label="Close post"><i class="fas fa-times" aria-hidden="true"></i> CLOSE</button> <div id="modal-content"></div> <div class="sno-scroll-end" id="sno-scroll-end" aria-hidden="true"></div> </div> </div> {{end}} - diff --git a/internal/generator/templates/shared/shared.css b/internal/generator/templates/shared/shared.css index 5fab9ac..35f4aef 100644 --- a/internal/generator/templates/shared/shared.css +++ b/internal/generator/templates/shared/shared.css @@ -21,10 +21,12 @@ gap:clamp(16px,4vw,48px); } /* Flex column layout: let #post-content shrink so overflow-y scrolls; footer stays visible */ #post-content.content { min-height:0; } -.page-nav-footer { flex-shrink:0; width:100%; box-sizing:border-box; } + .page-nav-footer { flex-shrink:0; width:100%; box-sizing:border-box; } .page-nav-footer .page-nav { margin:0; } /* ~Half-height footer bar vs default .page-nav padding */ .page-nav-footer .page-nav a { padding-top:4px; padding-bottom:4px; } +/* Reorganized nav-hints shared layout */ +.nav-hints { display:flex; flex-direction:column; gap:0; padding:0 !important; } /* Shared nav FX keyframes — themes apply these classes for brief effects */ /* Modal open: post zooms/flies into the modal overlay */ @keyframes sno-modal-zoom { 0%{transform:scale(0.82) translateY(18px);opacity:0} 60%{transform:scale(1.02) translateY(-3px);opacity:1} 100%{transform:scale(1) translateY(0);opacity:1} } @@ -947,9 +949,56 @@ body[data-sno-theme="matrix"] .post-text img, body[data-sno-theme="terminal"] .post-text img, body[data-sno-theme="dos"] .post-text img { animation:sno-matrix-decode 0.8s ease-out both; } -/* Mobile touch-target and padding overrides */ +/* Mobile touch-target and layout overrides */ @media(max-width:640px) { + .nav-hints { padding:4px 14px !important; gap:0; position:sticky; top:0; z-index:25; } + /* Remove navigation row on mobile */ + .nav-hints-nav { display:none !important; } + /* FX row: single horizontal line of icon-only tiles */ + .nav-hints-fx { display:flex !important; flex-direction:row !important; + flex-wrap:nowrap; padding:0; gap:4px; overflow-x:auto; + -webkit-overflow-scrolling:touch; scrollbar-width:none; } + .nav-hints-fx::-webkit-scrollbar { display:none; } + .nav-hints-label, .nav-hints-spacer, + .nav-hints-row kbd, .sno-btn-text { display:none !important; } + .nav-fx-button { + flex:1 1 auto; min-height:44px; min-width:36px; max-width:none; + justify-content:center; align-items:center; gap:0; padding:0; + font-size:0; width:auto; + } + .nav-fx-button i, .nav-fx-button i.fas { font-size:1rem; display:inline-block; } + /* Suppress the theme select dropdown in the bar */ + .nav-hints-fx #sno-theme-select { display:none !important; } .modal-inner { padding:20px 14px !important; } .transmit-btn { padding-top:11px !important; padding-bottom:11px !important; min-height:44px !important; } .page-nav a { padding-top:10px !important; padding-bottom:10px !important; min-height:44px !important; } + /* Floating action button for quick one-handed control */ + #sno-fab { + position:fixed; right:14px; bottom:14px; z-index:5001; + width:56px; height:56px; border-radius:50%; + border:0; background:rgba(0,0,0,0.75); color:#fff; + font-size:1.2rem; cursor:pointer; display:flex; align-items:center; justify-content:center; + box-shadow:0 4px 16px rgba(0,0,0,0.48); backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px); + transition:transform 0.18s ease, box-shadow 0.18s ease; + -webkit-tap-highlight-color:transparent; + } + #sno-fab:focus-visible { outline:2px solid #fff; outline-offset:3px; } + #sno-fab:active { transform:scale(0.94); } + #sno-fab .sno-fab-menu { + position:absolute; bottom:64px; right:0; + display:flex; flex-direction:column; gap:10px; + opacity:0; pointer-events:none; transform:translateY(6px); + transition:opacity 0.2s ease, transform 0.2s ease; + } + #sno-fab.open .sno-fab-menu { opacity:1; pointer-events:auto; transform:translateY(0); } + #sno-fab .sno-fab-menu button { + width:48px; height:48px; border-radius:50%; + border:0; background:rgba(0,0,0,0.72); color:#fff; + font-size:1.05rem; display:flex; align-items:center; justify-content:center; + box-shadow:0 2px 10px rgba(0,0,0,0.35); backdrop-filter:blur(4px); + -webkit-tap-highlight-color:transparent; + } + #sno-fab .sno-fab-menu button:active { transform:scale(0.92); } + /* Swipe hint for first-time mobile visitors */ + #sno-swipe-hint { display:flex; } } diff --git a/internal/generator/templates/shared/shared.js b/internal/generator/templates/shared/shared.js index 09de580..1e29831 100644 --- a/internal/generator/templates/shared/shared.js +++ b/internal/generator/templates/shared/shared.js @@ -471,6 +471,9 @@ var melodyIndex = 0; var drumTimer = null; var drumIndex = 0; + var ambientAudio = null; + var ambientAudioSrc = ''; + var ambientAudioFadeTimer = null; function wildifyPreset(base) { if (!base) return base; @@ -493,9 +496,108 @@ var ambient = SNONUX_SOUNDS.ambient; if (!ambient) return null; var base = isWild ? (ambient.wild || ambient.normal) : ambient.normal; + if (base && !base.file && ambient.normal && ambient.normal.file) { + var inherited = {}; + for (var k in base) inherited[k] = base[k]; + inherited.file = ambient.normal.file; + if (inherited.volume == null && ambient.normal.volume != null) inherited.volume = ambient.normal.volume; + base = inherited; + } return isWild ? wildifyPreset(base) : base; } + function resolveAmbientFile(file) { + if (!file) return ''; + if (/^(https?:)?\/\//.test(file) || file.charAt(0) === '/') return file; + if (file.indexOf('themes/') === 0) return file; + return 'themes/' + snonuxDetectThemeName() + '/' + file; + } + + function ambientFileVolume(preset) { + var v = preset && preset.volume != null ? preset.volume : 0.46; + if (isWild) v = Math.min(v * 1.08, 0.8); + return Math.max(0, Math.min(v, 0.8)); + } + + function clearAmbientAudioFade() { + if (ambientAudioFadeTimer) { + clearInterval(ambientAudioFadeTimer); + ambientAudioFadeTimer = null; + } + } + + function fadeAmbientAudioTo(target, duration, done) { + if (!ambientAudio) { + if (done) done(); + return; + } + clearAmbientAudioFade(); + var start = ambientAudio.volume || 0; + var dur = Math.max(duration || 0.35, 0.01); + var started = Date.now(); + ambientAudioFadeTimer = setInterval(function() { + if (!ambientAudio) { + clearAmbientAudioFade(); + if (done) done(); + return; + } + var t = Math.min((Date.now() - started) / (dur * 1000), 1); + ambientAudio.volume = start + (target - start) * t; + if (t >= 1) { + clearAmbientAudioFade(); + if (done) done(); + } + }, 30); + } + + function stopFileAudio(resetPosition) { + clearAmbientAudioFade(); + if (!ambientAudio) return; + try { + ambientAudio.pause(); + ambientAudio.volume = 0; + if (resetPosition) ambientAudio.currentTime = 0; + } catch (_) {} + } + + function startFileAudio(preset) { + var src = resolveAmbientFile(preset.file); + if (!src) return; + currentPreset = preset; + + if (!ambientAudio || ambientAudioSrc !== src) { + if (ambientAudio) { + try { ambientAudio.pause(); } catch (_) {} + } + ambientAudio = new Audio(src); + ambientAudio.loop = true; + ambientAudio.preload = 'auto'; + ambientAudio.volume = 0.22; + ambientAudioSrc = src; + } + + isPlaying = true; + var target = ambientFileVolume(preset); + if (ambientAudio.volume < 0.18) ambientAudio.volume = Math.min(target, 0.22); + var playResult; + try { + playResult = ambientAudio.play(); + } catch (_) { + isPlaying = false; + return; + } + if (playResult && playResult.then) { + playResult.then(function() { + if (!isPlaying) return; + fadeAmbientAudioTo(target, 0.16); + }).catch(function() { + isPlaying = false; + }); + } else { + fadeAmbientAudioTo(target, 0.16); + } + } + function ensureCtx() { if (!ctx) { ctx = new (window.AudioContext || window.webkitAudioContext)(); @@ -732,6 +834,7 @@ stopDrums(); stopDrones(); stopNoise(); + stopFileAudio(true); } // ── drum synthesizer ────────────────────────────────────────────── @@ -849,6 +952,10 @@ var preset = getPreset(); if (!preset) return; currentPreset = preset; + if (preset.file) { + startFileAudio(preset); + return; + } ensureCtx(); // Begin scheduling only once the AudioContext is running. @@ -887,7 +994,12 @@ function pauseEngine() { isPlaying = false; - var fadeOut = currentPreset && currentPreset.release != null ? currentPreset.release : 0.5; + var fadeOut = ambientAudio ? 0.5 : (currentPreset && currentPreset.release != null ? currentPreset.release : 0.5); + if (ambientAudio) { + fadeAmbientAudioTo(0, fadeOut, function() { + if (!isPlaying) stopFileAudio(false); + }); + } fadeMasterTo(0, fadeOut); setTimeout(function() { if (!isPlaying) stopAll(); @@ -919,10 +1031,15 @@ return; } fadeMasterTo(0, 0.3); + if (ambientAudio) fadeAmbientAudioTo(0, 0.3); setTimeout(function() { if (!isPlaying) return; stopAll(); currentPreset = newPreset; + if (newPreset.file) { + startFileAudio(newPreset); + return; + } melodyIndex = 0; startDrones(newPreset); startNoise(newPreset); @@ -946,6 +1063,10 @@ // we keep hearing whatever was cached as silence on top of it. stopAll(); currentPreset = preset; + if (preset.file) { + startFileAudio(preset); + return; + } melodyIndex = 0; startDrones(preset); startNoise(preset); @@ -1741,18 +1862,117 @@ blank: toggleBlankMode, ambient: toggleAmbientMode, flash: triggerFlashEffect, - scatter: triggerScatterEffect + scatter: triggerScatterEffect, + theme: function() { + var pick = snonuxRandomTheme(); + if (pick) snonuxSwitchTheme(pick); + } + }; + var navHandlers = { + 'prev-post': function() { + if (currentIndex <= 0) { bounceEffect('up'); } + else { selectPost(currentIndex - 1, 'up'); } + }, + 'next-post': function() { + if (currentIndex >= posts.length - 1) { bounceEffect('down'); } + else { selectPost(currentIndex + 1, 'down'); } + }, + 'prev-page': function() { + if (prevPageURL) { window.location.href = prevPageURL; } + else { bounceEffect('left'); } + }, + 'next-page': function() { + if (nextPageURL) { window.location.href = nextPageURL; } + else { bounceEffect('right'); } + }, + 'open': function() { openPostAt(currentIndex, true); }, + 'close': function() { + if (document.getElementById('post-modal').classList.contains('active')) closeModal(); + } }; document.querySelectorAll('.nav-fx-button').forEach(function(button) { button.addEventListener('click', function(e) { e.preventDefault(); var fx = button.getAttribute('data-sno-fx'); if (fxHandlers[fx]) fxHandlers[fx](); + var nav = button.getAttribute('data-sno-nav'); + if (navHandlers[nav]) navHandlers[nav](); }); }); syncFxButtonStates(); })(); + // Bind clickables on splash overlay so it is easy to tap-open. + (function touchSplashDismiss() { + var splash = document.getElementById('splash-overlay'); + if (!splash || splash.classList.contains('splash--dismissed')) return; + splash.addEventListener('click', function() { + if (window._snonuxDismissSplash) window._snonuxDismissSplash(); + }); + })(); + + // Mobile floating action button (FAB): appears only on narrow viewports when nav-hints + // are visible but too cramped. Provides one-tap access to wild, music, and flash. + (function mobileFAB() { + if (document.getElementById('sno-fab')) return; + var btn = document.createElement('button'); + btn.id = 'sno-fab'; + btn.setAttribute('aria-label', 'Open controls menu'); + btn.setAttribute('aria-haspopup', 'true'); + btn.setAttribute('aria-expanded', 'false'); + btn.innerHTML = '<span id="sno-fab-icon"><i class="fas fa-bars" aria-hidden="true"></i></span>' + + '<span class="sno-fab-menu" role="menu" aria-hidden="true">' + + '<button type="button" role="menuitem" data-sno-fx="wild" aria-label="Toggle wild mode"><i class="fas fa-bolt" aria-hidden="true"></i></button>' + + '<button type="button" role="menuitem" data-sno-fx="ambient" aria-label="Toggle ambient music"><i class="fas fa-music" aria-hidden="true"></i></button>' + + '<button type="button" role="menuitem" data-sno-fx="flash" aria-label="Trigger flash effect"><i class="fas fa-camera" aria-hidden="true"></i></button>' + + '<button type="button" role="menuitem" data-sno-fx="scatter" aria-label="Trigger scatter"><i class="fas fa-wind" aria-hidden="true"></i></button>' + + '<button type="button" role="menuitem" data-sno-fx="crt" aria-label="Toggle CRT effect"><i class="fas fa-tv" aria-hidden="true"></i></button>' + + '<button type="button" role="menuitem" data-sno-fx="ghost" aria-label="Toggle ghost mode"><i class="fas fa-ghost" aria-hidden="true"></i></button>' + + '<button type="button" role="menuitem" data-sno-fx="blank" aria-label="Toggle blank mode"><i class="fas fa-eye-slash" aria-hidden="true"></i></button>' + + '<button type="button" role="menuitem" data-sno-fx="theme" aria-label="Random theme"><i class="fas fa-palette" aria-hidden="true"></i></button>' + + '</span>'; + document.body.appendChild(btn); + + var fxHandlers = { + wild: function() { toggleWildMode(); }, + crt: toggleCrtMode, + ghost: toggleGhostMode, + blank: toggleBlankMode, + ambient: toggleAmbientMode, + flash: triggerFlashEffect, + scatter: triggerScatterEffect, + theme: function() { + var pick = snonuxRandomTheme(); + if (pick) snonuxSwitchTheme(pick); + } + }; + + var menu = btn.querySelector('.sno-fab-menu'); + var open = false; + function setOpen(o) { + open = o; + btn.classList.toggle('open', open); + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + menu.setAttribute('aria-hidden', open ? 'false' : 'true'); + } + btn.addEventListener('click', function(e) { + if (e.target.closest('[role="menuitem"]')) return; + e.preventDefault(); + setOpen(!open); + }); + btn.querySelectorAll('[role="menuitem"]').forEach(function(item) { + item.addEventListener('click', function(e) { + e.stopPropagation(); + var fx = item.getAttribute('data-sno-fx'); + if (fxHandlers[fx]) fxHandlers[fx](); + setOpen(false); + }); + }); + document.addEventListener('click', function(e) { + if (open && !btn.contains(e.target)) setOpen(false); + }); + })(); + // Restore ambient preference on load (opt-in; default off). (function initAmbientPreference() { if (snonuxAmbientLoadPreference() && window.snonuxAmbientStart) { |
