feat: improve dictation controls and creative workspaces

This commit is contained in:
Palash Debnath
2026-09-09 18:55:19 +05:30
parent 8f140e550d
commit 99a534882c
75 changed files with 4319 additions and 1591 deletions
+5
View File
@@ -33,6 +33,11 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
- `frontend/package.json` dep changes require regenerating root `bun.lock` (Docker runs `--frozen-lockfile`).
- Issues: absorb or decline — never defer to a future version. Check the open-PR queue before implementing community-reported fixes.
## Shared select controls
- Use `frontend/src/components/SearchableSelect.jsx` for all new or redesigned select boxes. Reuse `VoiceSelector` for voice choices. Do not introduce native `<select>` controls.
- Provide a localized `ariaLabel`; use `menuPortal` inside scrolling or clipping containers. Preserve keyboard selection and disabled states.
## Agent skills
Project development skills are pinned in `skills-lock.json` and installed under
+6
View File
@@ -9,6 +9,8 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
- The floating dictation bubble now has pause, resume, stop, and close controls. (#1903)
- Transcriptions checks model readiness and offers an inline download before recording. (#1903)
- Validate current-user Windows installers under a standard account on hosted runners (#1883)
@@ -67,6 +69,10 @@ the frozen-backend fallback mirror it for their toolchains.
- Speak tilde separators in integer, signed, and decimal ranges in English, Korean, Japanese, and Chinese (#1821) — thanks @flutterkage2k!
- Keep recording and conversion work safe while switching methods, synchronize dubbing language controls, and localize timeline controls and timing warnings (#1841)
- Audiobook is now a Write → Cast → Produce tab workspace matching the voice workspace, with the warnings/progress/result rail pinned below (#1841)
- Gallery uses a workspace header with zone tabs, hairline section dividers, theme-token cards, and borderless import rows (#1841)
- Gallery cards reset native button faces, cluster icon actions in the header so Use voice never wraps, and use a roomier grid floor (#1841)
- Gallery filters gain name search, removable iconified pills with clear-all, and dimension icons on every facet (#1841)
- Dubbing playback starts before waveform decoding, automatic cast names are readable, and transcript timestamps have more room (#1823)
- The title-bar engine button stays compact and stable while cycling labels, with engine names aligned right (#1823)
+3 -1
View File
@@ -59,7 +59,9 @@
The Voice workspace starts with three tabs: **From audio** for cloning, **By design** for creating a voice, and **Convert** for speech-to-speech conversion. Each tab displays its own workflow, with Synthesize Audio or Convert pinned below the scrolling form. The top-bar **Engines** panel combines engine selection, loaded models, and unload/flush controls; <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> opens it. The searchable language picker shares Dubbings flags and language list layout, selects one output language, and retains Auto and the full cloning catalogue. Language options flow into multiple columns when space allows. Expand **Workspaces** in the sidebar to reveal navigation labels; Escape collapses it.
Dubbing places playback controls over the video with background blur and combines the waveform and timed transcript in one compact editing surface. Drag the zoomed waveform left or right to pan; click to seek. Translation language and ISO-code controls stay synchronized; Auto clears any previous language code and dialect. Transcript items group editable text, timing and status, and voice controls into three readable rows that wrap with the panel width. Output Options stays compact with the active settings shown in its summary; expand it to change output, timing, or voice matching. Transcript, glossary, and paste controls share a toolbar above the segment editor. Project details, workflow steps, and Generate/Verify/Export actions use an unfilled header.
Dubbing starts with file upload or URL import and nearby language choices. Its **Projects** panel lists previous dubs so they can be reopened by clicking anywhere on a card; action buttons operate independently. Advanced import options include captions and optional YouTube sign-in. Dubbing places playback controls over the video with background blur and combines the waveform and timed transcript in one compact editing surface. Drag the zoomed waveform left or right to pan; click to seek. Translation language and ISO-code controls stay synchronized; Auto clears any previous language code and dialect. Transcript items group editable text, timing and status, and voice controls into three readable rows that wrap with the panel width. Output Options stays compact with the active settings shown in its summary; expand it to change output, timing, or voice matching. Transcript, glossary, and paste controls share a toolbar above the segment editor. Project details, workflow steps, and Generate/Verify/Export actions use an unfilled header.
The Audiobook Script editor fills the available workspace beneath its markup toolbar; Voices and Book settings stay in their own tabs.
Output settings use aligned rows; review status appears before the collapsible transcript and glossary. Glossary terms have labelled entry fields and an explicit edit action. Launchpad arranges recent files and saved voices side by side when space allows, with responsive card grids and visible Open actions.
+9 -1
View File
@@ -349,6 +349,7 @@ async def ws_transcribe(websocket: WebSocket):
audio_chunks: list[bytes] = []
total_bytes = 0
last_audio_time = time.monotonic()
paused = False
running = True
partial_text = ""
# Track whether the client initiated the disconnect. When True the
@@ -366,7 +367,7 @@ async def ws_transcribe(websocket: WebSocket):
message as the authoritative result and skip the duplicate HTTP
POST that used to run on every dictation.
"""
nonlocal total_bytes, last_audio_time, running, client_disconnected
nonlocal total_bytes, last_audio_time, running, client_disconnected, paused
try:
while running:
msg = await websocket.receive()
@@ -397,6 +398,10 @@ async def ws_transcribe(websocket: WebSocket):
total_bytes += len(data)
last_audio_time = time.monotonic()
continue
if msg.get("text") in ("PAUSE", "RESUME"):
paused = msg["text"] == "PAUSE"
last_audio_time = time.monotonic()
continue
if _is_end_control(msg.get("text")):
# Client signals end-of-audio but stays connected for `final`.
running = False
@@ -429,6 +434,9 @@ async def ws_transcribe(websocket: WebSocket):
if not running:
break
if paused:
continue
# Check silence timeout
if time.monotonic() - last_audio_time > SILENCE_TIMEOUT_S and total_bytes > MIN_BUFFER_BYTES:
running = False
+12
View File
@@ -86,6 +86,18 @@ def list_dictation_models():
}
@router.get("/dictation/readiness", dependencies=[Depends(require_local)])
def dictation_readiness(model_id: str | None = None) -> dict:
"""Check capture's model selection without loading or downloading weights."""
from services.asr_backend import asr_model_missing_error
missing = asr_model_missing_error(
purpose="dictation",
sherpa_model_id=model_id or _read_prefs()["model_id"],
)
return {"ready": missing is None, "missing": missing}
@router.get("/dictation/prefs", dependencies=[Depends(require_local)])
def get_dictation_prefs():
return _read_prefs()
+18
View File
@@ -17,6 +17,14 @@ own microphone audio to the versioned WebSocket API. See the
3. Put the cursor in a text field, press the shortcut, speak, then release or
press again.
The **Transcriptions** page offers the same recorder as one contextual
**Start dictation** action: it appears in the empty state before the first
transcript and moves to the page header once history exists. A desktop start
wakes the recorder window before dispatch, so a hidden WebView cannot silently
miss the request. The in-app action confirms listener receipt, then resolves
only after microphone startup is accepted. Disabled, rejected, timed-out, or
failed starts are reported back on the page.
Whisper Tiny is the recommended default on macOS, Windows, and Linux. It
auto-detects more than 90 languages. Parakeet TDT v3 remains available for its
25 supported European languages, but it is not selected automatically.
@@ -67,3 +75,13 @@ changes. `dotool` needs direct write access to `/dev/uinput`; `ydotool` 1.0+
needs a running `ydotoold` with that access and a user-readable socket.
VoiceStudio checks these prerequisites before selection. Tray-started Wayland
dictation always stays copy-only.
### Transcriptions model setup
Transcriptions checks the active dictation model before enabling **Start dictation**. If weights are missing, download the recommended model directly on the page; its name, download size, and installation progress are shown. Downloads require an explicit click. Failed downloads can be retried, and model state refreshes when returning from Settings. Once installation is verified, Start dictation becomes available; recording never starts automatically. Existing transcription history remains accessible during setup.
### Floating recording controls
The recording bubble includes **Pause / Resume**, **Stop**, and **Close**. Pause disables microphone tracks, stops sending new audio, and freezes the elapsed recording timer while preserving the session. Resume continues the same session. Stop (or the recording shortcut) finishes and transcribes, including when paused. Close cancels pending recording/transcription and releases the microphone; text already delivered to another app cannot be retracted. Pausing is manual, not triggered by silence or desktop inactivity.
The Transcriptions page displays the effective recording shortcut and the platform paste shortcut. The floating bubble places its live transcript below the controls in a multiline preview, so controls cannot squeeze the text into a few characters.
+2 -1
View File
@@ -300,7 +300,8 @@ version** reverts to the build the app shipped with.
First update yt-dlp under **Settings → Audio tools**. If YouTube still requires
your signed-in session, export its cookies in Netscape `cookies.txt` format,
then choose that file beside the URL field before importing. VoiceStudio uses
then open **Advanced** on the dubbing import card and choose that file under
**YouTube sign-in** before importing. VoiceStudio uses
the export for that import only and makes two best-effort attempts to delete
its temporary copy.
@@ -31,11 +31,11 @@ Nothing studio-grade is on the default surface; everything is one click away.
### Full-height pro workflow (2026-08-11)
1. **Start working immediately:** on a pristine install, Stories creates and opens **The Lighthouse at Wits' End** as a normal saved project. Its 2 chapters, 3-character cast, pauses, expressive tags, and voice assignments exercise the real preview, stems, and Generate paths—not a visual mock.
2. **Set up on the left:** a persistent production rail owns project naming/saving, saved projects, cast-to-voice mapping, global pacing, and stems. Sections collapse independently without covering the manuscript.
2. **Choose a workspace tab:** Script holds the manuscript, Cast maps characters to voices, Export holds pacing and output options, and Projects handles naming, saving, and opening stories. Tabs share the Clone workspace navigation and support keyboard arrow keys.
3. **Write in the center:** the full-height manuscript canvas owns import, paste/auto-cast, line and chapter creation, editing, reorder, preview, and per-line direction. Long stories stay fast through `content-visibility`.
4. **Deliver from the header:** story length, runtime, format, progress, and Generate stay reachable while the manuscript scrolls.
4. **Deliver from Export:** choose the audio format and reading speed, generate the complete story, or export character stems. Story length and estimated runtime remain visible in the header.
The hierarchy is spatial instead of label-heavy: setup rail → manuscript → output header. The default sample is authored content stored through the same project actions as user work, automatically adopts installed voice profiles, and remains fully editable or deletable.
The task tabs give the manuscript the full workspace width; project data and unfinished text remain intact when switching tabs. The default sample is authored content stored through the same project actions as user work, automatically adopts installed voice profiles, and remains fully editable or deletable.
## 3. Interaction model (chosen: line cards)
+153 -5
View File
@@ -12,7 +12,7 @@ use tauri_plugin_dialog::DialogExt;
use crate::config::{load_config, save_config};
use crate::dictation_shortcut::{update_tray_hint, DictationShortcutManager, ShortcutInfo};
use crate::{AppFlags, TrayHandle};
use crate::{AppFlags, CaptureAcceptanceTimeout, CaptureReceiptCancellation, TrayHandle};
use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING};
// ── Native host-path authorization ───────────────────────────────────────
@@ -1006,12 +1006,145 @@ pub fn get_effective_dictation_shortcut(
}
#[tauri::command]
pub fn request_dictation_capture(app: tauri::AppHandle, action: String) -> Result<(), String> {
pub async fn request_dictation_capture(
app: tauri::AppHandle,
action: String,
) -> Result<(), String> {
if action != "start" && action != "stop" && action != "toggle" {
return Err("capture action must be start, stop, or toggle".into());
}
crate::dispatch_dictation_capture(&app, &action);
Ok(())
let delivery_id = crate::request_dictation_capture_delivery(&app, &action)
.ok_or_else(|| "capture request could not be queued".to_string())?;
let wait_app = app.clone();
let mut acknowledged = tauri::async_runtime::spawn_blocking(move || {
wait_for_capture_delivery(
|| {
let flags = wait_app.state::<AppFlags>();
let capture = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
Ok(capture.delivery_pending(delivery_id))
},
CAPTURE_DELIVERY_TIMEOUT,
)
})
.await
.map_err(|error| format!("capture acknowledgement worker failed: {error}"))??;
if !acknowledged {
let flags = app.state::<AppFlags>();
let timeout_outcome = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.cancel_unreceived_delivery(delivery_id);
match timeout_outcome {
CaptureReceiptCancellation::Received => acknowledged = true,
CaptureReceiptCancellation::Cancelled(event) => {
if event.name == "tray-dictate" {
flags.output.finish_session(event.payload.session_id);
}
return Err("capture window did not acknowledge the request".into());
}
CaptureReceiptCancellation::Missing => {
return Err("capture request disappeared before acknowledgement".into());
}
}
}
debug_assert!(acknowledged);
let outcome_app = app.clone();
let completed = tauri::async_runtime::spawn_blocking(move || {
wait_for_capture_delivery(
|| {
let flags = outcome_app.state::<AppFlags>();
let capture = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
Ok(!capture.completion_ready(delivery_id))
},
CAPTURE_ACCEPTANCE_TIMEOUT,
)
})
.await
.map_err(|error| format!("capture acceptance worker failed: {error}"))??;
let flags = app.state::<AppFlags>();
if completed {
let completion = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.take_completion(delivery_id);
return completion
.unwrap_or_else(|| Err("capture request completed without an outcome".into()));
}
let timeout_outcome = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.take_completion_or_cancel(delivery_id);
match timeout_outcome {
CaptureAcceptanceTimeout::Completed(completion) => return completion,
CaptureAcceptanceTimeout::Cancelled(event) if event.name == "tray-dictate" => {
flags.output.finish_session(event.payload.session_id);
}
CaptureAcceptanceTimeout::Cancelled(_) | CaptureAcceptanceTimeout::Missing => {}
}
Err("dictation capture did not start in time".into())
}
const CAPTURE_DELIVERY_TIMEOUT: Duration = Duration::from_secs(2);
const CAPTURE_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(60);
const CAPTURE_DELIVERY_POLL: Duration = Duration::from_millis(20);
fn wait_for_capture_delivery<F>(mut pending: F, timeout: Duration) -> Result<bool, String>
where
F: FnMut() -> Result<bool, String>,
{
let deadline = Instant::now() + timeout;
loop {
if !pending()? {
return Ok(true);
}
if Instant::now() >= deadline {
return Ok(false);
}
std::thread::sleep(CAPTURE_DELIVERY_POLL);
}
}
#[cfg(test)]
mod capture_request_tests {
use super::wait_for_capture_delivery;
use std::time::Duration;
#[test]
fn listener_acknowledgement_completes_the_request() {
let mut polls = 0;
let acknowledged = wait_for_capture_delivery(
|| {
polls += 1;
Ok(polls < 2)
},
Duration::from_millis(50),
)
.expect("poll succeeds");
assert!(acknowledged);
}
#[test]
fn missing_listener_acknowledgement_times_out() {
let acknowledged = wait_for_capture_delivery(
|| Ok(true),
Duration::from_millis(0),
)
.expect("poll succeeds");
assert!(!acknowledged);
}
}
/// Distance from the bottom edge of the work area, in logical pixels — clear of
@@ -1162,13 +1295,28 @@ pub fn acknowledge_dictation_capture_delivery(
app: tauri::AppHandle,
registration_id: u64,
delivery_id: u64,
) -> bool {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return false;
};
capture.acknowledge(registration_id, delivery_id)
}
#[tauri::command]
pub fn complete_dictation_capture_delivery(
app: tauri::AppHandle,
registration_id: u64,
delivery_id: u64,
error: Option<String>,
) {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
capture.acknowledge(registration_id, delivery_id);
capture.complete(registration_id, delivery_id, error);
}
#[tauri::command]
+286 -25
View File
@@ -26,7 +26,7 @@ pub mod watch_folder;
#[cfg(target_os = "linux")]
pub mod wayland_shortcut;
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::process::Child;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -107,6 +107,29 @@ pub struct CaptureDispatchState {
registration_counter: u64,
delivery_counter: u64,
active_registration: Option<u64>,
in_flight: HashMap<u64, CaptureInFlight>,
}
struct CaptureInFlight {
event: CaptureEvent,
outcome: Option<Result<(), String>>,
}
pub(crate) enum CaptureReceiptCancellation {
Received,
Cancelled(CaptureEvent),
Missing,
}
pub(crate) enum CaptureAcceptanceTimeout {
Completed(Result<(), String>),
Cancelled(CaptureEvent),
Missing,
}
struct CaptureEnqueue {
delivery_id: u64,
event: Option<CaptureEvent>,
}
impl Default for CaptureDispatchState {
@@ -117,6 +140,7 @@ impl Default for CaptureDispatchState {
registration_counter: 0,
delivery_counter: 0,
active_registration: None,
in_flight: HashMap::new(),
}
}
}
@@ -147,25 +171,125 @@ impl CaptureDispatchState {
.collect()
}
pub(crate) fn enqueue(&mut self, mut event: CaptureEvent) -> Option<CaptureEvent> {
fn enqueue(&mut self, mut event: CaptureEvent) -> CaptureEnqueue {
self.delivery_counter = self.delivery_counter.wrapping_add(1).max(1);
event.payload.delivery_id = self.delivery_counter;
self.pending.push_back(event.clone());
let registration_id = self.active_registration.filter(|_| self.ready)?;
event.payload.registration_id = registration_id;
Some(event)
let ready_event = self
.active_registration
.filter(|_| self.ready)
.map(|registration_id| {
event.payload.registration_id = registration_id;
event
});
CaptureEnqueue {
delivery_id: self.delivery_counter,
event: ready_event,
}
}
pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) {
pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) -> bool {
if self.active_registration != Some(registration_id) {
return;
return false;
}
if let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
{
self.pending.remove(index);
if let Some(event) = self.pending.remove(index) {
if event.await_result {
self.in_flight.insert(
delivery_id,
CaptureInFlight {
event,
outcome: None,
},
);
}
return true;
}
}
false
}
pub(crate) fn complete(
&mut self,
registration_id: u64,
delivery_id: u64,
error: Option<String>,
) {
if self.active_registration != Some(registration_id) {
return;
}
if let Some(delivery) = self.in_flight.get_mut(&delivery_id) {
delivery.outcome = Some(error.map_or_else(|| Ok(()), Err));
}
}
pub(crate) fn completion_ready(&self, delivery_id: u64) -> bool {
self.in_flight
.get(&delivery_id)
.is_some_and(|delivery| delivery.outcome.is_some())
}
pub(crate) fn take_completion(&mut self, delivery_id: u64) -> Option<Result<(), String>> {
let ready = self.completion_ready(delivery_id);
ready
.then(|| self.in_flight.remove(&delivery_id))
.flatten()
.and_then(|delivery| delivery.outcome)
}
pub(crate) fn delivery_pending(&self, delivery_id: u64) -> bool {
self.pending
.iter()
.any(|event| event.payload.delivery_id == delivery_id)
}
pub(crate) fn cancel_unreceived_delivery(
&mut self,
delivery_id: u64,
) -> CaptureReceiptCancellation {
if self.in_flight.contains_key(&delivery_id) {
return CaptureReceiptCancellation::Received;
}
let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
else {
return CaptureReceiptCancellation::Missing;
};
self.pending
.remove(index)
.map_or(CaptureReceiptCancellation::Missing, CaptureReceiptCancellation::Cancelled)
}
pub(crate) fn cancel_delivery(&mut self, delivery_id: u64) -> Option<CaptureEvent> {
if let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
{
return self.pending.remove(index);
}
self.in_flight
.remove(&delivery_id)
.map(|delivery| delivery.event)
}
pub(crate) fn take_completion_or_cancel(
&mut self,
delivery_id: u64,
) -> CaptureAcceptanceTimeout {
if let Some(completion) = self.take_completion(delivery_id) {
CaptureAcceptanceTimeout::Completed(completion)
} else {
self.cancel_delivery(delivery_id).map_or(
CaptureAcceptanceTimeout::Missing,
CaptureAcceptanceTimeout::Cancelled,
)
}
}
@@ -189,6 +313,7 @@ pub(crate) struct DictationCapturePayload {
pub(crate) struct CaptureEvent {
pub(crate) name: &'static str,
pub(crate) payload: DictationCapturePayload,
await_result: bool,
}
pub struct TrayHandle {
@@ -205,10 +330,22 @@ fn dictation_capture_event(action: &str, dictating: bool) -> &'static str {
}
pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut);
let _ = dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, false);
}
fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin: CaptureOrigin) {
pub(crate) fn request_dictation_capture_delivery(
app: &tauri::AppHandle,
action: &str,
) -> Option<u64> {
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, true)
}
fn dispatch_dictation_capture_from(
app: &tauri::AppHandle,
action: &str,
origin: CaptureOrigin,
await_result: bool,
) -> Option<u64> {
let flags = app.state::<AppFlags>();
let event = dictation_capture_event(action, flags.dictating.load(Ordering::SeqCst));
let session_id = if event == "tray-dictate" {
@@ -217,8 +354,21 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
session_id
} else {
log::warn!("Dictation capture '{action}' ignored — no active output session");
return;
return None;
};
// The recorder lives in the widget WebView. WebKit can suspend that
// document while its window is hidden, so an event cannot be relied on to
// wake the very listener that must receive it. Preserve the output target
// first, then show the non-activating pill before enqueueing/emitting the
// start event. The widget's idle reconcile hides it again if capture is
// disabled or startup exits early.
if event == "tray-dictate" {
if let Err(error) = commands::show_dictation_pill(app.clone()) {
log::warn!("Dictation capture '{action}' could not wake the capture window: {error}");
}
}
let capture_event = CaptureEvent {
name: event,
payload: DictationCapturePayload {
@@ -226,12 +376,15 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
delivery_id: 0,
registration_id: 0,
},
await_result,
};
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
return None;
};
if let Some(capture_event) = capture.enqueue(capture_event) {
let enqueued = capture.enqueue(capture_event);
let delivery_id = enqueued.delivery_id;
if let Some(capture_event) = enqueued.event {
drop(capture);
// A press that reaches Rust but produces no recording is otherwise
// indistinguishable from one the compositor never delivered, so say
@@ -246,12 +399,14 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
"Dictation capture '{action}' queued — the capture window has not registered yet"
);
}
Some(delivery_id)
}
#[cfg(test)]
mod dictation_capture_tests {
use super::{
dictation_capture_event, CaptureDispatchState, CaptureEvent, DictationCapturePayload,
dictation_capture_event, CaptureAcceptanceTimeout, CaptureDispatchState, CaptureEvent,
CaptureReceiptCancellation, DictationCapturePayload,
};
fn capture_event(name: &'static str) -> CaptureEvent {
@@ -262,6 +417,7 @@ mod dictation_capture_tests {
delivery_id: 0,
registration_id: 0,
},
await_result: false,
}
}
@@ -295,10 +451,108 @@ mod dictation_capture_tests {
assert_eq!(retried[0].payload.delivery_id, delivery_id);
assert_eq!(retried[0].payload.registration_id, current);
state.acknowledge(stale, delivery_id);
assert!(!state.acknowledge(stale, delivery_id));
assert_eq!(state.pending.len(), 1);
state.acknowledge(current, delivery_id);
assert!(state.delivery_pending(delivery_id));
assert!(state.acknowledge(current, delivery_id));
assert!(state.pending.is_empty());
assert!(!state.delivery_pending(delivery_id));
}
#[test]
fn timed_out_delivery_can_be_cancelled_without_touching_others() {
let mut state = CaptureDispatchState::default();
state.enqueue(capture_event("tray-dictate"));
state.enqueue(capture_event("tray-dictate-stop"));
let first_id = state.pending[0].payload.delivery_id;
let second_id = state.pending[1].payload.delivery_id;
let cancelled = state.cancel_delivery(first_id).expect("delivery exists");
assert_eq!(cancelled.payload.session_id, 7);
assert!(!state.delivery_pending(first_id));
assert!(state.delivery_pending(second_id));
}
#[test]
fn awaited_delivery_preserves_frontend_rejection_for_the_requester() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
assert!(state.acknowledge(registration_id, delivery_id));
assert!(!state.completion_ready(delivery_id));
state.complete(
registration_id,
delivery_id,
Some("Dictation is disabled".into()),
);
assert!(state.completion_ready(delivery_id));
assert_eq!(
state.take_completion(delivery_id),
Some(Err("Dictation is disabled".into()))
);
assert_eq!(state.take_completion(delivery_id), None);
}
#[test]
fn cancellation_suppresses_an_event_cloned_for_ready_emission() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let enqueued = state.enqueue(event);
let emitted = enqueued.event.expect("ready event was cloned");
assert!(matches!(
state.cancel_unreceived_delivery(enqueued.delivery_id),
CaptureReceiptCancellation::Cancelled(_)
));
assert!(!state.acknowledge(
emitted.payload.registration_id,
emitted.payload.delivery_id
));
}
#[test]
fn listener_receipt_wins_atomically_over_timeout_cancellation() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
assert!(state.acknowledge(registration_id, delivery_id));
assert!(matches!(
state.cancel_unreceived_delivery(delivery_id),
CaptureReceiptCancellation::Received
));
}
#[test]
fn completion_at_the_timeout_boundary_wins_over_cancellation() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
state.acknowledge(registration_id, delivery_id);
state.complete(registration_id, delivery_id, None);
assert!(matches!(
state.take_completion_or_cancel(delivery_id),
CaptureAcceptanceTimeout::Completed(Ok(()))
));
assert!(matches!(
state.take_completion_or_cancel(delivery_id),
CaptureAcceptanceTimeout::Missing
));
}
#[test]
@@ -727,6 +981,7 @@ pub fn run() {
commands::begin_dictation_capture_registration,
commands::mark_dictation_capture_ready,
commands::acknowledge_dictation_capture_delivery,
commands::complete_dictation_capture_delivery,
commands::end_dictation_capture_registration,
commands::show_dictation_pill,
commands::get_launch_as_widget,
@@ -797,7 +1052,7 @@ pub fn run() {
WebviewUrl::App("index.html".into()),
)
.title("Capture")
.inner_size(300.0, 64.0)
.inner_size(460.0, 164.0)
.resizable(false)
.transparent(true)
.decorations(false)
@@ -879,12 +1134,8 @@ pub fn run() {
match event.state {
ShortcutState::Pressed => {
log::info!("Global shortcut pressed: dictation start");
// The widget window stays hidden until the
// capture itself reaches a state worth
// showing — the widget calls
// `show_dictation_pill` then, so a press
// that bails early never strands an empty
// capsule on the desktop.
// Dispatch preserves the focused target,
// wakes the recorder WebView, then emits.
dispatch_dictation_capture(app_handle, "start");
}
ShortcutState::Released => {
@@ -1034,9 +1285,19 @@ pub fn run() {
// current by the frontend's existing
// `set_tray_recording` call on every start and stop.
if app.state::<AppFlags>().dictating.load(Ordering::SeqCst) {
dispatch_dictation_capture_from(app, "stop", CaptureOrigin::Tray);
let _ = dispatch_dictation_capture_from(
app,
"stop",
CaptureOrigin::Tray,
false,
);
} else {
dispatch_dictation_capture_from(app, "start", CaptureOrigin::Tray);
let _ = dispatch_dictation_capture_from(
app,
"start",
CaptureOrigin::Tray,
false,
);
}
}
"settings" => {
+2 -2
View File
@@ -31,8 +31,8 @@
{
"label": "widget",
"title": "Capture",
"width": 300,
"height": 64,
"width": 460,
"height": 164,
"resizable": false,
"fullscreen": false,
"transparent": true,
+2 -2
View File
@@ -21,8 +21,8 @@
{
"label": "widget",
"title": "Capture",
"width": 300,
"height": 64,
"width": 460,
"height": 164,
"resizable": false,
"fullscreen": false,
"transparent": true,
+5 -20
View File
@@ -45,7 +45,7 @@ import NavRail from './components/NavRail';
import TitleTabs from './components/TitleTabs';
import WorkspaceHistory from './components/WorkspaceHistory';
import WorkspaceVoices from './components/WorkspaceVoices';
import WorkspaceProjects from './components/WorkspaceProjects';
import DubWorkspaceSidebar from './components/DubWorkspaceSidebar';
import ErrorBoundary from './components/ErrorBoundary';
import FloatingPill from './components/FloatingPill';
import GlobalAudioPlayer from './components/GlobalAudioPlayer';
@@ -1577,19 +1577,6 @@ function App() {
<div
className={`studio-with-history ${dubStep === 'idle' ? '' : 'studio-with-history--editing'}`}
>
{dubStep === 'idle' && (
<div className="studio-projects">
<WorkspaceProjects
projects={studioProjects}
activeProjectId={activeProjectId}
canSave={false}
saveProject={saveProject}
loadProject={loadProject}
deleteProject={deleteProject}
renameProject={renameProject}
/>
</div>
)}
<div className="studio-with-history__main">
<ErrorBoundary name="dub">
<Suspense fallback={<LazyFallback />}>
@@ -1653,13 +1640,11 @@ function App() {
</Suspense>
</ErrorBoundary>
</div>
{/* Dub home: the Projects + History landing shows only when no project
is being edited. Opening/creating one switches to the full-width
editor (dubStep !== 'idle'). */}
{dubStep === 'idle' && (
{/* Keep the start screen focused after a source or project is selected.
The combined library rail is only part of the pristine Dub landing. */}
{dubStep === 'idle' && !dubVideoFile && !dubJobId && !activeProjectId && (
<div className="studio-right">
<WorkspaceHistory
variant="dub"
<DubWorkspaceSidebar
dubHistory={dubHistory}
restoreDubHistory={restoreDubHistory}
deleteHistory={deleteHistory}
+162 -27
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { copyText } from '../utils/copyText';
import { X, Loader } from 'lucide-react';
import { X, Loader, Pause, Play, Square } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { useAppStore } from '../store';
import { useTranslation } from 'react-i18next';
@@ -374,6 +374,8 @@ export default function CaptureWidget({ onDismiss }) {
const { t } = useTranslation();
const [state, setState] = useState('idle'); // idle | setup | recording | transcribing | done | error
const [transcript, setTranscript] = useState('');
const [paused, setPaused] = useState(false);
const pausedRef = useRef(false);
const [duration, setDuration] = useState(0);
const [captureMode] = useState(() => localStorage.getItem(LS_CAPTURE_MODE) || 'fast');
const [, setLastEngine] = useState('');
@@ -711,13 +713,31 @@ export default function CaptureWidget({ onDismiss }) {
const eventRegistrationId = event?.payload?.registrationId;
if (eventRegistrationId != null && eventRegistrationId !== registrationId) return false;
if (deliveryId != null) {
void tauriInvoke('acknowledge_dictation_capture_delivery', {
return tauriInvoke('acknowledge_dictation_capture_delivery', {
registrationId,
deliveryId,
}).catch((err) => console.warn('dictation delivery acknowledgement failed:', err));
})
.catch((err) => {
console.warn('dictation delivery acknowledgement failed:', err);
return false;
})
.then((acknowledged) => acknowledged !== false);
}
return true;
};
const completeDelivery = async (event, error = null) => {
const deliveryId = event?.payload?.deliveryId;
if (deliveryId == null) return;
try {
await tauriInvoke('complete_dictation_capture_delivery', {
registrationId,
deliveryId,
error,
});
} catch (err) {
console.warn('dictation delivery completion failed:', err);
}
};
(async () => {
try {
registrationId = await tauriInvoke('begin_dictation_capture_registration');
@@ -727,13 +747,20 @@ export default function CaptureWidget({ onDismiss }) {
}
const { listen } = await import('@tauri-apps/api/event');
unlistenStart = await listen('tray-dictate', async (event) => {
if (!acknowledgeDelivery(event)) return;
let acknowledgement = acknowledgeDelivery(event);
if (acknowledgement === false) return;
if (acknowledgement !== true) acknowledgement = await acknowledgement;
if (!acknowledgement) return;
const now = Date.now();
if (now - nativeEventAtRef.current.start < 150) return;
if (now - nativeEventAtRef.current.start < 150) {
await completeDelivery(event, 'Duplicate dictation start ignored');
return;
}
nativeEventAtRef.current.start = now;
const sessionId = event?.payload?.sessionId;
if (!sessionId) {
hideWidgetWindow();
await completeDelivery(event, 'Dictation output session is missing');
return;
}
await ensureDictationPrefsHydrated();
@@ -741,7 +768,8 @@ export default function CaptureWidget({ onDismiss }) {
// The hotkey is inert, but Rust has already shown the window.
// Put it back rather than leaving an empty capsule on screen.
hideWidgetWindow();
finishOutputSession(sessionId);
await finishOutputSession(sessionId);
await completeDelivery(event, 'Dictation is disabled');
return;
}
const sequence = ++nativeStartSequenceRef.current;
@@ -757,6 +785,7 @@ export default function CaptureWidget({ onDismiss }) {
} catch (err) {
console.warn('reject dictation output session failed:', err);
}
await completeDelivery(event, 'Dictation is already active');
return;
}
const trackHold = modeRef.current === 'hold';
@@ -781,11 +810,13 @@ export default function CaptureWidget({ onDismiss }) {
clearPendingHold();
await finishOutputSession(sessionId);
hideWidgetWindow();
await completeDelivery(event, `Could not activate dictation output: ${err}`);
return;
}
if (cancelled || sequence !== nativeStartSequenceRef.current || !enabledRef.current) {
clearPendingHold();
await finishOutputSession(sessionId);
await completeDelivery(event, 'Dictation start was cancelled');
return;
}
if (startupWasInFlight) {
@@ -793,8 +824,10 @@ export default function CaptureWidget({ onDismiss }) {
if (startInFlightRef.current) {
outputSessionIdRef.current = sessionId;
pendingNativeStartRef.current = { sessionId, trackHold, sequence };
await completeDelivery(event, 'Another dictation start is already in progress');
} else if (current === 'recording' || current === 'transcribing') {
outputSessionIdRef.current = sessionId;
await completeDelivery(event);
} else if (
current === 'idle' ||
current === 'done' ||
@@ -802,10 +835,14 @@ export default function CaptureWidget({ onDismiss }) {
current === 'setup'
) {
outputSessionIdRef.current = sessionId;
startRecordingRef.current?.(trackHold, sessionId);
void Promise.resolve(startRecordingRef.current?.(trackHold, sessionId)).then(
(accepted) =>
completeDelivery(event, accepted ? null : 'Dictation could not start'),
);
} else {
clearPendingHold();
await finishOutputSession(sessionId);
await completeDelivery(event, 'Dictation could not accept this start');
}
return;
}
@@ -814,34 +851,66 @@ export default function CaptureWidget({ onDismiss }) {
// in System Settings. A missing grant no longer blocks capture:
// native delivery can truthfully fall back to clipboard-only.
outputSessionIdRef.current = sessionId;
checkAccessibility().then(() => {
if (outputSessionIdRef.current !== sessionId) return;
startRecordingRef.current?.(modeRef.current === 'hold', sessionId);
void checkAccessibility().then(async () => {
if (outputSessionIdRef.current !== sessionId) {
await completeDelivery(event, 'Dictation output session changed before startup');
return;
}
const accepted = await startRecordingRef.current?.(
modeRef.current === 'hold',
sessionId,
);
await completeDelivery(event, accepted ? null : 'Dictation could not start');
});
return;
}
const idle = s === 'idle' || s === 'done' || s === 'error';
if (modeRef.current === 'toggle') {
// Press once to start, again to stop.
if (idle) startRecordingRef.current?.(false, sessionId);
else if (s === 'recording') stopRecordingRef.current?.();
if (idle) {
void Promise.resolve(startRecordingRef.current?.(false, sessionId)).then((accepted) =>
completeDelivery(event, accepted ? null : 'Dictation could not start'),
);
return;
} else if (s === 'recording') {
stopRecordingRef.current?.();
await completeDelivery(event);
return;
}
} else if (idle) {
// Hold mode: keydown start.
startRecordingRef.current?.(true, sessionId);
void Promise.resolve(startRecordingRef.current?.(true, sessionId)).then((accepted) =>
completeDelivery(event, accepted ? null : 'Dictation could not start'),
);
return;
}
await completeDelivery(event, 'Dictation could not start');
});
unlistenStop = await listen('tray-dictate-stop', async (event) => {
if (!acknowledgeDelivery(event)) return;
let acknowledgement = acknowledgeDelivery(event);
if (acknowledgement === false) return;
if (acknowledgement !== true) acknowledgement = await acknowledgement;
if (!acknowledgement) return;
const now = Date.now();
if (now - nativeEventAtRef.current.stop < 150) return;
if (now - nativeEventAtRef.current.stop < 150) {
await completeDelivery(event, 'Duplicate dictation stop ignored');
return;
}
nativeEventAtRef.current.stop = now;
await ensureDictationPrefsHydrated();
// Only hold mode acts on release; toggle ignores it.
let accepted = false;
if (modeRef.current === 'hold' && stateRef.current === 'recording') {
stopRecordingRef.current?.();
accepted = true;
} else if (modeRef.current === 'hold' && holdStartRef.current === 'starting') {
holdStartRef.current = 'released';
accepted = true;
}
await completeDelivery(
event,
accepted ? null : 'Dictation is not recording in hold mode',
);
});
await ensureDictationPrefsHydrated();
if (cancelled) {
@@ -901,13 +970,17 @@ export default function CaptureWidget({ onDismiss }) {
// Timer while recording
useEffect(() => {
if (state === 'recording') {
const t0 = Date.now();
timerRef.current = setInterval(() => setDuration(Date.now() - t0), 100);
if (state === 'recording' && !paused) {
let previous = Date.now();
timerRef.current = setInterval(() => {
const now = Date.now();
setDuration((elapsed) => elapsed + now - previous);
previous = now;
}, 100);
return () => clearInterval(timerRef.current);
}
clearInterval(timerRef.current);
}, [state]);
}, [state, paused]);
// Waveform poll: 50 ms 23 worklet frames, so bars visibly move well
// within ~100 ms of mic start. Only runs while the worklet is feeding us.
@@ -925,6 +998,8 @@ export default function CaptureWidget({ onDismiss }) {
dismissTimerRef.current = null;
}
if (aecModeRef.current || sherpaModeRef.current || pcmModeRef.current) teardownAec();
pausedRef.current = false;
setPaused(false);
setState('idle');
setTranscript('');
setPartialText('');
@@ -1458,6 +1533,7 @@ export default function CaptureWidget({ onDismiss }) {
}
}
wsPendingRef.current = [];
if (pausedRef.current) ws.send('PAUSE');
};
ws.onmessage = async (evt) => {
if (!isCurrent() || wsRef.current !== ws) return;
@@ -1713,7 +1789,7 @@ export default function CaptureWidget({ onDismiss }) {
return;
}
const sendBuf = (buf) => {
if (!isCurrent()) return;
if (!isCurrent() || pausedRef.current) return;
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
try {
@@ -1739,6 +1815,7 @@ export default function CaptureWidget({ onDismiss }) {
const stopMicCapture = await startMicCapture(
stream,
(f) => {
if (pausedRef.current) return;
waveRef.current.push(f);
sendTagged(f, AEC_NEAR);
},
@@ -1758,6 +1835,7 @@ export default function CaptureWidget({ onDismiss }) {
const stopMicCapture = await startMicCapture(
stream,
(f) => {
if (pausedRef.current) return;
waveRef.current.push(f);
const i16 = floatToInt16(f);
sendBuf(i16.buffer.slice(i16.byteOffset, i16.byteOffset + i16.byteLength));
@@ -1788,6 +1866,8 @@ export default function CaptureWidget({ onDismiss }) {
stopCaptureGraph();
return;
}
pausedRef.current = false;
setPaused(false);
startTimeRef.current = Date.now();
setTrayRecording(true);
setWaveOn(pcmMode);
@@ -1856,11 +1936,11 @@ export default function CaptureWidget({ onDismiss }) {
// newer non-empty lease without launching a second microphone graph.
outputSessionIdRef.current = sessionId;
}
return;
return false;
}
if (inTauri() && !sessionId) {
hideWidgetWindow();
return;
return false;
}
startInFlightRef.current = true;
if (sessionId) outputSessionIdRef.current = sessionId;
@@ -1893,10 +1973,26 @@ export default function CaptureWidget({ onDismiss }) {
}
}
}
return stateRef.current === 'recording' || stateRef.current === 'transcribing';
},
[startRecordingImpl],
);
const togglePause = useCallback(() => {
if (stateRef.current !== 'recording') return;
const next = !pausedRef.current;
const recorder = mediaRecorderRef.current;
if (recorder?.state === 'recording' && next) recorder.pause();
else if (recorder?.state === 'paused' && !next) recorder.resume();
pausedRef.current = next;
streamRef.current?.getTracks().forEach((track) => {
track.enabled = !next;
});
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) ws.send(next ? 'PAUSE' : 'RESUME');
setPaused(next);
}, []);
const stopRecording = useCallback(() => {
const generation = captureGenerationRef.current;
const sessionId = outputSessionIdRef.current;
@@ -2112,7 +2208,9 @@ export default function CaptureWidget({ onDismiss }) {
// Pill label
let label = '';
let emoji = '';
if (state === 'setup') {
if (state === 'recording' && paused) {
label = t('common.paused');
} else if (state === 'setup') {
// One-time Accessibility setup shown instead of pretending to work.
emoji = '🔒';
label = t('capture.a11y_setup');
@@ -2156,9 +2254,13 @@ export default function CaptureWidget({ onDismiss }) {
state === 'error' && errorInfo?.kind === 'mic' && errorInfo?.deniedByOs && inTauri();
return (
<div className={`capture-pill capture-pill--${state}`} role="status" aria-live="polite">
<div
className={`capture-pill capture-pill--${state === 'recording' && paused ? 'paused' : state}`}
role="status"
aria-live="polite"
>
{/* Live waveform while the worklet feeds us; pulsing dot otherwise */}
{state === 'recording' && waveOn && !modelStatus ? (
{state === 'recording' && !paused && waveOn && !modelStatus ? (
<div className="capture-pill__wave" aria-hidden="true">
{bars.map((v, i) => (
<span
@@ -2173,9 +2275,9 @@ export default function CaptureWidget({ onDismiss }) {
)}
{/* Content */}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="capture-pill__preview">
<span
className="block overflow-hidden text-ellipsis whitespace-nowrap text-[12.5px] font-medium tracking-[0.01em]"
className="block text-[14px] leading-[1.5] font-medium"
title={state === 'error' ? errorInfo?.message || undefined : undefined}
>
{emoji} {label}
@@ -2219,6 +2321,39 @@ export default function CaptureWidget({ onDismiss }) {
</button>
)}
{state === 'recording' && (
<>
<button
type="button"
className="capture-pill__control"
onClick={togglePause}
aria-label={t(paused ? 'common.resume' : 'common.pause')}
title={t(paused ? 'common.resume' : 'common.pause')}
>
{paused ? <Play size={14} /> : <Pause size={14} />}
</button>
<button
type="button"
className="capture-pill__control"
onClick={stopRecording}
aria-label={t('common.stop')}
title={t('common.stop')}
>
<Square size={12} />
</button>
</>
)}
{(state === 'recording' || state === 'transcribing') && (
<button
type="button"
className="capture-pill__control"
onClick={cancelSession}
aria-label={t('common.close')}
title={t('common.close')}
>
<X size={14} />
</button>
)}
{/* Dismiss — done/error/setup */}
{(state === 'done' || state === 'error' || state === 'setup') && (
<button
@@ -0,0 +1,25 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import WorkspaceHistory from './WorkspaceHistory';
export default function DubWorkspaceSidebar({
dubHistory = [],
restoreDubHistory,
deleteHistory,
clearHistory,
}) {
const { t } = useTranslation();
return (
<div className="dub-project-library flex min-h-0 flex-1 flex-col">
<WorkspaceHistory
variant="dub"
title={t('projects.title')}
clearLabel={t('transcriptions.clear_title')}
dubHistory={dubHistory}
restoreDubHistory={restoreDubHistory}
deleteHistory={deleteHistory}
clearHistory={clearHistory}
/>
</div>
);
}
@@ -0,0 +1,39 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import DubWorkspaceSidebar from './DubWorkspaceSidebar';
describe('Dubbing projects panel', () => {
it('shows the previous dubs as Projects without separate tabs and preserves actions', () => {
const item = {
id: 'dub-1',
filename: 'Completed dub.mp4',
duration: 42,
segments_count: 3,
job_data: { input_type: 'video' },
};
const open = vi.fn();
const remove = vi.fn();
render(
<DubWorkspaceSidebar
projects={[{ id: 'old', name: 'Old saved list' }]}
dubHistory={[item]}
restoreDubHistory={open}
deleteHistory={remove}
/>,
);
expect(screen.getByText('Projects')).toBeVisible();
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
expect(screen.queryByText('Old saved list')).not.toBeInTheDocument();
expect(screen.getByText(item.filename)).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: `Open: ${item.filename}` }));
expect(open).toHaveBeenCalledTimes(1);
open.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(open).toHaveBeenCalledWith(item);
expect(open).toHaveBeenCalledTimes(1);
open.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(remove).toHaveBeenCalledWith(item.id, 'dub');
expect(open).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -114,6 +114,8 @@ function LazyWaveform({ height = 36, className = '', ...rest }) {
}
export default function WorkspaceHistory({
title,
clearLabel,
variant = 'voice', // 'voice' (clone/design synth) | 'dub'
history = [],
dubHistory = [],
@@ -140,9 +142,9 @@ export default function WorkspaceHistory({
type="button"
className="history-action-btn danger flex-[0_0_auto]"
onClick={clearHistory}
title={t('sidebar.clear_history')}
title={clearLabel || t('sidebar.clear_history')}
>
<Trash2 size={10} /> {t('sidebar.clear_history')}
<Trash2 size={10} /> {clearLabel || t('sidebar.clear_history')}
</button>
) : null;
@@ -161,7 +163,8 @@ export default function WorkspaceHistory({
<div className="flex-[0_0_auto] flex flex-col gap-[8px] py-[10px] px-[12px]">
<div className="flex items-center justify-between gap-[6px]">
<span className="inline-flex items-center gap-[6px] [font-family:var(--chrome-font-mono,var(--font-mono))] text-[0.72rem] font-semibold [letter-spacing:0.04em] uppercase text-[color:var(--chrome-fg-muted)]">
<History size={13} /> {t('history.dub_title', { defaultValue: 'Dub history' })}
<History size={13} />{' '}
{title || t('history.dub_title', { defaultValue: 'Dub history' })}
</span>
{clearAllButton(dubHistory.length)}
</div>
@@ -176,7 +179,13 @@ export default function WorkspaceHistory({
const inputType = dubInputType(item);
const MediaIcon = inputType === 'audio' ? AudioWaveform : Film;
return (
<div key={`dub-${item.id}`} className="history-item history-item--dub">
<div key={`dub-${item.id}`} className="history-item history-item--dub relative">
<button
type="button"
className="absolute inset-0 z-[1] cursor-pointer rounded-[inherit] border-0 bg-transparent focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
aria-label={`${t('sidebar.open')}: ${item.filename}`}
onClick={() => restoreDubHistory(item)}
/>
<div className="flex min-w-0 gap-[8px]">
<DubMediaPreview item={item} inputType={inputType} />
<div className="flex min-w-0 flex-1 flex-col gap-[2px]">
@@ -200,7 +209,7 @@ export default function WorkspaceHistory({
</div>
</div>
</div>
<div className="history-actions">
<div className="history-actions relative z-[2]">
<button
type="button"
className="history-action-btn accent"
@@ -0,0 +1,104 @@
import { BookText, Disc3, SpellCheck, Code } from 'lucide-react';
import Section from './Section';
import SearchableSelect from '../SearchableSelect';
import BookDetails from './BookDetails';
import LexiconEditor from './LexiconEditor';
/**
* AudiobookBookPanel the Produce tab: the book as an artefact.
*
* Output format + loudness stay pinned on top (they shape the render); book
* details, pronunciation lexicon, and the markup cheat-sheet fold into
* collapsible sections so the tab stays scannable. Mirrors the clone
* workspace's section rhythm (label-row kickers, borderless surfaces).
*/
export default function AudiobookBookPanel({
t,
format,
setFormat,
loudness,
setLoudness,
coverPreview,
onCoverPick,
clearCover,
meta,
setMetaField,
lex,
setLexRow,
addLexRow,
removeLexRow,
detailCount,
lexiconCount,
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto px-[2px] py-[2px]">
<div className="grid grid-cols-2 gap-[10px] max-[640px]:grid-cols-1">
<div className="flex min-w-0 flex-col gap-[4px]">
<span className="label-row mb-0">
<Disc3 className="label-icon" size={14} aria-hidden="true" /> {t('audiobook.format')}
</span>
<SearchableSelect
value={format}
onChange={setFormat}
ariaLabel={t('audiobook.format')}
menuPortal
options={[
{ value: 'm4b', label: t('audiobook.format_m4b') },
{ value: 'mp3', label: t('audiobook.format_mp3') },
]}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<span className="label-row mb-0">{t('audiobook.loudness')}</span>
<SearchableSelect
value={loudness}
onChange={setLoudness}
ariaLabel={t('audiobook.loudness')}
menuPortal
options={[
{ value: 'off', label: t('audiobook.loudness_off') },
{ value: 'acx', label: t('audiobook.loudness_acx') },
{ value: 'podcast', label: t('audiobook.loudness_podcast') },
]}
/>
</div>
</div>
<Section
title={`${t('audiobook.details')}${detailCount > 0 ? ` · ${detailCount}` : ''}`}
icon={<BookText size={12} aria-hidden="true" />}
defaultOpen={detailCount > 0}
>
<BookDetails
t={t}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
/>
</Section>
<Section
title={`${t('audiobook.lexicon')}${lexiconCount > 0 ? ` · ${lexiconCount}` : ''}`}
icon={<SpellCheck size={12} aria-hidden="true" />}
defaultOpen={lexiconCount > 0}
>
<LexiconEditor
t={t}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
</Section>
<Section title={t('audiobook.markup_help')} icon={<Code size={12} aria-hidden="true" />}>
<p className="m-0 text-[0.68rem] leading-[1.55] text-fg-muted">
{t('audiobook.markup_hint')}
</p>
</Section>
</div>
);
}
@@ -1,254 +0,0 @@
import { useState } from 'react';
import {
BookText,
Code,
Languages,
Mic2,
SlidersHorizontal,
SpellCheck,
Users,
} from 'lucide-react';
import VoiceSelector from '../VoiceSelector';
import SearchableSelect from '../SearchableSelect';
import AudiobookOverrides from './AudiobookOverrides';
import BookDetails from './BookDetails';
import CastPanel from './CastPanel';
import LexiconEditor from './LexiconEditor';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS } from '../../utils/constants';
const FIELD_LABEL =
'flex items-center gap-[5px] [font-family:var(--chrome-font-mono)] [font-size:var(--chrome-label-size)] font-semibold [letter-spacing:var(--chrome-label-track)] uppercase text-fg-muted';
const TOOL_BUTTON =
'relative flex h-[46px] min-w-0 flex-col items-center justify-center gap-[3px] rounded-[8px] border border-transparent bg-transparent px-[6px] text-[0.62rem] text-fg-muted cursor-pointer transition-[background,color,box-shadow] duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-fg focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:-2px] aria-pressed:bg-primary/[0.12] aria-pressed:text-primary aria-pressed:shadow-[inset_0_-2px_0_var(--color-brand)]';
function ToolButton({ active, badge = 0, icon, label, onClick }) {
return (
<button
type="button"
className={TOOL_BUTTON}
aria-pressed={active}
title={label}
onClick={onClick}
>
{icon}
<span className="w-full truncate text-center">{label}</span>
{badge > 0 && (
<span
className="absolute right-[5px] top-[4px] min-w-[15px] rounded-full bg-[var(--chrome-hover-bg)] px-[4px] text-center text-[0.55rem] leading-[15px] text-fg [font-variant-numeric:tabular-nums]"
aria-hidden="true"
>
{badge}
</span>
)}
</button>
);
}
/** Compact right-rail property inspector with one optional tool panel at a time. */
export default function AudiobookInspector({
t,
profiles,
defaultVoice,
setDefaultVoice,
language,
setLanguage,
format,
setFormat,
loudness,
setLoudness,
castNames,
voiceCast,
setVoiceCast,
overrides,
setOverrides,
emotionSupported,
coverPreview,
onCoverPick,
clearCover,
meta,
setMetaField,
lex,
setLexRow,
addLexRow,
removeLexRow,
}) {
// `undefined` means the user has not chosen a panel yet: Cast becomes the
// useful default as soon as the script contains [voice:] tags. Once the user
// switches or closes a panel, their explicit choice wins.
const [activePanel, setActivePanel] = useState(undefined);
const panel =
activePanel === undefined
? castNames.length > 0
? 'cast'
: null
: activePanel === 'cast' && castNames.length === 0
? null
: activePanel;
const togglePanel = (next) => setActivePanel(panel === next ? null : next);
const detailCount =
Object.values(meta).filter((value) => value?.trim()).length + (coverPreview ? 1 : 0);
const lexiconCount = lex.filter((row) => row.word.trim() || row.say.trim()).length;
const outputCount =
(loudness !== 'off' ? 1 : 0) +
(Object.values(overrides).some((value) => value !== null && value !== false && value !== '')
? 1
: 0);
return (
<div className="flex flex-col gap-[9px] [container-type:inline-size] [container-name:audiobook-inspector]">
<div className="grid grid-cols-1 gap-[8px] rounded-[11px] bg-[var(--chrome-bg)] p-[10px] @min-[360px]/audiobook-inspector:grid-cols-2 @min-[560px]/audiobook-inspector:[grid-template-columns:minmax(170px,1.35fr)_minmax(120px,0.9fr)_minmax(100px,0.7fr)]">
<div className="flex min-w-0 flex-col gap-[4px] @min-[360px]/audiobook-inspector:col-span-2 @min-[560px]/audiobook-inspector:col-span-1">
<label className={FIELD_LABEL}>
<Mic2 size={11} aria-hidden="true" /> {t('audiobook.default_voice')}
</label>
<VoiceSelector
value={defaultVoice}
onChange={setDefaultVoice}
profiles={profiles}
defaultLabel={t('audiobook.engine_default')}
ariaLabel={t('audiobook.default_voice')}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<label className={FIELD_LABEL}>
<Languages size={11} aria-hidden="true" /> {t('audiobook.language')}
</label>
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.audiobookLang"
onChange={setLanguage}
ariaLabel={t('audiobook.language')}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.format')}</label>
<select
className="input-base"
name="audiobook-format"
value={format}
onChange={(event) => setFormat(event.target.value)}
aria-label={t('audiobook.format')}
>
<option value="m4b">{t('audiobook.format_m4b')}</option>
<option value="mp3">{t('audiobook.format_mp3')}</option>
</select>
</div>
</div>
<div className="overflow-hidden rounded-[11px] bg-[var(--chrome-bg)] shadow-[inset_0_0_0_1px_var(--chrome-border)]">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(68px,1fr))] gap-[2px] p-[4px]"
role="toolbar"
aria-label={t('audiobook.title')}
>
{castNames.length > 0 && (
<ToolButton
active={panel === 'cast'}
badge={castNames.length}
icon={<Users size={13} aria-hidden="true" />}
label={t('audiobook.cast')}
onClick={() => togglePanel('cast')}
/>
)}
<ToolButton
active={panel === 'output'}
badge={outputCount}
icon={<SlidersHorizontal size={13} aria-hidden="true" />}
label={t('audiobook.output')}
onClick={() => togglePanel('output')}
/>
<ToolButton
active={panel === 'details'}
badge={detailCount}
icon={<BookText size={13} aria-hidden="true" />}
label={t('audiobook.details')}
onClick={() => togglePanel('details')}
/>
<ToolButton
active={panel === 'lexicon'}
badge={lexiconCount}
icon={<SpellCheck size={13} aria-hidden="true" />}
label={t('audiobook.lexicon')}
onClick={() => togglePanel('lexicon')}
/>
<ToolButton
active={panel === 'markup'}
icon={<Code size={13} aria-hidden="true" />}
label={t('audiobook.markup_help')}
onClick={() => togglePanel('markup')}
/>
</div>
{panel && (
<div className="border-t border-transparent p-[11px]">
{panel === 'cast' && (
<CastPanel
t={t}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
profiles={profiles}
/>
)}
{panel === 'output' && (
<div className="flex flex-col gap-[8px]">
<div className="flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.loudness')}</label>
<select
className="input-base"
name="audiobook-loudness"
value={loudness}
onChange={(event) => setLoudness(event.target.value)}
aria-label={t('audiobook.loudness')}
>
<option value="off">{t('audiobook.loudness_off')}</option>
<option value="acx">{t('audiobook.loudness_acx')}</option>
<option value="podcast">{t('audiobook.loudness_podcast')}</option>
</select>
</div>
<AudiobookOverrides
t={t}
overrides={overrides}
onChange={setOverrides}
emotionSupported={emotionSupported}
/>
</div>
)}
{panel === 'details' && (
<BookDetails
t={t}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
/>
)}
{panel === 'lexicon' && (
<div className="flex flex-col gap-[6px]">
<LexiconEditor
t={t}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
</div>
)}
{panel === 'markup' && (
<p className="m-0 text-[0.68rem] leading-[1.55] text-fg-muted">
{t('audiobook.markup_hint')}
</p>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import MarkupToolbar from './MarkupToolbar';
import StatsBar from './StatsBar';
/**
* AudiobookScriptPanel the Write tab: the chapter-delimited manuscript.
*
* Markdown `# H1` headings delimit chapters; inline `[voice:NAME]` and
* `[pause …]` tags are honoured by the backend parser. Mirrors the clone
* workspace's ScriptPanel: one labelled surface, toolbar docked on top, the
* editor filling the remaining height.
*/
export default function AudiobookScriptPanel({
t,
text,
setText,
textareaRef,
onScriptKeyDown,
warningsDismissed,
setWarningsDismissed,
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-[7px]">
<div className="flex min-h-[18px] flex-wrap items-center justify-between gap-x-[12px] gap-y-1 px-[4px]">
<span className="label-row mb-0">{t('audiobook.script')}</span>
{text.trim() ? <StatsBar t={t} text={text} /> : null}
</div>
<div className="audiobook-tab__manuscript flex min-h-0 flex-1 flex-col overflow-hidden rounded-[14px]">
<div className="border-b border-transparent px-[10px] py-[7px]">
<MarkupToolbar t={t} textareaRef={textareaRef} text={text} setText={setText} />
</div>
<textarea
ref={textareaRef}
className="input-base"
value={text}
onChange={(e) => {
setText(e.target.value);
if (warningsDismissed) setWarningsDismissed(false);
}}
onKeyDown={onScriptKeyDown}
placeholder={t('audiobook.script_placeholder')}
aria-label={t('audiobook.script')}
/>
{!text.trim() && (
<p className="m-0 border-t border-transparent px-[14px] py-[9px] text-[var(--text-sm)] text-fg-muted">
{t('audiobook.empty_hint')}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { Mic2, Languages, Users } from 'lucide-react';
import VoiceSelector from '../VoiceSelector';
import SearchableSelect from '../SearchableSelect';
import CastPanel from './CastPanel';
import AudiobookOverrides from './AudiobookOverrides';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS } from '../../utils/constants';
/**
* AudiobookVoicesPanel the Cast tab: who says it.
*
* Default voice + language stay pinned on top (the two controls every book
* needs); the `[voice:NAME]` cast map and the expressive overrides follow as
* labelled groups. Mirrors the clone workspace's Voice section (label-row
* kickers, borderless surfaces).
*/
export default function AudiobookVoicesPanel({
t,
profiles,
defaultVoice,
setDefaultVoice,
language,
setLanguage,
castNames,
voiceCast,
setVoiceCast,
overrides,
setOverrides,
emotionSupported,
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto px-[2px] py-[2px]">
<div className="grid grid-cols-2 gap-[10px] max-[640px]:grid-cols-1">
<div className="flex min-w-0 flex-col gap-[4px]">
<span className="label-row mb-0">
<Mic2 className="label-icon" size={14} aria-hidden="true" />{' '}
{t('audiobook.default_voice')}
</span>
<VoiceSelector
value={defaultVoice}
onChange={setDefaultVoice}
profiles={profiles}
defaultLabel={t('audiobook.engine_default')}
ariaLabel={t('audiobook.default_voice')}
/>
</div>
<div className="flex min-w-0 flex-col gap-[4px]">
<span className="label-row mb-0">
<Languages className="label-icon" size={14} aria-hidden="true" />{' '}
{t('audiobook.language')}
</span>
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.audiobookLang"
onChange={setLanguage}
ariaLabel={t('audiobook.language')}
/>
</div>
</div>
<div className="flex min-w-0 flex-col gap-[6px]">
<span className="label-row mb-0">
<Users className="label-icon" size={14} aria-hidden="true" /> {t('audiobook.cast')}
{castNames.length > 0 && (
<span
className="ml-auto rounded-full bg-[var(--chrome-hover-bg)] px-[8px] text-center [font-variant-numeric:tabular-nums]"
aria-hidden="true"
>
{castNames.length}
</span>
)}
</span>
<CastPanel
t={t}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
profiles={profiles}
/>
</div>
<AudiobookOverrides
t={t}
overrides={overrides}
onChange={setOverrides}
emotionSupported={emotionSupported}
/>
</div>
);
}
+10 -12
View File
@@ -377,24 +377,22 @@ export default function DubRightColumn({
{multiLangMode && batchTargets?.length > 1 && (
<label className="mb-[4px] flex max-w-[320px] items-center gap-[7px] px-[2px]">
<span className={OUT_TITLE}>{t('dub.language')}:</span>
<select
className="input-base min-w-0 flex-1 !px-[7px] !py-[3px] !text-[0.68rem]"
<SearchableSelect
ariaLabel={t('dub.language')}
value={dubLangCode}
disabled={multiBatchBusy}
aria-label={t('dub.language')}
onChange={(event) => {
const target = batchTargets.find((item) => item.code === event.target.value);
menuPortal
options={batchTargets.map((target) => ({
value: target.code,
label: `${target.lang} · ${target.code.toUpperCase()}`,
}))}
onChange={(code) => {
const target = batchTargets.find((item) => item.code === code);
if (!target) return;
setDubLang(target.lang);
setDubLangCode(target.code);
}}
>
{batchTargets.map((target) => (
<option key={target.code} value={target.code}>
{target.lang} · {target.code.toUpperCase()}
</option>
))}
</select>
/>
</label>
)}
@@ -161,7 +161,7 @@ describe('DubRightColumn language targets', () => {
({ rerender } = render(column(true, setDubLang, setDubLangCode)));
await Promise.resolve();
});
const language = screen.getByRole('combobox', { name: 'dub.language' });
const language = screen.getByRole('button', { name: 'dub.language' });
expect(language).toBeDisabled();
expect(setDubLangCode).not.toHaveBeenCalled();
@@ -170,9 +170,8 @@ describe('DubRightColumn language targets', () => {
rerender(column(false, setDubLang, setDubLangCode));
await Promise.resolve();
});
fireEvent.change(screen.getByRole('combobox', { name: 'dub.language' }), {
target: { value: 'es' },
});
fireEvent.click(screen.getByRole('button', { name: 'dub.language' }));
fireEvent.mouseDown(screen.getByRole('option', { name: /Spanish/ }));
expect(setDubLang).toHaveBeenCalledWith('Spanish');
expect(setDubLangCode).toHaveBeenCalledWith('es');
});
+256 -260
View File
@@ -22,6 +22,7 @@ import {
import { Button, Progress } from '../../ui';
import { useEffect, useRef } from 'react';
import WaveformTimeline from '../WaveformTimeline';
import SearchableSelect from '../SearchableSelect';
import DubbingDemo from '../DubbingDemo';
import DubFailureNotice from './DubFailureNotice';
import PrepOverlay from './PrepOverlay';
@@ -108,52 +109,66 @@ export default function IdleSkeleton({
onOpenQueue,
}) {
const youtubeCookieInputRef = useRef(null);
const videoUploadInputRef = useRef(null);
useEffect(() => {
if (!youtubeCookieFile && youtubeCookieInputRef.current) {
youtubeCookieInputRef.current.value = '';
}
}, [youtubeCookieFile]);
const languageOptions = LANG_CODES.map((language) => ({
value: language.code,
label: `${languageLabel(language.code, uiLocale, language.label)}${language.code}`,
}));
const sourceOptions = [{ value: 'auto', label: t('bootstrap.auto_detect') }, ...languageOptions];
const isPristineIdle = !dubVideoFile && dubStep === 'idle' && !dubJobId;
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header bar */}
<div className="flex justify-between items-center px-[12px] py-[5px] shrink-0 bg-[rgba(255,255,255,0.015)] [border:1px_solid_rgba(255,255,255,0.04)] rounded-md mb-[2px]">
<div className="label-row dub-head__title">
<Film className="label-icon" size={11} />
<span className="font-semibold text-[0.85rem] overflow-hidden text-ellipsis whitespace-nowrap text-fg">
{dubVideoFile ? dubVideoFile.name : t('dub.video_dubbing_studio')}
</span>
{dubVideoFile && (
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.72rem]">
· {(dubVideoFile.size / 1024 / 1024).toFixed(1)} MB
</span>
)}
{activeProjectName && activeProjectName !== dubFilename && (
<span className="text-[#b8bb26] ml-[var(--space-3)] whitespace-nowrap text-[0.72rem]">
{activeProjectName}
<div className={`flex-1 flex flex-col min-h-0${isPristineIdle ? ' dub-start-screen' : ''}`}>
{/* Selected source details appear only after choosing media. */}
{dubVideoFile && (
<div className="flex justify-between items-center px-[12px] py-[5px] shrink-0 bg-[rgba(255,255,255,0.015)] [border:1px_solid_rgba(255,255,255,0.04)] rounded-md mb-[2px]">
<div className="label-row dub-head__title">
<Film className="label-icon" size={11} />
<span className="font-semibold text-[0.85rem] overflow-hidden text-ellipsis whitespace-nowrap text-fg">
{dubVideoFile.name}
</span>
{dubVideoFile && (
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.72rem]">
· {(dubVideoFile.size / 1024 / 1024).toFixed(1)} MB
</span>
)}
{activeProjectName && activeProjectName !== dubFilename && (
<span className="text-[#b8bb26] ml-[var(--space-3)] whitespace-nowrap text-[0.72rem]">
{activeProjectName}
</span>
)}
</div>
{!isPristineIdle && (
<div className="flex gap-[var(--space-2)] items-center shrink-0">
<Button
variant="subtle"
size="sm"
disabled
title={t('dub.save')}
aria-label={t('dub.save')}
>
<Save size={12} />
</Button>
<Button
variant="ghost"
size="sm"
disabled
title={t('dub.reset')}
aria-label={t('dub.reset')}
>
<RotateCcw size={12} />
</Button>
</div>
)}
</div>
<div className="flex gap-[var(--space-2)] items-center shrink-0">
<Button
variant="subtle"
size="sm"
disabled
title={t('dub.save')}
aria-label={t('dub.save')}
>
<Save size={12} />
</Button>
<Button
variant="ghost"
size="sm"
disabled
title={t('dub.reset')}
aria-label={t('dub.reset')}
>
<RotateCcw size={12} />
</Button>
</div>
</div>
)}
{/* Transcription failure banner shown in the idle state when a
job exists but transcription produced zero segments (or threw).
@@ -277,23 +292,14 @@ export default function IdleSkeleton({
)}
<label className="inline-flex items-center gap-[5px] text-[12px] text-[var(--muted,#a89984)] whitespace-nowrap">
<Globe size={13} /> {t('dub.source_language')}
<select
className="input-base text-[0.65rem]"
<SearchableSelect
ariaLabel={t('dub.source_language')}
value={dubSourceLangCode}
disabled={
dubStep === 'uploading' ||
dubStep === 'transcribing' ||
dubStep === 'installing-asr'
}
onChange={(event) => setDubSourceLangCode(event.target.value)}
>
<option value="auto">{t('bootstrap.auto_detect')}</option>
{LANG_CODES.map((language) => (
<option key={language.code} value={language.code}>
{languageLabel(language.code, uiLocale, language.label)} {language.code}
</option>
))}
</select>
options={sourceOptions}
onChange={setDubSourceLangCode}
menuPortal
disabled={['uploading', 'transcribing', 'installing-asr'].includes(dubStep)}
/>
</label>
<label
className="inline-flex items-center gap-[5px] text-[12px] text-[var(--muted,#a89984)] whitespace-nowrap"
@@ -367,232 +373,218 @@ export default function IdleSkeleton({
) : dubStep === 'idle' ? (
<>
{!demoDismissed && <DubbingDemo onDismiss={dismissDubDemo} />}
<label
htmlFor="video-upload"
className="dub-idle-drop"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
if (
file &&
(file.type.startsWith('video/') ||
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name))
) {
setDubVideoFile(file);
// #119: an audio file audio-only dubbing (skip video work, output audio).
setDubInputType(
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name)
? 'audio'
: 'video',
);
setDubStep('idle');
fileToMediaUrl(file, null).then((urls) => setDubLocalBlobUrl(urls));
}
}}
>
<div className="dub-idle-drop__puck">
<UploadCloud color="#d3869b" size={28} />
</div>
<div className="text-center">
<div className="text-[0.9rem] text-fg font-medium mb-[4px]">
{t('dub.drop_here')}
</div>
<div className="text-[0.7rem] text-[#665c54]">{t('dub.supported_formats')}</div>
</div>
<div className="dub-start-card">
<div
className="flex gap-[6px] items-center px-[10px] py-[6px] mt-[10px] bg-[rgba(255,255,255,0.02)] [border:1px_solid_rgba(255,255,255,0.06)] rounded-[6px] w-[min(420px,80%)]"
onClick={(e) => e.preventDefault()}
className="dub-idle-drop dub-start-drop"
role="group"
aria-labelledby="dub-start-title"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
if (
file &&
(file.type.startsWith('video/') ||
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name))
) {
setDubVideoFile(file);
// #119: an audio file audio-only dubbing (skip video work, output audio).
setDubInputType(
file.type.startsWith('audio/') ||
/\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name)
? 'audio'
: 'video',
);
setDubStep('idle');
fileToMediaUrl(file, null).then((urls) => setDubLocalBlobUrl(urls));
}
}}
>
<Link2 size={13} color="#a89984" />
<div className="dub-idle-drop__puck dub-start-mark" aria-hidden="true">
<UploadCloud size={28} />
</div>
<div className="text-center">
<div id="dub-start-title" className="dub-start-title text-fg font-medium">
{t('dub.drop_here')}
</div>
<div className="dub-start-formats text-fg-muted">
{t('dub.supported_formats')}
</div>
</div>
<Button
variant="primary"
size="sm"
className="dub-start-choose"
onClick={() => videoUploadInputRef.current?.click()}
leading={<UploadCloud size={14} aria-hidden="true" />}
>
{t('gallery.upload')}
</Button>
</div>
<div className="dub-start-url">
<Link2 size={15} aria-hidden="true" />
<input
type="text"
type="url"
aria-label={t('dub.paste_url')}
placeholder={t('dub.paste_url')}
value={ingestUrl}
onChange={(e) => setIngestUrl(e.target.value)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
onIngestUrl();
}
}}
className="flex-1 bg-transparent border-none outline-none text-fg text-[0.75rem]"
className="input-base flex-1"
/>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onIngestUrl();
}}
onClick={onIngestUrl}
disabled={!ingestUrl.trim()}
className={`dub-ingest-row__cta ${ingestUrl.trim() ? 'is-ready' : ''}`}
>
{t('dub.ingest')}
</button>
</div>
<label
className="flex items-center gap-[6px] mt-[6px] px-[6px] py-[4px] text-[0.62rem] text-fg-muted cursor-pointer rounded-[4px] bg-[rgba(255,255,255,0.02)] hover:text-fg hover:bg-[rgba(255,255,255,0.05)]"
title={t('dub.pull_captions_title')}
onClick={(e) => {
e.stopPropagation();
}}
>
<input
type="checkbox"
className="m-0 accent-[var(--color-brand)]"
checked={fetchYtSubs}
onChange={(e) => setFetchYtSubs(e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
<span>{t('dub.pull_captions')}</span>
</label>
<div
className="flex items-center gap-[6px] mt-[4px] text-[0.62rem] text-fg-muted"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<span>{t('dub.youtube_auth')}</span>
<input
ref={youtubeCookieInputRef}
type="file"
accept=".txt,text/plain"
aria-label={t('dub.youtube_cookie_file')}
className="max-w-[230px] text-[0.6rem] file:mr-[6px] file:rounded-[4px] file:border-0 file:px-[7px] file:py-[3px] file:bg-[rgba(255,255,255,0.08)] file:text-fg file:cursor-pointer"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setYoutubeCookieFile(e.target.files?.[0] || null)}
/>
{youtubeCookieFile && (
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setYoutubeCookieFile(null)}
aria-label={t('dub.remove_cookie_file')}
>
×
</button>
)}
<div className="dub-start-languages">
<label className="dub-landing-opts__lang inline-flex min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} aria-hidden="true" />
<span className="text-[0.72rem] font-medium">{t('dub.source_language')}</span>
<SearchableSelect
ariaLabel={t('dub.source_language')}
value={dubSourceLangCode}
options={sourceOptions}
onChange={setDubSourceLangCode}
menuPortal
buttonClassName="input-base dub-start-language-select"
/>
</label>
<label className="dub-landing-opts__lang inline-flex min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} aria-hidden="true" />
<span className="text-[0.72rem] font-medium">{t('dub.target_language')}</span>
<SearchableSelect
ariaLabel={t('dub.target_language')}
value={dubLangCode}
options={languageOptions}
onChange={(code) => {
setDubLangCode(code);
const language = LANG_CODES.find((item) => item.code === code);
if (language) setDubLang(language.label);
}}
menuPortal
buttonClassName="input-base dub-start-language-select"
/>
</label>
</div>
{/* Quiet path into the batch dubbing queue the queue page
itself (many videos × many languages, plus the watch
folder) had no UI entry point anywhere in the app. */}
<button
type="button"
className="dub-start-advanced"
onClick={() => setLandingAdvOpen((open) => !open)}
aria-expanded={landingAdvOpen}
aria-controls="dub-start-advanced-options"
>
<span>{t('dub.advanced')}</span>
{landingAdvOpen ? (
<ChevronUp size={13} aria-hidden="true" />
) : (
<ChevronDown size={13} aria-hidden="true" />
)}
</button>
<div
id="dub-start-advanced-options"
className="dub-start-options"
hidden={!landingAdvOpen}
>
<label className="dub-start-caption-option" title={t('dub.pull_captions_title')}>
<input
type="checkbox"
className="m-0 accent-[var(--color-brand)]"
checked={fetchYtSubs}
onChange={(e) => setFetchYtSubs(e.target.checked)}
/>
<FileText size={13} aria-hidden="true" />
<span>{t('dub.pull_captions')}</span>
</label>
<div className="dub-start-cookie-option">
<span>{t('dub.youtube_auth')}</span>
<input
ref={youtubeCookieInputRef}
type="file"
accept=".txt,text/plain"
aria-label={t('dub.youtube_cookie_file')}
onChange={(e) => setYoutubeCookieFile(e.target.files?.[0] || null)}
/>
{youtubeCookieFile && (
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setYoutubeCookieFile(null)}
aria-label={t('dub.remove_cookie_file')}
>
×
</button>
)}
</div>
<div className="dub-start-generation-options">
<label className="dub-landing-adv__field" title={t('dub.num_speakers_help')}>
<span>
<Users size={12} aria-hidden="true" /> {t('dub.num_speakers_label')}
</span>
<input
type="number"
min={1}
max={20}
step={1}
className={SPEAKERS_INPUT}
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
setDubNumSpeakers(
Number.isFinite(value) && value > 0 ? Math.min(value, 20) : null,
);
}}
/>
</label>
<label className="dub-landing-adv__field dub-landing-adv__field--grow">
<span>
<UserSquare2 size={12} aria-hidden="true" /> {t('dub.style')}
</span>
<input
type="text"
className="input-base"
placeholder={t('dub.style_placeholder')}
value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)}
/>
</label>
</div>
</div>
<button
type="button"
data-testid="dub-open-batch-queue"
className="mt-[8px] inline-flex cursor-pointer items-center gap-[6px] rounded-[4px] border-0 bg-[rgba(255,255,255,0.02)] px-[8px] py-[4px] text-[0.62rem] text-fg-muted hover:bg-[rgba(255,255,255,0.05)] hover:text-fg"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenQueue?.();
}}
className="dub-start-batch"
onClick={() => onOpenQueue?.()}
>
<Activity size={11} aria-hidden="true" />
<Activity size={12} aria-hidden="true" />
<span>{t('dub.batch_queue_link')}</span>
</button>
</label>
{/* One decision up front: the target language. Everything else
(speakers, style) hides behind Advanced ElevenLabs-style
flow, VoiceStudio chrome. The pick pre-seeds the editor. */}
<div className="flex items-center justify-between gap-[10px] mt-[10px] px-[10px] py-[8px] [border:1px_solid_var(--chrome-border)] rounded-[10px] bg-[var(--chrome-hover-bg)]">
<label className="dub-landing-opts__lang inline-flex items-center gap-[7px] min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} />
<span className="text-[0.72rem] font-medium whitespace-nowrap">
{t('dub.source_language')}
</span>
<select
className="input-base text-[0.65rem]"
value={dubSourceLangCode}
onChange={(event) => setDubSourceLangCode(event.target.value)}
>
<option value="auto">{t('bootstrap.auto_detect')}</option>
{LANG_CODES.map((language) => (
<option key={language.code} value={language.code}>
{languageLabel(language.code, uiLocale, language.label)} {language.code}
</option>
))}
</select>
</label>
<label className="dub-landing-opts__lang inline-flex items-center gap-[7px] min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} />
<span className="text-[0.72rem] font-medium whitespace-nowrap">
{t('dub.target_language', { defaultValue: 'Dub into' })}
</span>
<select
className="input-base text-[0.65rem]"
value={dubLangCode}
onChange={(e) => {
const lc = LANG_CODES.find((l) => l.code === e.target.value);
setDubLangCode(e.target.value);
if (lc) setDubLang(lc.label);
}}
>
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{languageLabel(lc.code, uiLocale, lc.label)} {lc.code}
</option>
))}
</select>
</label>
<button
type="button"
className="inline-flex items-center gap-[5px] px-[10px] py-[5px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-transparent rounded-[var(--chrome-radius-pill,999px)] cursor-pointer transition-colors hover:text-[var(--chrome-fg)] hover:border-transparent"
onClick={() => setLandingAdvOpen((o) => !o)}
aria-expanded={landingAdvOpen}
>
{t('dub.advanced', { defaultValue: 'Advanced' })}
{landingAdvOpen ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
</button>
</div>
{landingAdvOpen && (
<div className="flex flex-wrap items-center gap-[12px] mt-[6px] px-[10px] py-[8px] [border:1px_solid_var(--chrome-border)] rounded-[10px]">
<label
className="dub-landing-adv__field inline-flex items-center gap-[6px] text-[0.7rem] text-[var(--chrome-fg-muted)]"
title={t('dub.num_speakers_help')}
>
<Users size={12} /> {t('dub.num_speakers_label')}
<input
type="number"
min={1}
max={20}
step={1}
className={SPEAKERS_INPUT}
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setDubNumSpeakers(Number.isFinite(v) && v > 0 ? Math.min(v, 20) : null);
}}
/>
</label>
<label className="dub-landing-adv__field dub-landing-adv__field--grow inline-flex items-center gap-[6px] text-[0.7rem] text-[var(--chrome-fg-muted)]">
<UserSquare2 size={12} /> {t('dub.style')}
<input
type="text"
className="input-base text-[0.65rem]"
placeholder={t('dub.style_placeholder')}
value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)}
/>
</label>
</div>
)}
</>
) : (
// Any other active pipeline step reaching the no-file path (e.g.
@@ -604,6 +596,7 @@ export default function IdleSkeleton({
)}
<input
ref={videoUploadInputRef}
type="file"
accept="video/*,audio/*,.mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma"
id="video-upload"
@@ -623,10 +616,7 @@ export default function IdleSkeleton({
: 'video',
);
setDubStep('idle');
setDubLocalBlobUrl((prev) => {
fileToMediaUrl(file, prev).then((urls) => setDubLocalBlobUrl(urls));
return prev;
});
fileToMediaUrl(file, dubLocalBlobUrl).then((urls) => setDubLocalBlobUrl(urls));
}}
/>
@@ -655,15 +645,21 @@ export default function IdleSkeleton({
<div className="label-row">
<Globe className="label-icon" size={9} /> {t('dub.language')}
</div>
<select className="input-base text-[0.65rem]" disabled>
<option>{t('dub.auto')}</option>
</select>
<SearchableSelect
ariaLabel={t('dub.language')}
value="auto"
options={[{ value: 'auto', label: t('dub.auto') }]}
disabled
/>
</div>
<div className="flex-1 min-w-[80px]">
<div className="label-row">{t('dub.iso_code')}</div>
<select className="input-base text-[0.65rem]" disabled>
<option>en {t('dub.original_audio')}</option>
</select>
<SearchableSelect
ariaLabel={t('dub.iso_code')}
value="en"
options={[{ value: 'en', label: `en — ${t('dub.original_audio')}` }]}
disabled
/>
</div>
<div className="flex-1 min-w-[90px]">
<div className="label-row">
@@ -40,49 +40,115 @@ export default function ArchetypeCard({
const hasChips = Boolean(accentLabel || a.facets.whisper);
const cardBase =
'group relative flex min-h-[168px] flex-col gap-[9px] p-[13px] rounded-[10px] ' +
'border border-transparent bg-[rgba(255,255,255,0.026)] ' +
'group relative flex min-h-[168px] flex-col gap-[9px] p-[14px] rounded-[12px] ' +
'border border-transparent bg-[color-mix(in_srgb,var(--chrome-fg)_3%,transparent)] ' +
'transition-[transform,box-shadow,background-color] duration-150 ' +
'hover:-translate-y-px ' +
'hover:bg-[rgba(255,255,255,0.042)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.32)] ' +
'hover:bg-[color-mix(in_srgb,var(--chrome-fg)_5.5%,transparent)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.32)] ' +
'motion-reduce:transition-none motion-reduce:hover:translate-y-0';
const cardState = isPlaying
? 'shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
? 'bg-[color-mix(in_srgb,var(--card-accent)_9%,transparent)] shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
: '';
// No Tailwind preflight in this app (theme + utilities only) every native
// <button> carries the OS face (grey fill, outset border) unless reset, so
// each ghost control repeats the border-transparent + bg-transparent reset.
const iconBtn =
'inline-flex items-center justify-center w-[28px] h-[28px] flex-shrink-0 rounded-[8px] border border-transparent bg-transparent text-[var(--color-fg-muted)] cursor-pointer transition-[opacity,color,background-color] duration-150 hover:bg-[var(--chrome-hover-bg)] disabled:cursor-not-allowed disabled:opacity-30';
const designerBtn = onDesign ? (
<button
type="button"
className={`${iconBtn} opacity-50 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[var(--card-accent)]`}
onClick={() => onDesign(a)}
disabled={materializationLocked}
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
aria-label={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
>
<Wand2 size={14} aria-hidden="true" />
</button>
) : null;
const moreBtn =
onUseInStories || onUseAsAudiobookDefault ? (
<Menu
placement="bottom-end"
disabled={materializationLocked}
items={[
onUseInStories
? {
id: 'stories',
icon: BookOpen,
label: t('gallery.use_in_stories', { defaultValue: 'Use in Stories' }),
onSelect: () => onUseInStories(a),
}
: null,
onUseAsAudiobookDefault
? {
id: 'audiobook',
icon: Headphones,
label: t('gallery.set_audiobook_default', {
defaultValue: 'Set as Audiobook default',
}),
onSelect: () => onUseAsAudiobookDefault(a),
}
: null,
].filter(Boolean)}
>
<button
type="button"
className={`${iconBtn} opacity-50 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[var(--card-accent)]`}
aria-label={t('gallery.more_actions', { defaultValue: 'More actions' })}
title={t('gallery.more_actions', { defaultValue: 'More actions' })}
>
<Ellipsis size={15} aria-hidden="true" />
</button>
</Menu>
) : null;
return (
<div
data-testid="gallery-persona-card"
className={`${cardBase} ${cardState}`}
style={{ '--card-accent': color }}
>
{/* Header — the name is the focal point; metadata recedes (smaller, muted). */}
<div className="flex items-start gap-[10px]">
<ArchetypeAvatar item={a} size={40} />
{/* Header the name is the focal point; metadata recedes (smaller, muted).
Icon actions cluster top-right so the bottom row fits Preview + Use. */}
<div className="flex items-center gap-[10px]">
<ArchetypeAvatar item={a} size={44} />
<div className="flex-1 min-w-0">
<div className="text-[0.82rem] font-semibold leading-tight text-[var(--color-fg)] truncate">
<div
className="text-[0.82rem] font-semibold leading-tight text-[var(--color-fg)] truncate"
title={a.name}
>
{a.name}
</div>
{sub && (
<div className="text-[0.66rem] text-[var(--color-fg-muted)] mt-[3px] truncate">
<div
className="text-[0.66rem] text-[var(--color-fg-muted)] mt-[3px] truncate"
title={sub}
>
{sub}
</div>
)}
</div>
<button
type="button"
className={`flex-shrink-0 flex items-center justify-center w-[26px] h-[26px] rounded-[7px] cursor-pointer transition-[color,background-color,opacity] hover:bg-[var(--chrome-hover-bg)] ${
isFavorite
? 'text-[#fabd2f]'
: 'text-[var(--color-fg-subtle)] opacity-70 group-hover:opacity-100 hover:text-[#fabd2f]'
}`}
onClick={() => onToggleFavorite(favoriteId)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-label={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-pressed={isFavorite}
>
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<div className="flex shrink-0 items-center gap-[2px]">
{designerBtn}
{moreBtn}
<button
type="button"
className={`flex items-center justify-center w-[28px] h-[28px] flex-shrink-0 rounded-[8px] border border-transparent bg-transparent cursor-pointer transition-[color,background-color,opacity] hover:bg-[var(--chrome-hover-bg)] ${
isFavorite
? 'text-[#fabd2f]'
: 'text-[var(--color-fg-subtle)] opacity-70 group-hover:opacity-100 hover:text-[#fabd2f]'
}`}
onClick={() => onToggleFavorite(favoriteId)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-label={t('gallery.favorite', { defaultValue: 'Favorite' })}
aria-pressed={isFavorite}
>
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
</div>
</div>
{/* Chips only render when present no empty reserved row. Cards without
@@ -105,11 +171,12 @@ export default function ArchetypeCard({
)}
{/* Actions quiet Preview (ghost, token hover), confident accent Use voice
(tinted solid accent with inverse text), subtle magic-wand icon. */}
(tinted solid accent with inverse text). Use voice never wraps: the
icon cluster lives in the header, so this row always fits. */}
<div className="mt-auto flex items-center gap-[6px] pt-[9px]">
<button
type="button"
className="inline-flex items-center gap-[6px] px-[9px] py-[6px] rounded-[6px] bg-transparent text-[var(--color-fg-muted)] text-[0.68rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)] disabled:cursor-not-allowed disabled:opacity-50"
className="inline-flex shrink-0 items-center gap-[6px] border border-transparent bg-transparent px-[9px] py-[6px] rounded-[6px] text-[var(--color-fg-muted)] text-[0.68rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => onPreview(a)}
disabled={previewLocked}
aria-busy={isLoadingPreview}
@@ -126,7 +193,7 @@ export default function ArchetypeCard({
</button>
<button
type="button"
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[6px] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.7rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)] disabled:cursor-not-allowed disabled:opacity-50"
className="flex-1 inline-flex min-w-0 items-center justify-center gap-[6px] whitespace-nowrap border border-transparent px-[10px] py-[6px] rounded-[6px] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.7rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => onUse(a)}
disabled={materializationLocked}
aria-busy={isMaterializing}
@@ -138,53 +205,6 @@ export default function ArchetypeCard({
)}{' '}
{t('gallery.use_voice', { defaultValue: 'Use voice' })}
</button>
{onDesign ? (
<button
type="button"
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 rounded-[8px] bg-transparent text-[var(--color-fg-muted)] cursor-pointer opacity-50 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--card-accent)]"
onClick={() => onDesign(a)}
disabled={materializationLocked}
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
aria-label={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
>
<Wand2 size={14} aria-hidden="true" />
</button>
) : null}
{onUseInStories || onUseAsAudiobookDefault ? (
<Menu
placement="bottom-end"
disabled={materializationLocked}
items={[
onUseInStories
? {
id: 'stories',
icon: BookOpen,
label: t('gallery.use_in_stories', { defaultValue: 'Use in Stories' }),
onSelect: () => onUseInStories(a),
}
: null,
onUseAsAudiobookDefault
? {
id: 'audiobook',
icon: Headphones,
label: t('gallery.set_audiobook_default', {
defaultValue: 'Set as Audiobook default',
}),
onSelect: () => onUseAsAudiobookDefault(a),
}
: null,
].filter(Boolean)}
>
<button
type="button"
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 rounded-[8px] bg-transparent text-[var(--color-fg-muted)] cursor-pointer opacity-50 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--card-accent)] disabled:cursor-not-allowed disabled:opacity-30"
aria-label={t('gallery.more_actions', { defaultValue: 'More actions' })}
title={t('gallery.more_actions', { defaultValue: 'More actions' })}
>
<Ellipsis size={15} aria-hidden="true" />
</button>
</Menu>
) : null}
</div>
</div>
);
@@ -1,11 +1,42 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Loader, Star, RotateCcw, Grid, List, SlidersHorizontal } from 'lucide-react';
import { Button, Select, Segmented } from '../../ui';
import {
Loader,
Star,
RotateCcw,
Grid,
List,
SlidersHorizontal,
Sparkles,
SearchX,
Search,
X,
User,
Cake,
AudioLines,
Flag,
Languages,
VolumeX,
LayoutGrid,
} from 'lucide-react';
import { Button, Input, Select, Segmented } from '../../ui';
import { useArchetypeCategories, useArchetypes } from '../../api/hooks';
import { titleCase, facetLabel } from './constants';
import { titleCase, facetLabel, GALLERY_GRID } from './constants';
import ArchetypeCard from './ArchetypeCard';
import GallerySectionHeader from './GallerySectionHeader';
const BROWSE_PAGE = 60;
const SEARCH_DEBOUNCE_MS = 200;
// Tiny local debounce so typing in the search box doesn't fire a backend
// query per keystroke (same pattern as VoiceSelector's picker search).
function useDebounced(value, ms) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return debounced;
}
// Facet vocabularies values must match the backend taxonomy tokens exactly.
const FACETS = {
@@ -44,6 +75,22 @@ const FACETS = {
const hasActiveFilters = (f) => Object.values(f).some((v) => v !== null && v !== '');
// One icon per filter dimension shared by the Filters panel selects and
// the active-filter pills so both read as one family.
const FACET_ICONS = {
use_case: LayoutGrid,
gender: User,
age: Cake,
pitch: AudioLines,
accent: Flag,
lang: Languages,
whisper: VolumeX,
q: Search,
favOnly: Star,
};
const facetIconCls = 'shrink-0 text-[var(--chrome-fg-muted)]';
// Archetypes zone
export default function ArchetypesZone({
t,
@@ -66,9 +113,13 @@ export default function ArchetypesZone({
const [favOnly, setFavOnly] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [offset, setOffset] = useState(0);
// Free-text search over name/instruct (backend `q`) the facet filters
// alone can't reach a specific voice by name in a several-hundred catalog.
const [rawQ, setRawQ] = useState('');
const q = useDebounced(rawQ.trim(), SEARCH_DEBOUNCE_MS);
useEffect(() => {
setOffset(0);
}, [filters]);
}, [filters, q]);
const cleanFilters = useMemo(() => {
const out = {};
@@ -78,17 +129,16 @@ export default function ArchetypesZone({
return out;
}, [filters]);
// The Featured strip shows only when nothing is filtered; in that case Browse
// excludes featured to avoid duplicating it. Once any filter is active the
// Featured strip is hidden (see below), so Browse must include featured too
// otherwise the curated multilingual languages (Spanish/French/), which have
// *only* featured archetypes, would filter down to an empty list.
const showFeatured = !hasActiveFilters(filters) && !favOnly;
// Featured/browse exclusion mirrors the filter logic: once the user narrows
// by facet OR by search text, the Featured strip hides and Browse includes
// everything otherwise curated-only languages would filter to empty.
const showFeatured = !hasActiveFilters(filters) && !favOnly && !q;
const categoriesQ = useArchetypeCategories();
const featuredQ = useArchetypes({ featured: true, limit: 100 });
const browseQ = useArchetypes({
...cleanFilters,
...(q ? { q } : {}),
...(showFeatured ? { featured: false } : {}),
limit: BROWSE_PAGE,
offset,
@@ -100,6 +150,54 @@ export default function ArchetypesZone({
const total = browseQ.data?.total ?? 0;
const favSet = useMemo(() => new Set(favorites), [favorites]);
const categoryName = (id) =>
categories.find((c) => c.id === id)?.name || titleCase(id || '');
// Active-filter pills: every applied narrowing as a removable chip, so the
// bar says what it is doing without opening the Filters panel. Clearing a
// pill clears just that dimension; Clear-all resets everything incl. search.
const pills = [
favOnly
? {
key: 'favOnly',
Icon: FACET_ICONS.favOnly,
label: t('gallery.favorites', { defaultValue: 'Favorites' }),
clear: () => setFavOnly(false),
}
: null,
filters.use_case
? {
key: 'use_case',
Icon: FACET_ICONS.use_case,
label: categoryName(filters.use_case),
clear: () => setFilter('use_case', null),
}
: null,
...['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) =>
filters[dim]
? {
key: dim,
Icon: FACET_ICONS[dim],
label: facetLabel(filters[dim]),
clear: () => setFilter(dim, null),
}
: null,
),
filters.whisper === true
? {
key: 'whisper',
Icon: FACET_ICONS.whisper,
label: t('archetypes.facet_whisper', { defaultValue: 'Whisper' }),
clear: () => setFilter('whisper', null),
}
: null,
q ? { key: 'q', Icon: FACET_ICONS.q, label: `${q}`, clear: () => setRawQ('') } : null,
].filter(Boolean);
const clearAll = () => {
resetFilters();
setFavOnly(false);
setRawQ('');
};
const applyFav = (list) => (favOnly ? list.filter((a) => favSet.has(a.id)) : list);
const advancedFilterCount = ['gender', 'age', 'pitch', 'accent', 'lang', 'whisper'].filter(
(key) => filters[key] !== null && filters[key] !== '',
@@ -126,11 +224,8 @@ export default function ArchetypesZone({
});
const facetToggle =
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[7px] border border-transparent bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg-muted)] text-[0.68rem] whitespace-nowrap cursor-pointer hover:text-[var(--chrome-fg)] hover:border-[color:var(--chrome-border-strong)]';
const gridClass =
viewMode === 'grid'
? 'grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]'
: 'flex flex-col gap-[6px]';
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg-muted)] text-[0.68rem] whitespace-nowrap cursor-pointer hover:text-[var(--chrome-fg)]';
const gridClass = viewMode === 'grid' ? GALLERY_GRID : 'flex flex-col gap-[6px]';
return (
// data-testid: stable e2e hook locale-independent, unlike the translated
@@ -138,20 +233,48 @@ export default function ArchetypesZone({
<div data-testid="archetypes-zone" className="flex-1 min-h-0 flex flex-col overflow-y-auto">
<div className="shrink-0 mb-[8px] pb-[8px] border-b border-transparent">
<div className="flex items-center gap-[6px] min-w-0">
<Select
size="sm"
className="w-auto min-w-[132px] max-w-[190px] shrink-0"
aria-label={t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
value={filters.use_case ?? ''}
onChange={(e) => setFilter('use_case', e.target.value || null)}
>
<option value="">{t('gallery.all', { defaultValue: 'All' })}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</option>
))}
</Select>
<div className="relative min-w-[140px] flex-[1_1_200px]">
<Search
size={14}
aria-hidden="true"
className="pointer-events-none absolute left-[9px] top-1/2 -translate-y-1/2 text-[var(--chrome-fg-muted)]"
/>
<Input
size="sm"
className="w-full pl-[30px] pr-[26px]"
value={rawQ}
onChange={(e) => setRawQ(e.target.value)}
placeholder={t('common.search', { defaultValue: 'Search…' })}
aria-label={t('common.search', { defaultValue: 'Search…' })}
/>
{rawQ && (
<button
type="button"
className="absolute right-[5px] top-1/2 flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center rounded-[6px] border border-transparent bg-transparent text-[var(--chrome-fg-muted)] transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
onClick={() => setRawQ('')}
aria-label={t('common.clear', { defaultValue: 'Clear' })}
>
<X size={12} aria-hidden="true" />
</button>
)}
</div>
<span className="inline-flex min-w-0 shrink-0 items-center gap-[5px]">
<LayoutGrid size={13} aria-hidden="true" className={facetIconCls} />
<Select
size="sm"
className="w-auto min-w-[118px] max-w-[170px]"
aria-label={t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
value={filters.use_case ?? ''}
onChange={(e) => setFilter('use_case', e.target.value || null)}
>
<option value="">{t('gallery.all', { defaultValue: 'All' })}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</option>
))}
</Select>
</span>
<Button
variant="ghost"
size="sm"
@@ -169,28 +292,19 @@ export default function ArchetypesZone({
>
{t('gallery.filters', { defaultValue: 'Filters' })}
</Button>
<label className={facetToggle}>
<input
type="checkbox"
checked={favOnly}
onChange={(e) => setFavOnly(e.target.checked)}
/>
<Star size={12} /> {t('gallery.favorites', { defaultValue: 'Favorites' })}
</label>
{hasActiveFilters(filters) || favOnly ? (
<Button
variant="icon"
iconSize="md"
onClick={() => {
resetFilters();
setFavOnly(false);
}}
title={t('gallery.reset', { defaultValue: 'Reset' })}
aria-label={t('gallery.reset', { defaultValue: 'Reset' })}
>
<RotateCcw size={13} />
</Button>
) : null}
<button
type="button"
className={`inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-transparent text-[0.68rem] whitespace-nowrap cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] ${
favOnly
? 'text-[#fabd2f]'
: 'text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]'
}`}
aria-pressed={favOnly}
onClick={() => setFavOnly((v) => !v)}
>
<Star size={12} fill={favOnly ? 'currentColor' : 'none'} aria-hidden="true" />{' '}
{t('gallery.favorites', { defaultValue: 'Favorites' })}
</button>
<div className="ml-auto shrink-0">
<Segmented
size="xs"
@@ -210,26 +324,32 @@ export default function ArchetypesZone({
{filtersOpen ? (
<div className="mt-[6px] flex items-center gap-[6px] overflow-x-auto pb-px [scrollbar-width:thin]">
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
<Select
key={dim}
size="sm"
className="w-auto min-w-[94px] max-w-[132px] shrink-0"
aria-label={t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
<option value="">
{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
</option>
{FACETS[dim].map((opt) => (
<option key={opt} value={opt}>
{facetLabel(opt)}
</option>
))}
</Select>
))}
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => {
const DimIcon = FACET_ICONS[dim];
return (
<span key={dim} className="inline-flex shrink-0 items-center gap-[5px]">
<DimIcon size={13} aria-hidden="true" className={facetIconCls} />
<Select
size="sm"
className="w-auto min-w-[88px] max-w-[124px]"
aria-label={t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
value={filters[dim] ?? ''}
onChange={(e) => setFilter(dim, e.target.value || null)}
>
<option value="">
{t(`archetypes.facet_${dim}`, { defaultValue: titleCase(dim) })}
</option>
{FACETS[dim].map((opt) => (
<option key={opt} value={opt}>
{facetLabel(opt)}
</option>
))}
</Select>
</span>
);
})}
<label className={`${facetToggle} shrink-0`}>
<VolumeX size={13} aria-hidden="true" className={facetIconCls} />
<input
type="checkbox"
checked={filters.whisper === true}
@@ -239,15 +359,47 @@ export default function ArchetypesZone({
</label>
</div>
) : null}
{/* Active narrowings as removable pills + one Clear-all the bar says
what it is doing without opening the Filters panel. */}
{pills.length > 0 && (
<div className="mt-[6px] flex flex-wrap items-center gap-[5px]">
{pills.map((pill) => (
<span
key={pill.key}
className="inline-flex max-w-full items-center gap-[5px] rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-accent-bg)] py-[2px] pl-[8px] pr-[5px] text-[0.66rem] text-[color:var(--chrome-accent)]"
>
<pill.Icon size={11} aria-hidden="true" className="shrink-0" />
<span className="min-w-0 truncate">{pill.label}</span>
<button
type="button"
className="flex h-[16px] w-[16px] shrink-0 items-center justify-center rounded-full border border-transparent bg-transparent transition-colors hover:bg-[color-mix(in_srgb,var(--chrome-accent)_22%,transparent)]"
onClick={pill.clear}
aria-label={t('common.remove', { defaultValue: 'Remove {{term}}', term: pill.label })}
>
<X size={10} aria-hidden="true" />
</button>
</span>
))}
<button
type="button"
className="inline-flex shrink-0 cursor-pointer items-center gap-[4px] border border-transparent bg-transparent px-[6px] text-[0.66rem] text-[var(--chrome-fg-muted)] transition-colors hover:text-[var(--color-fg)]"
onClick={clearAll}
aria-label={t('gallery.reset', { defaultValue: 'Reset' })}
>
<RotateCcw size={11} aria-hidden="true" />
{t('gallery.reset', { defaultValue: 'Reset' })}
</button>
</div>
)}
</div>
{showFeatured && (
<section className="mb-[14px]">
<div className="flex justify-between items-center pb-[8px] shrink-0">
<div className="text-[0.85rem] font-medium">
{t('archetypes.featured', { defaultValue: 'Featured' })}
</div>
</div>
<section className="mb-[16px]">
<GallerySectionHeader
icon={<Sparkles size={12} strokeWidth={1.5} color="#fabd2f" aria-hidden="true" />}
title={t('archetypes.featured', { defaultValue: 'Featured' })}
/>
<div className={gridClass}>
{applyFav(featured).map((a) => (
<ArchetypeCard key={a.id} {...cardProps(a)} />
@@ -257,16 +409,12 @@ export default function ArchetypesZone({
)}
<section className="mb-[14px]">
<div className="flex justify-between items-center pb-[8px] shrink-0">
<div className="text-[0.85rem] font-medium">
{t('archetypes.browse_all', { defaultValue: 'Browse all' })}
<span className="ml-[6px] px-[7px] py-[1px] rounded-[10px] bg-bg-elev-2 text-[var(--text-secondary)] text-[0.65rem] font-normal">
{total}
</span>
</div>
</div>
<GallerySectionHeader
title={t('archetypes.browse_all', { defaultValue: 'Browse all' })}
count={total}
/>
{browseQ.isLoading ? (
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
<div className="flex items-center justify-center gap-[8px] p-[24px] text-[var(--text-secondary)]">
<Loader className="spin" size={18} />
</div>
) : (
@@ -277,8 +425,11 @@ export default function ArchetypesZone({
))}
</div>
{applyFav(browse).length === 0 && (
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}
<div className="flex flex-col items-center justify-center gap-[8px] px-[16px] py-[32px] text-center text-[var(--text-secondary)]">
<SearchX size={20} strokeWidth={1.5} aria-hidden="true" />
<span className="max-w-[300px] text-[0.78rem] leading-[1.6]">
{t('gallery.no_matches', { defaultValue: 'No voices match these filters.' })}
</span>
</div>
)}
{offset + BROWSE_PAGE < total && !favOnly && (
@@ -1,9 +1,10 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import ArchetypesZone from './ArchetypesZone';
import ArchetypeCard from './ArchetypeCard';
import { useArchetypes } from '../../api/hooks';
vi.mock('../../api/hooks', () => ({
useArchetypeCategories: () => ({
@@ -12,11 +13,11 @@ vi.mock('../../api/hooks', () => ({
{ id: 'social', name: 'Social Media', icon: 'Radio' },
],
}),
useArchetypes: (filters) => ({
useArchetypes: vi.fn((filters) => ({
data: filters.featured ? { items: [] } : { items: [], total: 0 },
isLoading: false,
isFetching: false,
}),
})),
}));
const t = (_key, options = {}) => options.defaultValue || _key;
@@ -134,3 +135,63 @@ describe('ArchetypeCard accessibility', () => {
expect(onUseAsAudiobookDefault).toHaveBeenCalledWith(archetype);
});
});
describe('ArchetypesZone enhanced filters', () => {
it('searches the whole catalog by name after a short debounce', async () => {
render(<ArchetypesZone {...baseProps} />);
const search = screen.getByRole('textbox', { name: 'Search…' });
fireEvent.change(search, { target: { value: 'Librarian' } });
await waitFor(
() => {
const calls = vi.mocked(useArchetypes).mock.calls;
expect(calls.some(([f]) => f?.q === 'Librarian')).toBe(true);
},
{ timeout: 2000 },
);
});
it('clears the search from its inline button', async () => {
render(<ArchetypesZone {...baseProps} />);
fireEvent.change(screen.getByRole('textbox', { name: 'Search…' }), {
target: { value: 'Librarian' },
});
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.getByRole('textbox', { name: 'Search…' })).toHaveValue('');
});
it('shows each active filter as an iconified removable pill', () => {
const setFilter = vi.fn();
render(<ArchetypesZone {...baseProps} setFilter={setFilter} filters={{ ...baseProps.filters, gender: 'female' }} />);
// Pill carries the facet icon + readable label.
const pill = screen.getByText('Female').closest('span[class*="rounded-"]');
expect(pill.querySelector('svg')).not.toBeNull();
// The test t() mock leaves {{term}} uninterpolated match that literally.
fireEvent.click(screen.getByRole('button', { name: 'Remove {{term}}' }));
expect(setFilter).toHaveBeenCalledWith('gender', null);
});
it('clears everything from the pills row at once', () => {
const setFilter = vi.fn();
const resetFilters = vi.fn();
render(
<ArchetypesZone
{...baseProps}
setFilter={setFilter}
resetFilters={resetFilters}
filters={{ ...baseProps.filters, gender: 'female' }}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
expect(resetFilters).toHaveBeenCalledOnce();
});
it('iconifies the facet selects in the Filters panel', () => {
const { container } = render(<ArchetypesZone {...baseProps} />);
fireEvent.click(screen.getByRole('button', { name: 'Filters' }));
// Category + 5 facet selects each sit beside their dimension icon.
const icons = container.querySelectorAll(
'.border-b span[class*="inline-flex"] > svg',
);
expect(icons.length).toBeGreaterThanOrEqual(6);
});
});
@@ -1,9 +1,11 @@
import React, { useMemo } from 'react';
import { Loader, Send } from 'lucide-react';
import { Loader, Send, Store, CloudOff } from 'lucide-react';
import { useCommunityItems } from '../../api/hooks';
import { communitySubmitUrl } from '../../api/community';
import { openExternal } from '../../api/external';
import ArchetypeCard from './ArchetypeCard';
import GallerySectionHeader from './GallerySectionHeader';
import { GALLERY_GRID } from './constants';
const itemKey = (item) => `community:${item._source_repo || item.source || 'default'}:${item.id}`;
@@ -36,18 +38,18 @@ export default function CommunityZone({
};
const submitBtn =
'inline-flex items-center gap-[5px] px-[10px] py-[6px] border border-transparent bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--accent)] hover:text-[var(--accent)]';
'inline-flex items-center gap-[5px] px-[10px] py-[6px] border border-transparent bg-transparent text-[var(--text-secondary)] rounded-[var(--chrome-radius-pill)] text-[0.7rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]';
return (
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
<div className="shrink-0 px-[10px] py-[8px] mb-[8px] bg-bg-elev-2 rounded-[8px] text-[0.72rem] text-[var(--text-secondary)] leading-[1.4] flex items-center justify-between gap-[12px] flex-wrap">
<span>
<div className="mb-[10px] flex shrink-0 flex-wrap items-center justify-between gap-x-[12px] gap-y-[8px]">
<p className="m-0 min-w-0 flex-1 text-[0.72rem] leading-[1.5] text-[var(--text-secondary)]">
{t('gallery.community_explainer', {
defaultValue:
'Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.',
})}
</span>
<div className="flex gap-[6px] shrink-0">
</p>
<div className="flex shrink-0 gap-[6px]">
<button className={submitBtn} onClick={() => submit('preset')}>
<Send size={13} /> {t('gallery.submit_preset', { defaultValue: 'Submit a preset' })}
</button>
@@ -58,18 +60,27 @@ export default function CommunityZone({
</div>
{itemsQ.isLoading ? (
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
<div className="flex items-center justify-center gap-[8px] p-[24px] text-[var(--text-secondary)]">
<Loader className="spin" size={18} />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
{t('gallery.community_empty', {
defaultValue:
'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.',
})}
<div className="flex flex-col items-center justify-center gap-[8px] px-[16px] py-[32px] text-center text-[var(--text-secondary)]">
<CloudOff size={20} strokeWidth={1.5} aria-hidden="true" />
<span className="max-w-[340px] text-[0.78rem] leading-[1.6]">
{t('gallery.community_empty', {
defaultValue:
'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.',
})}
</span>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]">
<>
<GallerySectionHeader
icon={<Store size={12} strokeWidth={1.5} aria-hidden="true" />}
title={t('gallery.zone_community', { defaultValue: 'Community' })}
count={items.length}
/>
<div className={GALLERY_GRID}>
{items.map((it) => (
<ArchetypeCard
key={itemKey(it)}
@@ -90,7 +101,8 @@ export default function CommunityZone({
materializationLocked={Boolean(materializingId)}
/>
))}
</div>
</div>
</>
)}
</div>
);
@@ -0,0 +1,106 @@
// Gallery polish the borderless workspace redesign.
//
// Guards the design contract without snapshotting pixels: one shared fluid
// card grid (no fixed px floor that overflows narrow shells), theme-token
// card surfaces (no hardcoded white overlays), and the single section-header
// component every zone uses.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import ArchetypeCard from './ArchetypeCard';
import GallerySectionHeader from './GallerySectionHeader';
import { GALLERY_GRID } from './constants';
const t = (_key, options = {}) => options.defaultValue || _key;
const archetype = {
id: 'narrator',
name: 'Narrator',
language: 'English',
use_case: 'narration',
facets: { gender: 'female', age: 'adult', pitch: 'moderate pitch' },
attrs: {},
};
describe('Gallery polish', () => {
it('shares one fluid card grid with no fixed pixel floor', () => {
expect(GALLERY_GRID).toContain('gallery-cards');
expect(GALLERY_GRID).toContain('min(264px,100%)');
expect(GALLERY_GRID).not.toContain('minmax(248px');
});
it('renders section headers with a title, count pill, and hairline hook', () => {
render(<GallerySectionHeader title="Browse all" count={128} />);
expect(screen.getByText('Browse all')).toBeTruthy();
expect(screen.getByTestId('gallery-section-count')).toHaveTextContent('128');
expect(document.querySelector('.gallery-section-header')).toBeTruthy();
});
it('keeps cards on theme-token surfaces with a rounded shell', () => {
const { container } = render(
<ArchetypeCard
a={archetype}
t={t}
isFavorite={false}
isPlaying={false}
isLoadingPreview={false}
onPreview={vi.fn()}
onUse={vi.fn()}
onDesign={vi.fn()}
onToggleFavorite={vi.fn()}
/>,
);
const card = container.querySelector('[data-testid="gallery-persona-card"]');
expect(card.className).toContain('rounded-[12px]');
expect(card.className).toContain('color-mix(in_srgb,var(--chrome-fg)');
expect(card.className).not.toContain('rgba(255,255,255');
});
it('tints (not just rings) the playing card', () => {
const { container } = render(
<ArchetypeCard
a={archetype}
t={t}
isFavorite={false}
isPlaying
isLoadingPreview={false}
onPreview={vi.fn()}
onUse={vi.fn()}
onDesign={vi.fn()}
onToggleFavorite={vi.fn()}
/>,
);
const card = container.querySelector('[data-testid="gallery-persona-card"]');
expect(card.className).toContain('var(--card-accent)');
});
it('resets native button faces and never wraps the Use-voice label', () => {
const { container } = render(
<ArchetypeCard
a={archetype}
t={t}
isFavorite={false}
isPlaying={false}
isLoadingPreview={false}
onPreview={vi.fn()}
onUse={vi.fn()}
onDesign={vi.fn()}
onUseInStories={vi.fn()}
onUseAsAudiobookDefault={vi.fn()}
onToggleFavorite={vi.fn()}
/>,
);
const card = container.querySelector('[data-testid="gallery-persona-card"]');
// Every native <button> on the card carries the reset (no preflight).
for (const btn of card.querySelectorAll('button')) {
expect(btn.className).toContain('border-transparent');
}
const useBtn = screen.getByRole('button', { name: 'Use voice' });
expect(useBtn.className).toContain('whitespace-nowrap');
// Designer + overflow live in the header cluster, not the action row.
const header = card.querySelector('.truncate').closest('.flex');
expect(header.querySelector('[aria-label="Open in Designer"]')).not.toBeNull();
expect(header.querySelector('[aria-label="More actions"]')).not.toBeNull();
});
});
@@ -0,0 +1,23 @@
/**
* GallerySectionHeader one quiet section divider for every gallery zone.
*
* Mono kicker + optional count pill + a hairline that fades to the right
* (the Launchpad collection pattern): structure from spacing and type, never
* from boxes. `icon` tints the kicker; `count` renders the pill.
*/
export default function GallerySectionHeader({ icon = null, title, count = null }) {
return (
<div className="gallery-section-header m-0 mb-[10px] flex shrink-0 items-center gap-[9px] [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] font-medium uppercase [letter-spacing:var(--chrome-label-track)] text-[color:var(--chrome-fg-dim)] after:h-px after:flex-1 after:content-[''] after:[background-image:linear-gradient(90deg,color-mix(in_srgb,var(--chrome-fg)_14%,transparent),transparent)]">
{icon}
<span className="min-w-0 truncate">{title}</span>
{count !== null && count !== undefined && (
<span
className="shrink-0 rounded-full bg-[var(--chrome-hover-bg)] px-[8px] py-[1px] normal-case tracking-normal text-[color:var(--chrome-fg-muted)] [font-variant-numeric:tabular-nums]"
data-testid="gallery-section-count"
>
{count}
</span>
)}
</div>
);
}
+28 -23
View File
@@ -27,6 +27,7 @@ import {
previewVoiceUrl,
} from '../../api/gallery';
import AudioTrimmer from '../AudioTrimmer';
import GallerySectionHeader from './GallerySectionHeader';
import { apiFetch } from '../../api/client';
import { askConfirm } from '../../utils/dialog';
@@ -265,18 +266,20 @@ export default function ImportsZone({
};
const voicePlay =
'flex items-center justify-center w-[28px] h-[28px] rounded-full border border-transparent bg-bg-elev-1 text-[var(--text-primary)] cursor-pointer flex-shrink-0 hover:bg-[var(--accent)] hover:border-[color:var(--accent)] hover:text-white';
'flex items-center justify-center w-[30px] h-[30px] rounded-full border border-transparent bg-[var(--chrome-hover-bg)] text-[var(--text-primary)] cursor-pointer flex-shrink-0 transition-colors hover:bg-[var(--chrome-accent-bg)] hover:text-[var(--chrome-accent)] disabled:cursor-not-allowed disabled:opacity-50';
const actionBtn =
'flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-bg-elev-2 hover:text-[var(--text-primary)]';
'flex items-center justify-center w-[28px] h-[28px] bg-transparent text-[var(--text-secondary)] rounded-[8px] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--text-primary)]';
const dangerBtn =
'flex items-center justify-center w-[28px] h-[28px] bg-transparent text-[var(--text-secondary)] rounded-[8px] cursor-pointer transition-colors hover:bg-[color-mix(in_srgb,var(--chrome-severity-err,#cc241d)_12%,transparent)] hover:text-[color:var(--chrome-severity-err,#cc241d)]';
return (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="shrink-0 px-[10px] py-[8px] mb-[8px] bg-bg-elev-2 rounded-[8px] text-[0.72rem] text-[var(--text-secondary)] leading-[1.4]">
<p className="m-0 mb-[8px] shrink-0 text-[0.72rem] leading-[1.5] text-[var(--text-secondary)]">
{t('gallery.import_explainer', {
defaultValue:
'Paste a URL you have the rights to (or upload a file), trim the part you need, and save it as a voice. You are responsible for the licensing of anything you import.',
})}
</div>
</p>
<div className="shrink-0 flex flex-col gap-[10px]">
<div className="flex gap-[6px]">
@@ -341,8 +344,8 @@ export default function ImportsZone({
</div>
{results.length > 0 && (
<div className="shrink-0 bg-bg-elev-2 rounded-[8px] max-h-[180px] overflow-hidden flex flex-col">
<div className="flex justify-between items-center px-[10px] py-[8px] bg-bg-elev-1 text-[0.75rem] font-medium shrink-0">
<div className="shrink-0 rounded-[12px] bg-[color-mix(in_srgb,var(--chrome-fg)_2.5%,transparent)] max-h-[180px] overflow-hidden flex flex-col mb-[8px]">
<div className="flex justify-between items-center px-[12px] py-[8px] text-[0.75rem] font-medium shrink-0 text-[var(--text-secondary)]">
<span>
{t('gallery.search_results', {
defaultValue: '{{count}} results',
@@ -350,8 +353,9 @@ export default function ImportsZone({
})}
</span>
<button
className="bg-none border-none text-[var(--text-secondary)] cursor-pointer p-[2px]"
className="bg-transparent border border-transparent rounded-[8px] text-[var(--text-secondary)] cursor-pointer p-[4px] transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
onClick={() => setResults([])}
aria-label={t('common.close', { defaultValue: 'Close' })}
>
<X size={14} />
</button>
@@ -377,31 +381,31 @@ export default function ImportsZone({
</div>
)}
<div className="flex justify-between items-center pb-[8px] shrink-0">
<div className="text-[0.85rem] font-medium">
{t('gallery.my_imports', { defaultValue: 'My Imports' })}
<span className="ml-[6px] px-[7px] py-[1px] rounded-[10px] bg-bg-elev-2 text-[var(--text-secondary)] text-[0.65rem] font-normal">
{voices.length}
</span>
</div>
</div>
<GallerySectionHeader
icon={<Upload size={12} strokeWidth={1.5} aria-hidden="true" />}
title={t('gallery.my_imports', { defaultValue: 'My Imports' })}
count={voices.length}
/>
{voicesQ.isLoading ? (
<div className="flex items-center justify-center p-[24px] text-[var(--text-secondary)]">
<div className="flex items-center justify-center gap-[8px] p-[24px] text-[var(--text-secondary)]">
<Loader className="spin" size={18} />
</div>
) : voices.length === 0 ? (
<div className="flex flex-col items-center justify-center px-[16px] py-[32px] text-[var(--text-secondary)] text-center">
{t('gallery.no_imports', {
defaultValue: 'Nothing imported yet. Paste a URL above to get started.',
})}
<div className="flex flex-col items-center justify-center gap-[8px] px-[16px] py-[32px] text-center text-[var(--text-secondary)]">
<Upload size={20} strokeWidth={1.5} aria-hidden="true" />
<span className="max-w-[300px] text-[0.78rem] leading-[1.6]">
{t('gallery.no_imports', {
defaultValue: 'Nothing imported yet. Paste a URL above to get started.',
})}
</span>
</div>
) : (
<div className="flex flex-col gap-[4px] overflow-y-auto flex-1 pr-[4px]">
<div className="flex flex-col gap-[2px] overflow-y-auto flex-1 pr-[4px]">
{voices.map((v) => (
<div
key={v.id}
className="flex items-center gap-[8px] px-[10px] py-[8px] bg-bg-elev-2 rounded-[8px] transition-colors hover:bg-bg-elev-1"
className="group flex min-h-[56px] min-w-0 items-center gap-[10px] rounded-[10px] border border-transparent bg-transparent px-[10px] py-[8px] transition-colors hover:bg-[color-mix(in_srgb,var(--chrome-fg)_4%,transparent)]"
>
<button
className={`${voicePlay} disabled:cursor-not-allowed disabled:opacity-50`}
@@ -479,9 +483,10 @@ export default function ImportsZone({
</Menu>
) : null}
<button
className="flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-[#3d1f1f] hover:text-[#fb4934]"
className={dangerBtn}
onClick={() => handleDelete(v)}
title={t('gallery.delete', { defaultValue: 'Delete' })}
aria-label={t('gallery.delete', { defaultValue: 'Delete' })}
>
<Trash2 size={14} />
</button>
@@ -1,3 +1,10 @@
// Shared archetype facet helpers — used by ArchetypesZone + ArchetypeCard.
export const titleCase = (s) => (s ? String(s).replace(/\b\w/g, (c) => c.toUpperCase()) : s);
export const facetLabel = (v) => titleCase(String(v).replace(' pitch', '').replace(' accent', ''));
// One responsive card grid for every gallery zone: auto-filling columns with
// a fluid floor — `min(264px,100%)` (not a bare px value) so a narrow shell
// packs to a single column instead of overflowing sideways. 264px is the
// narrowest width that still fits the card's Preview + Use-voice row.
export const GALLERY_GRID =
'gallery-cards grid grid-cols-[repeat(auto-fill,minmax(min(264px,100%),1fr))] gap-[10px]';
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAppStore } from '../store';
import { apiJson } from '../api/client';
import { installRecommendedAsr } from '../utils/asrModelMissing';
// Keep model checks separate from microphone access: checking the page must
// never record audio or start a download. Capture still validates server-side.
export function useDictationReadiness() {
const modelId = useAppStore((store) => store.dictationModelId);
const [state, setState] = useState({ phase: 'checking', missing: null });
const task = useRef(null);
const mounted = useRef(false);
const checking = useRef(false);
const check = useCallback(async () => {
if (task.current || checking.current) return false;
checking.current = true;
try {
const result = await apiJson(
`/dictation/readiness?model_id=${encodeURIComponent(useAppStore.getState().dictationModelId || '')}`,
);
if (mounted.current && !task.current)
setState((previous) => ({
phase: result.ready === true ? 'ready' : result.missing ? 'missing' : 'error',
missing: result.missing,
error: result.ready !== true && previous.error,
}));
return result.ready === true;
} catch {
if (mounted.current && !task.current) setState({ phase: 'error', missing: null });
return false;
} finally {
checking.current = false;
}
}, [modelId]);
useEffect(() => {
mounted.current = true;
void check();
const refresh = () => {
if (document.visibilityState !== 'hidden') void check();
};
const timer = setInterval(refresh, 5000);
window.addEventListener('focus', refresh);
return () => {
mounted.current = false;
clearInterval(timer);
window.removeEventListener('focus', refresh);
task.current?.abort();
};
}, [check]);
const install = useCallback(async () => {
if (task.current || !state.missing?.recommended?.repo_id) return;
const controller = new AbortController();
task.current = controller;
setState((previous) => ({ ...previous, phase: 'installing', percent: 0, error: false }));
try {
const installed = await installRecommendedAsr(state.missing, {
signal: controller.signal,
onProgress: ({ percent }) => {
if (mounted.current) setState((previous) => ({ ...previous, percent }));
},
});
task.current = null;
if (installed?.dictation_id && mounted.current) {
// The installer persisted this selection; keep the recorder's store
// in sync before the next readiness check and capture request.
useAppStore.setState({ dictationModelId: installed.dictation_id });
}
if (mounted.current) await check();
} catch {
if (mounted.current) setState((previous) => ({ ...previous, phase: 'missing', error: true }));
} finally {
task.current = null;
}
}, [state.missing, check]);
return { ...state, check, install };
}
@@ -0,0 +1,66 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, expect, it, vi } from 'vitest';
const { apiJson, installRecommendedAsr } = vi.hoisted(() => ({
apiJson: vi.fn(),
installRecommendedAsr: vi.fn(),
}));
vi.mock('../store', () => ({
useAppStore: Object.assign((selector) => selector({ dictationModelId: 'sherpa-whisper-tiny' }), {
getState: () => ({ dictationModelId: 'sherpa-whisper-tiny' }),
setState: vi.fn(),
}),
}));
vi.mock('../api/client', () => ({ apiJson }));
vi.mock('../utils/asrModelMissing', () => ({ installRecommendedAsr }));
import { useDictationReadiness } from './useDictationReadiness';
const missing = { recommended: { repo_id: 'test/model', label: 'Tiny', size_gb: 0.1 } };
beforeEach(() => {
vi.resetAllMocks();
});
it('checks without downloading, then waits for explicit installation and rechecks', async () => {
apiJson.mockResolvedValueOnce({ ready: false, missing }).mockResolvedValue({ ready: true });
let finish;
installRecommendedAsr.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const { result } = renderHook(useDictationReadiness);
expect(result.current.phase).toBe('checking');
await waitFor(() => expect(result.current.phase).toBe('missing'));
expect(installRecommendedAsr).not.toHaveBeenCalled();
let pending;
act(() => {
pending = result.current.install();
});
expect(result.current.phase).toBe('installing');
await act(async () => {
finish();
await pending;
});
expect(result.current.phase).toBe('ready');
expect(apiJson).toHaveBeenCalledTimes(2);
});
it('keeps failures retryable without claiming a connection error is missing weights', async () => {
apiJson.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ ready: false, missing });
installRecommendedAsr.mockRejectedValue(new Error('download failed'));
const { result } = renderHook(useDictationReadiness);
await waitFor(() => expect(result.current.phase).toBe('error'));
expect(result.current.missing).toBeNull();
await act(async () => {
await result.current.check();
});
await act(async () => {
await result.current.install();
});
expect(result.current.phase).toBe('missing');
expect(result.current.error).toBe(true);
});
it('refreshes when returning from settings', async () => {
apiJson.mockResolvedValueOnce({ ready: false, missing }).mockResolvedValue({ ready: true });
const { result } = renderHook(useDictationReadiness);
await waitFor(() => expect(result.current.phase).toBe('missing'));
act(() => window.dispatchEvent(new Event('focus')));
await waitFor(() => expect(result.current.phase).toBe('ready'));
});
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "هل تريد حذف هذا المشروع نهائيًا؟"
},
"common": {
"stop": "توقف",
"resume": "استئناف",
"paused": "متوقف مؤقتًا",
"open": "مفتوح",
"cancel": "إلغاء",
"save": "حفظ",
@@ -2519,6 +2522,9 @@
"dismiss": "تجاهل",
"output": "الإخراج",
"title": "كتاب مسموع",
"tab_script": "النص",
"tab_voices": "الأصوات",
"tab_book": "الكتاب",
"subtitle": "اكتب قصتك أو استوردها، وأسند كل دور إلى صوت، ثم صدّر كتابًا صوتيًا متقنًا بفصول حقيقية.",
"script": "البرنامج النصي",
"script_placeholder": "#الفصل الأول\nذات مرة... [وقفة 500 مللي ثانية] النهاية.\n\n#الفصل الثاني\n[صوت: الراوي] وهكذا استمر.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Dieses Projekt dauerhaft löschen?"
},
"common": {
"stop": "Stopp",
"resume": "Fortsetzen",
"paused": "Pausiert",
"open": "Offen",
"cancel": "Abbrechen",
"save": "Speichern",
@@ -2519,6 +2522,9 @@
"dismiss": "Verwerfen",
"output": "Ausgabe",
"title": "Hörbuch",
"tab_script": "Skript",
"tab_voices": "Stimmen",
"tab_book": "Buch",
"subtitle": "Schreiben oder importieren Sie Ihre Geschichte, weisen Sie jeder Rolle eine Stimme zu und exportieren Sie ein fertiges Hörbuch mit echten Kapiteln.",
"script": "Skript",
"script_placeholder": "# Kapitel eins\nEs war einmal… [Pause 500 ms] das Ende.\n\n# Kapitel Zwei\n[Stimme:Erzähler] Und so ging es weiter.",
+6
View File
@@ -199,6 +199,9 @@
"dismiss": "Dismiss",
"output": "Output",
"title": "Audiobook",
"tab_script": "Script",
"tab_voices": "Voices",
"tab_book": "Book",
"subtitle": "Write or import your story, cast every voice, then export a polished audiobook with real chapters.",
"script": "Script",
"script_placeholder": "# Chapter One\nOnce upon a time… [pause 500ms] the end.\n\n# Chapter Two\n[voice:narrator] And so it continued.",
@@ -2253,6 +2256,9 @@
"restart_failed": "The voice backend keeps crashing and couldn't be restarted — check the crash notice or Settings → Logs → Backend."
},
"common": {
"stop": "Stop",
"resume": "Resume",
"paused": "Paused",
"open": "Open",
"cancel": "Cancel",
"save": "Save",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "¿Eliminar este proyecto permanentemente?"
},
"common": {
"stop": "Detener",
"resume": "Reanudar",
"paused": "En pausa",
"open": "Abierto",
"cancel": "Cancelar",
"save": "Guardar",
@@ -2519,6 +2522,9 @@
"dismiss": "Descartar",
"output": "Salida",
"title": "Audiolibro",
"tab_script": "Guion",
"tab_voices": "Voces",
"tab_book": "Libro",
"subtitle": "Escribe o importa tu historia, asigna cada voz y exporta un audiolibro pulido con capítulos reales.",
"script": "Guión",
"script_placeholder": "# Capítulo uno\nÉrase una vez… [pausa 500ms] el final.\n\n# Capítulo Dos\n[voz:narrador] Y así continuó.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Supprimer définitivement ce projet ?"
},
"common": {
"stop": "Arrêter",
"resume": "Reprendre",
"paused": "En pause",
"open": "Ouvert",
"cancel": "Annuler",
"save": "Enregistrer",
@@ -2519,6 +2522,9 @@
"dismiss": "Ignorer",
"output": "Sortie",
"title": "Livre audio",
"tab_script": "Script",
"tab_voices": "Voix",
"tab_book": "Livre",
"subtitle": "Écrivez ou importez votre histoire, attribuez chaque voix, puis exportez un livre audio soigné avec de vrais chapitres.",
"script": "Scénario",
"script_placeholder": "# Chapitre un\nIl était une fois… [pause 500ms] la fin.\n\n# Chapitre deux\n[voix:narrateur] Et ainsi de suite.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "इस प्रोजेक्ट को स्थायी रूप से मिटाएँ?"
},
"common": {
"stop": "रुकें",
"resume": "फिर शुरू करें",
"paused": "रोका गया",
"open": "खुला",
"cancel": "रद्द करें",
"save": "सहेजें",
@@ -2519,6 +2522,9 @@
"dismiss": "खारिज करें",
"output": "आउटपुट",
"title": "ऑडियोबुक",
"tab_script": "स्क्रिप्ट",
"tab_voices": "आवाज़ें",
"tab_book": "किताब",
"subtitle": "अपनी कहानी लिखें या आयात करें, हर पात्र को आवाज़ दें, फिर वास्तविक अध्यायों वाली तैयार ऑडियोबुक निर्यात करें।",
"script": "स्क्रिप्ट",
"script_placeholder": "#अध्याय एक\nएक बार की बात है... [500 एमएस रोकें] अंत।\n\n#अध्याय दो\n[आवाज:वर्णनकर्ता] और इसी तरह यह जारी रहा।",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Hapus proyek ini secara permanen?"
},
"common": {
"stop": "Berhenti",
"resume": "Lanjutkan",
"paused": "Dijeda",
"open": "Buka",
"cancel": "Batalkan",
"save": "Simpan",
@@ -2519,6 +2522,9 @@
"dismiss": "Tutup",
"output": "Keluaran",
"title": "Buku Audio",
"tab_script": "Naskah",
"tab_voices": "Suara",
"tab_book": "Buku",
"subtitle": "Tulis atau impor cerita Anda, pilih suara untuk setiap peran, lalu ekspor buku audio rapi dengan bab sungguhan.",
"script": "naskah",
"script_placeholder": "# Bab Satu\nSuatu ketika… [pause 500ms] berakhir.\n\n# Bab Dua\n[suara: narator] Dan hal itu berlanjut.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Eliminare definitivamente questo progetto?"
},
"common": {
"stop": "Fermati",
"resume": "Riprendi",
"paused": "In pausa",
"open": "Aperto",
"cancel": "Annulla",
"save": "Salva",
@@ -2519,6 +2522,9 @@
"dismiss": "Ignora",
"output": "Uscita",
"title": "Audiolibro",
"tab_script": "Copione",
"tab_voices": "Voci",
"tab_book": "Libro",
"subtitle": "Scrivi o importa la tua storia, assegna ogni voce e poi esporta un audiolibro rifinito con veri capitoli.",
"script": "Copione",
"script_placeholder": "# Capitolo primo\nC'era una volta… [pausa 500ms] fine.\n\n# Capitolo due\n[voce: narratore] E così è continuato.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "このプロジェクトを完全に削除しますか?"
},
"common": {
"stop": "停止",
"resume": "再開",
"paused": "一時停止中",
"open": "開く",
"cancel": "キャンセル",
"save": "保存",
@@ -2519,6 +2522,9 @@
"dismiss": "閉じる",
"output": "出力",
"title": "オーディオブック",
"tab_script": "台本",
"tab_voices": "音声",
"tab_book": "ブック",
"subtitle": "物語を書いたり読み込んだり、声を配役して、本格的な章付きオーディオブックを書き出せます。",
"script": "スクリプト",
"script_placeholder": "# 第一章\nむかしむかし… [500 ミリ秒停止] 終了。\n\n# 第二章\n[音声:ナレーター] そして、それは続きました。",
+6
View File
@@ -704,6 +704,9 @@
"deleteProjectConfirm": "이 프로젝트를 영구적으로 삭제할까요?"
},
"common": {
"stop": "중지",
"resume": "재개",
"paused": "일시 중지됨",
"open": "열기",
"cancel": "취소",
"save": "저장",
@@ -2923,6 +2926,9 @@
"dismiss": "닫기",
"output": "출력",
"title": "오디오북",
"tab_script": "대본",
"tab_voices": "음성",
"tab_book": "책",
"subtitle": "이야기를 쓰거나 가져오고, 각 배역의 목소리를 정한 다음 실제 챕터가 있는 완성도 높은 오디오북으로 내보내세요.",
"script": "스크립트",
"script_placeholder": "# Chapter One\nOnce upon a time… [pause 500ms] the end.\n\n# Chapter Two\n[voice:narrator] And so it continued.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Dit project permanent verwijderen?"
},
"common": {
"stop": "Stop",
"resume": "Hervatten",
"paused": "Gepauzeerd",
"open": "Openen",
"cancel": "Annuleer",
"save": "Opslaan",
@@ -2519,6 +2522,9 @@
"dismiss": "Sluiten",
"output": "Uitvoer",
"title": "Audioboek",
"tab_script": "Script",
"tab_voices": "Stemmen",
"tab_book": "Boek",
"subtitle": "Schrijf of importeer je verhaal, wijs elke stem toe en exporteer een verzorgd luisterboek met echte hoofdstukken.",
"script": "Script",
"script_placeholder": "# Hoofdstuk één\nEr was eens… [pauze 500 ms] het einde.\n\n# Hoofdstuk twee\n[stem:verteller] En zo ging het verder.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Usunąć ten projekt na stałe?"
},
"common": {
"stop": "Zatrzymaj się",
"resume": "Wznów",
"paused": "Wstrzymany",
"open": "Otwórz",
"cancel": "Anuluj",
"save": "Zapisz",
@@ -2519,6 +2522,9 @@
"dismiss": "Odrzuć",
"output": "Wyjście",
"title": "Książka audio",
"tab_script": "Scenariusz",
"tab_voices": "Głosy",
"tab_book": "Książka",
"subtitle": "Napisz lub zaimportuj historię, obsadź każdy głos, a następnie wyeksportuj dopracowany audiobook z prawdziwymi rozdziałami.",
"script": "Skrypt",
"script_placeholder": "#Rozdział pierwszy\nDawno, dawno temu… [pauza 500 ms] koniec.\n\n#Rozdział drugi\n[głos:narrator] I tak to trwało.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Excluir este projeto permanentemente?"
},
"common": {
"stop": "Pare",
"resume": "Retomar",
"paused": "Em pausa",
"open": "Abrir",
"cancel": "Cancelar",
"save": "Salvar",
@@ -2519,6 +2522,9 @@
"dismiss": "Dispensar",
"output": "Saída",
"title": "Audiolivro",
"tab_script": "Roteiro",
"tab_voices": "Vozes",
"tab_book": "Livro",
"subtitle": "Escreva ou importe sua história, escolha cada voz e exporte um audiolivro bem-acabado com capítulos de verdade.",
"script": "Roteiro",
"script_placeholder": "# Capítulo Um\nEra uma vez… [pausa de 500ms] o fim.\n\n# Capítulo Dois\n[voz: narrador] E assim continuou.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Удалить этот проект навсегда?"
},
"common": {
"stop": "Останавливаться",
"resume": "Возобновить",
"paused": "Приостановлен",
"open": "Открыть",
"cancel": "Отмена",
"save": "Сохранять",
@@ -2519,6 +2522,9 @@
"dismiss": "Закрыть",
"output": "Вывод",
"title": "Аудиокнига",
"tab_script": "Сценарий",
"tab_voices": "Голоса",
"tab_book": "Книга",
"subtitle": "Напишите или импортируйте историю, назначьте голоса и экспортируйте готовую аудиокнигу с настоящими главами.",
"script": "Скрипт",
"script_placeholder": "# Глава первая\nОднажды… [пауза 500 мс] конец.\n\n# Глава вторая\n[голос: диктор] И так продолжалось.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Ta bort projektet permanent?"
},
"common": {
"stop": "Sluta",
"resume": "Återuppta",
"paused": "Pausad",
"open": "Öppna",
"cancel": "Avbryt",
"save": "Spara",
@@ -2519,6 +2522,9 @@
"dismiss": "Avfärda",
"output": "Utdata",
"title": "Ljudbok",
"tab_script": "Manus",
"tab_voices": "Röster",
"tab_book": "Bok",
"subtitle": "Skriv eller importera din berättelse, tilldela varje roll en röst och exportera en finslipad ljudbok med riktiga kapitel.",
"script": "Manus",
"script_placeholder": "# Kapitel ett\nDet var en gång... [paus 500ms] slutet.\n\n# Kapitel två\n[voice:berättare] Och så fortsatte det.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "ลบโปรเจ็กต์นี้อย่างถาวรหรือไม่"
},
"common": {
"stop": "หยุด",
"resume": "ดำเนินต่อ",
"paused": "หยุดชั่วคราว",
"open": "เปิด",
"cancel": "ยกเลิก",
"save": "บันทึก",
@@ -2519,6 +2522,9 @@
"dismiss": "ปิด",
"output": "เอาต์พุต",
"title": "หนังสือเสียง",
"tab_script": "บท",
"tab_voices": "เสียงพูด",
"tab_book": "หนังสือ",
"subtitle": "เขียนหรือนำเข้าเรื่องราว เลือกเสียงให้แต่ละบทบาท แล้วส่งออกเป็นหนังสือเสียงที่สมบูรณ์พร้อมบทจริง",
"script": "สคริปต์",
"script_placeholder": "#บทที่หนึ่ง\nกาลครั้งหนึ่ง… [หยุดชั่วคราว 500ms] สิ้นสุด\n\n#บทที่สอง\n[เสียง: ผู้บรรยาย] และมันก็ดำเนินต่อไป",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Bu proje kalıcı olarak silinsin mi?"
},
"common": {
"stop": "Durdur",
"resume": "Sürdür",
"paused": "Duraklatıldı",
"open": "Açık",
"cancel": "İptal",
"save": "Kaydet",
@@ -2519,6 +2522,9 @@
"dismiss": "Kapat",
"output": "Çıktı",
"title": "Sesli kitap",
"tab_script": "Metin",
"tab_voices": "Sesler",
"tab_book": "Kitap",
"subtitle": "Hikâyenizi yazın veya içe aktarın, her rolü seslendirin ve gerçek bölümleri olan tamamlanmış bir sesli kitap dışa aktarın.",
"script": "Senaryo",
"script_placeholder": "# Birinci Bölüm\nBir varmış bir yokmuş… [500ms durakla] son.\n\n# İkinci Bölüm\n[ses:anlatıcı] Ve böyle devam etti.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Видалити цей проєкт назавжди?"
},
"common": {
"stop": "Стоп",
"resume": "Відновити",
"paused": "Призупинено",
"open": "відкритий",
"cancel": "Скасувати",
"save": "зберегти",
@@ -2519,6 +2522,9 @@
"dismiss": "Закрити",
"output": "Вивід",
"title": "Аудіокнига",
"tab_script": "Сценарій",
"tab_voices": "Голоси",
"tab_book": "Книга",
"subtitle": "Напишіть або імпортуйте історію, призначте голоси та експортуйте готову аудіокнигу зі справжніми розділами.",
"script": "Сценарій",
"script_placeholder": "# Розділ перший\nОдного разу... [пауза 500 мс] кінець.\n\n# Розділ другий\n[голос: оповідач] І так тривало.",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "Xóa vĩnh viễn dự án này?"
},
"common": {
"stop": "Dừng lại",
"resume": "Tiếp tục",
"paused": "Đã tạm dừng",
"open": "Mở",
"cancel": "Hủy bỏ",
"save": "Lưu",
@@ -2519,6 +2522,9 @@
"dismiss": "Bỏ qua",
"output": "Đầu ra",
"title": "Sách nói",
"tab_script": "Kịch bản",
"tab_voices": "Giọng đọc",
"tab_book": "Sách",
"subtitle": "Viết hoặc nhập câu chuyện, chọn giọng cho từng vai, rồi xuất sách nói hoàn chỉnh với các chương thực sự.",
"script": "kịch bản",
"script_placeholder": "# Chương Một\nNgày xửa ngày xưa… [tạm dừng 500ms] đoạn cuối.\n\n# Chương hai\n[giọng:người kể chuyện] Và cứ thế mọi chuyện tiếp tục.",
+6
View File
@@ -87,6 +87,9 @@
"deleteProjectConfirm": "永久删除此项目?"
},
"common": {
"stop": "停止",
"resume": "恢复",
"paused": "已暂停",
"open": "打开",
"cancel": "取消",
"save": "保存",
@@ -2526,6 +2529,9 @@
"dismiss": "关闭",
"output": "输出",
"title": "有声读物",
"tab_script": "文稿",
"tab_voices": "配音",
"tab_book": "书籍",
"subtitle": "编写或导入故事,为每个角色分配声音,再导出带有真实章节的精致有声书。",
"script": "脚本",
"script_placeholder": "#第一章\n从前……[停顿 500 毫秒]结束。\n\n# 第二章\n[声音:旁白] 就这样继续下去。",
+6
View File
@@ -472,6 +472,9 @@
"deleteProjectConfirm": "要永久刪除此專案嗎?"
},
"common": {
"stop": "停止",
"resume": "繼續",
"paused": "已暫停",
"open": "打開",
"cancel": "取消",
"save": "儲存",
@@ -2519,6 +2522,9 @@
"dismiss": "關閉",
"output": "輸出",
"title": "有聲書",
"tab_script": "文稿",
"tab_voices": "配音",
"tab_book": "書籍",
"subtitle": "撰寫或匯入故事,為每個角色分配聲音,再匯出具備真實章節的精緻有聲書。",
"script": "腳本",
"script_placeholder": "#第一章\n從前…[停頓 500 毫秒]結束。\n\n# 第二章\n[聲音:旁白] 就這樣繼續下去。",
+371 -3
View File
@@ -5768,7 +5768,7 @@ button.dub-stepper__action:focus-visible {
box-shadow: inset 0 0 0 1px var(--chrome-border-strong), 0 14px 34px rgba(0, 0, 0, 0.12);
}
.audiobook-tab__script textarea {
.audiobook-tab__manuscript textarea {
flex: 1 1 auto;
min-height: 0;
width: 100%;
@@ -5783,14 +5783,14 @@ button.dub-stepper__action:focus-visible {
box-shadow: none;
}
.audiobook-tab__script textarea:focus {
.audiobook-tab__manuscript textarea:focus {
border: 0;
background: transparent;
box-shadow: none;
}
@media (max-width: 900px) {
.audiobook-tab__script textarea { min-height: 240px; }
.audiobook-tab__manuscript textarea { min-height: 240px; }
}
/* Synced-lyrics player (audiobook result): chapter text under the native
@@ -6931,6 +6931,215 @@ button.dub-stepper__action:focus-visible {
}
}
/* Stories shares the Clone workspace tab navigation, with one task per panel. */
.stories-tabbed .stories-tabbar {
flex-shrink: 0;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.stories-tabbed .stories-tab span {
min-width: 0;
overflow-wrap: anywhere;
}
.stories-tabbed .stories-tab-panel {
min-height: 0;
min-width: 0;
flex: 1;
overflow-y: auto;
margin-top: 0;
}
.stories-tabbed .stories-tab-panel[data-state="inactive"] {
display: none;
}
.stories-tabbed .stories-workspace {
display: flex;
flex-direction: column;
}
.stories-tabbed .stories-manuscript {
height: 100%;
}
.stories-tabbed .stories-rail-section {
width: min(100% - 32px, 1040px);
margin: 20px auto;
padding: 20px;
gap: 18px;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--color-bg-elev-1);
box-shadow: none;
}
.stories-tabbed .stories-export-panel .stories-rail-section {
max-width: 640px;
}
.stories-tabbed .stories-rail-heading {
flex-wrap: wrap;
gap: 12px;
}
.stories-tabbed .stories-section-toggle {
cursor: default;
}
.stories-tabbed .stories-section-toggle,
.stories-tabbed .stories-manuscript-heading {
font-family: var(--font-sans);
font-size: 13px;
letter-spacing: normal;
text-transform: none;
}
.stories-tabbed .stories-section-count {
font-size: 12px;
min-width: 24px;
height: 24px;
}
.stories-tabbed .stories-cast-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
gap: 12px;
}
.stories-tabbed .stories-cast-member {
grid-template-columns: 8px minmax(0, 1fr) 32px;
gap: 12px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: 10px;
}
.stories-tabbed .stories-cast-member > span.min-w-0 {
grid-column: 2;
grid-row: 2;
}
.stories-tabbed .stories-cast-member > button {
border: 0;
grid-column: 3;
grid-row: 1;
}
.stories-tabbed .stories-cast-member input {
min-height: 36px;
font-size: 13px;
}
.stories-tabbed .stories-project-list {
max-height: none;
gap: 8px;
}
.stories-tabbed .stories-project-row {
padding: 8px;
border: 1px solid var(--color-border);
}
.stories-tabbed .stories-project-editor {
gap: 12px;
}
.stories-tabbed .stories-project-editor input {
min-height: 38px;
}
.stories-tabbed .stories-manuscript-toolbar {
padding: 12px 16px;
flex-wrap: wrap;
}
.stories-tabbed .stories-manuscript-toolbar > div:last-child {
flex-wrap: wrap;
}
.stories-tabbed .stories-track-list {
gap: 12px;
padding: 16px;
}
.stories-tabbed .stories-line {
grid-template-columns: 28px minmax(100px, 1fr) minmax(120px, 1fr) auto;
grid-template-areas:
'drag text text text'
'. character voice actions'
'. drawer drawer drawer';
gap: 10px;
padding: 14px;
cursor: default;
box-shadow: none;
border: 1px solid var(--color-border);
contain-intrinsic-size: 112px;
}
.stories-tabbed .stories-line__text {
min-height: 56px;
font-size: 15px;
line-height: 1.65;
}
.stories-tabbed .stories-line-number {
font-size: 11px;
}
.stories-tabbed .stories-line__actions {
opacity: 1;
}
.stories-tabbed .stories-line__actions button {
border: 0;
color: var(--color-fg-muted);
min-width: 32px;
min-height: 32px;
}
.stories-tabbed .stories-line__actions button:hover:enabled {
color: var(--color-fg);
background: var(--color-bg-elev-2);
}
.stories-tabbed .stories-line__actions button:last-child:hover:enabled {
color: var(--color-danger);
}
.stories-tabbed .stories-kicker {
font-size: 10px;
line-height: 1.4;
}
.stories-tabbed .stories-header {
padding: 14px 16px;
}
@media (max-width: 600px) {
.stories-tabbed .stories-tab {
flex-direction: column;
gap: 4px;
padding: 8px 4px;
}
.stories-tabbed .stories-header {
flex-wrap: wrap;
}
.stories-tabbed .stories-tabbar {
padding: 8px;
}
.stories-tabbed .stories-line {
grid-template-columns: 22px minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: 'drag text text' '. character voice' '. actions actions' '. drawer drawer';
padding: 10px;
}
.stories-tabbed .stories-rail-section {
width: calc(100% - 16px);
padding: 14px;
margin: 8px;
}
}
.stories-tabbed .stories-rail-label { font-size: 13px; }
.stories-tabbed .stories-rail-label > span { flex-shrink: 0; }
.stories-tabbed .stories-export-panel select { min-height: 40px; max-width: 220px; }
.shell-mini .stories-tabbed .stories-line {
grid-template-columns: 22px minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'drag text text'
'. character voice'
'. actions actions'
'. drawer drawer';
padding: 10px;
}
/* Compact two-row Stories command header. */
.stories-tabbed .stories-header { padding: 8px 16px; }
.stories-tabbed .stories-tabbar {
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 16px;
flex-wrap: wrap;
}
.stories-tabbed .stories-tab { min-height: 36px; padding: 6px 14px; }
.stories-tabbed .stories-script-tools { flex-wrap: wrap; }
@media (max-width: 600px) {
.stories-tabbed .stories-tabbar { padding: 6px 8px; gap: 6px; }
.stories-tabbed .stories-tabbar [role="tablist"] { width: 100%; }
.stories-tabbed .stories-tab { flex-direction: row; padding: 6px 4px; }
.stories-tabbed .stories-tab svg { display: none; }
}
/* ── Sticky header row ─────────────────────────────────────────── */
.ui-table-header {
display: flex;
@@ -7219,3 +7428,162 @@ button.dub-stepper__action:focus-visible {
justify-content: flex-start;
}
}
/* Dubbing start workspace: a focused import card with nearby language choices. */
.dub-start-screen .dub-panel-col {
overflow-y: auto;
padding: clamp(16px, 4vw, 48px);
background:
radial-gradient(ellipse at 50% 24%, color-mix(in srgb, var(--color-brand) 7%, transparent), transparent 62%),
var(--chrome-bg);
}
.dub-start-card {
width: min(100%, 740px);
min-width: 0;
flex-shrink: 0;
margin: auto;
padding: clamp(18px, 3vw, 32px);
border: 1px solid var(--color-border);
border-radius: 22px;
background: var(--color-bg-elev-1);
box-shadow: 0 20px 60px color-mix(in srgb, var(--chrome-bg) 35%, transparent);
}
.dub-start-card .dub-idle-drop {
flex: none;
margin: 0;
min-height: 220px;
padding: 26px 16px;
gap: 16px;
border: 1px dashed color-mix(in srgb, var(--color-brand) 45%, var(--color-border));
border-radius: 16px;
background: color-mix(in srgb, var(--color-brand) 4%, var(--chrome-bg));
}
.dub-start-card .dub-idle-drop:hover,
.dub-start-card .dub-idle-drop.is-dragging {
border-color: var(--color-brand);
background: color-mix(in srgb, var(--color-brand) 9%, var(--chrome-bg));
}
.dub-start-card .dub-idle-drop__puck {
width: 64px;
height: 64px;
border-radius: 20px;
color: var(--color-brand);
background: color-mix(in srgb, var(--color-brand) 12%, var(--color-bg-elev-1));
border-color: color-mix(in srgb, var(--color-brand) 24%, transparent);
box-shadow: 0 0 0 8px color-mix(in srgb, var(--color-brand) 3%, transparent);
}
.dub-start-card .dub-idle-drop__puck svg { color: inherit; }
.dub-start-card .dub-landing-opts__lang {
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.dub-start-card .dub-landing-opts__lang > svg { display: none; }
.dub-start-card .dub-landing-opts__lang select {
width: 100%;
max-width: none;
min-height: 42px;
font-size: 13px;
}
.dub-start-card input[type="text"],
.dub-start-card input[type="url"] { min-width: 0; font-size: 13px; }
.dub-start-card details { margin-top: 16px; }
.dub-start-card summary { cursor: pointer; padding: 10px 0; font-size: 13px; color: var(--color-fg-muted); }
.dub-start-card .dub-ingest-row__cta { min-height: 36px; font-size: 13px; }
@media (max-width: 600px) {
.dub-start-screen .dub-panel-col { padding: 12px; }
.dub-start-card { padding: 16px; border-radius: 16px; }
.dub-start-card .dub-idle-drop { min-height: 190px; padding: 20px 12px; }
}
.dub-start-title { margin: 0; color: var(--color-fg); font-size: 22px; font-weight: 600; line-height: 1.3; text-wrap: balance; }
.dub-start-formats { color: var(--color-fg-muted); font-size: 12px; line-height: 1.6; }
.dub-start-languages { display: flex; gap: 16px; align-items: stretch; margin-top: 24px; }
.dub-start-url { display: flex; align-items: center; gap: 10px; margin-top: 20px; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: 10px; background: var(--chrome-bg); }
.dub-start-url input { flex: 1; min-height: 36px; }
.dub-start-url:focus-within { box-shadow: var(--focus-ring); }
@media (max-width: 600px) {
.dub-start-title { font-size: 19px; }
.dub-start-languages { flex-direction: column; gap: 12px; }
}
.dub-start-choose { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 10px 22px; border-radius: 10px; background: var(--color-brand); color: var(--color-bg); font-size: 14px; font-weight: 600; }
.dub-start-advanced, .dub-start-batch { display: flex; align-items: center; gap: 8px; border: 0; background: transparent; color: var(--color-fg-muted); cursor: pointer; font-size: 13px; min-height: 40px; }
.dub-start-advanced { width: 100%; justify-content: space-between; margin-top: 12px; padding: 8px 0; }
.dub-start-batch { justify-content: center; width: 100%; margin-top: 16px; padding: 16px 8px 0; border-top: 1px solid var(--color-border); text-wrap: pretty; }
.dub-start-advanced:hover, .dub-start-batch:hover { color: var(--color-fg); }
.dub-start-advanced:focus-visible, .dub-start-batch:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: 6px; }
.dub-start-options { display: flex; flex-direction: column; gap: 18px; padding: 16px; border-radius: 10px; background: var(--chrome-bg); font-size: 13px; color: var(--color-fg-muted); }
.dub-start-caption-option, .dub-start-cookie-option { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.dub-start-caption-option input { width: 16px; height: 16px; }
.dub-start-cookie-option input { min-width: 0; max-width: 100%; font-size: 12px; }
.dub-start-generation-options { display: flex; flex-wrap: wrap; gap: 16px; }
.dub-start-generation-options label { display: flex; flex-direction: column; gap: 8px; }
.dub-start-generation-options label > span { display: flex; align-items: center; gap: 6px; }
.dub-start-generation-options input { min-height: 36px; margin-left: 0; }
.dub-start-generation-options input[type="number"] { width: 88px; }
.dub-start-languages label > span { font-size: 13px; }
.dub-start-options[hidden] { display: none; }
/* Layout utilities must not reveal inactive Radix tab panels. */
[data-slot="tabs-content"][hidden] {
display: none !important;
}
/* Give the stacked library its own scrollable area below the import form. */
.shell-narrow .studio-right:has(.dub-project-library),
.shell-mini .studio-right:has(.dub-project-library) {
flex: 0 0 min(420px, 60vh);
min-height: 240px;
}
.dub-start-language-select { width: 100%; min-height: 42px; font-size: 13px; }
/* Keep project cards stationary as their actions become visible. */
.dub-project-library .history-actions {
min-height: 30px;
max-height: none;
transition: opacity 0.2s;
}
@media (hover: none) {
.dub-project-library .history-actions { opacity: 1; }
}
.capture-pill__control {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 10%);
color: rgb(255 255 255 / 90%);
cursor: pointer;
}
.capture-pill__control:hover { background: rgb(255 255 255 / 20%); }
.capture-pill__control:focus-visible { outline: 2px solid white; outline-offset: 2px; }
/* Keep live text independent of the recording controls. */
.capture-pill {
width: 420px;
max-width: calc(100vw - 16px);
height: auto;
min-height: 48px;
flex-wrap: wrap;
border-radius: 20px;
}
.capture-pill__preview {
order: 2;
flex: 0 0 100%;
min-width: 0;
max-height: 84px;
overflow-y: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: white;
}
+150 -68
View File
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileText, Users, BookText } from 'lucide-react';
import {
audiobookPlan,
audiobookGenerate,
@@ -11,27 +12,39 @@ import { audioUrl } from '../api/generate';
import { useEngines } from '../api/hooks';
import { consumeLongformStream } from '../utils/longformStream';
import { useAppStore } from '../store';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs';
import { overridesToRequest } from '../components/audiobook/AudiobookOverrides';
import GenerationProgress from '../components/audiobook/GenerationProgress';
import PlanList from '../components/audiobook/PlanList';
import AudiobookResult from '../components/audiobook/AudiobookResult';
import MarkupToolbar from '../components/audiobook/MarkupToolbar';
import StatsBar from '../components/audiobook/StatsBar';
import ValidationWarnings from '../components/audiobook/ValidationWarnings';
import AudiobookHero from '../components/audiobook/AudiobookHero';
import AudiobookInspector from '../components/audiobook/AudiobookInspector';
import AudiobookScriptPanel from '../components/audiobook/AudiobookScriptPanel';
import AudiobookVoicesPanel from '../components/audiobook/AudiobookVoicesPanel';
import AudiobookBookPanel from '../components/audiobook/AudiobookBookPanel';
import { useAudiobookLexicon } from '../hooks/useAudiobookLexicon';
import { parseCastNames, validateScript } from '../utils/audiobookScript';
import { SAMPLE_AUDIOBOOK_SCRIPT } from '../data/sampleAudiobook';
// Chrome-mono uppercase form label (was the scoped `.audiobook-tab .field-label`
// rule; `.field-label` has no global styling, so it's reproduced as utilities).
// Stable empty-cast fallback: a literal `?? {}` mints a new object every render,
// which defeats the useMemos keyed on voiceCast (they'd recompute every render).
const EMPTY_CAST = Object.freeze({});
const FIELD_LABEL =
'[font-family:var(--chrome-font-mono)] [font-size:var(--chrome-label-size)] font-semibold [letter-spacing:var(--chrome-label-track)] uppercase [color:var(--chrome-fg-muted)]';
// Write Cast Produce: the audiobook pipeline as workspace tabs, mirroring
// the clone workspace (Script / Voice + pinned actions). Persisted per visit;
// validated on read so a stale value can never strand the workspace.
const BOOK_TABS = ['script', 'voices', 'book'];
const BOOK_TAB_KEY = 'omnivoice.audiobook.tab';
function readStoredBookTab() {
try {
const stored = localStorage.getItem(BOOK_TAB_KEY);
return BOOK_TABS.includes(stored) ? stored : 'script';
} catch {
return 'script';
}
}
/**
* AudiobookTab turn a chapter-delimited script into a chapterized m4b.
@@ -42,6 +55,18 @@ const FIELD_LABEL =
*/
export default function AudiobookTab({ profiles = [] }) {
const { t } = useTranslation();
// Workspace tab (Script / Voices / Book) local + persisted, like the
// catalogue pane. Manual activation so arrowing across the strip never
// yanks the panel out from under keyboard users.
const [bookTab, setBookTabRaw] = useState(readStoredBookTab);
const setBookTab = useCallback((next) => {
setBookTabRaw(next);
try {
localStorage.setItem(BOOK_TAB_KEY, next);
} catch {
/* private mode / quota — the tab still switches, it just won't persist */
}
}, []);
// Persisted via the unified LongformProject store (#31b) book identity,
// script, voice, and output prefs now survive a tab switch / reload (they
// used to live in component useState and evaporate).
@@ -148,17 +173,23 @@ export default function AudiobookTab({ profiles = [] }) {
return validateScript(text, { mappedNames, profileIds: profiles.map((p) => p.id) });
}, [text, voiceCast, profiles]);
// Tab badges (the inspector's counts, moved onto the strip): cast size on
// Voices, filled details + lexicon rows on Book.
const detailCount =
Object.values(meta).filter((value) => value?.trim()).length + (coverPreview ? 1 : 0);
const lexiconCount = lex.filter((row) => row.word.trim() || row.say.trim()).length;
const onCoverPick = useCallback((e) => {
const f = e.target.files?.[0];
if (!f) return;
setCoverFile(f);
setCoverPreview(URL.createObjectURL(f));
}, []);
}, [setCoverFile, setCoverPreview]);
const clearCover = useCallback(() => {
setCoverFile(null);
if (coverPreview) URL.revokeObjectURL(coverPreview);
setCoverPreview('');
}, [coverPreview]);
}, [coverPreview, setCoverFile, setCoverPreview]);
// Revoke the cover blob URL when it's replaced or the tab unmounts (React
// doesn't reclaim object URLs on its own).
useEffect(
@@ -384,6 +415,26 @@ export default function AudiobookTab({ profiles = [] }) {
[canRun, onCreate],
);
// The pinned status rail (warnings / progress / result / plan) only takes
// space once there is something to show a fresh book is just the tabs.
const showWarnings = !warningsDismissed && !generating && warnings.length > 0;
const hasStatus =
showWarnings || error || generating || (stopped && !generating) || output || plan;
const tabDefs = useMemo(
() => [
{ id: 'script', label: t('audiobook.tab_script'), Icon: FileText, badge: 0 },
{ id: 'voices', label: t('audiobook.tab_voices'), Icon: Users, badge: castNames.length },
{
id: 'book',
label: t('audiobook.tab_book'),
Icon: BookText,
badge: detailCount + lexiconCount,
},
],
[t, castNames.length, detailCount, lexiconCount],
);
return (
<div className="audiobook-tab flex h-full flex-col box-border px-[1.25rem] py-[1rem] gap-[10px] max-[1120px]:overflow-y-auto">
<AudiobookHero
@@ -400,68 +451,99 @@ export default function AudiobookTab({ profiles = [] }) {
onStop={onStop}
/>
<div className="audiobook-tab__body grid flex-auto grid-cols-[minmax(0,1fr)_minmax(440px,500px)] max-[1120px]:grid-cols-1 gap-[14px] min-h-0">
{/* Left: script editor fills the height */}
<div className="audiobook-tab__script flex flex-col min-h-0 gap-[7px]">
<div className="flex min-h-[18px] items-center justify-between gap-[12px] px-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.script')}</label>
{text.trim() ? <StatsBar t={t} text={text} /> : null}
</div>
<div className="audiobook-tab__manuscript flex min-h-0 flex-1 flex-col overflow-hidden rounded-[14px]">
<div className="border-b border-transparent px-[10px] py-[7px]">
<MarkupToolbar t={t} textareaRef={textareaRef} text={text} setText={setText} />
</div>
<textarea
ref={textareaRef}
className="input-base"
value={text}
onChange={(e) => {
setText(e.target.value);
if (warningsDismissed) setWarningsDismissed(false);
}}
onKeyDown={onScriptKeyDown}
placeholder={t('audiobook.script_placeholder')}
aria-label={t('audiobook.script')}
/>
{!text.trim() && (
<p className="m-0 border-t border-transparent px-[14px] py-[9px] text-[var(--text-sm)] text-fg-muted">
{t('audiobook.empty_hint')}
</p>
)}
</div>
<Tabs
value={bookTab}
onValueChange={setBookTab}
activationMode="manual"
className="flex min-h-0 flex-1 flex-col gap-0"
>
<div className="relative z-10 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 px-1 pb-3 pt-1 max-[600px]:px-0">
<TabsList
aria-label={t('audiobook.title')}
className="grid h-auto w-auto min-w-0 flex-[1_1_320px] grid-cols-3 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-bg)] p-[3px]"
>
{tabDefs.map(({ id, label, Icon, badge }) => (
<TabsTrigger
key={id}
value={id}
data-book-tab={id}
disabled={busy}
className="min-h-11 h-auto min-w-0 cursor-pointer whitespace-normal rounded-[var(--chrome-radius-pill)] border border-transparent bg-transparent px-3 py-2 text-sm font-medium text-[color:var(--chrome-fg-muted)] transition-colors data-[state=active]:border-[var(--chrome-accent-border)] data-[state=active]:bg-[var(--chrome-accent-bg)] data-[state=active]:font-semibold data-[state=active]:text-[color:var(--chrome-accent)] data-[state=active]:shadow-none dark:data-[state=active]:border-[var(--chrome-accent-border)] dark:data-[state=active]:bg-[var(--chrome-accent-bg)] dark:data-[state=active]:text-[color:var(--chrome-accent)] hover:data-[state=inactive]:bg-[var(--chrome-hover-bg)]"
>
<Icon size={16} aria-hidden="true" />
{label}
{badge > 0 && (
<span
className="rounded-full bg-[var(--chrome-hover-bg)] px-[8px] text-[0.7rem] [font-variant-numeric:tabular-nums]"
aria-hidden="true"
>
{badge}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</div>
{/* Right: settings + results, scrolls independently */}
<div className="audiobook-tab__side flex flex-col gap-[9px] min-h-0 overflow-y-auto max-[1120px]:overflow-visible rounded-[12px] bg-[var(--color-bg-elev-2)] p-[10px]">
<AudiobookInspector
t={t}
profiles={profiles}
defaultVoice={defaultVoice}
setDefaultVoice={setDefaultVoice}
language={language}
setLanguage={setLanguage}
format={format}
setFormat={setFormat}
loudness={loudness}
setLoudness={setLoudness}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
overrides={overrides}
setOverrides={setLongformOverrides}
emotionSupported={emotionSupported}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
<TabsContent value={bookTab} className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{bookTab === 'script' && (
<AudiobookScriptPanel
t={t}
text={text}
setText={setText}
textareaRef={textareaRef}
onScriptKeyDown={onScriptKeyDown}
warningsDismissed={warningsDismissed}
setWarningsDismissed={setWarningsDismissed}
/>
)}
{bookTab === 'voices' && (
<AudiobookVoicesPanel
t={t}
profiles={profiles}
defaultVoice={defaultVoice}
setDefaultVoice={setDefaultVoice}
language={language}
setLanguage={setLanguage}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
overrides={overrides}
setOverrides={setLongformOverrides}
emotionSupported={emotionSupported}
/>
)}
{bookTab === 'book' && (
<AudiobookBookPanel
t={t}
format={format}
setFormat={setFormat}
loudness={loudness}
setLoudness={setLoudness}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
detailCount={detailCount}
lexiconCount={lexiconCount}
/>
)}
</div>
</TabsContent>
</Tabs>
{!warningsDismissed && !generating && (
{hasStatus && (
<div
className="flex shrink-0 flex-col gap-[9px] overflow-y-auto rounded-[12px] bg-[var(--color-bg-elev-2)] p-[10px]"
data-testid="audiobook-status-rail"
>
{showWarnings && (
<ValidationWarnings
t={t}
warnings={warnings}
@@ -503,7 +585,7 @@ export default function AudiobookTab({ profiles = [] }) {
/>
)}
</div>
</div>
)}
</div>
);
}
+102 -7
View File
@@ -11,6 +11,8 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Mic, Copy, Trash2, Search, Clock, Languages, FileText, Download } from 'lucide-react';
import { Button } from '../ui';
import { detectPlatform } from '../utils/micError';
import { useDictationReadiness } from '../hooks/useDictationReadiness';
import { toast } from 'react-hot-toast';
import { copyText as copyToClipboard } from '../utils/copyText';
import { toMillis } from '../utils/relativeTime';
@@ -67,17 +69,26 @@ export default function TranscriptionsPage() {
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
const { info: shortcut } = useEffectiveDictationShortcut();
const readiness = useDictationReadiness();
const checkReadiness = readiness.check;
const [starting, setStarting] = useState(false);
const captureDisabled = readiness.phase !== 'ready' || starting;
const emptyDescription = t('transcriptions.empty_desc', { shortcut: shortcut.display });
const normalizedSearch = search.trim();
const startCapture = useCallback(async () => {
if (captureDisabled) return;
setStarting(true);
try {
if (!(await checkReadiness())) return;
await requestDictationCapture('start');
} catch (error) {
console.warn('Could not start dictation:', error);
toast.error(t('transcriptions.capture_failed'));
} finally {
setStarting(false);
}
}, [t]);
}, [t, captureDisabled, checkReadiness]);
// Listen for new transcriptions added from CaptureButton
useEffect(() => {
@@ -180,9 +191,17 @@ export default function TranscriptionsPage() {
</span>
</div>
<div className="txn-header__right flex items-center gap-[6px]">
<Button size="sm" variant="primary" onClick={startCapture}>
<Mic size={13} /> {t('transcriptions.capture')}
</Button>
{transcriptions.length > 0 && (
<Button
size="sm"
variant="primary"
leading={<Mic size={13} />}
disabled={captureDisabled}
onClick={startCapture}
>
{t('transcriptions.capture')}
</Button>
)}
<div className="txn-search relative flex items-center">
<Search
size={13}
@@ -219,6 +238,72 @@ export default function TranscriptionsPage() {
</div>
</div>
{readiness.phase !== 'ready' && (
<div
className="rounded-lg border border-border bg-bg-elev-1 p-4 flex flex-col gap-3"
role="status"
aria-live="polite"
>
<p className="text-sm text-fg m-0">
{readiness.phase === 'checking'
? t('setup.checking')
: readiness.phase === 'error'
? t('common.error')
: readiness.phase === 'installing'
? t('dub.install_progress', { engine: readiness.missing?.recommended?.label })
: t('asr_missing.message')}
</p>
{readiness.phase === 'installing' ? (
<progress
className="w-full"
max={100}
value={readiness.percent ?? undefined}
aria-label={t('dub.install_progress', {
engine: readiness.missing?.recommended?.label,
})}
/>
) : (
readiness.phase !== 'checking' && (
<div className="flex items-center gap-3">
{readiness.missing?.recommended?.repo_id && (
<Button
size="sm"
variant="primary"
leading={<Download size={13} />}
onClick={readiness.install}
>
{t('asr_missing.download', {
label: readiness.missing.recommended.label,
size: readiness.missing.recommended.size_gb,
})}
</Button>
)}
<Button size="sm" variant="ghost" onClick={readiness.check}>
{t('common.refresh')}
</Button>
{readiness.error && <span role="alert">{t('common.error')}</span>}
</div>
)
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">
<kbd className="rounded border border-border bg-bg-elev-1 px-2 py-1 font-mono">
{shortcut.display}
</kbd>
<span>
{t('transcriptions.capture')} / {t('common.stop')}
</span>
<span aria-hidden="true" className="mx-2">
·
</span>
<kbd className="rounded border border-border bg-bg-elev-1 px-2 py-1 font-mono">
{detectPlatform() === 'mac' ? '⌘+V' : 'Ctrl+V'}
</kbd>
<span>{t('clone.paste')}</span>
</div>
{/* Content */}
<div className="txn-content grid flex-1 grid-cols-[1fr_1fr] gap-[12px] min-h-0">
{/* List */}
@@ -232,11 +317,21 @@ export default function TranscriptionsPage() {
: t('transcriptions.empty_title')}
</p>
<p className="txn-empty__desc m-0 max-w-[280px] text-[var(--text-xs)] leading-[1.6] text-fg-muted">
{normalizedSearch ? t('transcriptions.empty_search_desc') : emptyDescription}
{normalizedSearch
? t('transcriptions.empty_search_desc')
: readiness.phase === 'ready'
? emptyDescription
: ''}
</p>
{!normalizedSearch && (
<Button size="sm" variant="primary" onClick={startCapture}>
<Mic size={13} /> {t('transcriptions.capture')}
<Button
size="sm"
variant="primary"
leading={<Mic size={13} />}
disabled={captureDisabled}
onClick={startCapture}
>
{t('transcriptions.capture')}
</Button>
)}
</div>
+40 -5
View File
@@ -1,12 +1,17 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestDictationCapture, copyToClipboard, toast } = vi.hoisted(() => ({
const { requestDictationCapture, copyToClipboard, toast, readiness } = vi.hoisted(() => ({
readiness: { phase: 'ready', check: vi.fn().mockResolvedValue(true), install: vi.fn() },
requestDictationCapture: vi.fn(),
copyToClipboard: vi.fn(),
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock('../hooks/useDictationReadiness', () => ({
useDictationReadiness: () => readiness,
}));
vi.mock('../utils/copyText', () => ({ copyText: copyToClipboard }));
vi.mock('../utils/dictationCapture', () => ({ requestDictationCapture }));
vi.mock('../components/EngineQuickSwitch', () => ({ default: () => null }));
@@ -25,6 +30,8 @@ import TranscriptionsPage, { addTranscription, segTimeRange } from './Transcript
describe('Transcriptions capture entry point', () => {
beforeEach(() => {
readiness.phase = 'ready';
readiness.missing = null;
localStorage.clear();
requestDictationCapture.mockReset().mockResolvedValue(undefined);
toast.error.mockReset();
@@ -32,16 +39,16 @@ describe('Transcriptions capture entry point', () => {
it('shows the effective shortcut and starts the shared recorder from the empty state', async () => {
render(<TranscriptionsPage />);
expect(screen.getByText(/Super\+Shift\+V/)).toBeInTheDocument();
expect(screen.getByText('Super+Shift+V', { selector: 'kbd' })).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Start dictation' }).at(-1));
fireEvent.click(screen.getByRole('button', { name: 'Start dictation' }));
await waitFor(() => expect(requestDictationCapture).toHaveBeenCalledWith('start'));
});
it('reports a capture-controller failure', async () => {
requestDictationCapture.mockRejectedValueOnce(new Error('event channel unavailable'));
render(<TranscriptionsPage />);
fireEvent.click(screen.getAllByRole('button', { name: 'Start dictation' }).at(-1));
fireEvent.click(screen.getByRole('button', { name: 'Start dictation' }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
@@ -56,10 +63,23 @@ describe('Transcriptions capture entry point', () => {
target: { value: ' ' },
});
expect(screen.getAllByRole('button', { name: 'Start dictation' })).toHaveLength(2);
const button = screen.getByRole('button', { name: 'Start dictation' });
expect(button.querySelector(':scope > svg')).toBeInTheDocument();
expect(button.querySelector(':scope > span')).toHaveTextContent('Start dictation');
expect(screen.getByText('No transcriptions yet')).toBeInTheDocument();
});
it('moves the single capture action to the header once history exists', () => {
addTranscription({ text: 'Existing transcript.', language: 'en' });
render(<TranscriptionsPage />);
const button = screen.getByRole('button', { name: 'Start dictation' });
expect(button.closest('.txn-header__right')).toBeInTheDocument();
expect(button.querySelector(':scope > svg')).toBeInTheDocument();
expect(button.querySelector(':scope > span')).toHaveTextContent('Start dictation');
expect(screen.queryByText('No transcriptions yet')).not.toBeInTheDocument();
});
it('shows a successful transcript emitted by the shared recorder', async () => {
render(<TranscriptionsPage />);
act(() => {
@@ -78,6 +98,8 @@ describe('Transcriptions capture entry point', () => {
// one the user could not read at all.
describe('segments without timings (#1798)', () => {
beforeEach(() => {
readiness.phase = 'ready';
readiness.missing = null;
localStorage.clear();
});
@@ -109,6 +131,8 @@ describe('segments without timings (#1798)', () => {
describe('transcription clipboard', () => {
beforeEach(() => {
readiness.phase = 'ready';
readiness.missing = null;
localStorage.clear();
toast.success.mockReset();
toast.error.mockReset();
@@ -135,3 +159,14 @@ describe('transcription clipboard', () => {
expect(toast.success).not.toHaveBeenCalled();
});
});
it('blocks recording and offers an explicit sized download when the model is missing', () => {
localStorage.clear();
readiness.phase = 'missing';
readiness.missing = { recommended: { repo_id: 'test/model', label: 'Tiny', size_gb: 0.1 } };
render(<TranscriptionsPage />);
expect(screen.getByRole('button', { name: 'Start dictation' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'Download Tiny (0.1 GB)' }));
expect(readiness.install).toHaveBeenCalled();
expect(screen.getByText('Super+Shift+V', { selector: 'kbd' })).toBeInTheDocument();
});
+47 -26
View File
@@ -6,8 +6,8 @@
// upload a file, trim it, save it. The project ships no celebrity catalog.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Sparkles, Store, Upload } from 'lucide-react';
import { Segmented } from '../ui';
import { Sparkles, Store, Upload, LibraryBig } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs';
import { archetypePreviewUrl, useArchetypeAsProfile } from '../api/archetypes';
import { addCommunityItem, communityPreviewUrl } from '../api/community';
import { previewVoiceUrl } from '../api/gallery';
@@ -218,50 +218,71 @@ export default function VoiceGallery({ clearSelectedProfile = NOOP }) {
const zoneItems = [
{
value: 'archetypes',
label: (
<span className="inline-flex items-center gap-[5px]">
<Sparkles size={14} /> {t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
</span>
),
Icon: Sparkles,
label: t('gallery.zone_archetypes', { defaultValue: 'Archetypes' }),
},
{
value: 'community',
label: (
<span className="inline-flex items-center gap-[5px]">
<Store size={14} /> {t('gallery.zone_community', { defaultValue: 'Community' })}
</span>
),
Icon: Store,
label: t('gallery.zone_community', { defaultValue: 'Community' }),
},
{
value: 'imports',
label: (
<span className="inline-flex items-center gap-[5px]">
<Upload size={14} /> {t('gallery.zone_imports', { defaultValue: 'My Imports' })}
</span>
),
Icon: Upload,
label: t('gallery.zone_imports', { defaultValue: 'My Imports' }),
},
];
return (
<div className="flex flex-col gap-[12px] p-[12px] h-full overflow-hidden">
<div className="shrink-0">
<div className="flex justify-between items-center">
<div>
<h2 className="text-[1.1rem] font-semibold m-0 text-[var(--text-primary)]">
<div
data-testid="voice-gallery"
className="gallery flex h-full min-h-0 flex-col gap-[10px] overflow-hidden px-[20px] pb-[14px] pt-[16px] [container-type:inline-size] [container-name:gallery] @max-[640px]/gallery:px-[12px]"
>
<header className="flex shrink-0 flex-wrap items-end justify-between gap-x-[24px] gap-y-[12px]">
<div className="flex min-w-0 items-center gap-[12px]">
<span
className="inline-flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[12px] bg-[color-mix(in_srgb,#fabd2f_11%,transparent)] text-[#fabd2f]"
aria-hidden="true"
>
<LibraryBig size={17} strokeWidth={1.6} />
</span>
<div className="min-w-0">
<h2 className="m-0 truncate [font-family:var(--font-serif)] text-[1.5rem] font-normal leading-tight tracking-[-0.02em] text-[color:var(--chrome-fg)]">
{t('gallery.title', { defaultValue: 'VoiceStudio Gallery' })}
</h2>
<p className="mt-[2px] mr-0 mb-0 ml-0 text-[0.72rem] text-[var(--text-secondary)]">
<p className="m-0 mt-[2px] truncate text-[0.72rem] text-[color:var(--chrome-fg-muted)]">
{t('gallery.subtitle', {
defaultValue: 'Hundreds of ready-made designed voices — pick one and go.',
})}
</p>
</div>
<Segmented items={zoneItems} value={zone} onChange={setZone} />
</div>
</div>
<Tabs value={zone} onValueChange={setZone} activationMode="manual" className="gap-0">
<TabsList
aria-label={t('gallery.title', { defaultValue: 'VoiceStudio Gallery' })}
className="grid h-auto w-auto grid-cols-3 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-bg)] p-[3px]"
>
{zoneItems.map(({ value, Icon, label }) => (
<TabsTrigger
key={value}
value={value}
data-gallery-zone={value}
className="h-auto min-h-9 min-w-0 cursor-pointer whitespace-nowrap rounded-[var(--chrome-radius-pill)] border border-transparent bg-transparent px-3 py-[6px] text-[0.72rem] font-medium text-[color:var(--chrome-fg-muted)] transition-colors data-[state=active]:border-[var(--chrome-accent-border)] data-[state=active]:bg-[var(--chrome-accent-bg)] data-[state=active]:font-semibold data-[state=active]:text-[color:var(--chrome-accent)] data-[state=active]:shadow-none dark:data-[state=active]:border-[var(--chrome-accent-border)] dark:data-[state=active]:bg-[var(--chrome-accent-bg)] dark:data-[state=active]:text-[color:var(--chrome-accent)] hover:data-[state=inactive]:bg-[var(--chrome-hover-bg)]"
>
<Icon size={14} aria-hidden="true" />
{label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</header>
<div
className="h-px shrink-0 [background-image:linear-gradient(90deg,color-mix(in_srgb,var(--chrome-fg)_12%,transparent),transparent_70%)]"
aria-hidden="true"
/>
{notice && (
<div className="shrink-0 px-[10px] py-[7px] bg-bg-elev-2 border-l-[3px] border-l-[color:var(--accent)] rounded-[6px] text-[0.75rem] text-[var(--text-primary)]">
<div className="shrink-0 rounded-[var(--chrome-radius-pill)] bg-[color-mix(in_srgb,var(--chrome-accent)_9%,transparent)] px-[14px] py-[7px] text-[0.75rem] text-[color:var(--chrome-fg)]">
{notice}
</div>
)}
+62 -41
View File
@@ -1,7 +1,9 @@
// Audiobook tab layout the prod-polish compaction (#1214).
// Audiobook tab layout the Write Cast Produce workspace redesign.
//
// The right-hand settings column is a compact property inspector: essentials
// stay visible and one optional production tool opens at a time.
// The inspector rail is gone: Script / Voices / Book tabs (shadcn, manual
// activation, clone-workspace pill styling) own the column, and the
// warnings/progress/result/plan status rail only takes space once there is
// something to show.
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
@@ -36,69 +38,88 @@ const withI18n = (node) => (
<I18nextProvider i18n={i18n}>{node}</I18nextProvider>
</QueryClientProvider>
);
describe('AudiobookTab — compact grouped layout (#1214)', () => {
function tabTrigger(name) {
return screen.getByRole('tab', { name });
}
// Real browsers focus on mousedown and activate on click; fireEvent.click
// alone skips the focus half, which manual-activation Radix tabs need.
function switchTab(name) {
const tab = tabTrigger(name);
fireEvent.mouseDown(tab);
fireEvent.click(tab);
return tab;
}
describe('AudiobookTab — Write/Cast/Produce tabs', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.getState().setLastOutput('');
useAppStore.getState().setScript('');
});
it('keeps the primary inputs always visible', () => {
const { container } = render(withI18n(<AudiobookTab profiles={[]} />));
// Script editor, default voice, language the three always-on controls.
expect(screen.getByLabelText(en.audiobook.script)).toBeTruthy();
expect(screen.getByText(en.audiobook.default_voice)).toBeTruthy();
expect(screen.getByText(en.audiobook.language)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.format)).toBeTruthy();
// Secondary actions stay discoverable through accessible icon labels.
it('opens on the Script tab with the manuscript editor', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
for (const label of [en.audiobook.tab_script, en.audiobook.tab_voices, en.audiobook.tab_book]) {
expect(screen.getByRole('tab', { name: new RegExp(label) })).toBeTruthy();
}
expect(tabTrigger(en.audiobook.tab_script)).toHaveAttribute('aria-selected', 'true');
expect(screen.getByLabelText(en.audiobook.script, { selector: 'textarea' })).toBeTruthy();
// Hero actions stay discoverable through accessible labels.
expect(screen.getByLabelText(en.audiobook.load_sample)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.import)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.preview_plan)).toBeTruthy();
expect(screen.getByText(en.audiobook.create)).toBeTruthy();
expect(screen.getByRole('heading', { level: 2, name: en.audiobook.title })).toBeTruthy();
expect(container.querySelector('[class*="container-name:audiobook-inspector"]')).toBeTruthy();
expect(
container.querySelector('[class*="@min-[360px]/audiobook-inspector:grid-cols-2"]'),
).toBeTruthy();
});
it('groups optional controls into an icon-led tool strip', () => {
it('keeps the status rail out of the way until there is something to show', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
for (const title of [
en.audiobook.output,
en.audiobook.details,
en.audiobook.lexicon,
en.audiobook.markup_help,
]) {
expect(screen.getByRole('button', { name: title })).toBeTruthy();
expect(screen.queryByTestId('audiobook-status-rail')).toBeNull();
});
it('shows voices controls only on the Voices tab', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
expect(screen.queryByText(en.audiobook.default_voice)).toBeNull();
switchTab(en.audiobook.tab_voices);
expect(tabTrigger(en.audiobook.tab_voices)).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText(en.audiobook.default_voice)).toBeTruthy();
expect(screen.getByText(en.audiobook.language)).toBeTruthy();
expect(screen.getByText(en.audiobook.cast)).toBeTruthy();
expect(screen.getByText(en.audiobook.expressive)).toBeTruthy();
});
it('shows production controls only on the Book tab', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
expect(screen.queryByLabelText(en.audiobook.loudness)).toBeNull();
switchTab(en.audiobook.tab_book);
expect(screen.getByLabelText(en.audiobook.format)).toBeTruthy();
expect(screen.getByLabelText(en.audiobook.loudness)).toBeTruthy();
// Details / lexicon / markup fold into collapsible sections.
for (const title of [en.audiobook.details, en.audiobook.lexicon, en.audiobook.markup_help]) {
expect(screen.getByText(new RegExp(title))).toBeTruthy();
}
});
it('keeps optional panels closed by default', () => {
it('persists the workspace tab across visits', () => {
const { unmount } = render(withI18n(<AudiobookTab profiles={[]} />));
switchTab(en.audiobook.tab_voices);
expect(localStorage.getItem('omnivoice.audiobook.tab')).toBe('voices');
unmount();
render(withI18n(<AudiobookTab profiles={[]} />));
expect(screen.queryByLabelText(en.audiobook.loudness)).toBeNull();
expect(screen.queryByLabelText(en.audiobook.meta_title)).toBeNull();
expect(tabTrigger(en.audiobook.tab_voices)).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText(en.audiobook.default_voice)).toBeTruthy();
});
it('opens Cast by default when the script contains cast tags', () => {
it('opens Cast content when the script contains cast tags', () => {
useAppStore.getState().setScript('# Chapter\n[voice:Mara] Hello');
render(withI18n(<AudiobookTab profiles={[]} />));
expect(screen.getByRole('button', { name: en.audiobook.cast })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(tabTrigger(en.audiobook.tab_voices)).toHaveTextContent(String(1));
switchTab(en.audiobook.tab_voices);
expect(screen.getByLabelText(`${en.audiobook.cast}: Mara`)).toBeTruthy();
});
it('shows only the selected optional panel', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
fireEvent.click(screen.getByRole('button', { name: en.audiobook.details }));
expect(screen.getByLabelText(en.audiobook.meta_title)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: en.audiobook.output }));
expect(screen.queryByLabelText(en.audiobook.meta_title)).toBeNull();
expect(screen.getByLabelText(en.audiobook.loudness)).toBeTruthy();
});
it('pairs a persisted output with its render-time script after later edits', () => {
useAppStore
.getState()
@@ -8,7 +8,7 @@
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
const { toastMock, eventHandlers, eventState, eventUnlisteners } = vi.hoisted(() => ({
toastMock: Object.assign(vi.fn(), {
@@ -57,13 +57,14 @@ vi.mock('../utils/asrModelMissing', () => ({
// Deferred startMicCapture so each test controls WHEN (and HOW resolve or
// reject) the mic graph finishes setting up relative to the WS error frame.
const { micControl, micStop } = vi.hoisted(() => ({
micControl: { resolve: null, reject: null },
micControl: { resolve: null, reject: null, onFrame: null },
micStop: vi.fn(async () => {}),
}));
vi.mock('../utils/aec/micCapture', () => ({
startMicCapture: vi.fn(
() =>
(_stream, onFrame) =>
new Promise((resolve, reject) => {
micControl.onFrame = onFrame;
micControl.resolve = resolve;
micControl.reject = reject;
}),
@@ -112,8 +113,11 @@ class FakeWS {
function pressShortcut() {
const handler = eventHandlers['tray-dictate'];
if (handler) handler({ payload: { sessionId: 'setup-race-session' } });
else eventState.pendingStart = true;
if (handler) {
handler({
payload: { sessionId: 'setup-race-session', deliveryId: 9, registrationId: 1 },
});
} else eventState.pendingStart = true;
}
let realWebSocket;
@@ -128,7 +132,7 @@ beforeEach(() => {
if (cmd === 'mark_dictation_capture_ready' && eventState.pendingStart) {
eventState.pendingStart = false;
return eventHandlers['tray-dictate']?.({
payload: { sessionId: 'setup-race-session' },
payload: { sessionId: 'setup-race-session', deliveryId: 9, registrationId: 1 },
});
}
return undefined;
@@ -136,6 +140,7 @@ beforeEach(() => {
eventState.pendingStart = false;
eventUnlisteners.length = 0;
FakeWS.instances = [];
storeState.dictationEnabled = true;
storeState.dictationModelId = 'sherpa-parakeet-v3';
realWebSocket = globalThis.WebSocket;
globalThis.WebSocket = FakeWS;
@@ -192,6 +197,74 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
});
});
it('does not start capture when native receipt acknowledgement fails', async () => {
invokeMock.mockImplementation(async (cmd) => {
if (cmd === 'begin_dictation_capture_registration') return 1;
if (cmd === 'check_microphone') return 'granted';
if (cmd === 'check_accessibility') return true;
if (cmd === 'acknowledge_dictation_capture_delivery') {
throw new Error('receipt state unavailable');
}
return undefined;
});
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
expect(invokeMock).not.toHaveBeenCalledWith('activate_dictation_output_session', {
sessionId: 7,
});
expect(invokeMock).not.toHaveBeenCalledWith(
'complete_dictation_capture_delivery',
expect.anything(),
);
});
it('completes an in-page delivery only after microphone startup is accepted', async () => {
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
expect(invokeMock).not.toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: null,
});
micControl.resolve(micStop);
await waitFor(() =>
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: null,
}),
);
});
it('rejects an in-page delivery when dictation is disabled', async () => {
storeState.dictationEnabled = false;
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: 'Dictation is disabled',
});
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
});
it('turns a PCM-fallback socket failure into a terminal error', async () => {
storeState.dictationModelId = 'whisperx';
render(<CaptureWidget />);
@@ -238,6 +311,11 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
expect(screen.getByText(/No speech-to-text model/)).toBeInTheDocument();
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
expect(invokeMock).not.toHaveBeenCalledWith('set_tray_recording', { recording: true });
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: 'Dictation could not start',
});
});
it('setup REJECTION after the terminal frame must not clobber it with a mic error', async () => {
@@ -272,3 +350,26 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
expect(invokeMock).not.toHaveBeenCalledWith('set_tray_recording', { recording: true });
});
});
it('pauses and resumes the microphone, then closes and releases capture', async () => {
const track = { enabled: true, stop: vi.fn() };
navigator.mediaDevices.getUserMedia.mockResolvedValue({ getTracks: () => [track] });
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
micControl.resolve(micStop);
const pause = await screen.findByRole('button', { name: 'Pause' });
fireEvent.click(pause);
expect(track.enabled).toBe(false);
fireEvent.click(screen.getByRole('button', { name: 'Resume' }));
expect(track.enabled).toBe(true);
fireEvent.click(screen.getByRole('button', { name: 'Pause' }));
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => expect(track.stop).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByRole('button', { name: 'Resume' })).not.toBeInTheDocument(),
);
});
+118 -8
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n';
@@ -105,10 +105,93 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
});
it('shows the idle dropzone only when the pipeline is truly idle (no job)', () => {
const { container } = renderIdle({ dubStep: 'idle', dubJobId: null });
expect(container.querySelector('.dub-idle-drop')).not.toBeNull();
expect(container.querySelector('.dub-start-screen')).not.toBeNull();
expect(container.querySelector('.dub-start-card')).not.toBeNull();
expect(container.querySelector('.dub-idle-drop.dub-start-drop')).not.toBeNull();
expect(screen.getByText(DROP_HINT)).toBeInTheDocument();
expect(screen.getByText(i18n.t('gallery.upload'))).toBeInTheDocument();
expect(screen.getByPlaceholderText(URL_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByLabelText('Choose a cookies.txt export')).toBeInTheDocument();
expect(screen.getByRole('button', { name: i18n.t('dub.source_language') })).toBeVisible();
expect(screen.getByRole('button', { name: i18n.t('dub.target_language') })).toBeVisible();
expect(screen.getByLabelText('Choose a cookies.txt export')).not.toBeVisible();
});
it('opens the native file picker from the keyboard-accessible import button', () => {
const { container } = renderIdle();
const input = container.querySelector('#video-upload');
const clickInput = vi.spyOn(input, 'click');
const upload = screen.getByRole('button', { name: i18n.t('gallery.upload') });
expect(upload.tagName).toBe('BUTTON');
upload.focus();
expect(upload).toHaveFocus();
fireEvent.click(upload);
expect(clickInput).toHaveBeenCalledOnce();
});
it('accepts a local audio file from the central import control', async () => {
const setDubVideoFile = vi.fn();
const setDubInputType = vi.fn();
const setDubStep = vi.fn();
const setDubLocalBlobUrl = vi.fn((next) => {
if (typeof next === 'function') next(null);
});
const urls = { audioUrl: 'blob:audio', videoUrl: null };
const fileToMediaUrl = vi.fn().mockResolvedValue(urls);
const { container } = renderIdle({
setDubVideoFile,
setDubInputType,
setDubStep,
setDubLocalBlobUrl,
fileToMediaUrl,
});
const file = new File(['audio'], 'chapter.wav', { type: 'audio/wav' });
fireEvent.change(container.querySelector('#video-upload'), { target: { files: [file] } });
expect(setDubVideoFile).toHaveBeenCalledWith(file);
expect(setDubInputType).toHaveBeenCalledWith('audio');
expect(setDubStep).toHaveBeenCalledWith('idle');
await waitFor(() => expect(fileToMediaUrl).toHaveBeenCalledWith(file, null));
expect(setDubLocalBlobUrl).toHaveBeenLastCalledWith(urls);
});
it('submits a pasted URL from both the button and Enter key', () => {
const onIngestUrl = vi.fn();
renderIdle({ ingestUrl: 'https://example.test/video', onIngestUrl });
const input = screen.getByRole('textbox', { name: URL_PLACEHOLDER });
fireEvent.click(screen.getByRole('button', { name: i18n.t('dub.ingest') }));
fireEvent.keyDown(input, { key: 'Enter' });
expect(onIngestUrl).toHaveBeenCalledTimes(2);
});
it('keeps optional import and generation settings behind Advanced', () => {
const setLandingAdvOpen = vi.fn();
const { rerender } = renderIdle({ landingAdvOpen: false, setLandingAdvOpen });
const advanced = screen.getByRole('button', { name: i18n.t('dub.advanced') });
expect(advanced).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByLabelText('Choose a cookies.txt export')).not.toBeVisible();
fireEvent.click(advanced);
expect(setLandingAdvOpen).toHaveBeenCalledOnce();
expect(setLandingAdvOpen.mock.calls[0][0](false)).toBe(true);
rerender(
<I18nextProvider i18n={i18n}>
<IdleSkeleton {...baseProps({ landingAdvOpen: true, setLandingAdvOpen })} />
</I18nextProvider>,
);
expect(screen.getByRole('button', { name: i18n.t('dub.advanced') })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByLabelText('Choose a cookies.txt export')).toBeVisible();
expect(screen.getByRole('checkbox', { name: i18n.t('dub.pull_captions') })).toBeVisible();
expect(screen.getByRole('spinbutton')).toBeVisible();
expect(screen.getByPlaceholderText(i18n.t('dub.style_placeholder'))).toBeVisible();
});
it('keeps source-language selection available after choosing a local file', () => {
@@ -118,9 +201,8 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
setDubSourceLangCode,
});
fireEvent.change(screen.getByRole('combobox', { name: i18n.t('dub.source_language') }), {
target: { value: 'th' },
});
fireEvent.click(screen.getByRole('button', { name: i18n.t('dub.source_language') }));
fireEvent.mouseDown(screen.getByRole('option', { name: /Thai.*th/ }));
expect(setDubSourceLangCode).toHaveBeenCalledWith('th');
});
@@ -130,6 +212,7 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
uiLocale: 'fr',
});
fireEvent.click(screen.getByRole('button', { name: i18n.t('dub.source_language') }));
const thai = new Intl.DisplayNames(['fr', 'en'], { type: 'language' }).of('th');
expect(screen.getByRole('option', { name: `${thai} — th` })).toBeInTheDocument();
});
@@ -138,19 +221,46 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
const selected = new File(['# Netscape HTTP Cookie File\n'], 'cookies.txt', {
type: 'text/plain',
});
const { rerender } = renderIdle({ youtubeCookieFile: selected });
const { rerender } = renderIdle({ youtubeCookieFile: selected, landingAdvOpen: true });
const input = screen.getByLabelText('Choose a cookies.txt export');
fireEvent.change(input, { target: { files: [selected] } });
expect(input.files).toHaveLength(1);
rerender(
<I18nextProvider i18n={i18n}>
<IdleSkeleton {...baseProps({ youtubeCookieFile: null })} />
<IdleSkeleton {...baseProps({ youtubeCookieFile: null, landingAdvOpen: true })} />
</I18nextProvider>,
);
expect(input.value).toBe('');
});
it('preserves the native cookie selection while Advanced is closed and reopened', () => {
const selected = new File(['# Netscape HTTP Cookie File\n'], 'cookies.txt', {
type: 'text/plain',
});
const { rerender } = renderIdle({ youtubeCookieFile: selected, landingAdvOpen: true });
const input = screen.getByLabelText('Choose a cookies.txt export');
fireEvent.change(input, { target: { files: [selected] } });
rerender(
<I18nextProvider i18n={i18n}>
<IdleSkeleton {...baseProps({ youtubeCookieFile: selected, landingAdvOpen: false })} />
</I18nextProvider>,
);
expect(screen.getByLabelText('Choose a cookies.txt export')).toBe(input);
expect(input).not.toBeVisible();
expect(input.files).toHaveLength(1);
rerender(
<I18nextProvider i18n={i18n}>
<IdleSkeleton {...baseProps({ youtubeCookieFile: selected, landingAdvOpen: true })} />
</I18nextProvider>,
);
expect(screen.getByLabelText('Choose a cookies.txt export')).toBe(input);
expect(input).toBeVisible();
expect(input.files).toHaveLength(1);
});
it('does NOT show the idle dropzone while transcribing a URL-ingested job', () => {
const { container } = renderIdle({ dubStep: 'transcribing', dubJobId: 'job-url-1' });
// The desync: dropzone + paste-URL input must be gone during transcribe.
@@ -78,23 +78,65 @@ describe('StoriesEditor voice pickers (#1220)', () => {
it('cast picker renders VoiceSelector and stores the character voice', () => {
renderEditor();
const castRegion = screen.getByRole('complementary', { name: /Stories/ });
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Cast' }), { button: 0 });
const castRegion = screen.getByRole('tabpanel', { name: 'Cast' });
const trigger = within(castRegion).getByRole('button', { name: /Default/ });
fireEvent.click(trigger);
fireEvent.mouseDown(screen.getByText('Aria'));
expect(useAppStore.getState().cast[0].profileId).toBe('p_clone');
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Script' }), { button: 0 });
expect(within(screen.getByRole('list')).getByRole('button', { name: /Aria/ })).toBeVisible();
});
it('renders a calm writing hierarchy with the project stats and line canvas', () => {
it('defaults to Script and renders the project stats and line canvas', () => {
renderEditor();
expect(screen.getByRole('heading', { level: 1, name: /Untitled story/ })).toBeInTheDocument();
expect(screen.getAllByText('1 lines').length).toBeGreaterThan(0);
expect(screen.getByRole('tab', { name: 'Script' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('main')).toHaveClass('stories-manuscript');
expect(screen.getByRole('complementary')).toHaveClass('stories-sidebar');
expect(screen.getByRole('list')).toHaveClass('stories-track-list');
expect(screen.getByRole('listitem')).toHaveClass('stories-line');
});
it('preserves script edits while moving through the workspace tabs', () => {
renderEditor();
const line = screen.getByDisplayValue('Once upon a time');
fireEvent.change(line, { target: { value: 'A revised opening line' } });
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Cast' }), { button: 0 });
expect(screen.getByRole('tabpanel', { name: 'Cast' })).toBeVisible();
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Export' }), { button: 0 });
expect(screen.getByRole('tabpanel', { name: 'Export' })).toBeVisible();
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Script' }), { button: 0 });
expect(screen.getByDisplayValue('A revised opening line')).toBeVisible();
expect(useAppStore.getState().storyTracks[0].text).toBe('A revised opening line');
});
it('supports keyboard navigation across all workspace tabs', async () => {
renderEditor();
const script = screen.getByRole('tab', { name: 'Script' });
const cast = screen.getByRole('tab', { name: 'Cast' });
const projects = screen.getByRole('tab', { name: 'Projects' });
script.focus();
fireEvent.keyDown(script, { key: 'ArrowRight' });
await waitFor(() => expect(cast).toHaveFocus());
expect(script).toHaveAttribute('aria-selected', 'true');
expect(cast).toHaveAttribute('aria-selected', 'false');
fireEvent.keyDown(cast, { key: 'Enter' });
expect(cast).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(cast, { key: 'End' });
await waitFor(() => expect(projects).toHaveFocus());
expect(cast).toHaveAttribute('aria-selected', 'true');
expect(projects).toHaveAttribute('aria-selected', 'false');
fireEvent.keyDown(projects, { key: 'Enter' });
expect(projects).toHaveAttribute('aria-selected', 'true');
});
it('loads a comprehensive working sample by default', async () => {
useAppStore.setState({ storyTracks: [], storyProjects: [], currentProjectId: null });
renderEditor();
@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
for (const width of [1280, 390]) {
test(`audiobook manuscript fills its workspace at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto('/src/test/visual/harness.html?component=AudiobookWorkspace');
const editor = page.getByRole('textbox', { name: 'Script', exact: true });
await expect(editor).toBeVisible();
const box = await editor.boundingBox();
expect(box!.height).toBeGreaterThan(300);
expect(await editor.evaluate((el) => getComputedStyle(el).resize)).toBe('none');
for (const name of ['Voices', 'Book', 'Script']) {
await page.getByRole('tab', { name: new RegExp(name) }).click();
await expect(page.getByRole('tabpanel')).toBeVisible();
}
await expect(editor).toHaveValue(/Chapter One/);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true,
);
await page.screenshot({ path: `/tmp/audiobook-layout-${width}.png`, fullPage: true });
});
}
@@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test';
const START_CONTROLS = [
'.dub-start-card',
'.dub-start-drop',
'.dub-start-choose',
'.dub-start-url',
'.dub-start-url input',
'.dub-start-url button',
'.dub-start-languages',
'.dub-start-languages label',
'.dub-start-languages button',
'.dub-start-advanced',
];
const ADVANCED_CONTROLS = [
'.dub-start-options',
'.dub-start-caption-option',
'.dub-start-cookie-option',
'.dub-start-generation-options',
'.dub-start-generation-options input',
];
async function expectControlsInsideViewport(page, selectors: string[]) {
const measurements = await page.locator(selectors.join(',')).evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return {
selector: element.className || element.tagName,
left: rect.left,
right: rect.right,
width: rect.width,
};
}),
);
expect(measurements.length).toBeGreaterThanOrEqual(selectors.length);
for (const control of measurements) {
expect(control.width, `${control.selector} has no rendered width`).toBeGreaterThan(0);
expect(control.left, `${control.selector} is clipped on the left`).toBeGreaterThanOrEqual(-1);
expect(control.right, `${control.selector} is clipped on the right`).toBeLessThanOrEqual(
(await page.viewportSize())!.width + 1,
);
}
}
for (const width of [1280, 390]) {
test(`dubbing start screen keeps import controls usable at ${width}px`, async ({ page }) => {
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.setViewportSize({ width, height: 900 });
await page.goto('/src/test/visual/harness.html?component=DubIdleStart');
await page.waitForFunction(
() => document.documentElement.getAttribute('data-visual-ready') === 'true',
);
const start = page.locator('.dub-start-screen');
const card = page.locator('.dub-start-card');
await expect(start).toBeVisible();
await expect(page.getByText('Video Dubbing Studio', { exact: true })).toHaveCount(0);
await expect(card).toBeVisible();
await expect(page.getByRole('group', { name: 'Drop video or audio here' })).toBeVisible();
await expect(page.getByRole('button', { name: /Upload/ })).toBeVisible();
await expect(
page.getByRole('textbox', { name: '…or paste YouTube / video URL' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Spoken language' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Dub into' })).toBeVisible();
const source = page.getByRole('button', { name: 'Spoken language' });
await source.click();
const menu = page.getByRole('listbox');
await expect(menu).toBeVisible();
await menu.locator('input').fill('Thai');
await menu.locator('input').press('Enter');
await expect(source).toContainText('Thai');
await expect(menu).toHaveCount(0);
const advanced = page.getByRole('button', { name: 'Advanced' });
await expect(advanced).toHaveAttribute('aria-expanded', 'false');
await expect(page.locator('#dub-start-advanced-options')).toBeHidden();
await expectControlsInsideViewport(page, START_CONTROLS);
await expect
.poll(() => card.evaluate((element) => element.scrollWidth <= element.clientWidth + 1))
.toBe(true);
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
await page.screenshot({
path: `/tmp/dub-idle-start-${width}-default.png`,
fullPage: true,
animations: 'disabled',
});
await advanced.click();
await expect(advanced).toHaveAttribute('aria-expanded', 'true');
const options = page.locator('#dub-start-advanced-options');
await expect(options).toBeVisible();
await expect(page.getByRole('checkbox', { name: /Pull YouTube captions/ })).toBeVisible();
await expect(page.getByLabel('Choose a cookies.txt export')).toBeVisible();
await expect(page.getByRole('spinbutton', { name: 'Speakers' })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Voice style' })).toBeVisible();
await options.scrollIntoViewIfNeeded();
await expectControlsInsideViewport(page, [...START_CONTROLS, ...ADVANCED_CONTROLS]);
await expect
.poll(() => options.evaluate((element) => element.scrollWidth <= element.clientWidth + 1))
.toBe(true);
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
expect(pageErrors).toEqual([]);
await page.screenshot({
path: `/tmp/dub-idle-start-${width}-advanced.png`,
fullPage: true,
animations: 'disabled',
});
});
}
@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test';
for (const width of [1280, 390]) {
test(`Projects list stays stable on hover at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto('/src/test/visual/harness.html?component=DubIdleWorkspace');
await page.waitForFunction(
() => document.documentElement.getAttribute('data-visual-ready') === 'true',
);
if (width === 390)
await page.locator('#visual-root').evaluate((root) => root.classList.add('shell-mini'));
const library = page.locator('.dub-project-library');
await expect(library.getByText('Projects', { exact: true })).toBeVisible();
await expect(page.getByRole('tab')).toHaveCount(0);
await expect(library.getByText('Documentary final.mp4')).toBeVisible();
const cards = library.locator('.history-item');
await cards.first().scrollIntoViewIfNeeded();
const before = await cards.evaluateAll((nodes) =>
nodes.map((n) => {
const r = n.getBoundingClientRect();
return { y: r.y, height: r.height };
}),
);
const title = cards.first().locator('.history-title');
const titleHit = await title.evaluate((el) => {
const r = el.getBoundingClientRect();
return document
.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
?.closest('button')
?.getAttribute('aria-label');
});
expect(titleHit).toBe('Open: Documentary final.mp4');
await cards.first().hover();
await expect(cards.first().getByRole('button', { name: 'Open', exact: true })).toBeVisible();
await page.waitForTimeout(250);
const after = await cards.evaluateAll((nodes) =>
nodes.map((n) => {
const r = n.getBoundingClientRect();
return { y: r.y, height: r.height };
}),
);
expect(after).toEqual(before);
await cards.first().getByRole('button', { name: 'Open', exact: true }).focus();
await expect(cards.first().getByRole('button', { name: 'Open', exact: true })).toBeFocused();
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
await page.screenshot({ path: `/tmp/dub-projects-${width}.png`, fullPage: true });
});
}
+232
View File
@@ -12,12 +12,16 @@
import React from 'react';
import DubSegmentTable from '../../components/DubSegmentTable.jsx';
import DubWorkspaceSidebar from '../../components/DubWorkspaceSidebar.jsx';
import DubSelectionToolbar from '../../components/dub/DubSelectionToolbar.jsx';
import IdleSkeleton from '../../components/dub/IdleSkeleton.jsx';
import i18n from '../../i18n';
import WaveformTimeline from '../../components/WaveformTimeline.jsx';
import DubWorkspaceFixture from './DubWorkspaceFixture.jsx';
import '../../components/dub/DubRightColumn.css';
import Header from '../../components/Header';
import StoriesEditor from '../../components/StoriesEditor.jsx';
import AudiobookTab from '../../pages/AudiobookTab.jsx';
import { Download, Mic, Search, Sparkles, Trash2 } from 'lucide-react';
import Badge from '../../ui/Badge.jsx';
@@ -199,7 +203,235 @@ function WaveformPanFixture() {
);
}
function DubIdleStartFixture() {
const [ingestUrl, setIngestUrl] = React.useState('');
const [sourceLanguage, setSourceLanguage] = React.useState('auto');
const [targetLanguage, setTargetLanguage] = React.useState('es');
const [optionalOpen, setOptionalOpen] = React.useState(false);
const noop = () => {};
return (
<div style={{ height: 760, minWidth: 0 }}>
<IdleSkeleton
t={i18n.t.bind(i18n)}
uiLocale="en"
dubVideoFile={null}
activeProjectName=""
dubFilename=""
dubError=""
dubJobId={null}
dubStep="idle"
dubFailure={null}
asrInstall={null}
handleInstallMissingAsr={noop}
handleDubRetryTranscribe={noop}
handleDubImportSrt={noop}
dubLocalBlobUrl={null}
dubPrepStage={null}
dubPrepProgress={{ percent: null, speedBps: null, etaS: null, stageStartedAt: null }}
handleDubAbort={noop}
transcribeElapsed={0}
transcribeProgress={null}
dubDuration={0}
dubNumSpeakers={null}
setDubNumSpeakers={noop}
handleDubUpload={noop}
demoDismissed
dismissDubDemo={noop}
setDubVideoFile={noop}
setDubInputType={noop}
setDubStep={noop}
fileToMediaUrl={async () => ({ audioUrl: null, videoUrl: null })}
setDubLocalBlobUrl={noop}
ingestUrl={ingestUrl}
setIngestUrl={setIngestUrl}
onIngestUrl={noop}
fetchYtSubs={false}
setFetchYtSubs={noop}
youtubeCookieFile={null}
setYoutubeCookieFile={noop}
dubLangCode={targetLanguage}
dubSourceLangCode={sourceLanguage}
setDubSourceLangCode={setSourceLanguage}
setDubLangCode={setTargetLanguage}
setDubLang={noop}
landingAdvOpen={optionalOpen}
setLandingAdvOpen={setOptionalOpen}
dubInstruct=""
setDubInstruct={noop}
onOpenQueue={noop}
/>
</div>
);
}
const DUB_SIDEBAR_PROJECTS = [
{
id: 'dub-project-1',
name: 'Documentary voice-over',
updated_at: '2026-09-08T12:00:00Z',
duration: 128,
video_path: 'documentary.mp4',
},
{
id: 'dub-project-2',
name: 'Product launch — Spanish',
updated_at: '2026-09-07T12:00:00Z',
duration: 64,
video_path: 'product-launch.mov',
},
];
const DUB_SIDEBAR_HISTORY = [
{
id: 'dub-history-1',
filename: 'Documentary final.mp4',
duration: 128,
segments_count: 18,
language: 'Spanish',
language_code: 'es',
job_data: { input_type: 'video' },
},
{
id: 'dub-history-2',
filename: 'Interview voice track.wav',
duration: 76,
segments_count: 9,
language: 'French',
language_code: 'fr',
job_data: { input_type: 'audio' },
},
];
function DubIdleWorkspaceFixture() {
const noop = () => {};
return (
<div className="studio-with-history" style={{ height: 760, minWidth: 0 }}>
<div className="studio-with-history__main">
<DubIdleStartFixture />
</div>
<div className="studio-right">
<DubWorkspaceSidebar
projects={DUB_SIDEBAR_PROJECTS}
activeProjectId={null}
loadProject={noop}
deleteProject={noop}
renameProject={noop}
dubHistory={DUB_SIDEBAR_HISTORY}
restoreDubHistory={noop}
deleteHistory={noop}
clearHistory={noop}
/>
</div>
</div>
);
}
const STORIES_CAST = [
{ id: 'narrator', name: 'Narrator', color: '#b8bb26', profileId: 'voice-aria' },
{ id: 'mara', name: 'Mara', color: '#d3869b', profileId: 'voice-mara' },
{ id: 'cole', name: 'Cole', color: '#83a598', profileId: 'voice-cole' },
];
const STORIES_TRACKS = [
{
id: 101,
character: 'narrator',
text: '# The Signal at Sundown',
profileId: null,
emotion: null,
speed: null,
generating: false,
audioUrl: null,
},
{
id: 102,
character: 'narrator',
text: 'The lighthouse had been silent for eleven winters.',
profileId: null,
emotion: null,
speed: null,
generating: false,
audioUrl: null,
},
{
id: 103,
character: 'mara',
text: 'Cole, did you hear that? [pause 0.4s] The old radio is calling us.',
profileId: null,
emotion: null,
speed: 0.95,
generating: false,
audioUrl: null,
},
{
id: 104,
character: 'cole',
text: 'I heard it. Stay close, and keep the lantern low.',
profileId: null,
emotion: null,
speed: null,
generating: false,
audioUrl: null,
},
];
const STORIES_PROJECT = {
id: 'visual-story',
name: 'The Signal at Sundown',
cast: STORIES_CAST,
tracks: STORIES_TRACKS,
updatedAt: 1,
};
export const SPECS = {
AudiobookWorkspace: {
width: '100%',
providers: {
store: {
script:
'# Chapter One\n\nA light shone across the water.\n\n# Chapter Two\n\nThe boat returned safely.',
lastOutput: '',
},
fetch: () => ({}),
},
render: () => (
<div style={{ height: 800 }}>
<AudiobookTab profiles={[]} />
</div>
),
},
DubIdleStart: {
width: '100%',
providers: {},
render: () => <DubIdleStartFixture />,
},
DubIdleWorkspace: {
width: '100%',
providers: {},
render: () => <DubIdleWorkspaceFixture />,
},
StoriesWorkspaceLayout: {
width: '100%',
providers: {
store: {
cast: STORIES_CAST,
storyTracks: STORIES_TRACKS,
storyProjects: [STORIES_PROJECT],
currentProjectId: STORIES_PROJECT.id,
},
},
render: () => (
<div style={{ height: 780, minWidth: 0 }}>
<StoriesEditor
profiles={[
{ id: 'voice-aria', name: 'Aria — warm narrator' },
{ id: 'voice-mara', name: 'Mara — intimate alto' },
{ id: 'voice-cole', name: 'Cole — steady baritone' },
]}
/>
</div>
),
},
DubWorkspaceLayout: {
width: '100%',
providers: {},
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
for (const { width, shellMini } of [
{ width: 1280, shellMini: false },
{ width: 390, shellMini: false },
{ width: 1280, shellMini: true },
]) {
const scenario = shellMini ? `${width}px shell-mini` : `${width}px`;
test(`Stories tabs stay usable without horizontal overflow at ${scenario}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto('/src/test/visual/harness.html?component=StoriesWorkspaceLayout');
await page.waitForFunction(
() => document.documentElement.getAttribute('data-visual-ready') === 'true',
);
if (shellMini) {
await page.locator('#visual-root').evaluate((root) => root.classList.add('shell-mini'));
}
const tabs = ['Script', 'Cast', 'Export', 'Projects'];
await expect(page.getByRole('tab', { name: 'Script' })).toHaveAttribute(
'aria-selected',
'true',
);
for (const tab of tabs) {
const trigger = page.getByRole('tab', { name: tab });
await trigger.click();
await expect(trigger).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: tab })).toBeVisible();
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
const suffix = shellMini ? `${width}-shell-mini` : String(width);
await page.screenshot({
path: `/tmp/stories-tabs-${suffix}-${tab.toLowerCase()}.png`,
animations: 'disabled',
});
}
});
}
@@ -67,18 +67,6 @@ describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
expect(app).toMatch(/<div className="studio-right">\s*<WorkspaceHistory\s+history=\{history\}/);
});
it('gives Dub Projects its own narrower rail than Dub History', () => {
expect(app).toMatch(/className="studio-projects">\s*<WorkspaceProjects/);
expect(css).toMatch(/\.studio-projects\s*\{[^}]*flex:\s*0 0 240px/s);
expect(css).toMatch(/\.studio-right\s*\{[^}]*flex:\s*0 0 340px/s);
});
it('keeps Save unavailable in the idle-only Projects rail', () => {
expect(app).toMatch(
/dubStep === 'idle'[\s\S]*?className="studio-projects"[\s\S]*?canSave=\{false\}/,
);
});
it('keeps the Script editor useful without pushing voice setup below the fold', () => {
expect(indexRaw).toMatch(/\.studio-script-input\s*\{[^}]*min-height:\s*168px/s);
expect(indexRaw).toMatch(/\.shell-narrow\s+\.studio-script-input[^}]*min-height:\s*144px/s);
+1
View File
@@ -58,6 +58,7 @@ GET /community/sources
GET /community/submit-url
GET /dictation/models
GET /dictation/prefs
GET /dictation/readiness
GET /docs
GET /dub/ass/{job_id}
GET /dub/ass/{job_id}/{filename}
+23
View File
@@ -287,3 +287,26 @@ def test_capture_preload_ram_guard(monkeypatch):
raise RuntimeError("no vm info")
monkeypatch.setattr(psutil, "virtual_memory", _boom)
assert main._capture_preload_ram_ok()
def test_pause_outlasts_silence_timeout_and_resume_keeps_audio(client, monkeypatch):
from api.routers import capture_ws as cw
monkeypatch.setattr(cw, 'SILENCE_TIMEOUT_S', 0.05)
monkeypatch.setattr(cw, 'PARTIAL_INTERVAL_S', 0.01)
sizes = []
async def final(chunks, **kwargs):
sizes.append(sum(map(len, chunks)))
return {'text': 'kept both parts', 'segments': [], 'language': 'en', 'engine': 'stub'}
monkeypatch.setattr(cw, '_transcribe_buffer_full', final)
with client.websocket_connect('/ws/transcribe') as ws:
ws.send_bytes(_audio_chunk())
ws.send_text('PAUSE')
time.sleep(0.15)
ws.send_text('RESUME')
ws.send_bytes(_audio_chunk())
ws.send_text('EOF')
while ws.receive_json().get('type') != 'final':
pass
assert sizes == [40_000]
+48 -23
View File
@@ -1,9 +1,9 @@
"""The dictation widget window must stay hidden, and must stamp its identity.
"""The dictation widget must stay safe while hidden and wake before capture.
The widget window hosts the recorder (`getUserMedia` + `MediaRecorder` + the
transcription WebSocket all live in `CaptureWidget.jsx`), so it has to exist
but it is never shown (owner decision, 2026-08-07): dictation gives no
on-screen pill.
transcription WebSocket all live in `CaptureWidget.jsx`), so it has to exist
while idle. Some WebView engines suspend that hidden document, however, so a
start request must wake it before emitting the event it needs to record.
Two things keep that safe, and both are easy to undo by accident:
@@ -14,8 +14,9 @@ Two things keep that safe, and both are easy to undo by accident:
300x64, with an opaque background and no CaptureWidget to hide it again:
the dark rectangle that could only be cleared by killing the app.
2. Nothing calls `.show()` on it. A re-added show would put that rectangle
back on screen for anyone whose window lost the identity race.
2. Capture dispatch wakes it only through the non-activating pill command,
after preserving the output target and before emitting the start event.
The widget owns hiding itself again when it is idle.
The frontend half is pinned by
`frontend/src/test/DictationNoPillWindow.test.jsx`.
@@ -27,6 +28,9 @@ from pathlib import Path
import pytest
_LIB_RS = Path(__file__).resolve().parents[1] / "frontend" / "src-tauri" / "src" / "lib.rs"
_COMMANDS_RS = (
Path(__file__).resolve().parents[1] / "frontend" / "src-tauri" / "src" / "commands.rs"
)
@pytest.fixture
@@ -34,6 +38,11 @@ def lib_rs() -> str:
return _LIB_RS.read_text(encoding="utf-8")
@pytest.fixture
def commands_rs() -> str:
return _COMMANDS_RS.read_text(encoding="utf-8")
def test_widget_window_stamps_its_identity_before_page_scripts(lib_rs: str) -> None:
assert "initialization_script" in lib_rs, (
"The widget window no longer injects an initialization_script. Window "
@@ -47,24 +56,40 @@ def test_widget_window_stamps_its_identity_before_page_scripts(lib_rs: str) -> N
)
def test_nothing_shows_the_widget_window(lib_rs: str) -> None:
"""No `.show()` may be reachable from a widget window handle.
Scoped to blocks that bind the widget handle, so an unrelated
`main_win.show()` elsewhere in the file doesn't trip this.
"""
offenders = []
for match in re.finditer(r'get_webview_window\("widget"\)', lib_rs):
# The handle's usable scope: to the end of the enclosing block. Take a
# generous window and look for a show on it — cheap and hard to fool.
block = lib_rs[match.start() : match.start() + 1200]
for show in re.finditer(r"\b(\w+)\.show\(\)|show_pill_noactivate\(", block):
offenders.append(show.group(0))
assert not offenders, (
f"Something shows the dictation widget window again: {offenders}. "
"It is a hidden recorder host — showing it is what put an empty "
"rectangle on the user's desktop."
def test_capture_dispatch_wakes_widget_before_emitting_start(lib_rs: str) -> None:
"""A hidden WebView cannot receive the event that tells it to show itself."""
dispatch = re.search(
r"fn dispatch_dictation_capture_from\(.*?\n\}", lib_rs, re.S
)
assert dispatch, "Could not locate dictation capture dispatch."
body = dispatch.group(0)
begin = body.find("begin_session")
wake_guard = body.find('if event == "tray-dictate"', begin)
wake = body.find("commands::show_dictation_pill")
emit = body.find("app.emit")
assert -1 not in (begin, wake_guard, wake, emit), (
"Capture dispatch must preserve the output target, wake the hidden "
"widget, then emit the start event."
)
assert begin < wake_guard < wake < emit, (
"Wake the hidden recorder after preserving the output target and "
"before emitting only a start; otherwise macOS can silently drop the "
"request or a stop can reopen the pill."
)
def test_in_page_capture_waits_for_listener_acceptance(commands_rs: str) -> None:
request = re.search(
r"pub async fn request_dictation_capture\(.*?\n\}", commands_rs, re.S
)
assert request, "The in-page capture command must remain asynchronous."
body = request.group(0)
assert "request_dictation_capture_delivery" in body
assert "wait_for_capture_delivery" in body
assert "completion_ready" in body
assert "take_completion_or_cancel" in body
assert "capture window did not acknowledge the request" in body
assert "dictation capture did not start in time" in body
def test_no_computed_window_target_can_resolve_to_the_widget(lib_rs: str) -> None:
+21
View File
@@ -112,3 +112,24 @@ def test_reset_failure_does_not_persist_new_preferences(monkeypatch):
assert getattr(caught.value, "status_code", None) == 503
assert store == {dr.PREF_MODE: "toggle"}
@pytest.mark.parametrize('missing', [None, {'error': 'asr_model_missing', 'recommended': {'repo_id': 'test/model'}}])
def test_readiness_uses_capture_preflight(client, monkeypatch, missing):
from services import asr_backend
calls = []
def probe(**kwargs):
calls.append(kwargs)
return missing
monkeypatch.setattr(asr_backend, 'asr_model_missing_error', probe)
response = client.get('/dictation/readiness')
assert response.status_code == 200
assert response.json() == {'ready': missing is None, 'missing': missing}
assert calls == [{'purpose': 'dictation', 'sherpa_model_id': 'sherpa-whisper-tiny'}]
def test_readiness_honors_recorder_model_override(client, monkeypatch):
from services import asr_backend
calls = []
monkeypatch.setattr(asr_backend, 'asr_model_missing_error', lambda **kw: calls.append(kw))
assert client.get('/dictation/readiness?model_id=sherpa-zipformer-en-20m').status_code == 200
assert calls == [{'purpose': 'dictation', 'sherpa_model_id': 'sherpa-zipformer-en-20m'}]