summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-01 15:57:13 +0300
committerPaul Buetow <paul@buetow.org>2026-05-01 15:57:13 +0300
commit65ee2e4d7f2be8036457e3cdca0e26e09eb56e0b (patch)
treecbd8143db9e81baed51239a628dfedae2dbf375f /web
parent56666ca87f11e72521dcda00de72438e13899b95 (diff)
add readme and so on
Diffstat (limited to 'web')
-rw-r--r--web/css/layout.css31
-rw-r--r--web/css/player.css9
-rw-r--r--web/index.html5
-rw-r--r--web/js/app.js92
-rw-r--r--web/js/keyboard.js31
-rw-r--r--web/js/player.js20
-rw-r--r--web/js/selection.js70
-rw-r--r--web/js/state.js1
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,