영상 뷰어를 완전한 미디어 플레이어로 개선
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:
@@ -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;
|
||||
|
||||
@@ -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<PlaybackPlan | undefined>();
|
||||
const [src, setSrc] = createSignal<string | undefined>();
|
||||
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<typeof setTimeout> | 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 (
|
||||
<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
|
||||
when={plan()?.mode !== "unsupported"}
|
||||
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 ?? "재생할 수 없는 형식입니다"}
|
||||
</div>
|
||||
}
|
||||
@@ -110,45 +280,143 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src()}
|
||||
class="max-h-[calc(100%-44px)] max-w-full flex-1 bg-black outline-none"
|
||||
controls={!isStream()}
|
||||
class="max-h-full max-w-full outline-none"
|
||||
autoplay
|
||||
onTimeUpdate={() => setCurrent(seekBase() + (videoRef?.currentTime ?? 0))}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onClick={() => isStream() && togglePlay()}
|
||||
/>
|
||||
{/* 스트림 모드 커스텀 컨트롤 (fMP4 라이브 스트림은 네이티브 시킹 불가) */}
|
||||
<Show when={isStream()}>
|
||||
<div class="flex h-11 w-full max-w-3xl items-center gap-3 px-2">
|
||||
<button
|
||||
class="w-8 rounded px-1 py-0.5 text-lg text-[var(--text-primary)] hover:bg-[var(--bg-hover)]"
|
||||
playsinline
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onProgress={onTimeUpdate}
|
||||
onPlay={() => {
|
||||
setPlaying(true);
|
||||
showControls();
|
||||
}}
|
||||
onPause={() => {
|
||||
setPlaying(false);
|
||||
setControlsShown(true);
|
||||
clearTimeout(hideTimer);
|
||||
}}
|
||||
onWaiting={() => setWaiting(true)}
|
||||
onPlaying={() => setWaiting(false)}
|
||||
onCanPlay={() => setWaiting(false)}
|
||||
onEnded={() => setControlsShown(true)}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{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))}
|
||||
onDblClick={toggleFullscreen}
|
||||
/>
|
||||
<span class="w-14 shrink-0 text-[11px] tabular-nums text-[var(--text-secondary)]">
|
||||
{fmtTime(durationSec())}
|
||||
</span>
|
||||
<span class="shrink-0 rounded bg-[var(--bg-panel-raised)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)]">
|
||||
{plan()?.mode === "stream" && (plan() as { video: string }).video === "transcode"
|
||||
? "변환 재생"
|
||||
: "리먹스 재생"}
|
||||
</span>
|
||||
|
||||
{/* 로딩 스피너 */}
|
||||
<Show when={waiting()}>
|
||||
<div class="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user