diff options
Diffstat (limited to 'web/js')
| -rw-r--r-- | web/js/app.js | 130 | ||||
| -rw-r--r-- | web/js/keyboard.js | 50 | ||||
| -rw-r--r-- | web/js/lightbox.js | 181 | ||||
| -rw-r--r-- | web/js/player.js | 207 |
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}">📋</button> <button class="icon-btn btn-sm" title="Revoke" data-revoke="${sh.token}">✕</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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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() { |
