summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app.rb22
-rw-r--r--public/css/style.css9
-rw-r--r--public/js/album.js16
-rw-r--r--public/js/slideshow.js115
-rw-r--r--views/album.erb8
-rw-r--r--views/layout.erb2
-rw-r--r--views/slideshow.erb14
7 files changed, 159 insertions, 27 deletions
diff --git a/app.rb b/app.rb
index 8667471..dde6125 100644
--- a/app.rb
+++ b/app.rb
@@ -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>