diff options
| author | Ken D'Ambrosio <ken@jots.org> | 2026-05-10 14:38:04 +0000 |
|---|---|---|
| committer | Ken D'Ambrosio <ken@jots.org> | 2026-05-10 14:38:04 +0000 |
| commit | a7c16b99a4284826ac5ac0ace4ee0f760a548ff4 (patch) | |
| tree | c8cb3679de1a88d3d8a54f787462aae944c91114 /public | |
| parent | fa36e54d878a3274f7728eb0b84c351b33f3c6ed (diff) | |
Add slideshow: root-level, shuffle, fullscreen, click-to-album
- Root slideshow: all_media_entries walks the full media tree so
/slideshow/ shows every photo across all albums; Slideshow button
always appears on the root album page
- Shuffle and Full screen checkboxes sit next to the Slideshow button
on the album page; options pass as ?shuffle=1&fullscreen=1 URL params
- Fullscreen uses a tap-to-activate overlay (browsers block auto-entry
on page load); webkit-prefixed for Safari; ⛶ button and F key for
mid-session toggle
- Fullscreen mode hides controls, counter, caption bar, and site header
- Exiting fullscreen auto-pauses so the current photo stays visible
- Click/tap anywhere in the stage navigates to the photo's album
lightbox; reads the live src attribute instead of ssIdx to avoid a
race where ssIdx advances during the cross-fade while the old photo
is still on screen
- layout.erb excluded from slideshow (layout: false) so the site header
never appears there
- CSS cache-busted with ?v=2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'public')
| -rw-r--r-- | public/css/style.css | 9 | ||||
| -rw-r--r-- | public/js/album.js | 16 | ||||
| -rw-r--r-- | public/js/slideshow.js | 115 |
3 files changed, 127 insertions, 13 deletions
diff --git a/public/css/style.css b/public/css/style.css index 3dd2450..a142f8a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -288,11 +288,20 @@ main { max-width: 1400px; margin: 0 auto; padding: 24px; } background: rgba(0,0,0,.7); flex-shrink: 0; } +.ss-opt-label { display: inline-flex; align-items: center; gap: 5px; font-size: .9rem; color: var(--text-dim); cursor: pointer; } +.ss-opt-label input { cursor: pointer; } .ss-interval-label { color: var(--text-dim); font-size: .85rem; display: flex; align-items: center; gap: 4px; margin-left: auto; } .ss-interval-label input { width: 48px; background: var(--bg3); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 2px 6px; } #ss-counter { position: absolute; top: 12px; right: 16px; color: rgba(255,255,255,.5); font-size: .8rem; pointer-events: none; } +/* Fullscreen: nothing but the photo */ +.ss-fullscreen .site-header, +.ss-fullscreen #ss-controls, +.ss-fullscreen #ss-counter, +.ss-fullscreen #ss-caption-bar { display: none !important; } +.ss-fullscreen #ss-stage { cursor: pointer; } + /* ── Admin ─────────────────────────────────────────────────────────────── */ .admin-login { max-width: 360px; margin: 80px auto; diff --git a/public/js/album.js b/public/js/album.js index fe492af..dd54ed2 100644 --- a/public/js/album.js +++ b/public/js/album.js @@ -128,6 +128,22 @@ window.addEventListener('DOMContentLoaded', () => { }); })(); +// Slideshow launch options (Shuffle / Full screen checkboxes next to the button) +(function () { + const link = document.getElementById('ss-launch'); + if (!link) return; + const base = link.dataset.base; + function update() { + const p = []; + if (document.getElementById('ss-opt-shuffle').checked) p.push('shuffle=1'); + if (document.getElementById('ss-opt-fullscreen').checked) p.push('fullscreen=1'); + link.href = base + (p.length ? '?' + p.join('&') : ''); + } + ['ss-opt-shuffle', 'ss-opt-fullscreen'].forEach(id => + document.getElementById(id).addEventListener('change', update) + ); +})(); + // Touch swipe (function () { let startX = null; diff --git a/public/js/slideshow.js b/public/js/slideshow.js index 2260034..a732962 100644 --- a/public/js/slideshow.js +++ b/public/js/slideshow.js @@ -3,6 +3,7 @@ let ssIdx = 0; let ssTimer = null; let ssPlaying = true; +let ssQueue = SS_ENTRIES.slice(); const img = document.getElementById('ss-img'); const vid = document.getElementById('ss-video'); @@ -16,25 +17,22 @@ function ssInterval() { } function ssShow(i, instant) { - const e = SS_ENTRIES[i]; + const e = ssQueue[i]; if (!e) return; ssIdx = i; title.textContent = e.title !== e.name ? e.title : ''; cap.textContent = e.caption || ''; - ctr.textContent = `${i + 1} / ${SS_ENTRIES.length}`; + ctr.textContent = `${i + 1} / ${ssQueue.length}`; if (instant) { - // First load — show without transition applyEntry(e); return; } if (e.type === 'video') { - // No preload possible for video; fade out then swap crossFade(() => applyEntry(e)); } else { - // Preload the image so the fade-in shows it immediately const preload = new Image(); preload.onload = preload.onerror = () => crossFade(() => applyEntry(e)); preload.src = e.src; @@ -46,7 +44,7 @@ function applyEntry(e) { img.style.display = 'none'; vid.style.display = ''; vid.src = e.src; - void vid.offsetWidth; // flush so transition fires + void vid.offsetWidth; vid.classList.remove('fading'); vid.play().catch(() => {}); } else { @@ -55,7 +53,7 @@ function applyEntry(e) { img.style.display = ''; img.src = e.src; img.alt = e.title; - void img.offsetWidth; // flush so transition fires + void img.offsetWidth; img.classList.remove('fading'); } } @@ -63,26 +61,26 @@ function applyEntry(e) { function crossFade(cb) { img.classList.add('fading'); vid.classList.add('fading'); - setTimeout(cb, 500); // matches CSS transition duration + setTimeout(cb, 500); } function ssSchedule() { clearTimeout(ssTimer); - if (ssPlaying && SS_ENTRIES.length > 1) { + if (ssPlaying && ssQueue.length > 1) { ssTimer = setTimeout(() => { - ssShow((ssIdx + 1) % SS_ENTRIES.length); + ssShow((ssIdx + 1) % ssQueue.length); ssSchedule(); }, ssInterval()); } } function ssNext() { - ssShow((ssIdx + 1) % SS_ENTRIES.length); + ssShow((ssIdx + 1) % ssQueue.length); if (ssPlaying) ssSchedule(); } function ssPrev() { - ssShow((ssIdx - 1 + SS_ENTRIES.length) % SS_ENTRIES.length); + ssShow((ssIdx - 1 + ssQueue.length) % ssQueue.length); if (ssPlaying) ssSchedule(); } @@ -92,9 +90,75 @@ function ssToggle() { if (ssPlaying) ssSchedule(); else clearTimeout(ssTimer); } +// ── Shuffle ──────────────────────────────────────────────────────────────────── + +function shuffle(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +// ── Full screen ──────────────────────────────────────────────────────────────── + +function ssFsActive() { + return !!(document.fullscreenElement || document.webkitFullscreenElement); +} + +function ssFsEnter() { + const el = document.documentElement; + return (el.requestFullscreen || el.webkitRequestFullscreen).call(el); +} + +function ssFsExit() { + return (document.exitFullscreen || document.webkitExitFullscreen).call(document); +} + +function ssFsToggle() { + if (ssFsActive()) ssFsExit(); + else ssFsEnter().catch(() => {}); +} + +function onFsChange() { + const inFs = ssFsActive(); + document.body.classList.toggle('ss-fullscreen', inFs); + if (inFs) { + const hint = document.createElement('div'); + hint.textContent = 'Press Esc to exit full screen'; + hint.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.75);color:#fff;padding:8px 18px;border-radius:6px;font-size:.9rem;pointer-events:none;transition:opacity 1.2s'; + document.body.appendChild(hint); + setTimeout(() => { hint.style.opacity = '0'; }, 1800); + setTimeout(() => { hint.remove(); }, 3100); + } else { + if (ssPlaying) ssToggle(); // pause so current photo stays visible + } +} + +document.addEventListener('fullscreenchange', onFsChange); +document.addEventListener('webkitfullscreenchange', onFsChange); + +// Click/tap anywhere in the stage → open photo in its album lightbox. +// Skip when the video element is the target so its native controls still work. +// Read the live src attribute rather than ssIdx, which updates ahead of the +// cross-fade and would point to the next photo while the old one is still visible. +document.getElementById('ss-stage').addEventListener('click', ev => { + if (ev.target === vid || vid.contains(ev.target)) return; + const activeSrc = vid.style.display !== 'none' ? vid.getAttribute('src') : img.getAttribute('src'); + const e = ssQueue.find(entry => entry.src === activeSrc) || ssQueue[ssIdx]; + if (!e) return; + const lastSlash = e.file_rel.lastIndexOf('/'); + const albumRel = lastSlash >= 0 ? e.file_rel.slice(0, lastSlash) : ''; + window.location.href = '/browse/' + (albumRel ? albumRel + '/' : '') + '#photo=' + encodeURIComponent(e.name); +}); + +// ── Keyboard & touch ─────────────────────────────────────────────────────────── + document.addEventListener('keydown', e => { if (e.key === 'ArrowRight') ssNext(); if (e.key === 'ArrowLeft') ssPrev(); + if (e.key === 'f') ssFsToggle(); if (e.key === ' ') { e.preventDefault(); ssToggle(); } }); @@ -107,7 +171,32 @@ document.addEventListener('touchend', e => { swipeX = null; }, { passive: true }); -if (SS_ENTRIES.length > 0) { +// ── Init ─────────────────────────────────────────────────────────────────────── + +const ssParams = new URLSearchParams(location.search); +if (ssParams.get('shuffle') === '1') { + ssQueue = shuffle(SS_ENTRIES); +} + +if (ssQueue.length > 0) { ssShow(0, true); ssSchedule(); } + +// Browsers require a live user gesture for requestFullscreen — entering it +// automatically on page load is always blocked. We wait for the first tap. +if (ssParams.get('fullscreen') === '1') { + const overlay = document.createElement('div'); + overlay.textContent = 'Click or tap anywhere to enter full screen.'; + overlay.style.cssText = [ + 'position:fixed;inset:0;z-index:200', + 'display:flex;align-items:center;justify-content:center', + 'color:rgba(255,255,255,.75);font-size:1.1rem;cursor:pointer', + 'background:rgba(0,0,0,.45)', + ].join(';'); + document.body.appendChild(overlay); + overlay.addEventListener('click', () => { + overlay.remove(); + ssFsEnter().catch(() => {}); + }, { once: true }); +} |
