diff options
| -rw-r--r-- | app.rb | 22 | ||||
| -rw-r--r-- | public/css/style.css | 9 | ||||
| -rw-r--r-- | public/js/album.js | 16 | ||||
| -rw-r--r-- | public/js/slideshow.js | 115 | ||||
| -rw-r--r-- | views/album.erb | 8 | ||||
| -rw-r--r-- | views/layout.erb | 2 | ||||
| -rw-r--r-- | views/slideshow.erb | 14 |
7 files changed, 159 insertions, 27 deletions
@@ -250,6 +250,18 @@ get '/slideshow/*' do slideshow_view(params[:splat].first.chomp('/')) end +def all_media_entries + dirs = [MEDIA_ROOT] + Dir.glob("#{MEDIA_ROOT}/**/*/").sort + dirs.flat_map do |dir| + rel = dir.delete_prefix(MEDIA_ROOT).delete_prefix('/') + data = load_album(dir) + next [] if data['visible'] == false && !admin? + album_files(dir, data).select { |e| %i[image video].include?(e[:type]) }.map do |e| + e.merge(file_rel: rel.empty? ? e[:name] : "#{rel}/#{e[:name]}") + end + end +end + def slideshow_view(rel) dir = resolve_dir(rel) halt 404 unless File.directory?(dir) @@ -258,8 +270,14 @@ def slideshow_view(rel) @rel = rel @title = data['title'] || (rel.empty? ? 'Albums' : File.basename(dir)) - @entries = album_files(dir, data).select { |e| %i[image video].include?(e[:type]) } - erb :slideshow + @entries = if rel.empty? + all_media_entries + else + album_files(dir, data) + .select { |e| %i[image video].include?(e[:type]) } + .map { |e| e.merge(file_rel: "#{rel}/#{e[:name]}") } + end + erb :slideshow, layout: false end # ── Admin routes ─────────────────────────────────────────────────────────────── 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 }); +} diff --git a/views/album.erb b/views/album.erb index 961b26a..c99ec3c 100644 --- a/views/album.erb +++ b/views/album.erb @@ -11,8 +11,10 @@ <h1><%= @title %></h1> <% if @desc %><p class="album-desc"><%= @desc %></p><% end %> <div class="album-actions"> - <% if @entries.any? { |e| %i[image video].include?(e[:type]) } %> - <a href="/slideshow/<%= @rel %>" class="btn">Slideshow</a> + <% if @rel.empty? ? @albums.any? : @entries.any? { |e| %i[image video].include?(e[:type]) } %> + <a href="/slideshow/<%= @rel %>" id="ss-launch" data-base="/slideshow/<%= @rel %>" class="btn">Slideshow</a> + <label class="ss-opt-label"><input type="checkbox" id="ss-opt-shuffle"> Shuffle</label> + <label class="ss-opt-label"><input type="checkbox" id="ss-opt-fullscreen"> Full screen</label> <% end %> </div> </div> @@ -103,4 +105,4 @@ const ENTRIES = <%= @entries.map { |e| e.merge(src: "/media/#{file_rel}") }.to_json %>; </script> -<script src="/js/album.js?v=2"></script> +<script src="/js/album.js?v=3"></script> diff --git a/views/layout.erb b/views/layout.erb index 3e798c4..1683bd0 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -4,7 +4,7 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title><%= @title %> — Albumen</title> - <link rel="stylesheet" href="/css/style.css"> + <link rel="stylesheet" href="/css/style.css?v=2"> </head> <body> <header class="site-header"> diff --git a/views/slideshow.erb b/views/slideshow.erb index 847da65..dfdf523 100644 --- a/views/slideshow.erb +++ b/views/slideshow.erb @@ -4,7 +4,7 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title><%= @title %> — Slideshow</title> - <link rel="stylesheet" href="/css/style.css"> + <link rel="stylesheet" href="/css/style.css?v=2"> </head> <body class="slideshow-page"> <div id="slideshow"> @@ -18,9 +18,10 @@ </div> <div id="ss-controls"> <a href="/browse/<%= @rel %>" class="btn btn-sm">← Album</a> - <button onclick="ssPrev()" class="btn btn-sm">‹ Prev</button> + <button onclick="ssPrev()" class="btn btn-sm">‹ Prev</button> <button onclick="ssToggle()" id="ss-play-btn" class="btn btn-sm">⏸ Pause</button> - <button onclick="ssNext()" class="btn btn-sm">Next ›</button> + <button onclick="ssNext()" class="btn btn-sm">Next ›</button> + <button onclick="ssFsToggle()" class="btn btn-sm" title="Toggle full screen (F)">⛶</button> <label class="ss-interval-label"> Interval <input type="number" id="ss-interval" value="4" min="1" max="60" step="1"> s @@ -30,12 +31,9 @@ </div> <script> - const SS_ENTRIES = <%= @entries.map { |e| - file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}" - e.merge(src: "/media/#{file_rel}") - }.to_json %>; + const SS_ENTRIES = <%= @entries.map { |e| e.merge(src: "/media/#{e[:file_rel]}") }.to_json %>; const SS_REL = <%= @rel.to_json %>; </script> - <script src="/js/slideshow.js"></script> + <script src="/js/slideshow.js?v=2"></script> </body> </html> |
