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
77 changes: 76 additions & 1 deletion components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels";
import { useEditorStore } from "@/lib/store";
import { getCutRanges, isWordCutOut } from "@/lib/edits";
Expand All @@ -9,8 +9,11 @@ import { VAD_SAMPLE_RATE } from "@/lib/vad";
import { isElectron } from "@/lib/platform";
import { reportError } from "@/lib/sentry";
import { startSessionReporting } from "@/lib/telemetry";
import { useCrossOriginIsolated } from "@/hooks/useCrossOriginIsolated";
import { useDesktopMenu } from "@/hooks/useDesktopMenu";
import { useIsDesktopLayout } from "@/hooks/useIsDesktopLayout";
import { useTranscriber } from "@/hooks/useTranscriber";
import { detectMediaKind, MEDIA_ACCEPT } from "@/lib/media";
import TopBar from "./TopBar";
import UploadScreen from "./UploadScreen";
import TranscriptPanel from "./TranscriptPanel";
Expand Down Expand Up @@ -137,6 +140,63 @@ export default function Editor() {
const [modeTransitioning, setModeTransitioning] = useState(false);
const wasIdle = useRef(status === "idle");

// File › Open Project… reaches the same picker the upload screen uses, from
// anywhere in the app.
const menuInputRef = useRef<HTMLInputElement>(null);
const isolation = useCrossOriginIsolated();
const isolated = isolation === "ready";
const openFilePicker = useCallback(() => menuInputRef.current?.click(), []);
useDesktopMenu(openFilePicker, isolated);

const startMenuFile = useCallback(
(file: File) => {
const { source, pendingTranscript } = useEditorStore.getState();
if (source === "import") {
if (!pendingTranscript) {
alert("Choose a transcript file from the source menu first.");
return;
}
loadVideo(file, {
words: pendingTranscript.words,
speakers: pendingTranscript.speakers,
});
return;
}
loadVideo(file);
},
[loadVideo]
);

// The pipeline needs SharedArrayBuffer, so a file picked before the page is
// cross-origin isolated waits here instead of failing on load.
const deferredFile = useRef<File | null>(null);
const startMenuFileRef = useRef(startMenuFile);
useEffect(() => {
startMenuFileRef.current = startMenuFile;
}, [startMenuFile]);
useEffect(() => {
if (!isolated || !deferredFile.current) return;
const file = deferredFile.current;
deferredFile.current = null;
startMenuFileRef.current(file);
}, [isolated]);

const handleMenuFile = useCallback(
(file: File | undefined) => {
if (!file) return;
if (!detectMediaKind(file)) {
alert("Please choose a video or audio file.");
return;
}
if (!isolated) {
deferredFile.current = file;
return;
}
startMenuFile(file);
},
[isolated, startMenuFile]
);

// Daily-active signal: reports the launch, then again on each day rollover so
// a long-running window doesn't look churned.
useEffect(() => startSessionReporting(), []);
Expand Down Expand Up @@ -335,6 +395,21 @@ export default function Editor() {
<LogoLoader size={44} />
</div>
)}
{isElectron && (
// Present in the layout tree (not display:none) so the native menu's
// click() reliably opens the picker.
<input
ref={menuInputRef}
type="file"
accept={MEDIA_ACCEPT}
className="sr-only"
onChange={(e) => {
handleMenuFile(e.target.files?.[0]);
// Allow re-picking the same file after a "start over".
e.target.value = "";
}}
/>
)}
<ExportDialog />
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion components/UploadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,8 @@ export default function UploadScreen({
/>
</label>

{ready && (
{/* On desktop the recent list lives in the native File menu instead. */}
{ready && !isElectron && (
<RecentProjects
projects={projects}
busyId={busyId}
Expand Down
103 changes: 102 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { app, BrowserWindow, ipcMain, protocol, screen, shell, net } from "electron";
import {
app,
BrowserWindow,
ipcMain,
protocol,
screen,
shell,
net,
type WebContents,
} from "electron";
import { join, normalize, extname } from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync, statSync } from "node:fs";
import { initMainSentry, setMainTelemetryEnabled } from "./sentry";
import { initAutoUpdater } from "./updater";
import {
buildAppMenu,
setRecentProjects,
type MenuCommand,
type RecentProject,
} from "./menu";

const isDev = !app.isPackaged;
const DEV_SERVER_URL = process.env.ELECTRON_START_URL ?? "http://localhost:3000";
Expand Down Expand Up @@ -121,6 +136,45 @@ function registerAppProtocol(): void {
/** Tracks each window's current mode so repeated requests are no-ops. */
const windowModes = new WeakMap<BrowserWindow, WindowMode>();

/** Renderers that have mounted and subscribed to menu commands. A freshly
* created (or reloading) window isn't listening yet, so its commands wait. */
const readyRenderers = new WeakSet<WebContents>();
const pendingCommands = new WeakMap<WebContents, MenuCommand[]>();

function deliverMenuCommand(contents: WebContents, command: MenuCommand): void {
if (command.type === "open-file") {
// Chromium only opens a file chooser under user activation, which an IPC
// message doesn't carry — the click() is silently dropped. executeJavaScript
// can grant one, so the picker is driven that way instead.
void contents
.executeJavaScript("window.rescriptOpenFilePicker?.()", true)
.catch((err: unknown) => console.error("Failed to open the file picker.", err));
return;
}
contents.send("menu:command", command);
}

function flushPendingCommands(contents: WebContents): void {
const queued = pendingCommands.get(contents);
pendingCommands.delete(contents);
for (const command of queued ?? []) deliverMenuCommand(contents, command);
}

/** Deliver a File-menu command, launching a window if the app is running
* window-less (macOS keeps the menu bar after the last window closes). */
function dispatchMenuCommand(command: MenuCommand): void {
const win =
BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? createWindow();
const contents = win.webContents;
if (readyRenderers.has(contents)) {
deliverMenuCommand(contents, command);
return;
}
const queued = pendingCommands.get(contents) ?? [];
queued.push(command);
pendingCommands.set(contents, queued);
}

function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
Expand Down Expand Up @@ -160,6 +214,10 @@ function applyWindowMode(win: BrowserWindow, mode: WindowMode): void {
);
}

/** Set once the app is really terminating, so the close interception below
* doesn't swallow the quit. */
let quitting = false;

function createWindow(): BrowserWindow {
const win = new BrowserWindow({
...WINDOW_SIZES.compact,
Expand Down Expand Up @@ -192,6 +250,25 @@ function createWindow(): BrowserWindow {

win.once("ready-to-show", () => win.show());

// A reload tears down the listener the renderer registered; make it re-announce.
win.webContents.on("did-start-navigation", (event) => {
if (event.isSameDocument) return;
readyRenderers.delete(win.webContents);
pendingCommands.delete(win.webContents);
});

// Closing while the editor is open drops the project rather than the window:
// the renderer returns to the upload screen and the shell shrinks back. The
// next close (already on the upload screen) is a real close. Guarded on the
// renderer being live, so an unresponsive page can still be closed.
win.on("close", (event) => {
if (quitting) return;
if (windowModes.get(win) !== "expanded") return;
if (!readyRenderers.has(win.webContents)) return;
event.preventDefault();
win.webContents.send("menu:command", { type: "close-project" } satisfies MenuCommand);
});

// The page pads its top bar for the traffic lights, which macOS hides in
// full screen; tell it when that changes so the gap can collapse.
const emitFullScreen = () => {
Expand Down Expand Up @@ -257,9 +334,33 @@ if (!gotLock) {
ipcMain.on("telemetry:set-enabled", (_event, value: unknown) => {
setMainTelemetryEnabled(value === true);
});
// The saved projects live in the renderer's IndexedDB; it pushes a snapshot
// whenever the list changes so the File menu can list them.
ipcMain.on("menu:set-recents", (_event, value: unknown) => {
if (!Array.isArray(value)) return;
const recents: RecentProject[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const { id, name } = entry as { id?: unknown; name?: unknown };
if (typeof id !== "string" || typeof name !== "string") continue;
recents.push({ id, name });
}
setRecentProjects(recents);
});
// The renderer announces itself once it is listening for menu commands; until
// then anything the menu fired at a just-opened window is held.
ipcMain.on("menu:renderer-ready", (event) => {
readyRenderers.add(event.sender);
flushPendingCommands(event.sender);
});

app.on("before-quit", () => {
quitting = true;
});

app.whenReady().then(() => {
if (!isDev) registerAppProtocol();
buildAppMenu(dispatchMenuCommand);
createWindow();
initAutoUpdater();

Expand Down
Loading
Loading