diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/bootstrap.html | 9 | ||||
| -rw-r--r-- | web/css/components.css | 65 | ||||
| -rw-r--r-- | web/css/layout.css | 46 | ||||
| -rw-r--r-- | web/css/login.css | 56 | ||||
| -rw-r--r-- | web/css/player.css | 2 | ||||
| -rw-r--r-- | web/detach.html | 1 | ||||
| -rw-r--r-- | web/index.html | 86 | ||||
| -rw-r--r-- | web/js/api.js | 1 | ||||
| -rw-r--r-- | web/js/app.js | 133 | ||||
| -rw-r--r-- | web/js/detach.js | 2 | ||||
| -rw-r--r-- | web/js/detachPopup.js | 50 | ||||
| -rw-r--r-- | web/js/keyboard.js | 40 | ||||
| -rw-r--r-- | web/js/playback.js | 36 | ||||
| -rw-r--r-- | web/js/podcasts.js | 24 | ||||
| -rw-r--r-- | web/js/search.js | 48 | ||||
| -rw-r--r-- | web/js/selection.js | 2 | ||||
| -rw-r--r-- | web/js/state.js | 1 | ||||
| -rw-r--r-- | web/js/tests/media-pagination.test.js | 56 | ||||
| -rw-r--r-- | web/js/tests/search-parser.test.js | 33 | ||||
| -rw-r--r-- | web/js/views/help.js | 2 | ||||
| -rw-r--r-- | web/js/views/media-grid.js | 243 | ||||
| -rw-r--r-- | web/js/views/playback-nav.js | 6 | ||||
| -rw-r--r-- | web/js/views/sets.js | 83 | ||||
| -rw-r--r-- | web/js/views/tags.js | 52 | ||||
| -rw-r--r-- | web/login.html | 6 | ||||
| -rw-r--r-- | web/sw.js | 4 |
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">▶</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 |
