From 41b99351fb417eb43830c604d910333970bc191c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 7 May 2026 00:29:49 +0300 Subject: b1 split player module --- web/js/detach.js | 350 +++++++----------- web/js/detachPopup.js | 260 ++++++++++++++ web/js/imageViewer.js | 193 ++++++++++ web/js/playback.js | 707 ++++++++++++++++++++++++++++++++++++ web/js/player.js | 977 +------------------------------------------------- 5 files changed, 1281 insertions(+), 1206 deletions(-) create mode 100644 web/js/detachPopup.js create mode 100644 web/js/imageViewer.js create mode 100644 web/js/playback.js (limited to 'web/js') 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 => { + m.addEventListener(event, () => { + console.log('[video-debug]', event, + 'readyState=', m.readyState, + 'networkState=', m.networkState, + 'paused=', m.paused, + 'src=', m.src?.slice(-40), + 'error=', m.error?.code || 'none', + 'errorMsg=', m.error?.message || ''); + }); + }); + } + setupVideoDebug(e.video); + setupVideoDebug(e.audio); + + let seeking = false; + const seekToFraction = (frac) => { + const m = currentMediaElement(); + const dur = effectiveDuration(); + if (!m || !dur) return; + m.currentTime = Math.max(0, Math.min(1, frac)) * dur; + }; + const handlePointer = (ev) => { + const r = e.track.getBoundingClientRect(); + const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX; + return (clientX - r.left) / r.width; + }; + e.track?.addEventListener('click', (ev) => seekToFraction(handlePointer(ev))); + e.track?.addEventListener('touchstart', (ev) => { + seeking = true; + ev.preventDefault(); + seekToFraction(handlePointer(ev)); + }, { passive: false }); + e.track?.addEventListener('touchmove', (ev) => { + if (!seeking) return; + ev.preventDefault(); + seekToFraction(handlePointer(ev)); + }, { passive: false }); + e.track?.addEventListener('touchend', () => { seeking = false; }); + e.track?.addEventListener('keydown', (ev) => { + const m = currentMediaElement(); + const dur = effectiveDuration(); + if (!m || !dur) return; + if (ev.key === 'ArrowLeft') { ev.preventDefault(); m.currentTime = Math.max(0, m.currentTime - 5); } + if (ev.key === 'ArrowRight') { ev.preventDefault(); m.currentTime = Math.min(dur, m.currentTime + 5); } + }); +} + +function updateFloatingSize() { + const e = els(); + if (!e.player || !e.video) return; + const vw = e.video.videoWidth || 0; + const vh = e.video.videoHeight || 0; + let w, h; + if (vw > 0 && vh > 0) { + const aspect = vw / vh; + const base = 240; // px reference height for the floating player + h = Math.max(180, Math.min(340, base)); + w = Math.max(240, Math.min(480, Math.round(h * aspect))); + } else { + w = 320; + h = 240; + } + e.player.style.setProperty('--floating-w', w + 'px'); + e.player.style.setProperty('--floating-h', h + 'px'); +} + +function updateBufferedRanges(m) { + const e = els(); + if (!e.buffered || !m || !m.duration || !isFinite(m.duration)) { + if (e.buffered) e.buffered.style.background = 'transparent'; + return; + } + + const ranges = []; + for (let i = 0; i < m.buffered.length; i++) { + const start = Math.max(0, Math.min(100, (m.buffered.start(i) / m.duration) * 100)); + const end = Math.max(0, Math.min(100, (m.buffered.end(i) / m.duration) * 100)); + if (end > start) ranges.push([start, end]); + } + if (!ranges.length) { + e.buffered.style.background = 'transparent'; + return; + } + + const color = 'var(--player-progress-buffered)'; + const stops = ['transparent 0%']; + for (const [start, end] of ranges) { + stops.push(`transparent ${start.toFixed(2)}%`); + stops.push(`${color} ${start.toFixed(2)}%`); + stops.push(`${color} ${end.toFixed(2)}%`); + stops.push(`transparent ${end.toFixed(2)}%`); + } + stops.push('transparent 100%'); + e.buffered.style.background = `linear-gradient(to right, ${stops.join(', ')})`; +} + +export function togglePlay() { + if (isDetached()) { + postToDetach({ type: 'detach-command', action: 'toggle-play' }); + return; + } + const m = currentMediaElement(); + if (!m) return; + if (m.paused) { m.play().catch(() => {}); } else { m.pause(); } +} + +export function hasLoadedMedia() { + return !!currentMedia; +} + +export function seekPercent(deltaPercent) { + const dp = Number(deltaPercent); + if (!currentMedia || !isFinite(dp)) return false; + if (isDetached()) { + postToDetach({ type: 'detach-command', action: 'seek-percent', percent: dp }); + return true; + } + const m = currentMediaElement(); + if (!m) return false; + const dur = effectiveDuration(); + if (!dur) return false; + const upper = m.duration && isFinite(m.duration) ? m.duration : dur; + m.currentTime = Math.max(0, Math.min(upper, (m.currentTime || 0) + dur * dp)); + return true; +} + +export function seekRelative(seconds) { + const amount = Number(seconds); + if (!currentMedia || !isFinite(amount)) return false; + if (isDetached()) { + postToDetach({ type: 'detach-command', action: 'seek-relative', seconds: amount }); + return true; + } + const m = currentMediaElement(); + if (!m) return false; + const upper = m.duration && isFinite(m.duration) ? m.duration : Infinity; + m.currentTime = Math.max(0, Math.min(upper, (m.currentTime || 0) + amount)); + return true; +} + +export function stopAndClose() { + const m = currentMediaElement(); + if (m) m.pause(); + if (currentMedia?.type === 'image') { + stopSlideshow(); + resetImageZoom(); + } + exitFullscreenIfNeeded(); + const e = els(); + e.player?.classList.remove('open', 'has-image', 'minimized'); + e.video?.classList.add('hidden'); + e.audio?.classList.add('hidden'); + e.image?.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.add('hidden'); + e.btnZoomOut?.classList.add('hidden'); + e.btnSlideshow?.classList.add('hidden'); + currentMedia = null; + currentMediaIndex = -1; + isPlaying = false; + stopProgressTimer(); + updateUI(false); + highlightPlayingCard(); + if (e.fill) e.fill.style.width = '0%'; + if (e.thumb) e.thumb.style.left = '0%'; + if (e.timeElapsed) e.timeElapsed.textContent = '0:00'; + if (e.timeTotal) e.timeTotal.textContent = '0:00'; + if (e.buffered) e.buffered.style.background = 'transparent'; +} + +export function selectAndPlay(media, index, resumeFrom = 0) { + currentMedia = media; + currentMediaIndex = index ?? -1; + if (isDetached()) { + isPlaying = true; + stopProgressTimer(); + sendDetachedLoad(media, resumeFrom, true); + highlightPlayingCard(); + return; + } + loadMedia(media, resumeFrom); + if (media.type === 'image') { + isPlaying = false; + highlightPlayingCard(); + return; + } + isPlaying = true; + const m = currentMediaElement(); + if (m) { + console.log('selectAndPlay: type=', media.type, 'src=', m.src, 'readyState=', m.readyState); + m.play().catch((err) => { console.error('play() failed:', err); }); + } else { + console.error('selectAndPlay: no media element found for type', media.type); + } + highlightPlayingCard(); +} + +export function loadMediaDirect(media, streamUrl, thumbnailUrl, resumeFrom = 0) { + currentMedia = media; + 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.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.classList.add('hidden'); + e.audio.classList.remove('hidden'); + e.image?.classList.add('hidden'); + e.audio.src = src; + seekWhenMetadataReady(e.audio, resumeFrom); + if (e.coverArt) { + if (thumbnailUrl) { + e.coverArt.src = thumbnailUrl; + e.coverArt.classList.remove('hidden'); + } else { + e.coverArt.classList.add('hidden'); + e.coverArt.src = ''; + } + } + } + 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); + e.buffered && (e.buffered.style.background = 'transparent'); + e.fill.style.width = '0%'; + e.thumb.style.left = '0%'; + updateMinimizedTitle(); + updateFloatingSize(); +} + +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 = ''; + e.video.style.display = ''; + e.audio.style.display = 'none'; + e.coverArt?.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.audio.src = src; + seekWhenMetadataReady(e.audio, resumeFrom); + if (e.coverArt) { + if (media.thumbnail_path) { + e.coverArt.src = `/api/media/${media.id}/thumbnail`; + e.coverArt.classList.remove('hidden'); + } else { + e.coverArt.classList.add('hidden'); + e.coverArt.src = ''; + } + } + } + e.timeTotal.textContent = fmt(media.duration ?? 0); + e.buffered && (e.buffered.style.background = 'transparent'); + e.fill.style.width = '0%'; + e.thumb.style.left = '0%'; + updateMinimizedTitle(); + updateFloatingSize(); +} + +function seekWhenMetadataReady(m, seconds) { + const target = Number(seconds || 0); + if (!m || !isFinite(target) || target <= 0) return; + const seek = () => { + try { + m.currentTime = target; + } catch {} + }; + if (m.readyState >= HTMLMediaElement.HAVE_METADATA) seek(); + else m.addEventListener('loadedmetadata', seek, { once: true }); +} + +function updateUI(playing) { + const e = els(); + isPlaying = playing; + e.btnPlay.textContent = playing ? '⏸' : '▶'; + if (playing) e.bigPlay?.classList.add('hidden'); + else e.bigPlay?.classList.remove('hidden'); +} + +function toggleMute() { + const m = currentMediaElement(); + if (!m) return; + m.muted = !m.muted; + updateMuteIcon(); +} + +function updateMuteIcon() { + const m = currentMediaElement(); + const e = els(); + if (!m || !e.btnMute) return; + e.btnMute.textContent = m.muted || m.volume === 0 ? '🔇' : '🔊'; +} + +export function toggleFullscreen() { + const p = els().player; + if (!p) return; + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + p.classList.remove('is-fullscreen'); + } else { + p.requestFullscreen().catch(() => {}); + p.classList.add('is-fullscreen'); + } +} + +export function toggleMinimize() { + const e = els(); + if (!e.player || !e.player.classList.contains('open')) return; + const willMinimize = !e.player.classList.contains('minimized'); + e.player.classList.toggle('minimized'); + if (willMinimize) exitFullscreenIfNeeded(); + updateMinimizedTitle(); +} + +function minimizePlayer() { + const e = els(); + if (!e.player || !e.player.classList.contains('open')) return; + e.player.classList.add('minimized'); + exitFullscreenIfNeeded(); + updateMinimizedTitle(); +} + +function updateMinimizedTitle() { + const e = els(); + if (!e.restoreTitle) return; + e.restoreTitle.textContent = currentMedia?.file_name ? `Restore: ${currentMedia.file_name}` : 'Restore player'; +} + +export function exitFullscreenIfNeeded() { + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + const p = els().player; + if (p) p.classList.remove('is-fullscreen', 'crop-mode'); + resetCropPosition(); + } +} + +function startProgressTimer() { + stopProgressTimer(); + if (!reportProgress) return; + progressInterval = setInterval(() => { + const m = currentMediaElement(); + if (m && currentMedia && !m.paused) { + API.progress(currentMedia.id, m.currentTime).catch(() => {}); + } + }, 3000); +} + +function stopProgressTimer() { + clearInterval(progressInterval); + progressInterval = null; +} + +function highlightPlayingCard() { + document.querySelectorAll('.media-card, .media-row').forEach((c) => c.classList.remove('playing')); + if (currentMedia?.id) { + document.querySelector(`.media-card[data-id="${currentMedia.id}"], .media-row[data-id="${currentMedia.id}"]`)?.classList.add('playing'); + } +} + +function triggerPrevious(options = {}) { + if (previousHandler) { + previousHandler(options); + return; + } + playPrevious(); +} + +function triggerNext(options = {}) { + if (nextHandler) { + nextHandler(options); + return; + } + playNext(); +} + +export function playPrevious() { + const list = state.media; + if (!list.length) return; + const currentIdx = currentMediaListIndex(); + const idx = currentIdx > 0 ? currentIdx - 1 : list.length - 1; + selectAndPlay(list[idx], idx); +} + +export function playNext() { + const list = state.media; + if (!list.length) return; + const currentIdx = currentMediaListIndex(); + const idx = currentIdx >= 0 && currentIdx + 1 < list.length ? currentIdx + 1 : 0; + selectAndPlay(list[idx], idx); +} + +function currentMediaListIndex() { + if (currentMediaIndex >= 0 && state.media[currentMediaIndex]?.id === currentMedia?.id) { + return currentMediaIndex; + } + const idx = state.media.findIndex((m) => m.id === currentMedia?.id); + return idx >= 0 ? idx : currentMediaIndex; +} + +export function currentMediaId() { return currentMedia?.id; } + +export function currentMediaInfo() { return currentMedia; } + +export function isPlaybackActive() { + if (isDetached()) return detachedIsPlaying(); + return isPlaying; +} + +export function isImageMode() { + return currentMedia?.type === 'image'; +} + +function localPlaybackState() { + const m = currentMediaElement(); + return { + media: currentMedia, + index: currentMediaIndex, + currentTime: m?.currentTime || 0, + duration: m?.duration || currentMedia?.duration || 0, + playing: !!m && !m.paused, + volume: m?.volume ?? 1, + muted: !!m?.muted, + }; +} + +function fmt(s) { + if (!isFinite(s) || s < 0) return '0:00'; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = Math.floor(s % 60); + const mm = String(m).padStart(2, '0'); + const ss = String(sec).padStart(2, '0'); + return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; +} diff --git a/web/js/player.js b/web/js/player.js index ad44e14..4c2291d 100644 --- a/web/js/player.js +++ b/web/js/player.js @@ -1,976 +1 @@ -import { API } from './api.js'; -import { state } from './state.js'; - -let currentMedia = null; -let isPlaying = false; -let progressInterval = null; -let currentMediaIndex = -1; -let reportProgress = true; -let detachWindow = null; -let detachReady = false; -let pendingDetachMessage = null; -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; -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; - -function currentMediaElement() { - const e = els(); - if (currentMedia?.type === 'audio') return e.audio; - if (currentMedia?.type === 'video') return e.video; - return null; -} - -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(); } }); - - // 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; - 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) { - p.classList.remove('crop-mode'); - cropMode = false; - resetCropPosition(); - } - }); - - function setupVideoDebug(m) { - const events = ['loadstart','loadeddata','loadedmetadata','canplay','canplaythrough','playing','waiting','stalled','suspend','error','abort','emptied','ended']; - events.forEach(event => { - m.addEventListener(event, () => { - console.log('[video-debug]', event, - 'readyState=', m.readyState, - 'networkState=', m.networkState, - 'paused=', m.paused, - 'src=', m.src?.slice(-40), - 'error=', m.error?.code || 'none', - 'errorMsg=', m.error?.message || ''); - }); - }); - } - setupVideoDebug(e.video); - setupVideoDebug(e.audio); - - let seeking = false; - const seekToFraction = (frac) => { - const m = currentMediaElement(); - const dur = effectiveDuration(); - if (!m || !dur) return; - m.currentTime = Math.max(0, Math.min(1, frac)) * dur; - }; - const handlePointer = (ev) => { - const r = e.track.getBoundingClientRect(); - const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX; - return (clientX - r.left) / r.width; - }; - e.track?.addEventListener('click', (ev) => seekToFraction(handlePointer(ev))); - e.track?.addEventListener('touchstart', (ev) => { - seeking = true; - ev.preventDefault(); - seekToFraction(handlePointer(ev)); - }, { passive: false }); - e.track?.addEventListener('touchmove', (ev) => { - if (!seeking) return; - ev.preventDefault(); - seekToFraction(handlePointer(ev)); - }, { passive: false }); - e.track?.addEventListener('touchend', () => { seeking = false; }); - e.track?.addEventListener('keydown', (ev) => { - const m = currentMediaElement(); - const dur = effectiveDuration(); - if (!m || !dur) return; - if (ev.key === 'ArrowLeft') { ev.preventDefault(); m.currentTime = Math.max(0, m.currentTime - 5); } - if (ev.key === 'ArrowRight') { ev.preventDefault(); m.currentTime = Math.min(dur, m.currentTime + 5); } - }); -} - -function updateFloatingSize() { - const e = els(); - if (!e.player || !e.video) return; - const vw = e.video.videoWidth || 0; - const vh = e.video.videoHeight || 0; - let w, h; - if (vw > 0 && vh > 0) { - const aspect = vw / vh; - const base = 240; // px reference height for the floating player - h = Math.max(180, Math.min(340, base)); - w = Math.max(240, Math.min(480, Math.round(h * aspect))); - } else { - w = 320; - h = 240; - } - e.player.style.setProperty('--floating-w', w + 'px'); - e.player.style.setProperty('--floating-h', h + 'px'); -} - -function updateBufferedRanges(m) { - const e = els(); - if (!e.buffered || !m || !m.duration || !isFinite(m.duration)) { - if (e.buffered) e.buffered.style.background = 'transparent'; - return; - } - - const ranges = []; - for (let i = 0; i < m.buffered.length; i++) { - const start = Math.max(0, Math.min(100, (m.buffered.start(i) / m.duration) * 100)); - const end = Math.max(0, Math.min(100, (m.buffered.end(i) / m.duration) * 100)); - if (end > start) ranges.push([start, end]); - } - if (!ranges.length) { - e.buffered.style.background = 'transparent'; - return; - } - - const color = 'var(--player-progress-buffered)'; - const stops = ['transparent 0%']; - for (const [start, end] of ranges) { - stops.push(`transparent ${start.toFixed(2)}%`); - stops.push(`${color} ${start.toFixed(2)}%`); - stops.push(`${color} ${end.toFixed(2)}%`); - stops.push(`transparent ${end.toFixed(2)}%`); - } - stops.push('transparent 100%'); - e.buffered.style.background = `linear-gradient(to right, ${stops.join(', ')})`; -} - -export function togglePlay() { - if (isDetached()) { - postToDetach({ type: 'detach-command', action: 'toggle-play' }); - return; - } - const m = currentMediaElement(); - if (!m) return; - if (m.paused) { m.play().catch(() => {}); } else { m.pause(); } -} - -export function hasLoadedMedia() { - return !!currentMedia; -} - -export function seekPercent(deltaPercent) { - const dp = Number(deltaPercent); - if (!currentMedia || !isFinite(dp)) return false; - if (isDetached()) { - postToDetach({ type: 'detach-command', action: 'seek-percent', percent: dp }); - return true; - } - const m = currentMediaElement(); - if (!m) return false; - const dur = effectiveDuration(); - if (!dur) return false; - const upper = m.duration && isFinite(m.duration) ? m.duration : dur; - m.currentTime = Math.max(0, Math.min(upper, (m.currentTime || 0) + dur * dp)); - return true; -} - -export function seekRelative(seconds) { - const amount = Number(seconds); - if (!currentMedia || !isFinite(amount)) return false; - if (isDetached()) { - postToDetach({ type: 'detach-command', action: 'seek-relative', seconds: amount }); - return true; - } - const m = currentMediaElement(); - if (!m) return false; - const upper = m.duration && isFinite(m.duration) ? m.duration : Infinity; - m.currentTime = Math.max(0, Math.min(upper, (m.currentTime || 0) + amount)); - return true; -} - -export function stopAndClose() { - const m = currentMediaElement(); - if (m) m.pause(); - if (currentMedia?.type === 'image') { - stopSlideshow(); - resetImageZoom(); - } - exitFullscreenIfNeeded(); - const e = els(); - e.player?.classList.remove('open', 'has-image', 'minimized'); - e.video?.classList.add('hidden'); - e.audio?.classList.add('hidden'); - e.image?.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.add('hidden'); - e.btnZoomOut?.classList.add('hidden'); - e.btnSlideshow?.classList.add('hidden'); - currentMedia = null; - currentMediaIndex = -1; - isPlaying = false; - stopProgressTimer(); - updateUI(false); - highlightPlayingCard(); - if (e.fill) e.fill.style.width = '0%'; - if (e.thumb) e.thumb.style.left = '0%'; - if (e.timeElapsed) e.timeElapsed.textContent = '0:00'; - if (e.timeTotal) e.timeTotal.textContent = '0:00'; - if (e.buffered) e.buffered.style.background = 'transparent'; -} - -export function selectAndPlay(media, index, resumeFrom = 0) { - currentMedia = media; - currentMediaIndex = index ?? -1; - if (isDetached()) { - isPlaying = true; - stopProgressTimer(); - sendDetachedLoad(media, resumeFrom, true); - highlightPlayingCard(); - return; - } - loadMedia(media, resumeFrom); - if (media.type === 'image') { - isPlaying = false; - highlightPlayingCard(); - return; - } - isPlaying = true; - const m = currentMediaElement(); - if (m) { - console.log('selectAndPlay: type=', media.type, 'src=', m.src, 'readyState=', m.readyState); - m.play().catch((err) => { console.error('play() failed:', err); }); - } else { - console.error('selectAndPlay: no media element found for type', media.type); - } - highlightPlayingCard(); -} - -export function loadMediaDirect(media, streamUrl, thumbnailUrl, resumeFrom = 0) { - currentMedia = media; - 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?.c