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
6 changes: 4 additions & 2 deletions components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import ModelSelector, {
ModelOptionSeparator,
} from "./ModelSelector";
import ImportTranscriptOption from "./ImportTranscriptOption";
import { MODEL_ORDER } from "@/lib/models";

/** How long the desktop mode-change overlay stays up. Matches the macOS
* `setBounds(..., animate)` duration plus a small buffer so the layout
Expand Down Expand Up @@ -230,8 +231,9 @@ export default function Editor() {
<>
{isElectron && <TopBar>
<ModelSelector groupLabel="Transcript source">
<ModelOption id="base" />
<ModelOption id="small" />
{MODEL_ORDER.map((id) => (
<ModelOption key={id} id={id} />
))}
<ModelOptionSeparator />
<LanguageSection />
<ModelOptionSeparator />
Expand Down
83 changes: 42 additions & 41 deletions components/ImportTranscriptOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
TRANSCRIPT_FILE_ERROR,
TRANSCRIPT_ACCEPT,
} from "@/lib/parseTranscript";
import { isWhisperModel } from "@/lib/models";
import { isModelId, type ModelId } from "@/lib/models";
import { useEditorStore } from "@/lib/store";
import {
ModelOption,
Expand All @@ -30,8 +30,8 @@ import {
export default function ImportTranscriptOption() {
const pendingTranscript = useEditorStore((s) => s.pendingTranscript);
const setPendingTranscript = useEditorStore((s) => s.setPendingTranscript);
const setModel = useEditorStore((s) => s.setModel);
const selected = useEditorStore((s) => s.model === "import");
const setSource = useEditorStore((s) => s.setSource);
const selected = useEditorStore((s) => s.source === "import");
const [reading, setReading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [picking, setPicking] = useState(false);
Expand All @@ -40,7 +40,7 @@ export default function ImportTranscriptOption() {
ModelOptionContextValue,
"keepMenuOpen" | "closeMenu" | "select"
> | null>(null);
const previousModelRef = useRef<"base" | "small">("base");
const previousModelRef = useRef<ModelId>("base");
const pickGenRef = useRef(0);

/** Reset import-pick state only — never touch the dropdown open state. */
Expand All @@ -49,15 +49,15 @@ export default function ImportTranscriptOption() {
setReading(false);
setError(null);
if (!useEditorStore.getState().pendingTranscript) {
setModel(previousModelRef.current);
setSource(previousModelRef.current);
}
}, [setModel]);
}, [setSource]);

