diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-07 00:29:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-07 00:29:49 +0300 |
| commit | 41b99351fb417eb43830c604d910333970bc191c (patch) | |
| tree | 9c3997d53214bf873dd697db1781702ab79f1a78 /web | |
| parent | a0603095a35473a6479beb612bc2ed7b33298260 (diff) | |
b1 split player module
Diffstat (limited to 'web')
| -rw-r--r-- | web/detach.html | 2 | ||||
| -rw-r--r-- | web/js/detach.js | 350 | ||||
| -rw-r--r-- | web/js/detachPopup.js | 260 | ||||
| -rw-r--r-- | web/js/imageViewer.js | 193 | ||||
| -rw-r--r-- | web/js/playback.js | 707 | ||||
| -rw-r--r-- | web/js/player.js | 977 | ||||
| -rw-r--r-- | web/sw.js | 7 |
7 files changed, 1288 insertions, 1208 deletions
diff --git a/web/detach.html b/web/detach.html index 8d6af63..94dbc6c 100644 --- a/web/detach.html +++ b/web/detach.html @@ -8,7 +8,7 @@ <link rel="stylesheet" href="/css/layout.css"> <link rel="stylesheet" href="/css/components.css"> <link rel="stylesheet" href="/css/player.css"> -<script type="module" src="/js/detach.js"></script> +<script type="module" src="/js/detachPopup.js"></script> </head> <body class="detach-page"> <aside id="player" class="player open detached" aria-label="Detached player"> diff --git a/web/js/detach.js b/web/js/detach.js index b1dfd87..fbd1e1b 100644 --- a/web/js/detach.js +++ b/web/js/detach.js @@ -1,260 +1,150 @@ -import { initPlayer, loadMediaDirect } from './player.js'; - -const openerOrigin = window.location.origin; -let currentMedia = null; -let currentIndex = -1; -let progressInterval = null; -let lastStateSent = 0; -let lastKnownPosition = 0; - -initPlayer(); - -const video = document.getElementById('media-video'); -const audio = document.getElementById('media-audio'); -const btnPlay = document.getElementById('btn-play'); -const bigPlay = document.getElementById('big-play'); -const btnPrev = document.getElementById('btn-prev'); -const btnNext = document.getElementById('btn-next'); - -btnPrev?.addEventListener('click', () => post({ type: 'detach-prev' })); -btnNext?.addEventListener('click', () => post({ type: 'detach-next' })); -window.__playerDetachCurrentState = currentState; - -[video, audio].forEach((el) => { - el?.addEventListener('play', () => { - sendState(); - startProgress(); - }); - el?.addEventListener('pause', () => { - sendState(); - stopProgress(); - }); - el?.addEventListener('timeupdate', () => { - lastKnownPosition = el.currentTime || lastKnownPosition; - const now = Date.now(); - if (now - lastStateSent > 500) { - sendState(); - lastStateSent = now; +let deps = {}; +let detachWindow = null; +let detachReady = false; +let pendingDetachMessage = null; +let detachedPlaybackState = null; + +export function initDetach(options = {}) { + deps = options; + window.addEventListener('message', (ev) => { + if (ev.origin !== window.location.origin) return; + if (!ev.data || typeof ev.data !== 'object') return; + if (ev.data.type === 'detach-ready') { + onDetachReady(ev.source); + } else if (ev.data.type === 'detach-closing') { + onDetachClosing(ev.data.state); + } else if (ev.data.type === 'detach-state') { + detachedPlaybackState = mergeDetachedState(detachedPlaybackState, ev.data.state); + } else if (ev.data.type === 'detach-prev') { + deps.triggerPrevious?.({ forcePlay: ev.data.play ?? true }); + } else if (ev.data.type === 'detach-next') { + deps.triggerNext?.({ forcePlay: ev.data.play ?? true }); } }); - el?.addEventListener('seeked', () => { - lastKnownPosition = el.currentTime || 0; - sendState(); - }); - el?.addEventListener('ended', () => post({ type: 'detach-next' })); -}); - -document.addEventListener('keydown', (ev) => { - const tag = ev.target?.tagName; - const editing = tag === 'INPUT' || tag === 'TEXTAREA' || ev.target?.isContentEditable; - if (editing || ev.ctrlKey || ev.metaKey || ev.altKey) return; - - if (ev.shiftKey && ev.code === 'KeyN') { - ev.preventDefault(); - post({ type: 'detach-next', play: currentState().playing }); - return; - } - if (ev.shiftKey && ev.code === 'KeyP') { - ev.preventDefault(); - post({ type: 'detach-prev', play: currentState().playing }); - return; - } - - if (ev.key === ' ' || ev.code === 'Space') { - ev.preventDefault(); - togglePlayback(); - } else if (ev.key === 'f') { - ev.preventDefault(); - toggleFullscreen(); - } else if (ev.key === 'ArrowLeft' || ev.key === 'h') { - ev.preventDefault(); - seekRelative(ev.repeat ? -15 : -5); - } else if (ev.key === 'ArrowRight' || ev.key === 'l') { - ev.preventDefault(); - seekRelative(ev.repeat ? 15 : 5); - } else if (ev.key === 'p') { - ev.preventDefault(); - togglePlayback(); - } else if (ev.key === 'N') { - ev.preventDefault(); - post({ type: 'detach-next', play: currentState().playing }); - } else if (ev.key === 'P') { - ev.preventDefault(); - post({ type: 'detach-prev', play: currentState().playing }); - } -}); - -document.addEventListener('fullscreenchange', () => { - document.getElementById('player')?.classList.toggle('is-fullscreen', !!document.fullscreenElement); -}); - -window.addEventListener('message', (ev) => { - if (ev.origin !== openerOrigin || !ev.data || typeof ev.data !== 'object') return; - - switch (ev.data.type) { - case 'detach-load': - loadDetachedMedia(ev.data); - break; - case 'detach-command': - handleCommand(ev.data); - break; - case 'detach-request-state': - sendState(); - break; - } -}); - -window.addEventListener('beforeunload', () => { - sendState(); - post({ type: 'detach-closing', state: currentState() }); -}); - -post({ type: 'detach-ready' }); - -function loadDetachedMedia(payload) { - currentMedia = payload.media || null; - currentIndex = Number.isInteger(payload.index) ? payload.index : -1; - if (!currentMedia) return; - - const resumeFrom = Number(payload.resumeFrom || 0); - lastKnownPosition = resumeFrom; - loadMediaDirect(currentMedia, payload.streamUrl, payload.thumbnailUrl, resumeFrom); - - const el = mediaElement(); - if (!el) return; - if (typeof payload.volume === 'number') el.volume = payload.volume; - el.muted = !!payload.muted; - - seekWhenReady(el, resumeFrom); - if (payload.play) playWhenReady(el); - else showPlayPrompt(); - sendState(); -} - -function handleCommand(payload) { - const action = payload?.action; - if (action === 'toggle-play') { - togglePlayback(); - } else if (action === 'pause') { - const el = mediaElement(); - if (!el) return; - el.pause(); - } else if (action === 'play') { - const el = mediaElement(); - if (!el) return; - el.play().catch(() => {}); - } else if (action === 'seek-relative') { - seekRelative(Number(payload.seconds || 0)); - } else if (action === 'seek-percent') { - seekPercent(Number(payload.percent || 0)); - } -} - -function togglePlayback() { - const el = mediaElement(); - if (!el) return; - if (el.paused) el.play().catch(showPlayPrompt); - else el.pause(); } -function seekRelative(seconds) { - const el = mediaElement(); - if (!currentMedia || !el || !isFinite(seconds)) return; - const upper = el.duration && isFinite(el.duration) ? el.duration : Infinity; - el.currentTime = Math.max(0, Math.min(upper, (el.currentTime || 0) + seconds)); - lastKnownPosition = el.currentTime || 0; - sendState(); +export function isDetached() { + return !!detachWindow && !detachWindow.closed; } -function seekPercent(percent) { - const el = mediaElement(); - if (!currentMedia || !el || !isFinite(percent)) return; - const dur = (el.duration && isFinite(el.duration) && el.duration > 0) ? el.duration : (currentMedia?.duration || 0); - if (!dur) return; - const upper = el.duration && isFinite(el.duration) ? el.duration : Infinity; - el.currentTime = Math.max(0, Math.min(upper, (el.currentTime || 0) + dur * percent)); - lastKnownPosition = el.currentTime || 0; - sendState(); +export function detachedIsPlaying() { + return !!detachedPlaybackState?.playing; } -function toggleFullscreen() { - const player = document.getElementById('player'); - if (!player) return; - if (document.fullscreenElement) { - document.exitFullscreen().catch(() => {}); - player.classList.remove('is-fullscreen'); - } else { - player.requestFullscreen().catch(() => {}); - player.classList.add('is-fullscreen'); +export function toggleDetach() { + if (isDetached()) { + reattachDetached(); + return; + } + const e = deps.els?.() || {}; + if (!e.player) return; + + const snapshot = deps.localPlaybackState?.() || {}; + const features = 'width=640,height=480,resizable=yes,scrollbars=no,status=no,location=no,menubar=no,toolbar=no'; + detachWindow = window.open('/detach.html', 'playerDetach', features); + if (!detachWindow) return; + + detachReady = false; + detachedPlaybackState = snapshot; + const media = deps.getCurrentMedia?.(); + if (media) { + pendingDetachMessage = detachedLoadMessage(media, snapshot.currentTime, snapshot.playing); } + deps.currentMediaElement?.()?.pause(); + e.player.classList.add('hidden'); + showDetachedPlaceholder(true); } -function mediaElement() { - return currentMedia?.type === 'audio' ? audio : video; +export function sendDetachedLoad(media, resumeFrom = 0, play = true) { + const msg = detachedLoadMessage(media, resumeFrom, play); + pendingDetachMessage = msg; + postToDetach(msg); } -function seekWhenReady(el, seconds) { - if (!seconds || seconds < 0) return; - const seek = () => { - try { - el.currentTime = seconds; - } catch {} - }; - if (el.readyState >= HTMLMediaElement.HAVE_METADATA) seek(); - else el.addEventListener('loadedmetadata', seek, { once: true }); +export function postToDetach(message) { + if (!detachWindow || detachWindow.closed) return; + if (!detachReady && message.type !== 'detach-request-state') { + pendingDetachMessage = message; + return; + } + detachWindow.postMessage(message, window.location.origin); } -function playWhenReady(el) { - const play = () => el.play().catch(showPlayPrompt); - if (el.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) play(); - else el.addEventListener('canplay', play, { once: true }); +function onDetachReady(popup) { + if (!popup || popup !== detachWindow) return; + detachReady = true; + if (pendingDetachMessage) postToDetach(pendingDetachMessage); } -function showPlayPrompt() { - if (btnPlay) btnPlay.textContent = '\u25b6'; - bigPlay?.classList.remove('hidden'); +function onDetachClosing(state = null) { + if (state) detachedPlaybackState = mergeDetachedState(detachedPlaybackState, state); + if (!detachWindow) return; + reattachDetached({ closePopup: false }); } -function startProgress() { - stopProgress(); - progressInterval = setInterval(() => { - const el = mediaElement(); - if (!currentMedia || !el || el.paused) return; - fetch('/api/progress', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ media_id: currentMedia.id, position_seconds: el.currentTime }), - }).catch(() => {}); - }, 3000); +function reattachDetached({ closePopup = true } = {}) { + const popup = detachWindow; + const state = readDetachedWindowState(popup) || detachedPlaybackState; + detachWindow = null; + detachReady = false; + pendingDetachMessage = null; + if (closePopup && popup && !popup.closed) { + popup.postMessage({ type: 'detach-request-state' }, window.location.origin); + popup.close(); + } + showDetachedPlaceholder(false); + deps.els?.().player?.classList.remove('hidden'); + + if (state?.media) { + deps.setCurrentMediaState?.(state.media, Number.isInteger(state.index) ? state.index : undefined); + deps.loadMedia?.(state.media, state.currentTime || 0); + const m = deps.currentMediaElement?.(); + if (m) { + if (typeof state.volume === 'number') m.volume = state.volume; + m.muted = !!state.muted; + if (state.playing) m.play().catch(() => {}); + } + } } -function stopProgress() { - clearInterval(progressInterval); - progressInterval = null; +function readDetachedWindowState(popup) { + if (!popup || popup.closed) return null; + try { + if (typeof popup.__playerDetachCurrentState === 'function') { + return popup.__playerDetachCurrentState(); + } + } catch {} + return null; } -function sendState() { - post({ type: 'detach-state', state: currentState() }); +function mergeDetachedState(previous, next) { + if (!next) return previous; + if (!previous?.media || !next.media || previous.media.id !== next.media.id) return next; + const prevTime = Number(previous.currentTime || 0); + const nextTime = Number(next.currentTime || 0); + if (prevTime > 0 && nextTime === 0 && next.positionReady !== true) { + return { ...next, currentTime: prevTime }; + } + return next; } -function currentState() { - const el = mediaElement(); - const positionReady = !el || el.readyState >= HTMLMediaElement.HAVE_METADATA; - const position = positionReady ? (el?.currentTime || 0) : (lastKnownPosition || 0); +function detachedLoadMessage(media, resumeFrom = 0, play = true) { + const local = deps.localPlaybackState?.() || {}; return { - media: currentMedia, - index: currentIndex, - currentTime: position, - positionReady, - duration: el?.duration || currentMedia?.duration || 0, - playing: !!el && !el.paused, - volume: el?.volume ?? 1, - muted: !!el?.muted, + type: 'detach-load', + media, + index: deps.getCurrentMediaIndex?.() ?? -1, + streamUrl: `/api/media/${media.id}/stream`, + thumbnailUrl: media.thumbnail_path ? `/api/media/${media.id}/thumbnail` : '', + resumeFrom, + play, + volume: detachedPlaybackState?.volume ?? local.volume, + muted: detachedPlaybackState?.muted ?? local.muted, }; } -function post(message) { - if (!window.opener || window.opener.closed) return; - window.opener.postMessage(message, openerOrigin); +function showDetachedPlaceholder(show) { + const bar = document.getElementById('detached-bar'); + if (!bar) return; + bar.classList.toggle('hidden', !show); } diff --git a/web/js/detachPopup.js b/web/js/detachPopup.js new file mode 100644 index 0000000..b1dfd87 --- /dev/null +++ b/web/js/detachPopup.js @@ -0,0 +1,260 @@ +import { initPlayer, loadMediaDirect } from './player.js'; + +const openerOrigin = window.location.origin; +let currentMedia = null; +let currentIndex = -1; +let progressInterval = null; +let lastStateSent = 0; +let lastKnownPosition = 0; + +initPlayer(); + +const video = document.getElementById('media-video'); +const audio = document.getElementById('media-audio'); +const btnPlay = document.getElementById('btn-play'); +const bigPlay = document.getElementById('big-play'); +const btnPrev = document.getElementById('btn-prev'); +const btnNext = document.getElementById('btn-next'); + +btnPrev?.addEventListener('click', () => post({ type: 'detach-prev' })); +btnNext?.addEventListener('click', () => post({ type: 'detach-next' })); +window.__playerDetachCurrentState = currentState; + +[video, audio].forEach((el) => { + el?.addEventListener('play', () => { + sendState(); + startProgress(); + }); + el?.addEventListener('pause', () => { + sendState(); + stopProgress(); + }); + el?.addEventListener('timeupdate', () => { + lastKnownPosition = el.currentTime || lastKnownPosition; + const now = Date.now(); + if (now - lastStateSent > 500) { + sendState(); + lastStateSent = now; + } + }); + el?.addEventListener('seeked', () => { + lastKnownPosition = el.currentTime || 0; + sendState(); + }); + el?.addEventListener('ended', () => post({ type: 'detach-next' })); +}); + +document.addEventListener('keydown', (ev) => { + const tag = ev.target?.tagName; + const editing = tag === 'INPUT' || tag === 'TEXTAREA' || ev.target?.isContentEditable; + if (editing || ev.ctrlKey || ev.metaKey || ev.altKey) return; + + if (ev.shiftKey && ev.code === 'KeyN') { + ev.preventDefault(); + post({ type: 'detach-next', play: currentState().playing }); + return; + } + if (ev.shiftKey && ev.code === 'KeyP') { + ev.preventDefault(); + post({ type: 'detach-prev', play: currentState().playing }); + return; + } + + if (ev.key === ' ' || ev.code === 'Space') { + ev.preventDefault(); + togglePlayback(); + } else if (ev.key === 'f') { + ev.preventDefault(); + toggleFullscreen(); + } else if (ev.key === 'ArrowLeft' || ev.key === 'h') { + ev.preventDefault(); + seekRelative(ev.repeat ? -15 : -5); + } else if (ev.key === 'ArrowRight' || ev.key === 'l') { + ev.preventDefault(); + seekRelative(ev.repeat ? 15 : 5); + } else if (ev.key === 'p') { + ev.preventDefault(); + togglePlayback(); + } else if (ev.key === 'N') { + ev.preventDefault(); + post({ type: 'detach-next', play: currentState().playing }); + } else if (ev.key === 'P') { + ev.preventDefault(); + post({ type: 'detach-prev', play: currentState().playing }); + } +}); + +document.addEventListener('fullscreenchange', () => { + document.getElementById('player')?.classList.toggle('is-fullscreen', !!document.fullscreenElement); +}); + +window.addEventListener('message', (ev) => { + if (ev.origin !== openerOrigin || !ev.data || typeof ev.data !== 'object') return; + + switch (ev.data.type) { + case 'detach-load': + loadDetachedMedia(ev.data); + break; + case 'detach-command': + handleCommand(ev.data); + break; + case 'detach-request-state': + sendState(); + break; + } +}); + +window.addEventListener('beforeunload', () => { + sendState(); + post({ type: 'detach-closing', state: currentState() }); +}); + +post({ type: 'detach-ready' }); + +function loadDetachedMedia(payload) { + currentMedia = payload.media || null; + currentIndex = Number.isInteger(payload.index) ? payload.index : -1; + if (!currentMedia) return; + + const resumeFrom = Number(payload.resumeFrom || 0); + lastKnownPosition = resumeFrom; + loadMediaDirect(currentMedia, payload.streamUrl, payload.thumbnailUrl, resumeFrom); + + const el = mediaElement(); + if (!el) return; + if (typeof payload.volume === 'number') el.volume = payload.volume; + el.muted = !!payload.muted; + + seekWhenReady(el, resumeFrom); + if (payload.play) playWhenReady(el); + else showPlayPrompt(); + sendState(); +} + +function handleCommand(payload) { + const action = payload?.action; + if (action === 'toggle-play') { + togglePlayback(); + } else if (action === 'pause') { + const el = mediaElement(); + if (!el) return; + el.pause(); + } else if (action === 'play') { + const el = mediaElement(); + if (!el) return; + el.play().catch(() => {}); + } else if (action === 'seek-relative') { + seekRelative(Number(payload.seconds || 0)); + } else if (action === 'seek-percent') { + seekPercent(Number(payload.percent || 0)); + } +} + +function togglePlayback() { + const el = mediaElement(); + if (!el) return; + if (el.paused) el.play().catch(showPlayPrompt); + else el.pause(); +} + +function seekRelative(seconds) { + const el = mediaElement(); + if (!currentMedia || !el || !isFinite(seconds)) return; + const upper = el.duration && isFinite(el.duration) ? el.duration : Infinity; + el.currentTime = Math.max(0, Math.min(upper, (el.currentTime || 0) + seconds)); + lastKnownPosition = el.currentTime || 0; + sendState(); +} + +function seekPercent(percent) { + const el = mediaElement(); + if (!currentMedia || !el || !isFinite(percent)) return; + const dur = (el.duration && isFinite(el.duration) && el.duration > 0) ? el.duration : (currentMedia?.duration || 0); + if (!dur) return; + const upper = el.duration && isFinite(el.duration) ? el.duration : Infinity; + el.currentTime = Math.max(0, Math.min(upper, (el.currentTime || 0) + dur * percent)); + lastKnownPosition = el.currentTime || 0; + sendState(); +} + +function toggleFullscreen() { + const player = document.getElementById('player'); + if (!player) return; + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + player.classList.remove('is-fullscreen'); + } else { + player.requestFullscreen().catch(() => {}); + player.classList.add('is-fullscreen'); + } +} + +function mediaElement() { + return currentMedia?.type === 'audio' ? audio : video; +} + +function seekWhenReady(el, seconds) { + if (!seconds || seconds < 0) return; + const seek = () => { + try { + el.currentTime = seconds; + } catch {} + }; + if (el.readyState >= HTMLMediaElement.HAVE_METADATA) seek(); + else el.addEventListener('loadedmetadata', seek, { once: true }); +} + +function playWhenReady(el) { + const play = () => el.play().catch(showPlayPrompt); + if (el.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) play(); + else el.addEventListener('canplay', play, { once: true }); +} + +function showPlayPrompt() { + if (btnPlay) btnPlay.textContent = '\u25b6'; + bigPlay?.classList.remove('hidden'); +} + +function startProgress() { + stopProgress(); + progressInterval = setInterval(() => { + const el = mediaElement(); + if (!currentMedia || !el || el.paused) return; + fetch('/api/progress', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ media_id: currentMedia.id, position_seconds: el.currentTime }), + }).catch(() => {}); + }, 3000); +} + +function stopProgress() { + clearInterval(progressInterval); + progressInterval = null; +} + +function sendState() { + post({ type: 'detach-state', state: currentState() }); +} + +function currentState() { + const el = mediaElement(); + const positionReady = !el || el.readyState >= HTMLMediaElement.HAVE_METADATA; + const position = positionReady ? (el?.currentTime || 0) : (lastKnownPosition || 0); + return { + media: currentMedia, + index: currentIndex, + currentTime: position, + positionReady, + duration: el?.duration || currentMedia?.duration || 0, + playing: !!el && !el.paused, + volume: el?.volume ?? 1, + muted: !!el?.muted, + }; +} + +function post(message) { + if (!window.opener || window.opener.closed) return; + window.opener.postMessage(message, openerOrigin); +} diff --git a/web/js/imageViewer.js b/web/js/imageViewer.js new file mode 100644 index 0000000..09e1563 --- /dev/null +++ b/web/js/imageViewer.js @@ -0,0 +1,193 @@ +let els = () => ({}); +let isImageMode = () => false; +let playNext = () => {}; + +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 cropMode = false; +let cropPositionX = 50; +let cropPositionY = 50; +const CROP_PRESETS = [ + { x: 50, y: 50, label: 'C' }, + { x: 50, y: 0, label: 'T' }, + { x: 50, y: 100, label: 'B' }, + { x: 0, y: 50, label: 'L' }, + { x: 100, y: 50, label: 'R' }, +]; +let cropPresetIndex = 0; + +export function initImageViewer(options = {}) { + els = typeof options.els === 'function' ? options.els : els; + isImageMode = typeof options.isImageMode === 'function' ? options.isImageMode : isImageMode; + playNext = typeof options.playNext === 'function' ? options.playNext : playNext; + + const e = els(); + e.btnZoomIn?.addEventListener('click', () => { zoomIn(); pauseSlideshow(); }); + e.btnZoomOut?.addEventListener('click', () => { zoomOut(); pauseSlideshow(); }); + e.btnSlideshow?.addEventListener('click', toggleSlideshow); + + 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; + const image = els().image; + if (image) image.style.cursor = zoomScale > 1 ? 'grab' : 'zoom-in'; + } + }); +} + +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'; +} + +export 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(); +} + +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 = '⏸'; +} + +export function stopSlideshow() { + clearInterval(slideshowTimer); + slideshowTimer = null; + const btn = els().btnSlideshow; + if (btn) btn.textContent = '⏵'; +} + +export function pauseSlideshow() { + slideshowPausedUntil = Date.now() + 10000; +} + +export function toggleCrop() { + const e = els(); + if (!e.player) return; + if (!document.fullscreenElement) return; + cropMode = !cropMode; + e.player.classList.toggle('crop-mode', cropMode); + if (!cropMode) { + resetCropPosition(); + } else { + applyCropPosition(); + } +} + +function applyCropPosition() { + const e = els(); + if (!e.player) return; + e.player.style.setProperty('--crop-x', cropPositionX + '%'); + e.player.style.setProperty('--crop-y', cropPositionY + '%'); + updateCropIndicator(); +} + +function updateCropIndicator() { + const e = els(); + if (!e.cropIndicator) return; + if (!cropMode) { + e.cropIndicator.classList.add('hidden'); + return; + } + const snap = 5; + let label = ''; + if (Math.abs(cropPositionX - 50) <= snap && Math.abs(cropPositionY - 50) <= snap) label = 'C'; + else if (Math.abs(cropPositionX - 50) <= snap && cropPositionY <= snap) label = 'T'; + else if (Math.abs(cropPositionX - 50) <= snap && cropPositionY >= 95) label = 'B'; + else if (cropPositionX <= snap && Math.abs(cropPositionY - 50) <= snap) label = 'L'; + else if (cropPositionX >= 95 && Math.abs(cropPositionY - 50) <= snap) label = 'R'; + else label = Math.round(cropPositionX) + ',' + Math.round(cropPositionY); + e.cropIndicator.textContent = label; + e.cropIndicator.classList.remove('hidden'); +} + +export function resetCropPosition() { + cropPositionX = 50; + cropPositionY = 50; + cropPresetIndex = 0; + applyCropPosition(); +} + +export function clearCropMode() { + cropMode = false; + const e = els(); + e.player?.classList.remove('crop-mode'); + resetCropPosition(); +} + +export function shiftCropPosition(dx, dy) { + if (!cropMode) return false; + cropPositionX = Math.max(0, Math.min(100, cropPositionX + dx)); + cropPositionY = Math.max(0, Math.min(100, cropPositionY + dy)); + applyCropPosition(); + return true; +} + +export function cycleCropPosition() { + if (!cropMode) return; + cropPresetIndex = (cropPresetIndex + 1) % CROP_PRESETS.length; + const preset = CROP_PRESETS[cropPresetIndex]; + cropPositionX = preset.x; + cropPositionY = preset.y; + applyCropPosition(); +} diff --git a/web/js/playback.js b/web/js/playback.js new file mode 100644 index 0000000..b4c31d7 --- /dev/null +++ b/web/js/playback.js @@ -0,0 +1,707 @@ +import { API } from './api.js'; +import { state } from './state.js'; +import { + clearCropMode, + initImageViewer, + resetCropPosition, + resetImageZoom, + stopSlideshow, + toggleCrop, + shiftCropPosition, + cycleCropPosition, + zoomIn, + zoomOut, + resetZoom, + toggleSlideshow, + isSlideshowActive, +} from './imageViewer.js'; +import { + detachedIsPlaying, + initDetach, + isDetached, + postToDetach, + sendDetachedLoad, + toggleDetach, +} from './detach.js'; + +export { + cycleCropPosition, + isDetached, + isSlideshowActive, + resetZoom, + shiftCropPosition, + toggleCrop, + toggleDetach, + toggleSlideshow, + zoomIn, + zoomOut, +}; + +let currentMedia = null; +let isPlaying = false; +let progressInterval = null; +let currentMediaIndex = -1; +let reportProgress = true; +let nextHandler = null; +let previousHandler = null; + +function currentMediaElement() { + const e = els(); + if (currentMedia?.type === 'audio') return e.audio; + if (currentMedia?.type === 'video') return e.video; + return null; +} + +function setCurrentMediaState(media, index) { + currentMedia = media; + if (Number.isInteger(index)) currentMediaIndex = index; +} + +function effectiveDuration() { + const m = currentMediaElement(); + if (!m) return 0; + if (isFinite(m.duration) && m.duration > 0) return m.duration; + return currentMedia?.duration || 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-player'), + btnRestore: document.getElementById('btn-restore-player'), + restoreTitle: document.getElementById('player-restore-title'), + bigPlay: document.getElementById('big-play'), + coverArt: document.getElementById('cover-art'), + track: document.getElementById('progress-track'), + buffered: document.getElementById('progress-buffered'), + fill: document.getElementById('progress-fill'), + thumb: document.getElementById('progress-thumb'), + volume: document.getElementById('volume-slider'), + timeElapsed: document.getElementById('time-elapsed'), + timeTotal: document.getElementById('time-total'), + cropIndicator: document.getElementById('crop-indicator'), +}); + +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; + + e.btnPlay?.addEventListener('click', togglePlay); + e.btnPrev?.addEventListener('click', () => triggerPrevious({ forcePlay: true })); + e.btnNext?.addEventListener('click', () => triggerNext({ forcePlay: true })); + e.btnMute?.addEventListener('click', toggleMute); + e.btnFs?.addEventListener('click', toggleFullscreen); + e.btnMinimize?.addEventListener('click', minimizePlayer); + e.btnRestore?.addEventListener('click', toggleMinimize); + e.bigPlay?.addEventListener('click', togglePlay); + e.bigPlay?.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); togglePlay(); } }); + + initImageViewer({ els, isImageMode, playNext }); + initDetach({ + els, + currentMediaElement, + getCurrentMedia: () => currentMedia, + getCurrentMediaIndex: () => currentMediaIndex, + setCurrentMediaState, + loadMedia, + localPlaybackState, + triggerPrevious, + triggerNext, + }); + + e.volume?.addEventListener('input', () => { + const v = parseFloat(e.volume.value); + e.video.volume = v; + e.audio.volume = v; + e.video.muted = v === 0; + e.audio.muted = v === 0; + updateMuteIcon(); + }); + + [e.video, e.audio].forEach((m) => { + m.addEventListener('timeupdate', () => { + const dur = effectiveDuration(); + if (!dur) return; + const pct = (m.currentTime / dur) * 100; + e.fill.style.width = pct.toFixed(2) + '%'; + e.thumb.style.left = pct.toFixed(2) + '%'; + e.timeElapsed.textContent = fmt(m.currentTime); + updateBufferedRanges(m); + }); + m.addEventListener('loadedmetadata', () => { + const dur = effectiveDuration(); + e.timeTotal.textContent = fmt(dur); + updateBufferedRanges(m); + }); + ['progress', 'durationchange', 'loadeddata', 'canplay', 'seeked'].forEach((event) => { + m.addEventListener(event, () => updateBufferedRanges(m)); + }); + m.addEventListener('ended', () => { + updateUI(false); + isPlaying = false; + stopProgressTimer(); + triggerNext({ forcePlay: true }); + }); + m.addEventListener('play', () => { updateUI(true); isPlaying = true; startProgressTimer(); }); + m.addEventListener('pause', () => { updateUI(false); isPlaying = false; stopProgressTimer(); }); + }); + + e.video.addEventListener('loadedmetadata', updateFloatingSize); + + document.addEventListener('fullscreenchange', () => { + const p = e.player; + if (!p) return; + if (!document.fullscreenElement) { + clearCropMode(); + } + }); + + function setupVideoDebug(m) { + const events = ['loadstart','loadeddata','loadedmetadata','canplay','canplaythrough','playing','waiting','stalled','suspend','error','abort','emptied','ended']; + events.forEach(event => { |
