1import { useEffect, useState } from 'react';
2import type { PublicConfig } from '../../shared/types.ts';
3import { OUTPUT_HEIGHTS } from '../../shared/types.ts';
4import { api } from '../api.ts';
5import { Icon } from '../components/Icon.tsx';
7export function Settings() {
8 const [cfg, setCfg] = useState<PublicConfig | null>(null);
9 const [key, setKey] = useState('');
10 const [status, setStatus] = useState<string | null>(null);
11 const [error, setError] = useState<string | null>(null);
13 const load = () => api.config().then(setCfg).catch((e) => setError(e.message));
14 useEffect(() => {
15 document.title = 'Settings · Takes';
16 load();
17 }, []);
19 const save = async (patch: Record<string, unknown>) => {
20 try {
21 await api.saveConfig(patch);
22 await load();
23 setStatus('Saved');
24 setTimeout(() => setStatus(null), 1500);
25 } catch (e) {
26 setError((e as Error).message);
27 }
28 };
30 if (!cfg) return <div className="page loading">{error ?? 'Loading…'}</div>;
32 return (
33 <div className="page">
34 <header className="topbar">
35 <a className="btn ghost" href="#/">
36 <Icon name="back" /> Videos
37 </a>
38 <div className="topbar-title">Settings</div>
39 <div className="spacer" />
40 {status && <span className="save-state saved">{status}</span>}
41 </header>
42 {error && <div className="banner error">{error}</div>}
43 <main className="settings">
44 <section className="panel">
45 <h4>Library</h4>
46 <p>
47 Videos are stored in <code>{cfg.libraryDir}</code>. Start the app with <code>--dir</code> to use another directory.
48 </p>
49 </section>
51 <section className="panel">
52 <h4>Transcription (Deepgram)</h4>
53 <p className="muted small">
54 The key is stored in <code>config.json</code> in the library directory, readable only by you. Audio is sent to Deepgram only when a take is transcribed.
55 </p>
56 <div className="row">
57 <input type="password" className="grow" placeholder={cfg.hasDeepgramKey ? 'A key is saved (enter a new one to replace it)' : 'Deepgram API key'} value={key} onChange={(e) => setKey(e.target.value)} />
58 <button
59 className="btn primary"
60 disabled={!key.trim()}
61 onClick={async () => {
62 await save({ deepgramApiKey: key });
63 setKey('');
64 }}
65 >
66 Save key
67 </button>
68 {cfg.hasDeepgramKey && (
69 <button className="btn ghost danger" onClick={() => save({ deepgramApiKey: '' })}>
70 Remove
71 </button>
72 )}
73 </div>
74 <label className="field">
75 <span>Model</span>
76 <input defaultValue={cfg.deepgramModel} onBlur={(e) => e.target.value !== cfg.deepgramModel && save({ deepgramModel: e.target.value })} />
77 </label>
78 <label className="check">
79 <input type="checkbox" checked={cfg.autoTranscribe} onChange={(e) => save({ autoTranscribe: e.target.checked })} />
80 Transcribe new takes automatically
81 </label>
82 </section>
84 <section className="panel">
85 <h4>Background removal</h4>
86 <p className="muted small">
87 By default the camera background is removed on this computer’s CPU. To use a machine with an NVIDIA GPU instead, enter its ssh destination (for example <code>office</code> or <code>user@host</code>). It must accept key-based login without a password prompt and have Python 3.10 or later and ffmpeg; the first job installs onnxruntime-gpu in <code>~/.cache/takes/venv</code> there. Each camera track is copied to that machine for processing and then deleted from it.
88 </p>
89 <label className="field">
90 <span>ssh host</span>
91 <input placeholder="This computer" defaultValue={cfg.matteHost} onBlur={(e) => e.target.value.trim() !== cfg.matteHost && save({ matteHost: e.target.value })} />
92 </label>
93 <label className="field">
94 <span>Setup command on that host</span>
95 <input placeholder="e.g. module load python ffmpeg" defaultValue={cfg.matteSetup} onBlur={(e) => e.target.value.trim() !== cfg.matteSetup && save({ matteSetup: e.target.value })} />
96 </label>
97 </section>
99 <section className="panel">
100 <h4>Defaults</h4>
101 <label className="row">
102 <span>Countdown before recording</span>
103 <select value={cfg.defaultCountdown} onChange={(e) => save({ defaultCountdown: parseInt(e.target.value) })}>
104 {[0, 3, 5, 10].map((n) => (
105 <option key={n} value={n}>
106 {n === 0 ? 'None' : `${n} s`}
107 </option>
108 ))}
109 </select>
110 </label>
111 <label className="row">
112 <span>Camera resolution</span>
113 <select value={cfg.defaultCameraHeight} onChange={(e) => save({ defaultCameraHeight: parseInt(e.target.value) })}>
114 <option value={720}>720p</option>
115 <option value={1080}>1080p</option>
116 </select>
117 </label>
118 <label className="row">
119 <span>Export resolution for new videos</span>
120 <select value={cfg.defaultOutputHeight} onChange={(e) => save({ defaultOutputHeight: parseInt(e.target.value) })}>
121 {OUTPUT_HEIGHTS.map((h) => (
122 <option key={h} value={h}>
123 {h}p
124 </option>
125 ))}
126 </select>
127 </label>
128 <p className="muted small">The recorder remembers your last choices; these defaults apply until you change them there.</p>
129 </section>
130 </main>
131 </div>
132 );
133}