영상 뷰어를 완전한 미디어 플레이어로 개선

VideoPlayer 재작성 — 직접재생/ffmpeg 스트림 공통 커스텀 컨트롤:
- 컨트롤바: 재생/정지, 10초 스킵, 시크바(버퍼+드래그), 시간,
  볼륨(호버 슬라이더)+음소거, 배속(0.5~2x), 전체화면
- 중앙 재생버튼, 좌우 이전/다음, 로딩 스피너, 컨트롤 자동숨김
- 키보드: Space/K, ←→±5s, J/L±10s, ↑↓볼륨, M, F, PageUp/Down
- 라이트박스는 영상일 때 ←→를 플레이어(시크)에 양보
- 볼륨 localStorage 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-17 03:46:00 +09:00
co-authored by Claude Opus 4.8
parent 5a7952bd2d
commit cb0207154d
2 changed files with 324 additions and 52 deletions
+4
View File
@@ -36,16 +36,20 @@ export const Lightbox: Component = () => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (lightboxIndex() == null) return; if (lightboxIndex() == null) return;
// 영상일 때 ←/→ 는 플레이어(시크)가 처리하므로 여기서 가로채지 않는다.
const isVideo = item()?.kind === 1;
switch (e.key) { switch (e.key) {
case "Escape": case "Escape":
e.preventDefault(); e.preventDefault();
closeLightbox(); closeLightbox();
break; break;
case "ArrowLeft": case "ArrowLeft":
if (isVideo) break;
e.preventDefault(); e.preventDefault();
navLightbox(-1); navLightbox(-1);
break; break;
case "ArrowRight": case "ArrowRight":
if (isVideo) break;
e.preventDefault(); e.preventDefault();
navLightbox(1); navLightbox(1);
break; break;
+319 -51
View File
@@ -1,10 +1,11 @@
// 비디오 플레이어 — 직접 재생(Range, 네이티브 컨트롤) 또는 // 미디어 플레이어 — 통일된 커스텀 컨트롤(직접 재생 + ffmpeg 스트림 공통).
// ffmpeg 스트림(리먹스/트랜스코딩, 커스텀 스크러버 + seekBase 오프셋). // 재생/정지·10초 스킵·시크바(버퍼)·볼륨·배속·전체화면·자동숨김·키보드 단축키.
import { import {
createEffect, createEffect,
createResource, createResource,
createSignal, createSignal,
onCleanup, onCleanup,
onMount,
Show, Show,
type Component, type Component,
} from "solid-js"; } from "solid-js";
@@ -18,7 +19,7 @@ import {
type PlaybackPlan, type PlaybackPlan,
type VideoMode, type VideoMode,
} from "../../ipc/commands"; } from "../../ipc/commands";
import { info as appInfoSig } from "../../state/store"; import { info as appInfoSig, navLightbox } from "../../state/store";
function fmtTime(sec: number): string { function fmtTime(sec: number): string {
const s = Math.max(0, Math.floor(sec)); const s = Math.max(0, Math.floor(sec));
@@ -30,17 +31,32 @@ function fmtTime(sec: number): string {
: `${m}:${String(r).padStart(2, "0")}`; : `${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) => { export const VideoPlayer: Component<{ fileId: number }> = (props) => {
let videoRef: HTMLVideoElement | undefined; let videoRef: HTMLVideoElement | undefined;
let containerRef: HTMLDivElement | undefined;
let barRef: HTMLDivElement | undefined;
const [pb] = createResource(() => props.fileId, playbackInfo); const [pb] = createResource(() => props.fileId, playbackInfo);
const [plan, setPlan] = createSignal<PlaybackPlan | undefined>(); const [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
const [src, setSrc] = createSignal<string | undefined>(); const [src, setSrc] = createSignal<string | undefined>();
const [seekBase, setSeekBase] = createSignal(0); const [seekBase, setSeekBase] = createSignal(0);
const [current, setCurrent] = createSignal(0); const [current, setCurrent] = createSignal(0);
const [buffered, setBuffered] = createSignal(0);
const [playing, setPlaying] = createSignal(false); 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 jobId: number | undefined;
let hideTimer: ReturnType<typeof setTimeout> | undefined;
const durationSec = () => (pb()?.durationMs ?? 0) / 1000; const durationSec = () => (pb()?.durationMs ?? 0) / 1000;
const isStream = () => plan()?.mode === "stream";
const cleanupJob = () => { const cleanupJob = () => {
if (jobId != null) { if (jobId != null) {
@@ -66,6 +82,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
cleanupJob(); cleanupJob();
setSeekBase(0); setSeekBase(0);
setCurrent(0); setCurrent(0);
setBuffered(0);
const decided = decidePlayback(p); const decided = decidePlayback(p);
setPlan(decided); setPlan(decided);
if (decided.mode === "direct") { if (decided.mode === "direct") {
@@ -77,32 +94,185 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
} }
}); });
onCleanup(cleanupJob); // 볼륨/배속/음소거를 video 요소에 반영
createEffect(() => {
const isStream = () => plan()?.mode === "stream"; if (videoRef) {
videoRef.volume = volume();
const onSeek = (t: number) => { videoRef.muted = muted();
const p = plan(); videoRef.playbackRate = rate();
if (!p) return;
if (p.mode === "direct") {
if (videoRef) videoRef.currentTime = t;
} else if (p.mode === "stream") {
void startStreamAt(t, p.video, p.audio);
} }
}; });
onCleanup(() => {
cleanupJob();
clearTimeout(hideTimer);
});
// ── 재생 제어 ──
const togglePlay = () => { const togglePlay = () => {
if (!videoRef) return; if (!videoRef) return;
if (videoRef.paused) void videoRef.play().catch(() => {}); if (videoRef.paused) void videoRef.play().catch(() => {});
else videoRef.pause(); 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 ( return (
<div class="flex h-full w-full flex-col items-center justify-center"> <div
ref={containerRef}
class="relative flex h-full w-full items-center justify-center bg-black"
classList={{ "cursor-none": !controlsShown() }}
onMouseMove={showControls}
onMouseLeave={() => playing() && setControlsShown(false)}
>
<Show <Show
when={plan()?.mode !== "unsupported"} when={plan()?.mode !== "unsupported"}
fallback={ fallback={
<div class="max-w-md text-center text-sm text-[var(--text-muted)]"> <div class="max-w-md px-6 text-center text-sm text-[var(--text-muted)]">
{(plan() as { reason?: string })?.reason ?? "재생할 수 없는 형식입니다"} {(plan() as { reason?: string })?.reason ?? "재생할 수 없는 형식입니다"}
</div> </div>
} }
@@ -110,45 +280,143 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
<video <video
ref={videoRef} ref={videoRef}
src={src()} src={src()}
class="max-h-[calc(100%-44px)] max-w-full flex-1 bg-black outline-none" class="max-h-full max-w-full outline-none"
controls={!isStream()}
autoplay autoplay
onTimeUpdate={() => setCurrent(seekBase() + (videoRef?.currentTime ?? 0))} playsinline
onPlay={() => setPlaying(true)} onTimeUpdate={onTimeUpdate}
onPause={() => setPlaying(false)} onProgress={onTimeUpdate}
onClick={() => isStream() && togglePlay()} onPlay={() => {
/> setPlaying(true);
{/* 스트림 모드 커스텀 컨트롤 (fMP4 라이브 스트림은 네이티브 시킹 불가) */} showControls();
<Show when={isStream()}> }}
<div class="flex h-11 w-full max-w-3xl items-center gap-3 px-2"> onPause={() => {
<button setPlaying(false);
class="w-8 rounded px-1 py-0.5 text-lg text-[var(--text-primary)] hover:bg-[var(--bg-hover)]" setControlsShown(true);
clearTimeout(hideTimer);
}}
onWaiting={() => setWaiting(true)}
onPlaying={() => setWaiting(false)}
onCanPlay={() => setWaiting(false)}
onEnded={() => setControlsShown(true)}
onClick={togglePlay} onClick={togglePlay}
> onDblClick={toggleFullscreen}
{playing() ? "⏸" : "▶"}
</button>
<span class="w-14 shrink-0 text-right text-[11px] tabular-nums text-[var(--text-secondary)]">
{fmtTime(current())}
</span>
<input
type="range"
class="h-1 flex-1 accent-[var(--accent)]"
min={0}
max={durationSec() || 0}
step={0.1}
value={current()}
onChange={(e) => onSeek(Number(e.currentTarget.value))}
/> />
<span class="w-14 shrink-0 text-[11px] tabular-nums text-[var(--text-secondary)]">
{fmtTime(durationSec())} {/* 로딩 스피너 */}
</span> <Show when={waiting()}>
<span class="shrink-0 rounded bg-[var(--bg-panel-raised)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)]"> <div class="pointer-events-none absolute inset-0 grid place-items-center">
{plan()?.mode === "stream" && (plan() as { video: string }).video === "transcode" <div class="h-10 w-10 animate-spin rounded-full border-2 border-white/30 border-t-white" />
? "변환 재생"
: "리먹스 재생"}
</span>
</div> </div>
</Show> </Show>
{/* 중앙 재생 버튼(일시정지 상태) */}
<Show when={!playing() && !waiting()}>
<button
class="pointer-events-auto absolute grid h-16 w-16 place-items-center rounded-full bg-black/50 text-3xl text-white backdrop-blur transition hover:bg-black/70"
onClick={togglePlay}
>
</button>
</Show>
{/* 이전/다음 파일 (호버 시) */}
<Show when={controlsShown()}>
<button
class="absolute left-2 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/40 text-xl text-white/80 hover:bg-black/60 hover:text-white"
onClick={() => navLightbox(-1)}
title="이전 (PageUp)"
>
</button>
<button
class="absolute right-2 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/40 text-xl text-white/80 hover:bg-black/60 hover:text-white"
onClick={() => navLightbox(1)}
title="다음 (PageDown)"
>
</button>
</Show>
{/* 하단 컨트롤바 */}
<div
class="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-gradient-to-t from-black/80 to-transparent px-3 pb-2 pt-6 transition-opacity duration-200"
classList={{ "opacity-0": !controlsShown(), "opacity-100": controlsShown() }}
onClick={(e) => e.stopPropagation()}
>
{/* 시크바 */}
<div
ref={barRef}
class="group relative h-4 cursor-pointer"
onPointerDown={onBarPointerDown}
>
<div class="absolute top-1/2 h-1 w-full -translate-y-1/2 rounded bg-white/25" />
<Show when={!isStream()}>
<div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-white/40"
style={{ width: `${bufferedPct()}%` }}
/>
</Show>
<div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-[var(--accent)]"
style={{ width: `${progressPct()}%` }}
/>
<div
class="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover:opacity-100"
style={{ left: `${progressPct()}%` }}
/>
</div>
{/* 버튼 행 */}
<div class="flex items-center gap-2 text-white">
<button class="rounded px-1 text-lg leading-none hover:text-[var(--accent)]" onClick={togglePlay} title="재생/정지 (Space)">
{playing() ? "⏸" : "▶"}
</button>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(-10)} title="10초 뒤로 (J)">
</button>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(10)} title="10초 앞으로 (L)">
</button>
{/* 볼륨 */}
<div class="group/vol flex items-center gap-1">
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={toggleMute} title="음소거 (M)">
{muted() || volume() === 0 ? "🔇" : volume() < 0.5 ? "🔉" : "🔊"}
</button>
<input
type="range"
class="h-1 w-0 accent-[var(--accent)] opacity-0 transition-all group-hover/vol:w-20 group-hover/vol:opacity-100"
min={0}
max={1}
step={0.02}
value={muted() ? 0 : volume()}
onInput={(e) => changeVolume(Number(e.currentTarget.value))}
/>
</div>
<span class="ml-1 text-[11px] tabular-nums text-white/80">
{fmtTime(current())} / {fmtTime(durationSec())}
</span>
<div class="ml-auto flex items-center gap-2">
<button
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
onClick={cycleRate}
title="재생 속도"
>
{rateLabel()}
</button>
<Show when={isStream()}>
<span class="rounded bg-white/15 px-1.5 py-0.5 text-[10px] text-white/70">
{(plan() as { video: string }).video === "transcode" ? "변환 재생" : "리먹스 재생"}
</span>
</Show>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={toggleFullscreen} title="전체화면 (F)">
{fullscreen() ? "🗗" : "⛶"}
</button>
</div>
</div>
</div>
</Show> </Show>
</div> </div>
); );