영상 뷰어를 완전한 미디어 플레이어로 개선
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) => {
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
showControls();
|
||||||
|
}}
|
||||||
|
onPause={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
setControlsShown(true);
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
}}
|
||||||
|
onWaiting={() => setWaiting(true)}
|
||||||
|
onPlaying={() => setWaiting(false)}
|
||||||
|
onCanPlay={() => setWaiting(false)}
|
||||||
|
onEnded={() => setControlsShown(true)}
|
||||||
|
onClick={togglePlay}
|
||||||
|
onDblClick={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
{/* 스트림 모드 커스텀 컨트롤 (fMP4 라이브 스트림은 네이티브 시킹 불가) */}
|
|
||||||
<Show when={isStream()}>
|
{/* 로딩 스피너 */}
|
||||||
<div class="flex h-11 w-full max-w-3xl items-center gap-3 px-2">
|
<Show when={waiting()}>
|
||||||
<button
|
<div class="pointer-events-none absolute inset-0 grid place-items-center">
|
||||||
class="w-8 rounded px-1 py-0.5 text-lg text-[var(--text-primary)] hover:bg-[var(--bg-hover)]"
|
<div class="h-10 w-10 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
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))}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user