diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-01 15:57:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-01 15:57:13 +0300 |
| commit | 65ee2e4d7f2be8036457e3cdca0e26e09eb56e0b (patch) | |
| tree | cbd8143db9e81baed51239a628dfedae2dbf375f /web | |
| parent | 56666ca87f11e72521dcda00de72438e13899b95 (diff) | |
add readme and so on
Diffstat (limited to 'web')
| -rw-r--r-- | web/css/layout.css | 31 | ||||
| -rw-r--r-- | web/css/player.css | 9 | ||||
| -rw-r--r-- | web/index.html | 5 | ||||
| -rw-r--r-- | web/js/app.js | 92 | ||||
| -rw-r--r-- | web/js/keyboard.js | 31 | ||||
| -rw-r--r-- | web/js/player.js | 20 | ||||
| -rw-r--r-- | web/js/selection.js | 70 | ||||
| -rw-r--r-- | web/js/state.js | 1 |
8 files changed, 231 insertions, 28 deletions
diff --git a/web/css/layout.css b/web/css/layout.css index 006f874..7f0e19e 100644 --- a/web/css/layout.css +++ b/web/css/layout.css @@ -78,6 +78,12 @@ body { color: var(--accent); font-weight: 600; } +.sidebar .set-item.selected { + border: 2px solid var(--accent); + background: var(--bg-elevated); + color: var(--text-primary); + font-weight: 600; +} .sidebar-header { display: flex; align-items: center; @@ -176,9 +182,21 @@ body { } .media-card .card-actions { + position: absolute; + bottom: 0.35rem; + left: 0; + right: 0; display: flex; gap: 0.25rem; - padding: 0 0.5rem 0.5rem; + justify-content: center; + opacity: 0; + transition: opacity var(--transition-fast); + z-index: 2; +} +.media-card:hover .card-actions, +.media-card.selected .card-actions, +.media-card.playing .card-actions { + opacity: 1; } /* Audio row variant */ @@ -239,6 +257,17 @@ body { background: var(--bg-elevated); } +/* Hide row action buttons unless hovered/selected/playing */ +.media-row button[data-action] { + opacity: 0; + transition: opacity var(--transition-fast); +} +.media-row:hover button[data-action], +.media-row.selected button[data-action], +.media-row.playing button[data-action] { + opacity: 1; +} + /* Modal */ .modal-overlay { position: fixed; diff --git a/web/css/player.css b/web/css/player.css index 29baf30..004e2bd 100644 --- a/web/css/player.css +++ b/web/css/player.css @@ -53,6 +53,15 @@ max-height: 100%; } +/* Cover art for audio playback */ +.player .cover-art { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + background: var(--player-stage-bg); +} + /* Overlay controls */ .player .controls { display: flex; diff --git a/web/index.html b/web/index.html index 33478ad..6152814 100644 --- a/web/index.html +++ b/web/index.html @@ -73,13 +73,15 @@ <!-- Player: hidden until something plays --> <aside id="player" class="player" aria-label="Player"> <div class="stage"> - <video id="media-video" playsinline controlslist="nodownload"></video> + <video id="media-video" playsinline preload="none" controlslist="nodownload"></video> <audio id="media-audio" class="hidden"></audio> + <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> </div> </div> <div class="controls"> + <button id="btn-toggle-stage" class="icon-btn" aria-label="Toggle stage (c)">⛶</button> <button id="btn-play" class="icon-btn" aria-label="Play/Pause (Space/p)">▶</button> <div class="time" id="time-elapsed">0:00</div> <div id="progress-track" class="progress-track" tabindex="0" aria-label="Seek"> @@ -116,6 +118,7 @@ <tr><td><kbd>Enter</kbd></td><td>Open selected media</td></tr> <tr><td><kbd>Space</kbd> <kbd>p</kbd></td><td>Play / pause</td></tr> <tr><td><kbd>f</kbd></td><td>Toggle fullscreen</td></tr> + <tr><td><kbd>c</kbd></td><td>Collapse / expand player stage</td></tr> <tr><td><kbd>Esc</kbd></td><td>Exit fullscreen, deselect item</td></tr> <tr><td><kbd>r</kbd></td><td>Toggle shuffle</td></tr> <tr><td><kbd>s</kbd></td><td>Share selected media</td></tr> diff --git a/web/js/app.js b/web/js/app.js index e4150ad..8f8ca84 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -1,7 +1,7 @@ import { API } from './api.js'; import { initKeyboard } from './keyboard.js'; -import { initSelection, select, selectByElement, next, prev, currentIndex, currentElement } from './selection.js'; -import { initPlayer, togglePlay, toggleFullscreen, exitFullscreenIfNeeded, currentMediaId, selectAndPlay, playPrevious, playNext } from './player.js'; +import { initSelection, select, selectByElement, next, prev, currentIndex, currentElement, navUp, navDown, navLeft, navRight } from './selection.js'; +import { initPlayer, togglePlay, toggleFullscreen, toggleStage, exitFullscreenIfNeeded, currentMediaId, selectAndPlay, playPrevious, playNext } from './player.js'; import { initSearch, focusSearch, trigger as triggerSearch } from './search.js'; import { initShuffle, toggle as toggleShuffle, isOn as isShuffle } from './shuffle.js'; import { initThemes } from './themes.js'; @@ -82,16 +82,25 @@ async function initApp() { onChange: () => loadMedia(), }); initKeyboard({ - navUp: () => prev(), - navDown: () => next(), - navLeft: () => setSetByDelta(-1), - navRight: () => setSetByDelta(1), + navUp: () => navUp(), + navDown: () => navDown(), + navLeft: () => navLeft(), + navRight: () => navRight(), + nextSet: () => setSetByDelta(1), + prevSet: () => setSetByDelta(-1), + isSidebarOpen: () => document.getElementById('sidebar')?.classList.contains('open'), + isSidebarFocused: () => { + const sidebar = document.getElementById('sidebar'); + return sidebar?.contains(document.activeElement); + }, + toggleSetSelect: () => toggleSetSelection(), enter: () => { const el = currentElement(); if (el) { selectByElement(el); playSelected(); } }, playPause: () => togglePlay(), fullscreen: () => toggleFullscreen(), + toggleStage: () => toggleStage(), escape: () => { exitFullscreenIfNeeded(); const el = currentElement(); if (el) el.classList.remove('selected'); closeAllModals(); }, shuffle: () => { toggleShuffle(); loadMedia(); }, share: () => shareSelected(), @@ -164,16 +173,20 @@ function renderSets() { const el = document.getElementById('set-list'); if (!el) return; el.innerHTML = state.sets.map((s) => - `<div class="set-row"> - <a href="#" class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</a> + `<div class="set-row" tabindex="0" role="button" data-id="${s.id}" aria-label="Set ${escapeHtml(s.name)}"> + <span class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</span> <button class="icon-btn btn-sm" data-cover-set="${s.id}" title="Regenerate cover">🔄</button> </div>` ).join(''); - el.querySelectorAll('.set-item').forEach((a) => { - a.addEventListener('click', (e) => { + el.querySelectorAll('.set-row').forEach((row) => { + row.addEventListener('click', (e) => { + if (e.target.closest('[data-cover-set]')) return; e.preventDefault(); - state.selectedSetId = parseInt(a.dataset.id, 10); - setActiveSet(state.selectedSetId); + // Single-select on click + const id = parseInt(row.dataset.id, 10); + state.selectedSetIds = [id]; + state.selectedSetId = id; + updateSetRowsUI(); loadMedia(); }); }); @@ -185,6 +198,39 @@ function renderSets() { catch (err) { toast(err.message || 'Cover failed', 'error'); } }); }); + updateSetRowsUI(); +} + +function updateSetRowsUI() { + document.querySelectorAll('#set-list .set-row').forEach((row) => { + const id = parseInt(row.dataset.id, 10); + const item = row.querySelector('.set-item'); + if (!item) return; + item.classList.toggle('active', id === state.selectedSetId); + item.classList.toggle('selected', state.selectedSetIds.includes(id)); + }); +} + +function toggleSetSelection() { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) return; + const focused = document.activeElement.closest('.set-row'); + if (!focused) return; + const id = parseInt(focused.dataset.id, 10); + const idx = state.selectedSetIds.indexOf(id); + if (idx >= 0) { + state.selectedSetIds.splice(idx, 1); + } else { + state.selectedSetIds.push(id); + } + // Also sync primary selectedSetId for media loading + if (state.selectedSetIds.length) { + state.selectedSetId = state.selectedSetIds[state.selectedSetIds.length - 1]; + } else { + state.selectedSetId = null; + } + updateSetRowsUI(); + loadMedia(); } function setActiveSet(id) { @@ -197,6 +243,7 @@ function setSetByDelta(dx) { if (idx < 0) idx = 0; const nextIdx = (idx + dx + state.sets.length) % state.sets.length; state.selectedSetId = state.sets[nextIdx].id; + state.selectedSetIds = [state.selectedSetId]; setActiveSet(state.selectedSetId); loadMedia(); } @@ -209,8 +256,11 @@ async function loadMedia() { hint?.classList.add('hidden'); grid.innerHTML = '<p class="text-muted text-sm grid-full">Loading...</p>'; try { + const setIds = state.selectedSetIds.length > 1 ? state.selectedSetIds.join(',') : ''; + const singleSetId = state.selectedSetIds.length === 1 ? String(state.selectedSetIds[0]) : (state.selectedSetId ?? ''); const params = { - set_id: state.selectedSetId ?? '', + set_id: setIds ? '' : singleSetId, + set_ids: setIds, type: state.filters.type, search: state.filters.search, favorites: state.filters.favorites ? 'true' : '', @@ -262,19 +312,19 @@ function renderItem(m, index) { <div class="thumb-wrap"> ${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No image</span>`} <span class="badge">${fmtDur(m.duration)}${sizeText ? ' • ' + sizeText : ''}</span> + <div class="card-actions"> + <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button> + <button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button> + <button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button> + <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button> + <button class="icon-btn btn-sm" data-action="tags" title="Tags">🏷</button> + <button class="icon-btn btn-sm" data-action="regen-thumb" title="Regenerate thumbnail">🔄</button> + </div> </div> <div class="meta"> <div class="title">${escapeHtml(m.file_name)}</div> <div class="subtitle">${escapeHtml(m.codec || '')} ${m.resolution || ''} ${m.bitrate ? Math.round(m.bitrate / 1000) + 'kbps' : ''}</div> </div> - <div class="card-actions"> - <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button> - <button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button> - <button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button> - <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button> - <button class="icon-btn btn-sm" data-action="tags" title="Tags">🏷</button> - <button class="icon-btn btn-sm" data-action="regen-thumb" title="Regenerate thumbnail">🔄</button> - </div> </div> `; } diff --git a/web/js/keyboard.js b/web/js/keyboard.js index 9294623..de67208 100644 --- a/web/js/keyboard.js +++ b/web/js/keyboard.js @@ -9,6 +9,32 @@ export function initKeyboard(handlers) { } return; } + + // Tab / Shift+Tab set navigation when sidebar is open + if (e.key === 'Tab') { + if (handlers.isSidebarOpen?.()) { + e.preventDefault(); + if (e.shiftKey) { + handlers.prevSet?.(e); + } else { + handlers.nextSet?.(e); + } + } + return; + } + + // Space toggles focused set selection when sidebar is focused/open + if (e.key === ' ') { + if (handlers.isSidebarFocused?.()) { + e.preventDefault(); + handlers.toggleSetSelect?.(e); + return; + } + e.preventDefault(); + handlers.playPause?.(e); + return; + } + switch (e.key) { // Navigation case 'ArrowUp': @@ -41,11 +67,8 @@ export function initKeyboard(handlers) { handlers.navRight?.(e); break; case 'Enter': handlers.enter?.(e); break; - case ' ': - e.preventDefault(); - handlers.playPause?.(e); - break; case 'p': handlers.playPause?.(e); break; + case 'c': handlers.toggleStage?.(e); break; case 'f': handlers.fullscreen?.(e); break; case 'Escape': handlers.escape?.(e); break; case 'r': handlers.shuffle?.(e); break; diff --git a/web/js/player.js b/web/js/player.js index b61be08..002a781 100644 --- a/web/js/player.js +++ b/web/js/player.js @@ -16,7 +16,9 @@ const els = () => ({ btnNext: document.getElementById('btn-next'), btnMute: document.getElementById('btn-mute'), btnFs: document.getElementById('btn-fullscreen'), + btnToggleStage: document.getElementById('btn-toggle-stage'), bigPlay: document.getElementById('big-play'), + coverArt: document.getElementById('cover-art'), track: document.getElementById('progress-track'), fill: document.getElementById('progress-fill'), thumb: document.getElementById('progress-thumb'), @@ -34,6 +36,7 @@ export function initPlayer() { e.btnNext?.addEventListener('click', playNext); e.btnMute?.addEventListener('click', toggleMute); e.btnFs?.addEventListener('click', toggleFullscreen); + e.btnToggleStage?.addEventListener('click', toggleStage); e.bigPlay?.addEventListener('click', togglePlay); e.bigPlay?.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); togglePlay(); } }); @@ -120,15 +123,25 @@ function loadMedia(media, resumeFrom = 0) { if (isVideo) { e.video.style.display = ''; e.audio.style.display = 'none'; + e.coverArt?.classList.add('hidden'); e.audio.pause(); e.audio.src = ''; e.video.src = src; e.video.currentTime = resumeFrom; } else { e.video.style.display = 'none'; - e.audio.style.display = ''; + e.audio.style.display = 'none'; /* keep playing but invisible so cover-art fills stage */ e.video.pause(); e.video.src = ''; e.audio.src = src; e.audio.currentTime = resumeFrom; + if (e.coverArt) { + if (media.thumbnail_path) { + e.coverArt.src = `/api/media/${media.id}/thumbnail`; + e.coverArt.classList.remove('hidden'); + } else { + e.coverArt.classList.add('hidden'); + e.coverArt.src = ''; + } + } } e.player?.classList.add('open'); e.btnPlay.textContent = '⏸'; @@ -179,6 +192,11 @@ export function toggleFullscreen() { } } +export function toggleStage() { + const e = els(); + e.player?.classList.toggle('collapsed'); +} + export function exitFullscreenIfNeeded() { if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); diff --git a/web/js/selection.js b/web/js/selection.js index 00b91b8..c83fd81 100644 --- a/web/js/selection.js +++ b/web/js/selection.js @@ -14,6 +14,21 @@ function refreshCards() { return Array.from(document.querySelectorAll('.media-card, .media-row')); } +// Compute grid geometry from the rendered cards. +function gridGeometry(cards) { + if (!cards.length) return { cols: 0, rowMap: [] }; + const first = cards[0].getBoundingClientRect(); + let colCount = 0; + for (let i = 0; i < cards.length; i++) { + const r = cards[i].getBoundingClientRect(); + if (Math.round(r.top) !== Math.round(first.top)) break; + colCount++; + } + // rowMap[i] = row index of cards[i] + const rowMap = cards.map((_, i) => Math.floor(i / colCount)); + return { cols: colCount || 1, rowMap }; +} + export function select(index) { const cards = refreshCards(); if (!cards.length) { selectedIndex = -1; return; } @@ -39,5 +54,60 @@ export function next(step = 1) { } export function prev(step = 1) { next(-step); } + +// Grid-aware keyboard navigation +export function navLeft() { + const cards = refreshCards(); + if (!cards.length) return; + const { cols } = gridGeometry(cards); + if (cols === 0) return; + const row = Math.floor(selectedIndex / cols); + const col = selectedIndex % cols; + if (col > 0) { + select(selectedIndex - 1); + } else if (row > 0) { + // wrap to end of previous row + select(row * cols - 1); + } +} + +export function navRight() { + const cards = refreshCards(); + if (!cards.length) return; + const { cols } = gridGeometry(cards); + if (cols === 0) return; + const row = Math.floor(selectedIndex / cols); + const col = selectedIndex % cols; + const lastInRow = Math.min((row + 1) * cols, cards.length) - 1; + if (col < (lastInRow - row * cols)) { + select(selectedIndex + 1); + } else if (selectedIndex < cards.length - 1) { + // wrap to start of next row + select(Math.min((row + 1) * cols, cards.length - 1)); + } +} + +export function navUp() { + const cards = refreshCards(); + if (!cards.length) return; + const { cols } = gridGeometry(cards); + if (cols === 0) return; + const idxAbove = selectedIndex - cols; + if (idxAbove >= 0) { + select(idxAbove); + } +} + +export function navDown() { + const cards = refreshCards(); + if (!cards.length) return; + const { cols } = gridGeometry(cards); + if (cols === 0) return; + const idxBelow = selectedIndex + cols; + if (idxBelow < cards.length) { + select(idxBelow); + } +} + export function currentIndex() { return selectedIndex; } export function currentElement() { return refreshCards()[selectedIndex] ?? null; } diff --git a/web/js/state.js b/web/js/state.js index fca16a6..a0e715b 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -2,6 +2,7 @@ export const state = { user: null, sets: [], selectedSetId: null, + selectedSetIds: [], // multi-selection media: [], filters: { type: '', search: '', favorites: false, tags: '', sort: 'name', minDuration: '', maxDuration: '', minFilesizeMB: '', maxFilesizeMB: '' }, isAdmin: false, |
