Recently Written · git

desktop-subtitle-replay

git clone https://github.com/equwal/desktop-subtitle-replay

Log | Files | Refs


control.html (8505 bytes)

1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>livecap control</title>
7 <style>
8   :root {
9     --bg: #0f1115; --panel: #171a21; --line: #262b36;
10     --fg: #e8ecf3; --dim: #98a2b3; --accent: #7dd3fc; --ok: #4ade80; --warn: #fbbf24;
11   }
12   * { box-sizing: border-box; }
13   body {
14     margin: 0; padding: 24px; background: var(--bg); color: var(--fg);
15     font: 15px/1.5 "Inter", "Segoe UI", system-ui, sans-serif;
16   }
17   .wrap { max-width: 760px; margin: 0 auto; display: grid; gap: 18px; }
18   h1 { font-size: 18px; margin: 0; font-weight: 650; letter-spacing: .01em; }
19   h1 span { color: var(--dim); font-weight: 400; font-size: 13px; margin-left: 8px; }
20   .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
21   .card h2 {
22     font-size: 11px; text-transform: uppercase; letter-spacing: .09em;
23     color: var(--dim); margin: 0 0 12px; font-weight: 600;
24   }
25   .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
26   button {
27     font: inherit; font-weight: 550; color: var(--fg); cursor: pointer;
28     background: #1e2430; border: 1px solid var(--line); border-radius: 7px;
29     padding: 8px 14px; transition: background .12s, border-color .12s;
30   }
31   button:hover:not(:disabled) { background: #273040; border-color: #3a4256; }
32   button.on { background: var(--accent); border-color: var(--accent); color: #06202c; }
33   button:disabled { opacity: .45; cursor: not-allowed; }
34   button .k {
35     font-size: 11px; color: var(--dim); margin-left: 7px;
36     font-variant-numeric: tabular-nums;
37   }
38   button.on .k { color: #06202c; opacity: .65; }
39   select {
40     font: inherit; color: var(--fg); background: #1e2430;
41     border: 1px solid var(--line); border-radius: 7px; padding: 8px 12px;
42   }
43   .status { display: flex; gap: 18px; flex-wrap: wrap; align-items: baseline; }
44   .status b { font-weight: 600; }
45   .dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 7px; }
46   .dot.up { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
47   .dot.down { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
48   .dot.busy { background: var(--warn); box-shadow: 0 0 8px var(--warn); }
49   #feed { display: grid; gap: 7px; min-height: 76px; }
50   .cap { padding: 8px 11px; background: #12161d; border-left: 2px solid var(--line); border-radius: 4px; }
51   .cap.partial { color: var(--dim); font-style: italic; border-left-color: var(--warn); }
52   .cap .tr { display: block; color: var(--accent); font-size: 13px; margin-top: 3px; }
53   .empty { color: var(--dim); font-size: 13px; }
54   .hint { color: var(--dim); font-size: 12.5px; margin-top: 10px; }
55   code { background: #12161d; padding: 2px 6px; border-radius: 4px; font-size: 12.5px; }
56 </style>
57 </head>
58 <body>
59 <div class="wrap">
60   <h1>livecap<span id="conn">connecting…</span></h1>
61 
62   <div class="card">
63     <h2>Language</h2>
64     <div class="row" id="langs"></div>
65     <div class="hint">Press <code>1</code>–<code>9</code> to switch instantly. Takes
66       effect on the next sentence; no restart, no model reload.</div>
67   </div>
68 
69   <div class="card">
70     <h2>Model</h2>
71     <div class="row">
72       <select id="model"></select>
73       <span id="loading" class="empty"></span>
74     </div>
75     <div class="hint">Switching reloads the model and pauses captions for a few
76       seconds. Bigger is more accurate but slower.</div>
77   </div>
78 
79   <div class="card">
80     <h2>Options</h2>
81     <div class="row">
82       <button id="translate">English translation</button>
83       <button id="partials">Live partial text</button>
84       <button id="test">Send test caption</button>
85       <button id="clear">Clear captions</button>
86     </div>
87   </div>
88 
89   <div class="card">
90     <h2>Live output</h2>
91     <div id="feed"><div class="empty">Waiting for speech…</div></div>
92   </div>
93 </div>
94 
95 <script>
96 (function () {
97   const q = new URLSearchParams(location.search);
98   const WS_PORT = parseInt(q.get("ws") || "8765", 10);
99 
100   // Whisper has no regional variants: Argentine Spanish is served by "es".
101   const NAMES = {
102     fi: "Finnish", ru: "Russian", ja: "Japanese", es: "Spanish",
103     pt: "Portuguese", en: "English", sv: "Swedish", de: "German",
104     fr: "French", it: "Italian", pl: "Polish", uk: "Ukrainian",
105     et: "Estonian", no: "Norwegian", da: "Danish", nl: "Dutch",
106     zh: "Chinese", ko: "Korean", auto: "Auto-detect"
107   };
108 
109   let ws = null, state = null;
110   const feed = [];
111 
112   const el = (id) => document.getElementById(id);
113   const send = (cmd, value) => ws && ws.readyState === 1 &&
114         ws.send(JSON.stringify({ cmd: cmd, value: value }));
115 
116   function renderControls() {
117     if (!state) return;
118 
119     const langs = el("langs");
120     langs.innerHTML = "";
121     state.langs.forEach((code, i) => {
122       const b = document.createElement("button");
123       b.className = code === state.lang ? "on" : "";
124       b.innerHTML = "";
125       b.appendChild(document.createTextNode(NAMES[code] || code));
126       if (i < 9) {
127         const k = document.createElement("span");
128         k.className = "k";
129         k.textContent = i + 1;
130         b.appendChild(k);
131       }
132       b.onclick = () => send("set_lang", code);
133       langs.appendChild(b);
134     });
135 
136     const sel = el("model");
137     if (sel.dataset.key !== state.models.join(",")) {
138       sel.dataset.key = state.models.join(",");
139       sel.innerHTML = "";
140       state.models.forEach((m) => {
141         const o = document.createElement("option");
142         o.value = m; o.textContent = m;
143         sel.appendChild(o);
144       });
145     }
146     sel.value = state.model;
147     sel.disabled = state.loading;
148     el("loading").textContent = state.loading ? "loading model…" : "";
149 
150     el("translate").className = state.translate ? "on" : "";
151     el("partials").className = state.partials ? "on" : "";
152 
153     el("conn").innerHTML = "";
154     const dot = document.createElement("span");
155     dot.className = "dot " + (state.loading ? "busy" : "up");
156     el("conn").appendChild(dot);
157     let label = NAMES[state.lang] || state.lang;
158     if (state.lang === "auto") {
159       label += state.detected
160         ? " → " + (NAMES[state.detected] || state.detected)
161         : " (listening…)";
162     }
163     el("conn").appendChild(document.createTextNode(label + " · " + state.model));
164   }
165 
166   function renderFeed() {
167     const box = el("feed");
168     box.innerHTML = "";
169     if (!feed.length) {
170       box.innerHTML = '<div class="empty">Waiting for speech…</div>';
171       return;
172     }
173     feed.slice(-6).forEach((m) => {
174       const d = document.createElement("div");
175       d.className = "cap" + (m.type === "partial" ? " partial" : "");
176       d.textContent = m.text;
177       if (m.tr) {
178         const s = document.createElement("span");
179         s.className = "tr";
180         s.textContent = m.tr;
181         d.appendChild(s);
182       }
183       box.appendChild(d);
184     });
185     box.scrollTop = box.scrollHeight;
186   }
187 
188   function connect() {
189     ws = new WebSocket("ws://127.0.0.1:" + WS_PORT);
190 
191     ws.onopen = () => send("status");
192 
193     ws.onclose = () => {
194       el("conn").innerHTML = '<span class="dot down"></span>disconnected — is run.ps1 still going?';
195       setTimeout(connect, 1500);
196     };
197     ws.onerror = () => ws.close();
198 
199     ws.onmessage = (ev) => {
200       let m;
201       try { m = JSON.parse(ev.data); } catch (e) { return; }
202 
203       if (m.type === "status") {
204         state = m;
205         renderControls();
206       } else if (m.type === "clear") {
207         feed.length = 0;
208         renderFeed();
209       } else if (m.type === "partial") {
210         if (feed.length && feed[feed.length - 1].type === "partial") feed.pop();
211         feed.push(m);
212         renderFeed();
213       } else if (m.type === "final") {
214         if (feed.length && feed[feed.length - 1].type === "partial") feed.pop();
215         feed.push(m);
216         if (feed.length > 40) feed.shift();
217         renderFeed();
218       }
219     };
220   }
221 
222   el("model").onchange = (e) => send("set_model", e.target.value);
223   el("translate").onclick = () => state && send("set_translate", !state.translate);
224   el("partials").onclick = () => state && send("set_partials", !state.partials);
225   el("clear").onclick = () => send("clear");
226   el("test").onclick = () => send("test");
227 
228   document.addEventListener("keydown", (e) => {
229     if (e.target.tagName === "SELECT") return;
230     const n = parseInt(e.key, 10);
231     if (state && n >= 1 && n <= 9 && state.langs[n - 1]) {
232       send("set_lang", state.langs[n - 1]);
233     }
234   });
235 
236   connect();
237 })();
238 </script>
239 </body>
240 </html>