summaryrefslogtreecommitdiff
path: root/web/js
diff options
context:
space:
mode:
Diffstat (limited to 'web/js')
-rw-r--r--web/js/app.js130
-rw-r--r--web/js/keyboard.js50
-rw-r--r--web/js/lightbox.js181
-rw-r--r--web/js/player.js207
4 files changed, 539 insertions, 29 deletions
diff --git a/web/js/app.js b/web/js/app.js
index f323531..4029825 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, clearSelection, select, selectByElement, next, prev, currentIndex, currentElement, navUp, navDown, navLeft, navRight } from './selection.js';
-import { initPlayer, togglePlay, toggleFullscreen, toggleMinimize, toggleDetach, exitFullscreenIfNeeded, currentMediaId, currentMediaInfo, hasLoadedMedia, isPlaybackActive, seekRelative, selectAndPlay } from './player.js';
+import { initPlayer, togglePlay, toggleFullscreen, toggleMinimize, toggleDetach, exitFullscreenIfNeeded, currentMediaId, currentMediaInfo, hasLoadedMedia, isPlaybackActive, seekRelative, selectAndPlay, zoomIn as playerZoomIn, zoomOut as playerZoomOut, toggleSlideshow as playerToggleSlideshow, isImageMode as playerIsImageMode } from './player.js';
import { initSearch, focusSearch, trigger as triggerSearch, parseQuery } from './search.js';
import { initShuffle, toggle as toggleShuffle, isOn as isShuffle } from './shuffle.js';
import { initThemes } from './themes.js';
@@ -9,6 +9,7 @@ import { initNotes, open as openNotes } from './notes.js';
import { initAdmin } from './admin.js';
import { state, setMedia } 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';
const pageMap = { '/index.html': 'spa', '/login.html': 'login', '/bootstrap.html': 'bootstrap' };
let scanProgressTimer = null;
@@ -88,6 +89,13 @@ async function initApp() {
initShuffle({
onChange: () => loadMedia(),
});
+ initLightbox({
+ onNavigate: (delta) => {
+ const images = state.media.filter((m) => m.type === 'image');
+ if (!images.length) return;
+ // Not needed - lightbox navigates internally
+ },
+ });
initKeyboard({
navUp: () => navUp(),
navDown: () => navDown(),
@@ -110,7 +118,13 @@ async function initApp() {
if (el.classList.contains('folder-card')) {
enterFolder(el.dataset.name);
} else {
- playSelected();
+ const idx = parseInt(el.dataset.index, 10);
+ const media = state.media[idx];
+ if (media?.type === 'image') {
+ openLightbox(state.media, media.id);
+ } else {
+ playSelected();
+ }
}
},
playPause: () => togglePlay(),
@@ -119,7 +133,7 @@ async function initApp() {
mediaInfo: () => toggleMediaInfo(),
fullscreen: () => toggleFullscreen(),
toggleMinimize: () => toggleMinimize(),
- escape: () => { exitFullscreenIfNeeded(); const el = currentElement(); if (el) el.classList.remove('selected'); closeAllModals(); },
+ escape: () => { exitFullscreenIfNeeded(); closeLightbox(); const el = currentElement(); if (el) el.classList.remove('selected'); closeAllModals(); },
shuffle: () => { toggleShuffle(); loadMedia(); },
share: () => shareSelected(),
search: () => showSearch(),
@@ -140,6 +154,23 @@ async function initApp() {
sharesNavDown: () => sharesNav(1),
sharesCopy: copySelectedShare,
sharesDelete: deleteSelectedShare,
+ isLightboxOpen,
+ isImageMode: () => currentMediaInfo()?.type === 'image',
+ zoomIn: () => {
+ if (isLightboxOpen()) { lightboxZoomIn(); }
+ else if (playerIsImageMode()) { playerZoomIn(); }
+ },
+ zoomOut: () => {
+ if (isLightboxOpen()) { lightboxZoomOut(); }
+ else if (playerIsImageMode()) { playerZoomOut(); }
+ },
+ toggleSlideshow: () => {
+ if (isLightboxOpen()) { lightboxToggleSlideshow(); }
+ else if (playerIsImageMode()) { playerToggleSlideshow(); }
+ },
+ lightboxNext,
+ lightboxPrev,
+ closeLightbox,
});
initNotes(() => toast('Note saved'));
initAdmin();
@@ -170,10 +201,18 @@ async function initApp() {
toast(err.message || 'Error loading sets', 'error');
}
- // Play on double-click
+ // Play on double-click (or open lightbox for images)
document.getElementById('media-grid')?.addEventListener('dblclick', (e) => {
const el = e.target.closest('.media-card, .media-row');
- if (el) { selectByElement(el); playSelected(); }
+ if (!el) return;
+ selectByElement(el);
+ const idx = parseInt(el.dataset.index, 10);
+ const media = state.media[idx];
+ if (media?.type === 'image') {
+ openLightbox(state.media, media.id);
+ } else {
+ playSelected();
+ }
});
// Folder navigation
@@ -336,7 +375,7 @@ function updateBreadcrumb(currentPath) {
let accumulated = '';
for (const part of parts) {
accumulated = accumulated ? `${accumulated}/${part}` : part;
- html += ` <span class="breadcrumb-sep">/</span> <button class="breadcrumb-part" data-folder="${accumulated}">${escapeHtml(part)}</button>`;
+ html += ` <span class="breadcrumb-sep">/</span> <button class="breadcrumb-part" data-folder="${escapeHtml(accumulated)}">${escapeHtml(part)}</button>`;
}
el.innerHTML = html;
el.classList.remove('hidden');
@@ -413,12 +452,20 @@ function renderBrowse(data) {
grid.querySelectorAll('.media-card, .media-row').forEach((el) => {
el.addEventListener('click', () => { selectByElement(el); });
const playBtn = el.querySelector('[data-action="play"]');
+ const viewBtn = el.querySelector('[data-action="view"]');
const favBtn = el.querySelector('[data-action="favorite"]');
const noteBtn = el.querySelector('[data-action="notes"]');
const downloadBtn = el.querySelector('[data-action="download"]');
const tagBtn = el.querySelector('[data-action="tags"]');
const thumbBtn = el.querySelector('[data-action="regen-thumb"]');
playBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); playSelected(); });
+ viewBtn?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ selectByElement(el);
+ const idx = parseInt(el.dataset.index, 10);
+ const media = state.media[idx];
+ if (media) openLightbox(state.media, media.id);
+ });
favBtn?.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(el.dataset.id, favBtn); });
noteBtn?.addEventListener('click', (e) => { e.stopPropagation(); openNotesForSelected(); });
downloadBtn?.addEventListener('click', (e) => { e.stopPropagation(); window.open(`/api/media/${el.dataset.id}/download`, '_blank'); });
@@ -458,12 +505,20 @@ function renderGrid(items) {
grid.querySelectorAll('.media-card, .media-row').forEach((el) => {
el.addEventListener('click', () => { selectByElement(el); });
const playBtn = el.querySelector('[data-action="play"]');
+ const viewBtn = el.querySelector('[data-action="view"]');
const favBtn = el.querySelector('[data-action="favorite"]');
const noteBtn = el.querySelector('[data-action="notes"]');
const downloadBtn = el.querySelector('[data-action="download"]');
const tagBtn = el.querySelector('[data-action="tags"]');
const thumbBtn = el.querySelector('[data-action="regen-thumb"]');
playBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); playSelected(); });
+ viewBtn?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ selectByElement(el);
+ const idx = parseInt(el.dataset.index, 10);
+ const media = state.media[idx];
+ if (media) openLightbox(state.media, media.id);
+ });
favBtn?.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(el.dataset.id, favBtn); });
noteBtn?.addEventListener('click', (e) => { e.stopPropagation(); openNotesForSelected(); });
downloadBtn?.addEventListener('click', (e) => { e.stopPropagation(); window.open(`/api/media/${el.dataset.id}/download`, '_blank'); });
@@ -491,7 +546,31 @@ function renderItem(m, index) {
</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 class="subtitle">${escapeHtml(m.codec || '')} ${escapeHtml(m.resolution || '')} ${m.bitrate ? Math.round(m.bitrate / 1000) + 'kbps' : ''}</div>
+ </div>
+ </div>
+ `;
+ }
+ if (m.type === 'image') {
+ const resText = escapeHtml(m.resolution || '');
+ const safeSizeText = escapeHtml(sizeText || '');
+ return `
+ <div class="media-card image-card" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
+ <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">${resText}${safeSizeText ? ' • ' + safeSizeText : ''}</span>
+ <div class="card-actions">
+ <button class="icon-btn btn-sm" data-action="view" title="View">👁</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">${resText} ${safeSizeText}</div>
</div>
</div>
`;
@@ -809,15 +888,11 @@ function renderMediaInfo(detail) {
const ext = media.file_name?.includes('.') ? media.file_name.split('.').pop().toUpperCase() : '';
const progress = detail?.progress;
const note = detail?.note;
- const rows = [
+ let 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],
@@ -827,11 +902,34 @@ function renderMediaInfo(detail) {
['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)],
+ ];
+ if (media.type === 'image') {
+ rows = rows.concat([
+ ['Dimensions', media.resolution],
+ ['Width', media.width],
+ ['Height', media.height],
+ ['Camera', media.exif_camera],
+ ['Lens', media.exif_lens],
+ ['Date Taken', media.exif_date],
+ ['ISO', media.exif_iso],
+ ['F-Number', media.exif_f_number],
+ ['Exposure', media.exif_exposure],
+ ['Focal Length', media.exif_focal_length],
+ ]);
+ } else {
+ rows = rows.concat([
+ ['Duration', media.duration ? `${fmtDur(media.duration)} (${Math.round(media.duration)} seconds)` : ''],
+ ['Bitrate', media.bitrate ? `${Math.round(media.bitrate / 1000)} kbps (${media.bitrate} bps)` : ''],
+ ['Codec', media.codec],
+ ['Resolution', media.resolution],
+ ['Saved position', progress ? `${fmtDur(progress.position_seconds)} (${Math.round(progress.position_seconds || 0)} seconds)` : ''],
+ ['Progress updated', fmtDateTime(progress?.updated_at)],
+ ]);
+ }
+ rows = rows.concat([
['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>`)
@@ -1109,7 +1207,7 @@ function renderSharesList(shares) {
return `
<div class="share-row flex gap-2 align-center py-1 border-b${i === 0 ? ' selected' : ''}" data-index="${i}" tabindex="0">
<span class="flex-1 text-sm truncate">${escapeHtml(sh.file_name || 'Unknown')}</span>
- <span class="text-xs text-muted">${sh.media_type === 'video' ? '🎬' : '🎵'} ${expired ? '<span class="text-danger">Expired</span>' : fmtDate(expires)}</span>
+ <span class="text-xs text-muted">${sh.media_type === 'video' ? '🎬' : sh.media_type === 'image' ? '🖼️' : '🎵'} ${expired ? '<span class="text-danger">Expired</span>' : fmtDate(expires)}</span>
<button class="icon-btn btn-sm" title="Copy link" data-copy="${url}">&#128203;</button>
<button class="icon-btn btn-sm" title="Revoke" data-revoke="${sh.token}">&#10005;</button>
</div>
diff --git a/web/js/keyboard.js b/web/js/keyboard.js
index 8623a10..951e573 100644
--- a/web/js/keyboard.js
+++ b/web/js/keyboard.js
@@ -3,6 +3,42 @@ export function initKeyboard(handlers) {
const tag = e.target.tagName;
const editing = tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable;
+ // Lightbox keyboard navigation (overrides global keys while open)
+ if (handlers.isLightboxOpen?.()) {
+ if (e.shiftKey && e.code === 'KeyS') {
+ e.preventDefault();
+ handlers.toggleSlideshow?.(e);
+ return;
+ }
+ switch (e.key) {
+ case 'ArrowLeft':
+ case 'h':
+ e.preventDefault();
+ handlers.lightboxPrev?.(e);
+ return;
+ case 'ArrowRight':
+ case 'l':
+ e.preventDefault();
+ handlers.lightboxNext?.(e);
+ return;
+ case 'Escape':
+ e.preventDefault();
+ handlers.closeLightbox?.(e);
+ return;
+ case '+':
+ case '=':
+ e.preventDefault();
+ handlers.zoomIn?.(e);
+ return;
+ case '-':
+ e.preventDefault();
+ handlers.zoomOut?.(e);
+ return;
+ }
+ // Allow only Escape/Arrows/h/l/+/−/Shift+S inside the lightbox; ignore everything else
+ return;
+ }
+
// Shares modal keyboard navigation (overrides global keys while open)
if (handlers.isSharesOpen?.()) {
switch (e.key) {
@@ -101,8 +137,15 @@ export function initKeyboard(handlers) {
}
if (e.code === 'KeyS') {
e.preventDefault();
- if (e.shiftKey) handlers.share?.(e);
- else handlers.sidebar?.(e);
+ if (e.shiftKey) {
+ if (handlers.isImageMode?.()) {
+ handlers.toggleSlideshow?.(e);
+ } else {
+ handlers.share?.(e);
+ }
+ } else {
+ handlers.sidebar?.(e);
+ }
return;
}
@@ -174,8 +217,7 @@ export function initKeyboard(handlers) {
case 'Escape': handlers.escape?.(e); break;
case 'Backspace': handlers.backspace?.(e); break;
case 'r': handlers.shuffle?.(e); break;
- case 's': handlers.sidebar?.(e); break;
- case 'S': handlers.share?.(e); break;
+ // Note: 's' / 'S' are handled above by the e.code === 'KeyS' block
case '/':
e.preventDefault();
handlers.search?.(e);
diff --git a/web/js/lightbox.js b/web/js/lightbox.js
new file mode 100644
index 0000000..876f456
--- /dev/null
+++ b/web/js/lightbox.js
@@ -0,0 +1,181 @@
+let currentMediaList = [];
+let currentIndex = -1;
+let zoomScale = 1;
+let panX = 0;
+let panY = 0;
+let isPanning = false;
+let panStart = { x: 0, y: 0 };
+let slideshowTimer = null;
+let slideshowPausedUntil = 0;
+let onNavigateCallback = null;
+
+export function initLightbox({ onNavigate }) {
+ onNavigateCallback = onNavigate;
+
+ document.getElementById('lb-close')?.addEventListener('click', close);
+ document.getElementById('lb-prev')?.addEventListener('click', prev);
+ document.getElementById('lb-next')?.addEventListener('click', next);
+ document.getElementById('lb-zoom-in')?.addEventListener('click', zoomIn);
+ document.getElementById('lb-zoom-out')?.addEventListener('click', zoomOut);
+ document.getElementById('lb-slideshow')?.addEventListener('click', toggleSlideshow);
+
+ // Close on backdrop click (clicking outside the image/toolbar)
+ document.getElementById('image-lightbox')?.addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) close();
+ });
+
+ const img = document.getElementById('lightbox-image');
+ if (img) {
+ img.addEventListener('mousedown', (e) => {
+ if (zoomScale > 1) {
+ isPanning = true;
+ panStart = { x: e.clientX - panX, y: e.clientY - panY };
+ img.style.cursor = 'grabbing';
+ e.preventDefault();
+ }
+ });
+ img.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ if (e.deltaY < 0) zoomIn();
+ else zoomOut();
+ }, { passive: false });
+ }
+ window.addEventListener('mousemove', (e) => {
+ if (!isPanning) return;
+ panX = e.clientX - panStart.x;
+ panY = e.clientY - panStart.y;
+ applyTransform();
+ });
+ window.addEventListener('mouseup', () => {
+ if (isPanning) {
+ isPanning = false;
+ const img = document.getElementById('lightbox-image');
+ if (img) img.style.cursor = zoomScale > 1 ? 'grab' : 'zoom-in';
+ }
+ });
+}
+
+export function open(mediaArray, startMediaId) {
+ currentMediaList = mediaArray.filter((m) => m.type === 'image');
+ currentIndex = currentMediaList.findIndex((m) => m.id === startMediaId);
+ if (currentIndex === -1) currentIndex = 0;
+ render();
+ document.getElementById('image-lightbox')?.classList.add('open');
+}
+
+export function close() {
+ stopSlideshow();
+ document.getElementById('image-lightbox')?.classList.remove('open');
+ resetZoom();
+ currentMediaList = [];
+ currentIndex = -1;
+}
+
+export function isOpen() {
+ return document.getElementById('image-lightbox')?.classList.contains('open');
+}
+
+export function next() {
+ if (!currentMediaList.length) return;
+ currentIndex = (currentIndex + 1) % currentMediaList.length;
+ render();
+ pauseSlideshow();
+}
+
+export function prev() {
+ if (!currentMediaList.length) return;
+ currentIndex = (currentIndex - 1 + currentMediaList.length) % currentMediaList.length;
+ render();
+ pauseSlideshow();
+}
+
+export function zoomIn() {
+ setZoom(zoomScale * 1.25);
+ pauseSlideshow();
+}
+
+export function zoomOut() {
+ setZoom(zoomScale / 1.25);
+ pauseSlideshow();
+}
+
+export function toggleSlideshow() {
+ if (slideshowTimer) stopSlideshow();
+ else startSlideshow();
+}
+
+export function isSlideshowActive() {
+ return !!slideshowTimer;
+}
+
+function render() {
+ const media = currentMediaList[currentIndex];
+ const img = document.getElementById('lightbox-image');
+ const meta = document.getElementById('lb-meta');
+ const counter = document.getElementById('lb-counter');
+ if (!media || !img) return;
+ img.src = `/api/media/${media.id}/stream`;
+ resetZoom();
+ if (meta) {
+ const size = media.file_size_bytes ? fmtSize(media.file_size_bytes) : '';
+ const res = media.resolution || '';
+ meta.textContent = `${media.file_name}${res ? ' — ' + res : ''}${size ? ' — ' + size : ''}`;
+ }
+ if (counter) {
+ counter.textContent = `${currentIndex + 1} / ${currentMediaList.length}`;
+ }
+}
+
+function setZoom(s) {
+ zoomScale = Math.max(0.5, Math.min(s, 5));
+ applyTransform();
+}
+
+function applyTransform() {
+ const img = document.getElementById('lightbox-image');
+ if (!img) return;
+ img.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomScale})`;
+ img.style.cursor = zoomScale > 1 ? 'grab' : 'zoom-in';
+}
+
+function resetZoom() {
+ zoomScale = 1;
+ panX = 0;
+ panY = 0;
+ applyTransform();
+}
+
+function startSlideshow() {
+ if (slideshowTimer) clearInterval(slideshowTimer);
+ slideshowTimer = setInterval(() => {
+ if (Date.now() < slideshowPausedUntil) return;
+ if (!isOpen()) { stopSlideshow(); return; }
+ next();
+ }, 5000);
+ const btn = document.getElementById('lb-slideshow');
+ if (btn) btn.textContent = '⏸';
+}
+
+function stopSlideshow() {
+ clearInterval(slideshowTimer);
+ slideshowTimer = null;
+ const btn = document.getElementById('lb-slideshow');
+ if (btn) btn.textContent = '⏵';
+}
+
+function pauseSlideshow() {
+ slideshowPausedUntil = Date.now() + 10000;
+}
+
+function fmtSize(bytes) {
+ if (!bytes || bytes <= 0) return '';
+ const kb = bytes / 1024;
+ if (kb < 1024) return Math.round(kb) + ' KB';
+ const mb = kb / 1024;
+ if (mb < 1024) return Math.round(mb * 10) / 10 + ' MB';
+ return Math.round((mb / 1024) * 10) / 10 + ' GB';
+}
+
+function escapeHtml(s) {
+ return (s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+}
diff --git a/web/js/player.js b/web/js/player.js
index e1d53df..6da8954 100644
--- a/web/js/player.js
+++ b/web/js/player.js
@@ -13,13 +13,26 @@ let detachedPlaybackState = null;
let nextHandler = null;
let previousHandler = null;
+// Image viewer state
+let zoomScale = 1;
+let panX = 0;
+let panY = 0;
+let isPanning = false;
+let panStart = { x: 0, y: 0 };
+let slideshowTimer = null;
+let slideshowPausedUntil = 0;
+
const els = () => ({
video: document.getElementById('media-video'),
audio: document.getElementById('media-audio'),
player: document.getElementById('player'),
+ image: document.getElementById('media-image'),
btnPlay: document.getElementById('btn-play'),
btnPrev: document.getElementById('btn-prev'),
btnNext: document.getElementById('btn-next'),
+ btnZoomIn: document.getElementById('btn-zoom-in'),
+ btnZoomOut: document.getElementById('btn-zoom-out'),
+ btnSlideshow: document.getElementById('btn-slideshow'),
btnMute: document.getElementById('btn-mute'),
btnFs: document.getElementById('btn-fullscreen'),
btnMinimize: document.getElementById('btn-minimize'),
@@ -40,7 +53,7 @@ export function initPlayer(options = {}) {
nextHandler = typeof options.onNext === 'function' ? options.onNext : null;
previousHandler = typeof options.onPrevious === 'function' ? options.onPrevious : null;
const e = els();
- if (!e.video || !e.audio) return;
+ if (!e.video && !e.audio) return;
e.btnPlay?.addEventListener('click', togglePlay);
e.btnPrev?.addEventListener('click', () => triggerPrevious({ forcePlay: true }));
@@ -52,6 +65,38 @@ export function initPlayer(options = {}) {
e.bigPlay?.addEventListener('click', togglePlay);
e.bigPlay?.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); togglePlay(); } });
+ // Image controls
+ e.btnZoomIn?.addEventListener('click', () => { zoomIn(); pauseSlideshow(); });
+ e.btnZoomOut?.addEventListener('click', () => { zoomOut(); pauseSlideshow(); });
+ e.btnSlideshow?.addEventListener('click', toggleSlideshow);
+
+ // Pan support for image viewer
+ e.image?.addEventListener('mousedown', (ev) => {
+ if (zoomScale > 1) {
+ isPanning = true;
+ panStart = { x: ev.clientX - panX, y: ev.clientY - panY };
+ e.image.style.cursor = 'grabbing';
+ ev.preventDefault();
+ }
+ });
+ e.image?.addEventListener('wheel', (ev) => {
+ ev.preventDefault();
+ if (ev.deltaY < 0) zoomIn();
+ else zoomOut();
+ }, { passive: false });
+ window.addEventListener('mousemove', (ev) => {
+ if (!isPanning) return;
+ panX = ev.clientX - panStart.x;
+ panY = ev.clientY - panStart.y;
+ applyImageTransform();
+ });
+ window.addEventListener('mouseup', () => {
+ if (isPanning) {
+ isPanning = false;
+ if (e.image) e.image.style.cursor = zoomScale > 1 ? 'grab' : 'zoom-in';
+ }
+ });
+
e.volume?.addEventListener('input', () => {
const v = parseFloat(e.volume.value);
e.video.volume = v;
@@ -225,6 +270,11 @@ export function selectAndPlay(media, index, resumeFrom = 0) {
return;
}
loadMedia(media, resumeFrom);
+ if (media.type === 'image') {
+ isPlaying = false;
+ highlightPlayingCard();
+ return;
+ }
isPlaying = true;
const m = currentMediaElement();
if (m) {
@@ -241,22 +291,59 @@ export function loadMediaDirect(media, streamUrl, thumbnailUrl, resumeFrom = 0)
currentMediaIndex = -1;
reportProgress = false;
const e = els();
+ if (media.type === 'image') {
+ resetImageZoom();
+ e.video.pause(); e.video.src = '';
+ e.audio.pause(); e.audio.src = '';
+ e.video.classList.add('hidden');
+ e.audio.classList.add('hidden');
+ e.coverArt?.classList.add('hidden');
+ e.bigPlay?.classList.add('hidden');
+ e.track?.classList.add('hidden');
+ e.timeElapsed?.classList.add('hidden');
+ e.timeTotal?.classList.add('hidden');
+ e.btnPlay?.classList.add('hidden');
+ e.btnZoomIn?.classList.remove('hidden');
+ e.btnZoomOut?.classList.remove('hidden');
+ e.btnSlideshow?.classList.remove('hidden');
+ e.image.classList.remove('hidden');
+ e.image.src = streamUrl;
+ e.player?.classList.add('open', 'has-image');
+ updateMinimizedTitle();
+ return;
+ }
const isVideo = media.type === 'video';
const src = streamUrl;
+ e.video.classList.remove('hidden');
+ e.audio.classList.remove('hidden');
+ e.track?.classList.remove('hidden');
+ e.timeElapsed?.classList.remove('hidden');
+ e.timeTotal?.classList.remove('hidden');
+ e.btnPlay?.classList.remove('hidden');
+ e.image?.classList.add('hidden');
+ e.btnZoomIn?.classList.add('hidden');
+ e.btnZoomOut?.classList.add('hidden');
+ e.btnSlideshow?.classList.add('hidden');
+ e.player?.classList.remove('has-image');
+ e.player?.classList.add('open');
+ e.btnPlay.textContent = '⏸';
+ e.bigPlay?.classList.add('hidden');
if (isVideo) {
e.video.pause();
e.audio.pause(); e.audio.src = '';
- e.video.style.display = '';
- e.audio.style.display = 'none';
+ e.video.classList.remove('hidden');
+ e.audio.classList.add('hidden');
e.coverArt?.classList.add('hidden');
+ e.image?.classList.add('hidden');
e.video.src = src;
e.video.load();
seekWhenMetadataReady(e.video, resumeFrom);
} else {
e.audio.pause();
e.video.pause(); e.video.src = '';
- e.video.style.display = 'none';
- e.audio.style.display = 'none';
+ e.video.classList.add('hidden');
+ e.audio.classList.remove('hidden');
+ e.image?.classList.add('hidden');
e.audio.src = src;
seekWhenMetadataReady(e.audio, resumeFrom);
if (e.coverArt) {
@@ -270,6 +357,7 @@ export function loadMediaDirect(media, streamUrl, thumbnailUrl, resumeFrom = 0)
}
}
e.player?.classList.add('open');
+ e.player?.classList.remove('has-image');
e.btnPlay.textContent = '⏸';
e.bigPlay?.classList.add('hidden');
e.timeTotal.textContent = fmt(media.duration ?? 0);
@@ -283,8 +371,43 @@ export function loadMediaDirect(media, streamUrl, thumbnailUrl, resumeFrom = 0)
function loadMedia(media, resumeFrom = 0) {
reportProgress = true;
const e = els();
+ if (media.type === 'image') {
+ resetImageZoom();
+ e.video.pause(); e.video.src = '';
+ e.audio.pause(); e.audio.src = '';
+ e.video.classList.add('hidden');
+ e.audio.classList.add('hidden');
+ e.coverArt?.classList.add('hidden');
+ e.bigPlay?.classList.add('hidden');
+ e.track?.classList.add('hidden');
+ e.timeElapsed?.classList.add('hidden');
+ e.timeTotal?.classList.add('hidden');
+ e.btnPlay?.classList.add('hidden');
+ e.btnZoomIn?.classList.remove('hidden');
+ e.btnZoomOut?.classList.remove('hidden');
+ e.btnSlideshow?.classList.remove('hidden');
+ e.image.classList.remove('hidden');
+ e.image.src = `/api/media/${media.id}/stream`;
+ e.player?.classList.add('open', 'has-image');
+ updateMinimizedTitle();
+ return;
+ }
const isVideo = media.type === 'video';
const src = `/api/media/${media.id}/stream`;
+ e.video.classList.remove('hidden');
+ e.audio.classList.remove('hidden');
+ e.track?.classList.remove('hidden');
+ e.timeElapsed?.classList.remove('hidden');
+ e.timeTotal?.classList.remove('hidden');
+ e.btnPlay?.classList.remove('hidden');
+ e.image?.classList.add('hidden');
+ e.btnZoomIn?.classList.add('hidden');
+ e.btnZoomOut?.classList.add('hidden');
+ e.btnSlideshow?.classList.add('hidden');
+ e.player?.classList.remove('has-image');
+ e.player?.classList.add('open');
+ e.btnPlay.textContent = '⏸';
+ e.bigPlay?.classList.add('hidden');
if (isVideo) {
e.video.pause();
e.audio.pause(); e.audio.src = '';
@@ -311,9 +434,6 @@ function loadMedia(media, resumeFrom = 0) {
}
}
}
- e.player?.classList.add('open');
- e.btnPlay.textContent = '⏸';
- e.bigPlay?.classList.add('hidden');
e.timeTotal.textContent = fmt(media.duration ?? 0);
e.buffered && (e.buffered.style.background = 'transparent');
e.fill.style.width = '0%';
@@ -325,7 +445,8 @@ function loadMedia(media, resumeFrom = 0) {
function currentMediaElement() {
const e = els();
if (currentMedia?.type === 'audio') return e.audio;
- return e.video;
+ if (currentMedia?.type === 'video') return e.video;
+ return null;
}
function seekWhenMetadataReady(m, seconds) {
@@ -476,6 +597,74 @@ export function isPlaybackActive() {
return isPlaying;
}
+export function isImageMode() {
+ return currentMedia?.type === 'image';
+}
+
+// Image zoom helpers
+function setImageZoom(s) {
+ zoomScale = Math.max(0.5, Math.min(s, 5));
+ applyImageTransform();
+}
+
+function applyImageTransform() {
+ const e = els();
+ if (!e.image) return;
+ e.image.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomScale})`;
+ e.image.style.cursor = zoomScale > 1 ? 'grab' : 'zoom-in';
+}
+
+function resetImageZoom() {
+ zoomScale = 1;
+ panX = 0;
+ panY = 0;
+ applyImageTransform();
+}
+
+export function zoomIn() {
+ setImageZoom(zoomScale * 1.25);
+}
+
+export function zoomOut() {
+ setImageZoom(zoomScale / 1.25);
+}
+
+export function resetZoom() {
+ resetImageZoom();
+}
+
+// Slideshow helpers
+export function toggleSlideshow() {
+ if (slideshowTimer) stopSlideshow();
+ else startSlideshow();
+}
+
+export function isSlideshowActive() {
+ return !!slideshowTimer;
+}
+
+function startSlideshow() {
+ if (slideshowTimer) clearInterval(slideshowTimer);
+ slideshowTimer = setInterval(() => {
+ if (Date.now() < slideshowPausedUntil) return;
+ if (!isImageMode()) { stopSlideshow(); return; }
+ playNext();
+ }, 5000);
+ const btn = els().btnSlideshow;
+ if (btn) btn.textContent = '⏸';
+}
+
+function stopSlideshow() {
+ clearInterval(slideshowTimer);
+ slideshowTimer = null;
+ const btn = els().btnSlideshow;
+ if (btn) btn.textContent = '⏵';
+}
+
+function pauseSlideshow() {
+ slideshowPausedUntil = Date.now() + 10000;
+}
+
export function isDetached() { return !!detachWindow && !detachWindow.closed; }
export function toggleDetach() {