Handle microphone init failure without aborting (#945)
* Handle microphone start failures without aborting Return recorder init errors instead of panicking/aborting in the worker thread and reset UI state when recording start fails. Refs #436 * prop errors to ui * format * Add missing recording error translation keys --------- Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
parent
fc05e4aee9
commit
785c3317e4
21 changed files with 199 additions and 90 deletions
|
|
@ -17,8 +17,8 @@ use once_cell::sync::Lazy;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tauri::AppHandle;
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
/// Drop guard that notifies the [`TranscriptionCoordinator`] when the
|
/// Drop guard that notifies the [`TranscriptionCoordinator`] when the
|
||||||
/// transcription pipeline finishes — whether it completes normally or panics.
|
/// transcription pipeline finishes — whether it completes normally or panics.
|
||||||
|
|
@ -328,7 +328,7 @@ impl ShortcutAction for TranscribeAction {
|
||||||
let is_always_on = settings.always_on_microphone;
|
let is_always_on = settings.always_on_microphone;
|
||||||
debug!("Microphone mode - always_on: {}", is_always_on);
|
debug!("Microphone mode - always_on: {}", is_always_on);
|
||||||
|
|
||||||
let mut recording_started = false;
|
let mut recording_error: Option<String> = None;
|
||||||
if is_always_on {
|
if is_always_on {
|
||||||
// Always-on mode: Play audio feedback immediately, then apply mute after sound finishes
|
// Always-on mode: Play audio feedback immediately, then apply mute after sound finishes
|
||||||
debug!("Always-on mode: Playing audio feedback immediately");
|
debug!("Always-on mode: Playing audio feedback immediately");
|
||||||
|
|
@ -341,35 +341,48 @@ impl ShortcutAction for TranscribeAction {
|
||||||
rm_clone.apply_mute();
|
rm_clone.apply_mute();
|
||||||
});
|
});
|
||||||
|
|
||||||
recording_started = rm.try_start_recording(&binding_id);
|
if let Err(e) = rm.try_start_recording(&binding_id) {
|
||||||
debug!("Recording started: {}", recording_started);
|
debug!("Recording failed: {}", e);
|
||||||
|
recording_error = Some(e);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// On-demand mode: Start recording first, then play audio feedback, then apply mute
|
// On-demand mode: Start recording first, then play audio feedback, then apply mute
|
||||||
// This allows the microphone to be activated before playing the sound
|
// This allows the microphone to be activated before playing the sound
|
||||||
debug!("On-demand mode: Starting recording first, then audio feedback");
|
debug!("On-demand mode: Starting recording first, then audio feedback");
|
||||||
let recording_start_time = Instant::now();
|
let recording_start_time = Instant::now();
|
||||||
if rm.try_start_recording(&binding_id) {
|
match rm.try_start_recording(&binding_id) {
|
||||||
recording_started = true;
|
Ok(()) => {
|
||||||
debug!("Recording started in {:?}", recording_start_time.elapsed());
|
debug!("Recording started in {:?}", recording_start_time.elapsed());
|
||||||
// Small delay to ensure microphone stream is active
|
// Small delay to ensure microphone stream is active
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let rm_clone = Arc::clone(&rm);
|
let rm_clone = Arc::clone(&rm);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
debug!("Handling delayed audio feedback/mute sequence");
|
debug!("Handling delayed audio feedback/mute sequence");
|
||||||
// Helper handles disabled audio feedback by returning early, so we reuse it
|
// Helper handles disabled audio feedback by returning early, so we reuse it
|
||||||
// to keep mute sequencing consistent in every mode.
|
// to keep mute sequencing consistent in every mode.
|
||||||
play_feedback_sound_blocking(&app_clone, SoundType::Start);
|
play_feedback_sound_blocking(&app_clone, SoundType::Start);
|
||||||
rm_clone.apply_mute();
|
rm_clone.apply_mute();
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
debug!("Failed to start recording");
|
Err(e) => {
|
||||||
|
debug!("Failed to start recording: {}", e);
|
||||||
|
recording_error = Some(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if recording_started {
|
if recording_error.is_none() {
|
||||||
// Dynamically register the cancel shortcut in a separate task to avoid deadlock
|
// Dynamically register the cancel shortcut in a separate task to avoid deadlock
|
||||||
shortcut::register_cancel_shortcut(app);
|
shortcut::register_cancel_shortcut(app);
|
||||||
|
} else {
|
||||||
|
// Starting failed (for example due to blocked microphone permissions).
|
||||||
|
// Revert UI state so we don't stay stuck in the recording overlay.
|
||||||
|
utils::hide_recording_overlay(app);
|
||||||
|
change_tray_icon(app, TrayIconState::Idle);
|
||||||
|
if let Some(err) = recording_error {
|
||||||
|
let _ = app.emit("recording-error", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ impl AudioRecorder {
|
||||||
|
|
||||||
let (sample_tx, sample_rx) = mpsc::channel::<Vec<f32>>();
|
let (sample_tx, sample_rx) = mpsc::channel::<Vec<f32>>();
|
||||||
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
||||||
|
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
let host = crate::audio_toolkit::get_cpal_host();
|
let host = crate::audio_toolkit::get_cpal_host();
|
||||||
let device = match device {
|
let device = match device {
|
||||||
|
|
@ -76,56 +77,108 @@ impl AudioRecorder {
|
||||||
let level_cb = self.level_cb.clone();
|
let level_cb = self.level_cb.clone();
|
||||||
|
|
||||||
let worker = std::thread::spawn(move || {
|
let worker = std::thread::spawn(move || {
|
||||||
let config = AudioRecorder::get_preferred_config(&thread_device)
|
let init_result = (|| -> Result<(cpal::Stream, u32), String> {
|
||||||
.expect("failed to fetch preferred config");
|
let config = AudioRecorder::get_preferred_config(&thread_device)
|
||||||
|
.map_err(|e| format!("Failed to fetch preferred config: {e}"))?;
|
||||||
|
|
||||||
let sample_rate = config.sample_rate().0;
|
let sample_rate = config.sample_rate().0;
|
||||||
let channels = config.channels() as usize;
|
let channels = config.channels() as usize;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}",
|
"Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}",
|
||||||
thread_device.name(),
|
thread_device.name(),
|
||||||
sample_rate,
|
sample_rate,
|
||||||
channels,
|
channels,
|
||||||
config.sample_format()
|
config.sample_format()
|
||||||
);
|
);
|
||||||
|
|
||||||
let stream = match config.sample_format() {
|
let stream = match config.sample_format() {
|
||||||
cpal::SampleFormat::U8 => {
|
cpal::SampleFormat::U8 => AudioRecorder::build_stream::<u8>(
|
||||||
AudioRecorder::build_stream::<u8>(&thread_device, &config, sample_tx, channels)
|
&thread_device,
|
||||||
.unwrap()
|
&config,
|
||||||
}
|
sample_tx,
|
||||||
cpal::SampleFormat::I8 => {
|
channels,
|
||||||
AudioRecorder::build_stream::<i8>(&thread_device, &config, sample_tx, channels)
|
)
|
||||||
.unwrap()
|
.map_err(|e| format!("Failed to build input stream: {e}"))?,
|
||||||
}
|
cpal::SampleFormat::I8 => AudioRecorder::build_stream::<i8>(
|
||||||
cpal::SampleFormat::I16 => {
|
&thread_device,
|
||||||
AudioRecorder::build_stream::<i16>(&thread_device, &config, sample_tx, channels)
|
&config,
|
||||||
.unwrap()
|
sample_tx,
|
||||||
}
|
channels,
|
||||||
cpal::SampleFormat::I32 => {
|
)
|
||||||
AudioRecorder::build_stream::<i32>(&thread_device, &config, sample_tx, channels)
|
.map_err(|e| format!("Failed to build input stream: {e}"))?,
|
||||||
.unwrap()
|
cpal::SampleFormat::I16 => AudioRecorder::build_stream::<i16>(
|
||||||
}
|
&thread_device,
|
||||||
cpal::SampleFormat::F32 => {
|
&config,
|
||||||
AudioRecorder::build_stream::<f32>(&thread_device, &config, sample_tx, channels)
|
sample_tx,
|
||||||
.unwrap()
|
channels,
|
||||||
}
|
)
|
||||||
_ => panic!("unsupported sample format"),
|
.map_err(|e| format!("Failed to build input stream: {e}"))?,
|
||||||
};
|
cpal::SampleFormat::I32 => AudioRecorder::build_stream::<i32>(
|
||||||
|
&thread_device,
|
||||||
|
&config,
|
||||||
|
sample_tx,
|
||||||
|
channels,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to build input stream: {e}"))?,
|
||||||
|
cpal::SampleFormat::F32 => AudioRecorder::build_stream::<f32>(
|
||||||
|
&thread_device,
|
||||||
|
&config,
|
||||||
|
sample_tx,
|
||||||
|
channels,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to build input stream: {e}"))?,
|
||||||
|
sample_format => {
|
||||||
|
return Err(format!("Unsupported sample format: {sample_format:?}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
stream.play().expect("failed to start stream");
|
stream
|
||||||
|
.play()
|
||||||
|
.map_err(|e| format!("Failed to start microphone stream: {e}"))?;
|
||||||
|
|
||||||
// keep the stream alive while we process samples
|
Ok((stream, sample_rate))
|
||||||
run_consumer(sample_rate, vad, sample_rx, cmd_rx, level_cb);
|
})();
|
||||||
// stream is dropped here, after run_consumer returns
|
|
||||||
|
match init_result {
|
||||||
|
Ok((stream, sample_rate)) => {
|
||||||
|
let _ = init_tx.send(Ok(()));
|
||||||
|
// Keep the stream alive while we process samples.
|
||||||
|
run_consumer(sample_rate, vad, sample_rx, cmd_rx, level_cb);
|
||||||
|
drop(stream);
|
||||||
|
}
|
||||||
|
Err(error_message) => {
|
||||||
|
let normalized_error = normalize_microphone_error(error_message);
|
||||||
|
log::error!("{normalized_error}");
|
||||||
|
let _ = init_tx.send(Err(normalized_error));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
self.device = Some(device);
|
match init_rx.recv() {
|
||||||
self.cmd_tx = Some(cmd_tx);
|
Ok(Ok(())) => {
|
||||||
self.worker_handle = Some(worker);
|
self.device = Some(device);
|
||||||
|
self.cmd_tx = Some(cmd_tx);
|
||||||
Ok(())
|
self.worker_handle = Some(worker);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(Err(error_message)) => {
|
||||||
|
let _ = worker.join();
|
||||||
|
let kind = if is_microphone_access_denied(&error_message) {
|
||||||
|
std::io::ErrorKind::PermissionDenied
|
||||||
|
} else {
|
||||||
|
std::io::ErrorKind::Other
|
||||||
|
};
|
||||||
|
Err(Box::new(Error::new(kind, error_message)))
|
||||||
|
}
|
||||||
|
Err(recv_error) => {
|
||||||
|
let _ = worker.join();
|
||||||
|
Err(Box::new(Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to initialize microphone worker: {recv_error}"),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
@ -239,6 +292,21 @@ impl AudioRecorder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_microphone_access_denied(error_message: &str) -> bool {
|
||||||
|
let normalized = error_message.to_lowercase();
|
||||||
|
normalized.contains("access is denied")
|
||||||
|
|| normalized.contains("permission denied")
|
||||||
|
|| normalized.contains("0x80070005")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_microphone_error(error_message: String) -> String {
|
||||||
|
if is_microphone_access_denied(&error_message) {
|
||||||
|
return "Microphone access was denied by the operating system. On Windows, enable Settings → Privacy & security → Microphone (including desktop app access), then restart Handy.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
error_message
|
||||||
|
}
|
||||||
|
|
||||||
fn run_consumer(
|
fn run_consumer(
|
||||||
in_sample_rate: u32,
|
in_sample_rate: u32,
|
||||||
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
|
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
|
||||||
|
|
|
||||||
|
|
@ -332,15 +332,16 @@ impl AudioRecordingManager {
|
||||||
|
|
||||||
/* ---------- recording --------------------------------------------------- */
|
/* ---------- recording --------------------------------------------------- */
|
||||||
|
|
||||||
pub fn try_start_recording(&self, binding_id: &str) -> bool {
|
pub fn try_start_recording(&self, binding_id: &str) -> Result<(), String> {
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
|
|
||||||
if let RecordingState::Idle = *state {
|
if let RecordingState::Idle = *state {
|
||||||
// Ensure microphone is open in on-demand mode
|
// Ensure microphone is open in on-demand mode
|
||||||
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
|
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
|
||||||
if let Err(e) = self.start_microphone_stream() {
|
if let Err(e) = self.start_microphone_stream() {
|
||||||
error!("Failed to open microphone stream: {e}");
|
let msg = format!("{e}");
|
||||||
return false;
|
error!("Failed to open microphone stream: {msg}");
|
||||||
|
return Err(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,13 +352,12 @@ impl AudioRecordingManager {
|
||||||
binding_id: binding_id.to_string(),
|
binding_id: binding_id.to_string(),
|
||||||
};
|
};
|
||||||
debug!("Recording started for binding {binding_id}");
|
debug!("Recording started for binding {binding_id}");
|
||||||
return true;
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
error!("Recorder not available");
|
Err("Recorder not available".to_string())
|
||||||
false
|
|
||||||
} else {
|
} else {
|
||||||
false
|
Err("Already recording".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
15
src/App.tsx
15
src/App.tsx
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { toast, Toaster } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import {
|
import {
|
||||||
checkAccessibilityPermission,
|
checkAccessibilityPermission,
|
||||||
|
|
@ -25,7 +26,7 @@ const renderSettingsContent = (section: SidebarSection) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
|
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
@ -93,6 +94,16 @@ function App() {
|
||||||
};
|
};
|
||||||
}, [settings?.debug_mode, updateSetting]);
|
}, [settings?.debug_mode, updateSetting]);
|
||||||
|
|
||||||
|
// Listen for recording errors from the backend and show a toast
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<string>("recording-error", (event) => {
|
||||||
|
toast.error(t("errors.recordingFailed", { error: event.payload }));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unlisten.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const checkOnboardingStatus = async () => {
|
const checkOnboardingStatus = async () => {
|
||||||
try {
|
try {
|
||||||
// Check if they have any models available
|
// Check if they have any models available
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "تجاهل"
|
"dismiss": "تجاهل"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "خطأ في تحميل المجلد: {{error}}"
|
"loadDirectory": "خطأ في تحميل المجلد: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "لغة التطبيق",
|
"title": "لغة التطبيق",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Zavřít"
|
"dismiss": "Zavřít"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Chyba při načítání adresáře: {{error}}"
|
"loadDirectory": "Chyba při načítání adresáře: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Jazyk aplikace",
|
"title": "Jazyk aplikace",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Schließen"
|
"dismiss": "Schließen"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Fehler beim Laden des Verzeichnisses: {{error}}"
|
"loadDirectory": "Fehler beim Laden des Verzeichnisses: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Anwendungssprache",
|
"title": "Anwendungssprache",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Dismiss"
|
"dismiss": "Dismiss"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Error loading directory: {{error}}"
|
"loadDirectory": "Error loading directory: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Application Language",
|
"title": "Application Language",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Descartar"
|
"dismiss": "Descartar"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Error al cargar el directorio: {{error}}"
|
"loadDirectory": "Error al cargar el directorio: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Idioma de la aplicación",
|
"title": "Idioma de la aplicación",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Ignorer"
|
"dismiss": "Ignorer"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Erreur lors du chargement du répertoire : {{error}}"
|
"loadDirectory": "Erreur lors du chargement du répertoire : {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Langue de l'application",
|
"title": "Langue de l'application",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Ignora"
|
"dismiss": "Ignora"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Errore di caricamento cartella: {{error}}"
|
"loadDirectory": "Errore di caricamento cartella: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Lingua Applicazione",
|
"title": "Lingua Applicazione",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "閉じる"
|
"dismiss": "閉じる"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "ディレクトリの読み込みエラー: {{error}}"
|
"loadDirectory": "ディレクトリの読み込みエラー: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "アプリケーション言語",
|
"title": "アプリケーション言語",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "닫기"
|
"dismiss": "닫기"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "디렉토리 로딩 오류: {{error}}"
|
"loadDirectory": "디렉토리 로딩 오류: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "애플리케이션 언어",
|
"title": "애플리케이션 언어",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Zamknij"
|
"dismiss": "Zamknij"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Błąd wczytywania katalogu: {{error}}"
|
"loadDirectory": "Błąd wczytywania katalogu: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Język aplikacji",
|
"title": "Język aplikacji",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Dispensar"
|
"dismiss": "Dispensar"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Erro ao carregar diretório: {{error}}"
|
"loadDirectory": "Erro ao carregar diretório: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Idioma da Aplicação",
|
"title": "Idioma da Aplicação",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Увольнять"
|
"dismiss": "Увольнять"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Ошибка загрузки каталога: {{error}}."
|
"loadDirectory": "Ошибка загрузки каталога: {{error}}.",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Язык приложения",
|
"title": "Язык приложения",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Yoksay"
|
"dismiss": "Yoksay"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Dizin yüklenirken hata oluştu: {{error}}"
|
"loadDirectory": "Dizin yüklenirken hata oluştu: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Uygulama Dili",
|
"title": "Uygulama Dili",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Закрити"
|
"dismiss": "Закрити"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Помилка завантаження папки: {{error}}"
|
"loadDirectory": "Помилка завантаження папки: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Мова інтерфейсу",
|
"title": "Мова інтерфейсу",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "Bỏ qua"
|
"dismiss": "Bỏ qua"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "Lỗi khi tải thư mục: {{error}}"
|
"loadDirectory": "Lỗi khi tải thư mục: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "Ngôn ngữ ứng dụng",
|
"title": "Ngôn ngữ ứng dụng",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "關閉"
|
"dismiss": "關閉"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "載入目錄時發生錯誤: {{error}}"
|
"loadDirectory": "載入目錄時發生錯誤: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "應用程式語言",
|
"title": "應用程式語言",
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,8 @@
|
||||||
"dismiss": "关闭"
|
"dismiss": "关闭"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadDirectory": "加载目录时出错: {{error}}"
|
"loadDirectory": "加载目录时出错: {{error}}",
|
||||||
|
"recordingFailed": "Failed to start recording: {{error}}"
|
||||||
},
|
},
|
||||||
"appLanguage": {
|
"appLanguage": {
|
||||||
"title": "应用语言",
|
"title": "应用语言",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue