summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-07 00:29:49 +0300
committerPaul Buetow <paul@buetow.org>2026-05-07 00:29:49 +0300
commit41b99351fb417eb43830c604d910333970bc191c (patch)
tree9c3997d53214bf873dd697db1781702ab79f1a78 /web
parenta0603095a35473a6479beb612bc2ed7b33298260 (diff)
b1 split player module
Diffstat (limited to 'web')
-rw-r--r--web/detach.html2
-rw-r--r--web/js/detach.js350
-rw-r--r--web/js/detachPopup.js260
-rw-r--r--web/js/imageViewer.js193
-rw-r--r--web/js/playback.js707
-rw-r--r--web/js/player.js977
-rw-r--r--web/sw.js7
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 => {