summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/bootstrap.html9
-rw-r--r--web/css/components.css65
-rw-r--r--web/css/layout.css46
-rw-r--r--web/css/login.css56
-rw-r--r--web/css/player.css2
-rw-r--r--web/detach.html1
-rw-r--r--web/index.html86
-rw-r--r--web/js/api.js1
-rw-r--r--web/js/app.js133
-rw-r--r--web/js/detach.js2
-rw-r--r--web/js/detachPopup.js50
-rw-r--r--web/js/keyboard.js40
-rw-r--r--web/js/playback.js36
-rw-r--r--web/js/podcasts.js24
-rw-r--r--web/js/search.js48
-rw-r--r--web/js/selection.js2
-rw-r--r--web/js/state.js1
-rw-r--r--web/js/tests/media-pagination.test.js56
-rw-r--r--web/js/tests/search-parser.test.js33
-rw-r--r--web/js/views/help.js2
-rw-r--r--web/js/views/media-grid.js243
-rw-r--r--web/js/views/playback-nav.js6
-rw-r--r--web/js/views/sets.js83
-rw-r--r--web/js/views/tags.js52
-rw-r--r--web/login.html6
-rw-r--r--web/sw.js4
26 files changed, 853 insertions, 234 deletions
diff --git a/web/bootstrap.html b/web/bootstrap.html
index 3f028d2..5d66c0d 100644
--- a/web/bootstrap.html
+++ b/web/bootstrap.html
@@ -10,8 +10,13 @@
</head>
<body>
<div class="login-container" role="main">
- <h1>Create Admin Account</h1>
- <p class="subtitle">One-time setup. This form is available only while no users exist.</p>
+ <div class="login-brand">
+ <img src="/favicon.svg" alt="" class="login-logo">
+ <div>
+ <h1>Create Admin Account</h1>
+ <p class="subtitle">One-time setup. This form is available only while no users exist.</p>
+ </div>
+ </div>
<form id="bootstrap-form" class="login-form" novalidate>
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
diff --git a/web/css/components.css b/web/css/components.css
index 475a911..a81753a 100644
--- a/web/css/components.css
+++ b/web/css/components.css
@@ -101,6 +101,7 @@ label {
/* Search bar */
.search-bar {
+ position: relative;
display: flex;
align-items: center;
gap: 0.4rem;
@@ -108,17 +109,75 @@ label {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.25rem 0.6rem;
- max-width: 22rem;
+ max-width: min(100%, 72rem);
width: 100%;
}
+.search-overlay {
+ position: fixed;
+ inset: 1rem 1rem auto 1rem;
+ z-index: 90;
+ display: flex;
+ justify-content: center;
+ pointer-events: none;
+}
+.search-overlay.hidden {
+ display: none;
+}
+.search-overlay-bar {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ max-width: min(72rem, calc(100vw - 2rem));
+ box-shadow: var(--shadow-lg);
+ pointer-events: auto;
+}
.search-bar input {
background: transparent;
border: none;
padding: 0.35rem 0.2rem;
- flex: 1 1 auto;
+ flex: 1 1 18rem;
+ min-width: 8rem;
}
.search-bar input:focus { box-shadow: none; }
-
+.tag-filter-list {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ flex: 1 1 18rem;
+ min-width: 0;
+ max-width: none;
+}
+.tag-filter-chip {
+ display: inline-flex;
+ align-items: center;
+ flex: 0 0 auto;
+ max-width: 9rem;
+ min-height: 2rem;
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-secondary);
+ font: inherit;
+ font-size: 0.78rem;
+ cursor: pointer;
+}
+.tag-filter-chip:hover,
+.tag-filter-chip:focus-visible {
+ background: var(--bg-surface-hover);
+ color: var(--text-primary);
+ outline: none;
+}
+.tag-filter-chip.active {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+ color: var(--text-primary);
+}
+.tag-filter-chip span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
/* Search syntax help tooltip */
.search-help {
position: absolute;
diff --git a/web/css/layout.css b/web/css/layout.css
index 177b301..0db69bc 100644
--- a/web/css/layout.css
+++ b/web/css/layout.css
@@ -23,6 +23,27 @@ body {
gap: var(--gap);
padding: 0 1rem;
z-index: 50;
+ transform: translateY(calc(-100% + 0.45rem));
+ transition: transform var(--transition-base);
+}
+.site-header::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: -0.45rem;
+ height: 0.45rem;
+}
+.site-header:hover,
+.site-header:focus-within {
+ transform: translateY(0);
+}
+.header-main {
+ display: flex;
+ align-items: center;
+ gap: var(--gap);
+ flex: 1 1 auto;
+ min-width: 0;
}
.site-header .logo {
font-weight: 700;
@@ -169,6 +190,8 @@ body {
/* Folder card placeholder */
.folder-card .thumb-wrap .placeholder { font-size: 3rem; }
+.set-card .thumb-wrap .placeholder { font-size: 3rem; }
+.set-card .thumb-wrap img + .placeholder { display: none; }
/* Media grid */
.media-grid {
@@ -176,6 +199,26 @@ body {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: var(--gap);
}
+.media-pager {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.5rem 0.65rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-surface);
+}
+.media-pager-top {
+ margin-bottom: 0.1rem;
+}
+.media-pager-bottom {
+ margin-top: 0.1rem;
+}
+.media-page-summary {
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+}
.media-card {
position: relative;
background: var(--bg-surface);
@@ -462,6 +505,9 @@ body {
gap: 0.35rem;
padding: 0 0.5rem;
}
+ .header-main {
+ gap: 0.35rem;
+ }
.search-bar {
max-width: none;
min-width: 0;
diff --git a/web/css/login.css b/web/css/login.css
index a69c9a9..a7b3ae0 100644
--- a/web/css/login.css
+++ b/web/css/login.css
@@ -1,33 +1,65 @@
/* Login / bootstrap page layout */
+*, *::before, *::after { box-sizing: border-box; }
+html, body { min-height: 100%; }
+body {
+ margin: 0;
+ font-family: var(--font-sans);
+ background: var(--bg-body);
+ color: var(--text-primary);
+}
.login-container {
min-height: 100vh;
- display: grid;
- place-items: center;
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
padding: 2rem 1rem;
background: var(--bg-body);
}
+.login-brand {
+ width: min(100%, 24rem);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+}
.login-container h1 {
- margin: 0 0 0.25rem;
- font-size: 1.5rem;
+ margin: 0;
+ color: var(--text-primary);
+ font-size: 1.55rem;
+ line-height: 1.1;
}
.login-container .subtitle {
- margin: 0 0 1rem;
+ margin: 0.3rem 0 0;
color: var(--text-secondary);
font-size: 0.85rem;
}
.login-form {
width: 100%;
- max-width: 22rem;
+ max-width: 24rem;
background: var(--bg-surface);
border: 1px solid var(--border);
- border-radius: var(--radius-lg);
+ border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
- padding: 1.25rem 1.25rem 1rem;
+ padding: 1.35rem;
+}
+.login-form label {
+ margin: 0 0 0.35rem;
+ color: var(--text-secondary);
+}
+.login-form input {
+ margin-bottom: 0.85rem;
+}
+.login-form .btn {
+ width: 100%;
+ margin-top: 0.15rem;
}
-.login-form .btn { width: 100%; margin-top: 0.5rem; }
.login-logo {
- width: 10rem;
- height: auto;
- margin-bottom: 0.5rem;
+ width: 3rem;
+ height: 3rem;
+ display: block;
+ flex: 0 0 auto;
}
diff --git a/web/css/player.css b/web/css/player.css
index c35cb07..45d80bc 100644
--- a/web/css/player.css
+++ b/web/css/player.css
@@ -129,7 +129,7 @@
}
.player.crop-mode video {
- object-fit: fill;
+ object-fit: cover;
object-position: var(--crop-x, 50%) var(--crop-y, 50%);
}
diff --git a/web/detach.html b/web/detach.html
index 94dbc6c..8ec4e96 100644
--- a/web/detach.html
+++ b/web/detach.html
@@ -15,6 +15,7 @@
<div class="stage">
<video id="media-video" playsinline controlslist="nodownload"></video>
<audio id="media-audio" class="hidden"></audio>
+ <img id="media-image" class="media-image hidden" alt="">
<img id="cover-art" class="cover-art hidden" alt="Cover" loading="lazy">
<div id="big-play" class="big-play" role="button" aria-label="Play" tabindex="0">
<div class="play-icon">&#9654;</div>
diff --git a/web/index.html b/web/index.html
index 15c41ec..ace150d 100644
--- a/web/index.html
+++ b/web/index.html
@@ -9,25 +9,46 @@
<link rel="stylesheet" href="/css/layout.css">
<link rel="stylesheet" href="/css/components.css">
<link rel="stylesheet" href="/css/player.css">
-<link rel="stylesheet" href="/css/lightbox.css">
<script type="module" src="/js/app.js"></script>
</head>
<body>
<header class="site-header" role="banner">
- <a href="/" class="logo"><img src="/logo.png" alt="Player" class="logo-img">Player</a>
- <div class="spacer"></div>
- <div id="scan-indicator" class="scan-indicator hidden" role="status" aria-live="polite">
- <span class="scan-dot" aria-hidden="true"></span>
- <span id="scan-indicator-text">Scanning</span>
+ <div class="header-main">
+ <a href="/" class="logo"><img src="/logo.png" alt="Player" class="logo-img">Player</a>
+ <div class="spacer"></div>
+ <div id="scan-indicator" class="scan-indicator hidden" role="status" aria-live="polite">
+ <span class="scan-dot" aria-hidden="true"></span>
+ <span id="scan-indicator-text">Scanning</span>
+ </div>
+ <button id="sidebar-toggle" class="icon-btn" title="Sets" aria-label="Toggle sets sidebar">โ˜ฐ</button>
+ <button id="shuffle-toggle" class="icon-btn" title="Shuffle (r)" aria-label="Toggle shuffle">๐Ÿ”€</button>
+ <button id="help-toggle" class="icon-btn" title="Help (?)">?</button>
+ <button id="theme-toggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme"></button>
+ <button id="admin-toggle" class="icon-btn hidden" title="Admin panel">โš™</button>
+ <button id="logout-btn" class="btn btn-ghost btn-sm">Log out</button>
</div>
- <button id="sidebar-toggle" class="icon-btn" title="Sets" aria-label="Toggle sets sidebar">โ˜ฐ</button>
- <button id="shuffle-toggle" class="icon-btn" title="Shuffle (r)" aria-label="Toggle shuffle">๐Ÿ”€</button>
- <button id="help-toggle" class="icon-btn" title="Help (?)">?</button>
- <button id="theme-toggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme"></button>
- <button id="admin-toggle" class="icon-btn hidden" title="Admin panel">โš™</button>
- <button id="logout-btn" class="btn btn-ghost btn-sm">Log out</button>
</header>
+<div id="search-overlay" class="search-overlay hidden">
+ <div id="search-bar" class="search-bar search-overlay-bar" role="search">
+ <span aria-hidden="true">๐Ÿ”Ž</span>
+ <input id="search-input" type="search" placeholder="Search or filterโ€ฆ (press /)" autocomplete="off" aria-label="Search media">
+ <button id="search-clear" class="icon-btn btn-sm" title="Clear">โœ•</button>
+ <div id="tag-filter-list" class="tag-filter-list" aria-label="Tag filters"></div>
+ <div id="search-help" class="search-help hidden">
+ <div class="search-help-title">Search syntax</div>
+ <div class="search-help-row"><code>min:30</code> minimum duration (seconds)</div>
+ <div class="search-help-row"><code>tag:a,b</code> filter by tags</div>
+ <div class="search-help-row"><code>like:1</code> liked items only</div>
+ <div class="search-help-row"><code>set:yoga</code> select a set by id or name</div>
+ <div class="search-help-row"><code>type:video</code> / <code>type:audio</code> / <code>type:image</code></div>
+ <div class="search-help-row"><code>sort:random</code> / <code>sort:name</code> / <code>sort:date</code> / <code>sort:duration</code></div>
+ <div class="search-help-row"><code>minsize:10</code> min file size (MB)</div>
+ <div class="search-help-row"><code>maxsize:500</code> max file size (MB)</div>
+ </div>
+ </div>
+</div>
+
<!-- Sidebar: hidden by default, toggled via keyboard or menu -->
<nav id="sidebar" class="sidebar" aria-label="Sets">
<div class="sidebar-header">
@@ -40,22 +61,6 @@
<!-- Main page area -->
<div class="page" role="main">
<div class="main-content">
- <!-- Search bar: shown on '/' key -->
- <div id="search-bar" class="search-bar hidden" role="search">
- <span aria-hidden="true">๐Ÿ”Ž</span>
- <input id="search-input" type="search" placeholder="Search or filterโ€ฆ (press /)" autocomplete="off" aria-label="Search media">
- <button id="search-clear" class="icon-btn btn-sm" title="Clear">โœ•</button>
- <div id="search-help" class="search-help hidden">
- <div class="search-help-title">Search syntax</div>
- <div class="search-help-row"><code>min:30</code> minimum duration (seconds)</div>
- <div class="search-help-row"><code>tag:a,b</code> filter by tags</div>
- <div class="search-help-row"><code>like:1</code> liked items only</div>
- <div class="search-help-row"><code>type:video</code> / <code>type:audio</code> / <code>type:image</code></div>
- <div class="search-help-row"><code>sort:random</code> / <code>sort:name</code> / <code>sort:date</code> / <code>sort:duration</code></div>
- <div class="search-help-row"><code>minsize:10</code> min file size (MB)</div>
- <div class="search-help-row"><code>maxsize:500</code> max file size (MB)</div>
- </div>
- </div>
<div id="result-count" class="text-muted text-xs mt-1"></div>
<!-- Empty state hint: shown when no set is selected -->
@@ -95,9 +100,6 @@
<div class="time" id="time-total">0:00</div>
<button id="btn-prev" class="icon-btn" aria-label="Previous">โฎ</button>
<button id="btn-next" class="icon-btn" aria-label="Next">โญ</button>
- <button id="btn-zoom-in" class="icon-btn image-only hidden" aria-label="Zoom in">+</button>
- <button id="btn-zoom-out" class="icon-btn image-only hidden" aria-label="Zoom out">โˆ’</button>
- <button id="btn-slideshow" class="icon-btn image-only hidden" aria-label="Slideshow">โต</button>
<div class="volume-control video-only">
<button id="btn-mute" class="icon-btn" aria-label="Mute">๐Ÿ”Š</button>
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1" class="volume-slider" aria-label="Volume">
@@ -148,8 +150,6 @@
<div class="help-row"><span><kbd>Backspace</kbd></span><span>Go up one folder</span></div>
<div class="help-row"><span><kbd>r</kbd></span><span>Toggle shuffle</span></div>
<div class="help-row"><span><kbd>s</kbd></span><span>Share selected media</span></div>
- <div class="help-row"><span><kbd>+</kbd> <kbd>โˆ’</kbd></span><span>Zoom in / out (image viewer)</span></div>
- <div class="help-row"><span><kbd>Shift+S</kbd></span><span>Toggle slideshow (images)</span></div>
<div class="help-row"><span><kbd>c</kbd></span><span>Toggle crop mode (fullscreen)</span></div>
<div class="help-row"><span><kbd>Shift+โ†‘</kbd> <kbd>Shift+โ†“</kbd> <kbd>Shift+โ†</kbd> <kbd>Shift+โ†’</kbd></span><span>Nudge crop position</span></div>
<div class="help-row"><span><kbd>x</kbd></span><span>Cycle crop anchor preset</span></div>
@@ -157,7 +157,7 @@
<div class="help-row"><span><kbd>?</kbd></span><span>Show / hide help</span></div>
</div>
<div class="help-search text-muted text-xs mt-2">
- Search: plain text searches file name. Syntax: <code>min:30</code> <code>max:55</code> <code>tag:a,b</code> <code>like:1</code> <code>type:video</code> <code>sort:random</code> <code>minsize:10</code> <code>maxsize:500</code>
+ Search: plain text searches file name. Syntax: <code>set:yoga</code> <code>min:30</code> <code>max:55</code> <code>tag:a,b</code> <code>like:1</code> <code>type:video</code> <code>sort:random</code> <code>minsize:10</code> <code>maxsize:500</code>
</div>
<div class="help-shares text-muted text-xs mt-2">My Shares: <kbd>โ†‘</kbd> / <kbd>โ†“</kbd> or <kbd>k</kbd> / <kbd>j</kbd> navigate, <kbd>Enter</kbd> copies, <kbd>Delete</kbd> revokes, <kbd>Esc</kbd> closes.</div>
</div>
@@ -270,7 +270,6 @@
<h4 class="my-1 text-90">Subscribe</h4>
<form id="podcast-form" class="mt-2">
<input id="podcast-url" type="url" placeholder="https://example.com/feed.xml" required autocomplete="off">
- <input id="podcast-name" type="text" placeholder="Folder name (optional)" autocomplete="off">
<button type="submit" class="btn btn-primary btn-sm mt-1">Subscribe</button>
</form>
</section>
@@ -300,22 +299,5 @@
</div>
</div>
-<!-- Image Lightbox -->
-<div id="image-lightbox" class="lightbox-overlay" role="dialog" aria-modal="true" aria-label="Image viewer">
- <button id="lb-close" class="icon-btn lightbox-close" aria-label="Close">โœ•</button>
- <button id="lb-prev" class="icon-btn lightbox-nav lightbox-prev" aria-label="Previous">โ€น</button>
- <button id="lb-next" class="icon-btn lightbox-nav lightbox-next" aria-label="Next">โ€บ</button>
- <div class="lightbox-stage">
- <img id="lightbox-image" alt="" draggable="false">
- </div>
- <div class="lightbox-toolbar">
- <button id="lb-zoom-in" class="icon-btn" aria-label="Zoom in">+</button>
- <button id="lb-zoom-out" class="icon-btn" aria-label="Zoom out">โˆ’</button>
- <button id="lb-slideshow" class="icon-btn" aria-label="Slideshow">โต</button>
- <span id="lb-counter" class="lightbox-counter"></span>
- <span id="lb-meta" class="lightbox-meta"></span>
- </div>
-</div>
-
</body>
</html>
diff --git a/web/js/api.js b/web/js/api.js
index 66fac59..9b0436e 100644
--- a/web/js/api.js
+++ b/web/js/api.js
@@ -44,6 +44,7 @@ export const API = {
login: (username, password) => api('/api/login', { method: 'POST', body: { username, password } }),
logout: () => api('/api/logout', { method: 'POST' }),
bootstrap: (username, password) => api('/api/bootstrap', { method: 'POST', body: { username, password } }),
+ config: () => api('/api/config'),
sets: () => api('/api/sets'),
media: (qs) => {
const params = new URLSearchParams();
diff --git a/web/js/app.js b/web/js/app.js
index b673d91..cfa43f1 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -17,7 +17,6 @@ import {
stopAndClose,
zoomIn as playerZoomIn,
zoomOut as playerZoomOut,
- toggleSlideshow as playerToggleSlideshow,
isImageMode as playerIsImageMode,
} from './player.js';
import { initSearch, parseQuery, showSearchHelp } from './search.js';
@@ -28,17 +27,6 @@ import { initAdmin } from './admin.js';
import { initPodcasts } from './podcasts.js';
import { state } from './state.js';
import { initPWA } from './pwa.js';
-import {
- initLightbox,
- open as openLightbox,
- close as closeLightbox,
- isOpen as isLightboxOpen,
- next as lightboxNext,
- prev as lightboxPrev,
- zoomIn as lightboxZoomIn,
- zoomOut as lightboxZoomOut,
- toggleSlideshow as lightboxToggleSlideshow,
-} from './lightbox.js';
import { closeAllModals } from './dom.js';
import { toast } from './utils.js';
import { showAdmin } from './views/admin-status.js';
@@ -48,6 +36,7 @@ import {
loadMedia,
enterFolder,
navigateBack,
+ setMediaPageSize,
} from './views/media-grid.js';
import {
downloadSelected,
@@ -74,8 +63,10 @@ import {
import {
initSets,
renderSets,
+ selectSetByHotkey,
setSetByDelta,
toggleSetSelection,
+ updateSetRowsUI,
} from './views/sets.js';
import {
copySelectedShare,
@@ -153,30 +144,34 @@ function initBootstrap() {
async function initApp() {
initPlayer({
- onNext: (options) => navigatePlayable(1, options),
- onPrevious: (options) => navigatePlayable(-1, options),
+ onNext: (options) => navigatePlayable(options?.delta ?? 1, options),
+ onPrevious: (options) => navigatePlayable(options?.delta ?? -1, options),
});
initSelection();
initSearch({
onChange: (q) => {
const parsed = parseQuery(q);
+ applySearchSet(parsed);
+ delete parsed.set;
Object.assign(state.filters, parsed);
state.folderPath = '';
+ document.dispatchEvent(new CustomEvent('filters:changed'));
console.log('[search] raw:', q, 'parsed:', parsed, 'filters:', state.filters);
loadMedia();
},
input: document.getElementById('search-input'),
clearBtn: document.getElementById('search-clear'),
});
+ document.addEventListener('search:navigate-results', () => navDown());
initShuffle({ onChange: () => loadMedia() });
- initLightbox({ onNavigate: () => {} });
initPlaybackNav({ isShuffle });
initSets({ onLoadMedia: loadMedia });
initMediaGrid({
isShuffle,
- openLightbox,
+ onSetCleared: updateSetRowsUI,
openNotesForSelected,
openTagsForElement,
+ openSet,
playSelected,
regenThumb,
toggleFavorite,
@@ -190,22 +185,45 @@ async function initApp() {
initHelp();
initShares();
initMediaInfo();
- initTags();
+ initTags({
+ onFilterChange: () => {
+ document.dispatchEvent(new CustomEvent('filters:changed'));
+ loadMedia();
+ },
+ });
initChrome();
try {
- const sets = await API.sets();
- state.sets = sets || [];
+ const [cfg, sets] = await Promise.all([
+ API.config().catch(() => null),
+ API.sets(),
+ ]);
+ setMediaPageSize(cfg?.media_page_size);
+ state.sets = (sets || []).slice().sort((a, b) => a.name.localeCompare(b.name));
API.users().then(() => {
state.isAdmin = true;
showAdmin();
}).catch(() => {});
renderSets();
+ await loadMedia();
} catch (err) {
toast(err.message || 'Error loading sets', 'error');
}
}
+function applySearchSet(parsed) {
+ const setQuery = parsed.set?.trim();
+ if (!setQuery) return;
+ const needle = setQuery.toLowerCase();
+ const match = state.sets.find((set) => String(set.id) === setQuery) ||
+ state.sets.find((set) => set.name.toLowerCase() === needle) ||
+ state.sets.find((set) => set.name.toLowerCase().includes(needle));
+ if (!match) return;
+ state.selectedSetId = match.id;
+ state.selectedSetIds = [match.id];
+ updateSetRowsUI();
+}
+
function keyboardHandlers() {
return {
navUp: () => navUp(),
@@ -221,6 +239,7 @@ function keyboardHandlers() {
},
nextSet: () => setSetByDelta(1),
prevSet: () => setSetByDelta(-1),
+ selectSetByHotkey: (key) => selectSetByHotkey(key),
isSidebarOpen: () => document.getElementById('sidebar')?.classList.contains('open'),
isSidebarFocused: () => {
const sidebar = document.getElementById('sidebar');
@@ -228,24 +247,11 @@ function keyboardHandlers() {
},
toggleSetSelect: () => toggleSetSelection(),
enter: () => {
- const el = currentElement();
- if (!el) return;
- selectByElement(el);
- if (el.classList.contains('folder-card')) {
- enterFolder(el.dataset.name);
- return;
- }
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media?.type === 'image') {
- openLightbox(state.media, media.id);
- } else {
- playSelected();
- }
+ activateGridElement(focusedGridElement() || currentElement() || firstGridElement());
},
playPause: () => togglePlay(),
- nextTrack: () => navigatePlayable(1),
- prevTrack: () => navigatePlayable(-1),
+ nextTrack: () => navigatePlayable(1, { forcePlay: true }),
+ prevTrack: () => navigatePlayable(-1, { forcePlay: true }),
playRandom: () => playRandom(),
mediaInfo: () => toggleMediaInfo(),
fullscreen: () => toggleFullscreen(),
@@ -255,7 +261,6 @@ function keyboardHandlers() {
cycleCropPosition: () => cycleCropPosition(),
escape: () => {
exitFullscreenIfNeeded();
- closeLightbox();
const el = currentElement();
if (el) el.classList.remove('selected');
closeAllModals();
@@ -289,26 +294,58 @@ function keyboardHandlers() {
sharesNavDown: () => sharesNav(1),
sharesCopy: copySelectedShare,
sharesDelete: deleteSelectedShare,
- isLightboxOpen,
isImageMode: () => currentMediaInfo()?.type === 'image',
+ imageFullscreenNavigate: (delta) => {
+ if (!document.fullscreenElement || !playerIsImageMode()) return false;
+ navigatePlayable(delta, { forcePlay: true });
+ return true;
+ },
zoomIn: () => {
- if (isLightboxOpen()) lightboxZoomIn();
- else if (playerIsImageMode()) playerZoomIn();
+ if (playerIsImageMode()) playerZoomIn();
},
zoomOut: () => {
- if (isLightboxOpen()) lightboxZoomOut();
- else if (playerIsImageMode()) playerZoomOut();
+ if (playerIsImageMode()) playerZoomOut();
},
- toggleSlideshow: () => {
- if (isLightboxOpen()) lightboxToggleSlideshow();
- else if (playerIsImageMode()) playerToggleSlideshow();
- },
- lightboxNext,
- lightboxPrev,
- closeLightbox,
};
}
+function focusedGridElement() {
+ const active = document.activeElement;
+ if (!active || typeof active.closest !== 'function') return null;
+ return active.closest('#media-grid .media-card, #media-grid .media-row, #media-grid .folder-card, #media-grid .set-card');
+}
+
+function firstGridElement() {
+ return document.querySelector('#media-grid .media-card, #media-grid .media-row, #media-grid .folder-card, #media-grid .set-card');
+}
+
+function activateGridElement(el) {
+ if (!el) return;
+ selectByElement(el);
+ if (el.classList.contains('set-card')) {
+ const id = parseInt(el.dataset.setId, 10);
+ openSet(id);
+ return;
+ }
+ if (el.classList.contains('folder-card')) {
+ enterFolder(el.dataset.name);
+ return;
+ }
+ const idx = parseInt(el.dataset.index, 10);
+ const media = state.media[idx];
+ if (media) playSelected();
+}
+
+function openSet(id) {
+ if (!Number.isFinite(id)) return;
+ state.selectedSetId = id;
+ state.selectedSetIds = [id];
+ state.folderPath = '';
+ state.mediaPage = 0;
+ updateSetRowsUI();
+ loadMedia();
+}
+
function initChrome() {
document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
toggleSidebar();
diff --git a/web/js/detach.js b/web/js/detach.js
index fbd1e1b..c216f3f 100644
--- a/web/js/detach.js
+++ b/web/js/detach.js
@@ -19,6 +19,8 @@ export function initDetach(options = {}) {
deps.triggerPrevious?.({ forcePlay: ev.data.play ?? true });
} else if (ev.data.type === 'detach-next') {
deps.triggerNext?.({ forcePlay: ev.data.play ?? true });
+ } else if (ev.data.type === 'detach-nav') {
+ deps.triggerNavigate?.(Number(ev.data.delta || 0), { forcePlay: ev.data.play ?? true });
}
});
}
diff --git a/web/js/detachPopup.js b/web/js/detachPopup.js
index b1dfd87..0efa701 100644
--- a/web/js/detachPopup.js
+++ b/web/js/detachPopup.js
@@ -49,14 +49,21 @@ document.addEventListener('keydown', (ev) => {
const editing = tag === 'INPUT' || tag === 'TEXTAREA' || ev.target?.isContentEditable;
if (editing || ev.ctrlKey || ev.metaKey || ev.altKey) return;
+ const imageDelta = fullscreenImageDelta(ev);
+ if (imageDelta) {
+ ev.preventDefault();
+ post({ type: 'detach-nav', delta: imageDelta, play: true });
+ return;
+ }
+
if (ev.shiftKey && ev.code === 'KeyN') {
ev.preventDefault();
- post({ type: 'detach-next', play: currentState().playing });
+ post({ type: 'detach-next', play: shouldContinuePlayback() });
return;
}
if (ev.shiftKey && ev.code === 'KeyP') {
ev.preventDefault();
- post({ type: 'detach-prev', play: currentState().playing });
+ post({ type: 'detach-prev', play: shouldContinuePlayback() });
return;
}
@@ -77,10 +84,10 @@ document.addEventListener('keydown', (ev) => {
togglePlayback();
} else if (ev.key === 'N') {
ev.preventDefault();
- post({ type: 'detach-next', play: currentState().playing });
+ post({ type: 'detach-next', play: shouldContinuePlayback() });
} else if (ev.key === 'P') {
ev.preventDefault();
- post({ type: 'detach-prev', play: currentState().playing });
+ post({ type: 'detach-prev', play: shouldContinuePlayback() });
}
});
@@ -121,7 +128,10 @@ function loadDetachedMedia(payload) {
loadMediaDirect(currentMedia, payload.streamUrl, payload.thumbnailUrl, resumeFrom);
const el = mediaElement();
- if (!el) return;
+ if (!el) {
+ sendState();
+ return;
+ }
if (typeof payload.volume === 'number') el.volume = payload.volume;
el.muted = !!payload.muted;
@@ -190,7 +200,33 @@ function toggleFullscreen() {
}
function mediaElement() {
- return currentMedia?.type === 'audio' ? audio : video;
+ if (currentMedia?.type === 'audio') return audio;
+ if (currentMedia?.type === 'video') return video;
+ return null;
+}
+
+function shouldContinuePlayback() {
+ return currentMedia?.type === 'image' || currentState().playing;
+}
+
+function fullscreenImageDelta(ev) {
+ if (currentMedia?.type !== 'image' || !document.fullscreenElement) return 0;
+ switch (ev.key) {
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ case 'h':
+ case 'k':
+ case 'PageUp':
+ return ev.key === 'PageUp' ? -10 : -1;
+ case 'ArrowRight':
+ case 'ArrowDown':
+ case 'j':
+ case 'l':
+ case 'PageDown':
+ return ev.key === 'PageDown' ? 10 : 1;
+ default:
+ return 0;
+ }
}
function seekWhenReady(el, seconds) {
@@ -248,7 +284,7 @@ function currentState() {
currentTime: position,
positionReady,
duration: el?.duration || currentMedia?.duration || 0,
- playing: !!el && !el.paused,
+ playing: currentMedia?.type === 'image' || (!!el && !el.paused),
volume: el