// If the user switches to Whisper while a picker/parse is in flight, invalidate
// so a late onChange/parse cannot flip model back to import.
// If the user switches to a speech model while a picker/parse is in flight,
// invalidate so a late onChange/parse cannot flip source back to import.
useEffect(() => {
return useEditorStore.subscribe((state, prev) => {
if (!isWhisperModel(state.model) || state.model === prev.model) return;
if (!isModelId(state.source) || state.source === prev.source) return;
pickGenRef.current += 1;
queueMicrotask(() => {
setPicking(false);
Expand Down Expand Up @@ -131,19 +131,19 @@ export default function ImportTranscriptOption() {
setPicking(false);
setError(TRANSCRIPT_FILE_ERROR);
setPendingTranscript(null);
setModel("import");
setSource("import");
menu?.keepMenuOpen();
return;
}
setReading(true);
setPicking(false);
setError(null);
setModel("import"); // so the closed trigger can show progress
setSource("import"); // so the closed trigger can show progress
try {
const words = await parseTranscriptFile(file);
if (pickGenRef.current !== gen) return;
setPendingTranscript({ name: file.name, words });
setModel("import");
setSource("import");
menu?.closeMenu();
} catch (err) {
if (pickGenRef.current !== gen) return;
Expand All @@ -154,7 +154,7 @@ export default function ImportTranscriptOption() {
? err.message
: "Could not read that transcript."
);
setModel("import");
setSource("import");
menu?.keepMenuOpen();
} finally {
if (pickGenRef.current === gen) setReading(false);
Expand All @@ -170,11 +170,11 @@ export default function ImportTranscriptOption() {
autoTrigger={false}
onSelect={(ctx) => {
menuRef.current = ctx;
const current = useEditorStore.getState().model;
if (isWhisperModel(current)) {
const current = useEditorStore.getState().source;
if (isModelId(current)) {
previousModelRef.current = current;
}
// Do not set model to "import" until a file is chosen. Close the menu
// Do not set source to "import" until a file is chosen. Close the menu
// before the OS dialog so cancel cannot leave it pinned open.
pickGenRef.current += 1;
setPicking(true);
Expand Down Expand Up @@ -210,8 +210,12 @@ function ImportTrigger({
"import",
{
label,
icon: FileText,
iconClassName: error ? "text-red-500" : "text-zinc-500 dark:text-zinc-400",
icon: busy ? Loader2 : FileText,
iconClassName: busy
? "animate-spin text-zinc-500"
: error
? "text-red-500"
: "text-zinc-500",
busy,
},
enabled
Expand All @@ -229,27 +233,24 @@ function ImportStatus({
picking: boolean;
}) {
const { selected } = useModelOption();
const pendingTranscript = useEditorStore((s) => s.pendingTranscript);
if (!picking && !selected && !reading && !error) return null;
if (!reading && !pendingTranscript && !error && !picking) return null;
return (
<span
className={`pl-[1.625rem] text-[11px] leading-snug ${
error ? "text-red-500" : "text-zinc-500 dark:text-zinc-400"
}`}
>
{reading ? (
<span className="inline-flex items-center gap-1">
<Loader2 size={11} className="animate-spin" />
Reading file…
</span>
) : error ? (
error
) : pendingTranscript ? (
`${pendingTranscript.name} · ${pendingTranscript.words.length} words`
) : picking ? (
"Choose an SRT, VTT, or JSON file…"
) : null}
</span>
);
if (reading) {
return (
<p className="mt-0.5 text-[11px] text-zinc-500 dark:text-zinc-400">
Reading file…
</p>
);
}
if (error) {
return (
<p className="mt-0.5 text-[11px] text-red-600 dark:text-red-400">{error}</p>
);
}
if (picking && !selected) {
return (
<p className="mt-0.5 text-[11px] text-zinc-500 dark:text-zinc-400">
Choose a file…
</p>
);
}
return null;
}
55 changes: 30 additions & 25 deletions components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ import {
TRANSCRIPT_LANGUAGES,
type TranscriptLanguage,
} from "@/lib/languages";
import { MODELS, isWhisperModel, type ModelChoice } from "@/lib/models";
import {
MODEL_ORDER,
MODELS,
isModelId,
isWhisperModel,
} from "@/lib/models";
import type { TranscriptSource } from "@/lib/source";
import {
hydrateModelPreference,
hydrateTranscriptLanguagePreference,
Expand All @@ -35,7 +41,7 @@ import Popover, { PopoverContent, PopoverTrigger } from "./Popover";

export type ModelOptionContextValue = {
/** Currently selected source id. */
value: ModelChoice;
value: TranscriptSource;
selected: boolean;
select: () => void;
/** Close the dropdown after a normal selection. */
Expand All @@ -53,8 +59,8 @@ export type OptionTrigger = {
};

type SelectorContextValue = {
value: ModelChoice;
setValue: (id: ModelChoice) => void;
value: TranscriptSource;
setValue: (id: TranscriptSource) => void;
closeMenu: () => void;
keepMenuOpen: () => void;
registerTrigger: (id: string, trigger: OptionTrigger) => void;
Expand Down Expand Up @@ -82,7 +88,7 @@ export function useModelOption(): ModelOptionContextValue {

/** Let a custom option drive the closed trigger while it is selected. */
export function useOptionTrigger(
id: ModelChoice,
id: TranscriptSource,
trigger: OptionTrigger,
enabled = true
) {
Expand Down Expand Up @@ -120,8 +126,8 @@ export default function ModelSelector({
/** Called when an option needs the parent panel to stay open (embedded). */
onKeepOpen?: () => void;
}) {
const model = useEditorStore((s) => s.model);
const setModel = useEditorStore((s) => s.setModel);
const source = useEditorStore((s) => s.source);
const setSource = useEditorStore((s) => s.setSource);
const transcriptLanguage = useEditorStore((s) => s.transcriptLanguage);
const [open, setOpen] = useState(false);
const [triggers, setTriggers] = useState<Record<string, OptionTrigger>>({});
Expand Down Expand Up @@ -168,39 +174,40 @@ export default function ModelSelector({

const ctx = useMemo(
() => ({
value: model,
setValue: setModel,
value: source,
setValue: setSource,
closeMenu,
keepMenuOpen,
registerTrigger,
unregisterTrigger,
}),
[model, setModel, closeMenu, keepMenuOpen, registerTrigger, unregisterTrigger]
[source, setSource, closeMenu, keepMenuOpen, registerTrigger, unregisterTrigger]
);

const activeTrigger = triggers[model];
const activeTrigger = triggers[source];
// Prefer the option's registered trigger. Fall back carefully so an unmounted
// custom option (e.g. import) never shows the raw id + default wave icon.
const TriggerIcon =
activeTrigger?.icon ?? (model === "import" ? FileText : AudioLines);
activeTrigger?.icon ?? (source === "import" ? FileText : AudioLines);
const baseTriggerLabel =
activeTrigger?.label ??
(isWhisperModel(model)
? MODELS[model].label
: model === "import"
(isModelId(source)
? MODELS[source].label
: source === "import"
? "Import transcript"
: String(model));
: String(source));
const languageInfo = TRANSCRIPT_LANGUAGES[transcriptLanguage];
const showLanguageInTrigger =
isWhisperModel(model) &&
isWhisperModel(source) &&
!activeTrigger?.busy &&
transcriptLanguage !== "en";

// Always mount options (hidden when closed) so custom triggers stay registered.
const options = children ?? (
<>
<ModelOption id="base" />
<ModelOption id="small" />
{MODEL_ORDER.map((id) => (
<ModelOption key={id} id={id} />
))}
</>
);

Expand Down Expand Up @@ -294,7 +301,7 @@ export default function ModelSelector({
);
}

/** Default option row: icon + label + optional meta. Whisper ids fill in from MODELS. */
/** Default option row: icon + label + optional meta. ASR ids fill in from MODELS. */
export function ModelOption({
id,
label,
Expand All @@ -305,7 +312,7 @@ export function ModelOption({
/** When false, a child owns the closed trigger via `useOptionTrigger`. */
autoTrigger = true,
}: {
id: ModelChoice;
id: TranscriptSource;
label?: string;
meta?: string;
icon?: LucideIcon;
Expand All @@ -316,10 +323,8 @@ export function ModelOption({
const selector = useSelectorCtx();
const selected = selector.value === id;

const resolvedLabel =
label ?? (isWhisperModel(id) ? MODELS[id].label : id);
const resolvedMeta =
meta ?? (isWhisperModel(id) ? MODELS[id].size : undefined);
const resolvedLabel = label ?? (isModelId(id) ? MODELS[id].label : id);
const resolvedMeta = meta ?? (isModelId(id) ? MODELS[id].size : undefined);

const optionCtx = useMemo<ModelOptionContextValue>(
() => ({
Expand Down
13 changes: 7 additions & 6 deletions components/UploadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
Clapperboard,
Film,
Loader2,
Lock,

Check warning on line 10 in components/UploadScreen.tsx

View workflow job for this annotation

GitHub Actions / check

'Lock' is defined but never used
Music,
Scissors,
ShieldAlert,
Expand All @@ -23,6 +23,7 @@
ModelOptionSeparator,
} from "./ModelSelector";
import ImportTranscriptOption from "./ImportTranscriptOption";
import { MODEL_ORDER } from "@/lib/models";
import { useCrossOriginIsolated } from "@/hooks/useCrossOriginIsolated";
import { detectMediaKind, MEDIA_ACCEPT } from "@/lib/media";
import { formatTime } from "@/lib/edits";
Expand Down Expand Up @@ -166,7 +167,7 @@
// would fail immediately and lose the file to that reload.
const isolation = useCrossOriginIsolated();
const ready = isolation === "ready";
const model = useEditorStore((s) => s.model);
const source = useEditorStore((s) => s.source);
const pendingTranscript = useEditorStore((s) => s.pendingTranscript);
const openProject = useEditorStore((s) => s.openProject);
const removeProject = useEditorStore((s) => s.removeProject);
Expand Down Expand Up @@ -205,8 +206,7 @@
alert("Please choose a video or audio file.");
return;
}
const { model: source, pendingTranscript: pending } =
useEditorStore.getState();
const { source, pendingTranscript: pending } = useEditorStore.getState();
if (source === "import") {
if (!pending) {
alert("Choose a transcript file from the source menu first.");
Expand Down Expand Up @@ -275,8 +275,9 @@
<SettingsMenu />
<div className="h-5 w-px bg-zinc-200 dark:bg-zinc-700" />
<ModelSelector groupLabel="Transcript source">
<ModelOption id="base" />
<ModelOption id="small" />
{MODEL_ORDER.map((id) => (
<ModelOption key={id} id={id} />
))}
<ModelOptionSeparator />
<LanguageSection />
<ModelOptionSeparator />
Expand Down Expand Up @@ -341,7 +342,7 @@
<span className="text-neutral-600 dark:text-neutral-300">browse</span>
</p>
<p className="mt-1 text-[13px] text-zinc-400 dark:text-zinc-500">
{model === "import"
{source === "import"
? pendingTranscript
? `Will use ${pendingTranscript.name} · MP4, WebM, MOV, MP3, WAV, …`
: "Pick a transcript in the menu above, then drop your media"
Expand Down
Loading
Loading