From cb0207154d31f1342d2ea2e19ce9fa99995fae98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=20=ED=95=9C?= Date: Fri, 17 Jul 2026 03:46:00 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=81=EC=83=81=20=EB=B7=B0=EC=96=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=99=84=EC=A0=84=ED=95=9C=20=EB=AF=B8=EB=94=94?= =?UTF-8?q?=EC=96=B4=20=ED=94=8C=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VideoPlayer 재작성 — 직접재생/ffmpeg 스트림 공통 커스텀 컨트롤: - 컨트롤바: 재생/정지, 10초 스킵, 시크바(버퍼+드래그), 시간, 볼륨(호버 슬라이더)+음소거, 배속(0.5~2x), 전체화면 - 중앙 재생버튼, 좌우 이전/다음, 로딩 스피너, 컨트롤 자동숨김 - 키보드: Space/K, ←→±5s, J/L±10s, ↑↓볼륨, M, F, PageUp/Down - 라이트박스는 영상일 때 ←→를 플레이어(시크)에 양보 - 볼륨 localStorage 유지 Co-Authored-By: Claude Opus 4.8 --- src/features/viewer/Lightbox.tsx | 4 + src/features/viewer/VideoPlayer.tsx | 372 ++++++++++++++++++++++++---- 2 files changed, 324 insertions(+), 52 deletions(-) diff --git a/src/features/viewer/Lightbox.tsx b/src/features/viewer/Lightbox.tsx index 9c34095..9cbff5b 100644 --- a/src/features/viewer/Lightbox.tsx +++ b/src/features/viewer/Lightbox.tsx @@ -36,16 +36,20 @@ export const Lightbox: Component = () => { const onKey = (e: KeyboardEvent) => { if (lightboxIndex() == null) return; + // 영상일 때 ←/→ 는 플레이어(시크)가 처리하므로 여기서 가로채지 않는다. + const isVideo = item()?.kind === 1; switch (e.key) { case "Escape": e.preventDefault(); closeLightbox(); break; case "ArrowLeft": + if (isVideo) break; e.preventDefault(); navLightbox(-1); break; case "ArrowRight": + if (isVideo) break; e.preventDefault(); navLightbox(1); break; diff --git a/src/features/viewer/VideoPlayer.tsx b/src/features/viewer/VideoPlayer.tsx index fa0a985..0f6f3a2 100644 --- a/src/features/viewer/VideoPlayer.tsx +++ b/src/features/viewer/VideoPlayer.tsx @@ -1,10 +1,11 @@ -// 비디오 플레이어 — 직접 재생(Range, 네이티브 컨트롤) 또는 -// ffmpeg 스트림(리먹스/트랜스코딩, 커스텀 스크러버 + seekBase 오프셋). +// 미디어 플레이어 — 통일된 커스텀 컨트롤(직접 재생 + ffmpeg 스트림 공통). +// 재생/정지·10초 스킵·시크바(버퍼)·볼륨·배속·전체화면·자동숨김·키보드 단축키. import { createEffect, createResource, createSignal, onCleanup, + onMount, Show, type Component, } from "solid-js"; @@ -18,7 +19,7 @@ import { type PlaybackPlan, type VideoMode, } from "../../ipc/commands"; -import { info as appInfoSig } from "../../state/store"; +import { info as appInfoSig, navLightbox } from "../../state/store"; function fmtTime(sec: number): string { const s = Math.max(0, Math.floor(sec)); @@ -30,17 +31,32 @@ function fmtTime(sec: number): string { : `${m}:${String(r).padStart(2, "0")}`; } +const RATES = [0.5, 1, 1.25, 1.5, 2]; +const VOL_KEY = "archive.player.volume"; + export const VideoPlayer: Component<{ fileId: number }> = (props) => { let videoRef: HTMLVideoElement | undefined; + let containerRef: HTMLDivElement | undefined; + let barRef: HTMLDivElement | undefined; + const [pb] = createResource(() => props.fileId, playbackInfo); const [plan, setPlan] = createSignal(); const [src, setSrc] = createSignal(); const [seekBase, setSeekBase] = createSignal(0); const [current, setCurrent] = createSignal(0); + const [buffered, setBuffered] = createSignal(0); const [playing, setPlaying] = createSignal(false); + const [waiting, setWaiting] = createSignal(false); + const [volume, setVolume] = createSignal(Number(localStorage.getItem(VOL_KEY) ?? "1")); + const [muted, setMuted] = createSignal(false); + const [rate, setRate] = createSignal(1); + const [fullscreen, setFullscreen] = createSignal(false); + const [controlsShown, setControlsShown] = createSignal(true); let jobId: number | undefined; + let hideTimer: ReturnType | undefined; const durationSec = () => (pb()?.durationMs ?? 0) / 1000; + const isStream = () => plan()?.mode === "stream"; const cleanupJob = () => { if (jobId != null) { @@ -66,6 +82,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => { cleanupJob(); setSeekBase(0); setCurrent(0); + setBuffered(0); const decided = decidePlayback(p); setPlan(decided); if (decided.mode === "direct") { @@ -77,32 +94,185 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => { } }); - onCleanup(cleanupJob); - - const isStream = () => plan()?.mode === "stream"; - - const onSeek = (t: number) => { - const p = plan(); - if (!p) return; - if (p.mode === "direct") { - if (videoRef) videoRef.currentTime = t; - } else if (p.mode === "stream") { - void startStreamAt(t, p.video, p.audio); + // 볼륨/배속/음소거를 video 요소에 반영 + createEffect(() => { + if (videoRef) { + videoRef.volume = volume(); + videoRef.muted = muted(); + videoRef.playbackRate = rate(); } - }; + }); + onCleanup(() => { + cleanupJob(); + clearTimeout(hideTimer); + }); + + // ── 재생 제어 ── const togglePlay = () => { if (!videoRef) return; if (videoRef.paused) void videoRef.play().catch(() => {}); else videoRef.pause(); }; + const seekTo = (t: number) => { + const p = plan(); + if (!p) return; + const clamped = Math.max(0, Math.min(durationSec() || t, t)); + if (p.mode === "direct") { + if (videoRef) videoRef.currentTime = clamped; + setCurrent(clamped); + } else if (p.mode === "stream") { + setCurrent(clamped); + void startStreamAt(clamped, p.video, p.audio); + } + }; + + const skip = (delta: number) => seekTo(current() + delta); + + const cycleRate = () => { + const idx = RATES.indexOf(rate()); + setRate(RATES[(idx + 1) % RATES.length]); + }; + + const toggleMute = () => setMuted((m) => !m); + + const changeVolume = (v: number) => { + const nv = Math.max(0, Math.min(1, v)); + setVolume(nv); + setMuted(nv === 0); + localStorage.setItem(VOL_KEY, String(nv)); + }; + + const toggleFullscreen = () => { + if (!containerRef) return; + if (document.fullscreenElement) void document.exitFullscreen(); + else void containerRef.requestFullscreen().catch(() => {}); + }; + + // ── 시크바 상호작용 ── + const seekFromPointer = (clientX: number) => { + if (!barRef) return; + const rect = barRef.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + seekTo(ratio * (durationSec() || 0)); + }; + + const onBarPointerDown = (e: PointerEvent) => { + e.preventDefault(); + seekFromPointer(e.clientX); + const move = (me: PointerEvent) => seekFromPointer(me.clientX); + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }; + + const progressPct = () => { + const d = durationSec(); + return d > 0 ? (current() / d) * 100 : 0; + }; + const bufferedPct = () => { + const d = durationSec(); + return d > 0 ? (buffered() / d) * 100 : 0; + }; + + // ── 컨트롤 자동 숨김 (재생 중 2.6초 무동작 시) ── + const showControls = () => { + setControlsShown(true); + clearTimeout(hideTimer); + hideTimer = setTimeout(() => { + if (playing()) setControlsShown(false); + }, 2600); + }; + + // ── 키보드 단축키 (플레이어가 마운트된 동안) ── + const onKey = (e: KeyboardEvent) => { + // 입력 요소에 포커스면 무시 + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + let handled = true; + switch (e.key) { + case " ": + case "k": + case "K": + togglePlay(); + break; + case "ArrowLeft": + case "j": + case "J": + skip(e.key === "ArrowLeft" ? -5 : -10); + break; + case "ArrowRight": + case "l": + case "L": + skip(e.key === "ArrowRight" ? 5 : 10); + break; + case "ArrowUp": + changeVolume(volume() + 0.05); + break; + case "ArrowDown": + changeVolume(volume() - 0.05); + break; + case "m": + case "M": + toggleMute(); + break; + case "f": + case "F": + toggleFullscreen(); + break; + case "PageUp": + navLightbox(-1); + break; + case "PageDown": + navLightbox(1); + break; + default: + handled = false; + } + if (handled) { + e.preventDefault(); + e.stopPropagation(); + showControls(); + } + }; + + onMount(() => { + // capture 단계에서 먼저 처리 → 라이트박스 전역 핸들러보다 우선 + window.addEventListener("keydown", onKey, { capture: true }); + const onFsChange = () => setFullscreen(!!document.fullscreenElement); + document.addEventListener("fullscreenchange", onFsChange); + onCleanup(() => { + window.removeEventListener("keydown", onKey, { capture: true }); + document.removeEventListener("fullscreenchange", onFsChange); + }); + }); + + const onTimeUpdate = () => { + setCurrent(seekBase() + (videoRef?.currentTime ?? 0)); + // 버퍼 끝(직접 모드) + if (videoRef && videoRef.buffered.length > 0 && !isStream()) { + setBuffered(videoRef.buffered.end(videoRef.buffered.length - 1)); + } + }; + + const rateLabel = () => (rate() === 1 ? "1x" : `${rate()}x`); + return ( -
+
playing() && setControlsShown(false)} + > +
{(plan() as { reason?: string })?.reason ?? "재생할 수 없는 형식입니다"}
} @@ -110,45 +280,143 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {