Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ function SplitWorkspace({ orientation }: { orientation: "horizontal" | "vertical

function EditorWorkspace() {
const isDesktop = useIsDesktopLayout();
const mediaKind = useEditorStore((s) => s.mediaKind);
// Audio has no visual preview — give the transcript the full workspace and
// mount MediaPreview off-layout so the hidden <audio> element still drives
// playback / spacebar / timeline controls.
if (mediaKind === "audio") {
return (
<>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<TranscriptPanel />
</div>
<MediaPreview />
</>
);
}
// Keyed so crossing the breakpoint remounts the group and restores that
// orientation's saved layout instead of carrying sizes across.
return isDesktop ? (
Expand Down Expand Up @@ -174,6 +188,8 @@ export default function Editor() {
}, [status, isElectron]);

// Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo, S = split.
// Capture phase so Space is ours before a focused <button> synthesizes a click
// (which would double-toggle playback and look like the hotkey "didn't work").
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
Expand All @@ -182,8 +198,8 @@ export default function Editor() {
const s = useEditorStore.getState();
if (e.code === "Space" && s.videoEl && !s.exportOpen) {
e.preventDefault();
if (s.videoEl.paused) void s.videoEl.play();
else s.videoEl.pause();
e.stopPropagation();
s.togglePlayback();
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
if (e.shiftKey) s.redo();
Expand All @@ -200,8 +216,8 @@ export default function Editor() {
s.splitAtPlayhead();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
document.addEventListener("keydown", handler, true);
return () => document.removeEventListener("keydown", handler, true);
}, []);

// Flush pending autosave when the tab hides or unloads.
Expand Down
143 changes: 35 additions & 108 deletions components/MediaPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,31 @@
"use client";

import { useCallback, useEffect, useMemo, useRef } from "react";
import { AudioLines, Pause, Play, SkipBack, SkipForward } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { useEditorStore } from "@/lib/store";
import {
cutRangeAt,
formatTime,
getCutRanges,
getEditedDuration,
originalToEdited,
} from "@/lib/edits";
import { cutRangeAt, getCutRanges } from "@/lib/edits";

/**
* Owns the <video>/<audio> element and the cut-skipping playback loop.
* In video mode this is the visual preview; in audio mode it mounts a hidden
* <audio> so playback still works when the preview panel is omitted.
*/
export default function MediaPreview() {
const mediaUrl = useEditorStore((s) => s.mediaUrl);
const videoFile = useEditorStore((s) => s.videoFile);
const mediaKind = useEditorStore((s) => s.mediaKind);
const words = useEditorStore((s) => s.words);
const manualCuts = useEditorStore((s) => s.manualCuts);
const duration = useEditorStore((s) => s.duration);
const playing = useEditorStore((s) => s.playing);
const currentTime = useEditorStore((s) => s.currentTime);
const setVideoEl = useEditorStore((s) => s.setVideoEl);
const setDuration = useEditorStore((s) => s.setDuration);
const setPlaying = useEditorStore((s) => s.setPlaying);
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);

const mediaRef = useRef<HTMLMediaElement | null>(null);
const isAudio = mediaKind === "audio";
const cuts = useMemo(
() => getCutRanges(words, duration, manualCuts),
[words, duration, manualCuts]
);
const cutsRef = useRef(cuts);
const cutsRef = useRef(getCutRanges(words, duration, manualCuts));
useEffect(() => {
cutsRef.current = cuts;
}, [cuts]);

const editedDuration = useMemo(
() => getEditedDuration(cuts, duration),
[cuts, duration]
);
cutsRef.current = getCutRanges(words, duration, manualCuts);
}, [words, duration, manualCuts]);

const refCb = useCallback(
(el: HTMLMediaElement | null) => {
Expand Down Expand Up @@ -80,97 +66,38 @@ export default function MediaPreview() {
}, [setCurrentTime]);

const togglePlay = useCallback(() => {
const media = mediaRef.current;
if (!media) return;
if (media.paused) {
// If we're parked inside a cut (or at the end), jump to kept content.
const cut = cutRangeAt(media.currentTime, cutsRef.current);
if (cut) media.currentTime = cut.end + 0.001;
if (media.currentTime >= media.duration - 0.05) media.currentTime = 0;
void media.play();
} else {
media.pause();
}
useEditorStore.getState().togglePlayback();
}, []);

const skip = useCallback((delta: number) => {
const media = mediaRef.current;
if (!media) return;
media.currentTime = Math.min(Math.max(0, media.currentTime + delta), media.duration);
}, []);
if (!mediaUrl) return null;

if (isAudio) {
return (
<audio
ref={refCb}
src={mediaUrl}
preload="metadata"
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="hidden"
/>
);
}

return (
<div className="flex min-h-0 flex-1 flex-col bg-zinc-50/70 p-3 sm:p-4">
<div className="flex min-h-0 flex-1 items-center justify-center">
{mediaUrl && isAudio && (
<>
<button
type="button"
onClick={togglePlay}
className="flex max-w-md cursor-pointer flex-col items-center gap-3 px-8 py-14 text-center transition"
>
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-zinc-900 text-white">
<AudioLines size={24} />
</div>
<p className="max-w-full truncate text-sm font-medium text-zinc-800">
{videoFile?.name ?? "Audio"}
</p>
<p className="text-xs text-zinc-400">Audio · click to play</p>
</button>
<audio
ref={refCb}
src={mediaUrl}
preload="metadata"
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="hidden"
/>
</>
)}
{mediaUrl && !isAudio && (
<video
ref={refCb}
src={mediaUrl}
playsInline
onClick={togglePlay}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="max-h-full max-w-full cursor-pointer rounded-sm bg-black shadow-lg shadow-zinc-900/10"
/>
)}
</div>

<div className="mt-3 flex shrink-0 items-center justify-center gap-2">
<span className="shrink-0 line-clamp-1 mr-1 w-24 text-right text-xs tabular-nums text-zinc-500 sm:mr-2 sm:w-28">
{formatTime(originalToEdited(currentTime, cuts))}
<span className="text-zinc-300"> / {formatTime(editedDuration)}</span>
</span>
<button
onClick={() => skip(-5)}
title="Back 5 s"
className="flex h-8 w-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-200"
>
<SkipBack size={15} />
</button>
<button
<video
ref={refCb}
src={mediaUrl}
playsInline
onClick={togglePlay}
title="Play / pause (space)"
className="flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full bg-zinc-900 text-white shadow transition hover:bg-zinc-700"
>
{playing ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
</button>
<button
onClick={() => skip(5)}
title="Forward 5 s"
className="flex h-8 w-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-200"
>
<SkipForward size={15} />
</button>
<span className="ml-2 hidden w-28 shrink-0 line-clamp-1 text-xs tabular-nums text-zinc-400 sm:block">
{editedDuration < duration - 0.01 && <>original {formatTime(duration)}</>}
</span>
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="max-h-full max-w-full cursor-pointer rounded-sm bg-black shadow-lg shadow-zinc-900/10"
/>
</div>
</div>
);
Expand Down
Loading
Loading