summaryrefslogtreecommitdiff
path: root/web/js/app.js
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-02 22:50:26 +0300
committerPaul Buetow <paul@buetow.org>2026-05-02 22:50:26 +0300
commit0482a98c538cbe9ca32b4775bd897b9c728cf686 (patch)
tree226bcd3b3d9c6b8330cff531de40f5c137291879 /web/js/app.js
parentdaccc2fd2c43545fe2a904cccb1f866e38817a10 (diff)
add playback navigation shortcuts
Diffstat (limited to 'web/js/app.js')
-rw-r--r--web/js/app.js141
1 files changed, 139 insertions, 2 deletions
diff --git a/web/js/app.js b/web/js/app.js
index b2e79e9..194d9c3 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, navUp, navDown, navLeft, navRight } from './selection.js';
-import { initPlayer, togglePlay, toggleFullscreen, toggleStage, toggleMinimize, toggleDetach, exitFullscreenIfNeeded, currentMediaId, selectAndPlay, playPrevious, playNext } from './player.js';
+import { initPlayer, togglePlay, toggleFullscreen, toggleStage, toggleMinimize, toggleDetach, exitFullscreenIfNeeded, currentMediaId, hasLoadedMedia, isPlaybackActive, seekRelative, selectAndPlay } 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';
@@ -88,6 +88,8 @@ async function initApp() {
navDown: () => navDown(),
navLeft: () => navLeft(),
navRight: () => navRight(),
+ seekBackward: (e) => seekByKeyboard(-1, e.repeat),
+ seekForward: (e) => seekByKeyboard(1, e.repeat),
nextSet: () => setSetByDelta(1),
prevSet: () => setSetByDelta(-1),
isSidebarOpen: () => document.getElementById('sidebar')?.classList.contains('open'),
@@ -107,6 +109,9 @@ async function initApp() {
}
},
playPause: () => togglePlay(),
+ nextTrack: () => navigatePlayable(1),
+ prevTrack: () => navigatePlayable(-1),
+ mediaInfo: () => toggleMediaInfo(),
fullscreen: () => toggleFullscreen(),
toggleStage: () => toggleStage(),
toggleMinimize: () => toggleMinimize(),
@@ -138,6 +143,7 @@ async function initApp() {
initUpload();
initHelp();
initShares();
+ initMediaInfo();
// Filters
document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; state.folderPath = ''; loadMedia(); });
@@ -376,7 +382,7 @@ function renderBrowse(data) {
return;
}
const folderHtml = folders.map((f, i) => renderFolder(f, i)).join('');
- const mediaHtml = media.map((m, i) => renderItem(m, i + folders.length)).join('');
+ const mediaHtml = media.map((m, i) => renderItem(m, i)).join('');
grid.innerHTML = folderHtml + mediaHtml;
grid.querySelectorAll('.folder-card').forEach((el) => {
@@ -516,6 +522,130 @@ async function playSelected() {
}
}
+function visiblePlayableCards() {
+ return Array.from(document.querySelectorAll('#media-grid .media-card[data-id], #media-grid .media-row[data-id]'));
+}
+
+function selectedPlayableIndex(cards, preferCurrent) {
+ const currentID = String(currentMediaId() || '');
+ if (preferCurrent && currentID) {
+ const idx = cards.findIndex((el) => el.dataset.id === currentID);
+ if (idx >= 0) return idx;
+ }
+ const selected = currentElement();
+ const idx = selected ? cards.indexOf(selected) : -1;
+ return idx >= 0 ? idx : -1;
+}
+
+function navigatePlayable(delta) {
+ const cards = visiblePlayableCards();
+ if (!cards.length) return;
+ const active = isPlaybackActive();
+ const base = selectedPlayableIndex(cards, active);
+ const nextIdx = (base + delta + cards.length) % cards.length;
+ const target = cards[nextIdx];
+ selectByElement(target);
+ if (active) {
+ playSelected();
+ }
+}
+
+function seekByKeyboard(direction, repeated) {
+ if (!hasLoadedMedia()) return false;
+ const step = repeated ? 15 : 5;
+ return seekRelative(direction * step);
+}
+
+function initMediaInfo() {
+ const modal = document.getElementById('media-info-modal');
+ const closeBtn = document.getElementById('media-info-close');
+ closeBtn?.addEventListener('click', closeMediaInfo);
+ modal?.addEventListener('click', (e) => { if (e.target === modal) closeMediaInfo(); });
+}
+
+function closeMediaInfo() {
+ document.getElementById('media-info-modal')?.classList.remove('open');
+}
+
+async function toggleMediaInfo() {
+ const modal = document.getElementById('media-info-modal');
+ if (!modal) return;
+ if (modal.classList.contains('open')) {
+ closeMediaInfo();
+ return;
+ }
+ const id = selectedMediaId();
+ if (!id) {
+ toast('No media selected', 'error');
+ return;
+ }
+ await openMediaInfo(id);
+}
+
+function selectedMediaId() {
+ const el = currentElement();
+ if (el?.dataset?.id) return el.dataset.id;
+ const id = currentMediaId();
+ return id ? String(id) : '';
+}
+
+async function openMediaInfo(id) {
+ const modal = document.getElementById('media-info-modal');
+ const body = document.getElementById('media-info-body');
+ if (!modal || !body) return;
+ body.innerHTML = '<p class="text-muted text-sm">Loading...</p>';
+ modal.classList.add('open');
+ try {
+ const detail = await API.mediaDetail(id);
+ body.innerHTML = renderMediaInfo(detail);
+ } catch (err) {
+ body.innerHTML = `<p class="error-message">${escapeHtml(err.message || 'Failed to load media info')}</p>`;
+ }
+}
+
+function renderMediaInfo(detail) {
+ const media = detail?.media || {};
+ const tags = Array.isArray(detail?.tags) ? detail.tags.map((t) => t.name).filter(Boolean).join(', ') : '';
+ const ext = media.file_name?.includes('.') ? media.file_name.split('.').pop().toUpperCase() : '';
+ const progress = detail?.progress;
+ const note = detail?.note;
+ const rows = [
+ ['Title', media.file_name],
+ ['Format', ext],
+ ['Type', media.type],
+ ['Duration', media.duration ? `${fmtDur(media.duration)} (${Math.round(media.duration)} seconds)` : ''],
+ ['File size', media.file_size_bytes ? `${fmtSize(media.file_size_bytes)} (${media.file_size_bytes} bytes)` : ''],
+ ['Bitrate', media.bitrate ? `${Math.round(media.bitrate / 1000)} kbps (${media.bitrate} bps)` : ''],
+ ['Codec', media.codec],
+ ['Resolution', media.resolution],
+ ['Relative path', media.rel_path],
+ ['Absolute path', media.abs_path],
+ ['Media ID', media.id],
+ ['Set ID', media.set_id],
+ ['Play count', media.play_count],
+ ['Added', fmtDateTime(media.created_at)],
+ ['Thumbnail', media.thumbnail_path],
+ ['Favorite', detail?.favorite ? 'Yes' : 'No'],
+ ['Tags', tags],
+ ['Saved position', progress ? `${fmtDur(progress.position_seconds)} (${Math.round(progress.position_seconds || 0)} seconds)` : ''],
+ ['Progress updated', fmtDateTime(progress?.updated_at)],
+ ['Note updated', fmtDateTime(note?.updated_at)],
+ ['Note length', note?.content ? `${note.content.length} characters` : ''],
+ ];
+ const table = rows
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
+ .map(([label, value]) => `<tr><th scope="row">${escapeHtml(label)}</th><td>${escapeHtml(String(value))}</td></tr>`)
+ .join('');
+ const raw = escapeHtml(JSON.stringify(detail || {}, null, 2));
+ return `
+ <table class="media-info-table">${table}</table>
+ <details class="media-info-raw">
+ <summary>Raw API detail</summary>
+ <pre>${raw}</pre>
+ </details>
+ `;
+}
+
async function shareSelected() {
const el = currentElement();
if (!el) return;
@@ -863,6 +993,13 @@ function fmtDate(d) {
return dt.toLocaleDateString();
}
+function fmtDateTime(d) {
+ if (!d) return '';
+ const dt = typeof d === 'string' ? new Date(d) : d;
+ if (Number.isNaN(dt.getTime())) return '';
+ return dt.toLocaleString();
+}
+
function fmtDur(s) {
if (!s || s < 0) return '0:00';
const h = Math.floor(s / 3600);