feat: settings and sounds

This commit is contained in:
zhom committed 2026-10-17 14:13:17 +04:00
1 parent 3be7658e49
commit 3d8379453e
37 files changed
+4146 -1790

No files matched your search

+27 -24
View File
@@ -4,7 +4,6 @@ import path from "node:path";
import test from "node:test";
import Color from "color";
import en from "../../src/i18n/locales/en.json" with { type: "json" };
import { THEMES } from "../../src/lib/themes.ts";
import { withApp } from "../lib/app.mjs";
import { extensionZipBase64 } from "../lib/fixtures.mjs";
@@ -294,16 +293,19 @@ test("paused CSS animations cannot retain dismissed selects or dropdowns", async
await resize(app, 1100, 760);
await createProfile(app, "Motion popup Alpha");
await createProfile(app, "Motion popup Beta");
await app.clickSelector(`[aria-label="${en.rail.settings}"]`);
await app.clickSelector(`[aria-label="${en.rail.network}"]`);
await app.clickSelector(
`[aria-label="${en.proxies.management.newProxy}"]`,
);
await app.waitFor(
() =>
app.execute(`
const trigger = document.querySelector('#theme-select');
const trigger = document.querySelector('#proxy-type');
if (!trigger || trigger.matches(':disabled')) return false;
trigger.focus();
return document.activeElement === trigger;
`),
{ description: "enabled theme select" },
{ description: "enabled proxy type select" },
);
await app.pressShortcut({ key: "\uE007" });
await assertPopupReadable(app, slot("select-content"));
@@ -324,7 +326,7 @@ test("paused CSS animations cannot retain dismissed selects or dropdowns", async
),
"paused",
);
await app.clickText(en.common.labels.custom, { roles: ["option"] });
await app.clickText(en.proxies.types.socks5, { roles: ["option"] });
await app.waitFor(
() =>
app.execute(
@@ -339,27 +341,19 @@ test("paused CSS animations cannot retain dismissed selects or dropdowns", async
// The next popup also opens under the paused clock: both its content
// and its pointer targets must be available at the initial frame.
await app.clickSelector("#theme-preset-select");
await app.clickSelector("#proxy-type");
await assertPopupReadable(app, slot("select-content"));
await app.capture("paused-css-preset-select");
const preset = THEMES.find((theme) => theme.id === "dracula");
assert.ok(preset);
await app.clickText(preset.name, { roles: ["option"] });
await app.capture("paused-css-proxy-type-select");
await app.clickText(en.proxies.types.http, { roles: ["option"] });
await waitForSelector(app, slot("select-content"), false);
assert.equal(
await app.execute(
`return document.querySelector("#theme-preset-select")?.textContent.trim();`,
`return document.querySelector("#proxy-type")?.textContent.trim();`,
),
preset.name,
en.proxies.types.http,
);
await app.clickText(en.common.buttons.saveSettings, {
roles: ["button"],
});
await app.waitFor(
() => app.visibleTextIncludes(en.common.buttons.saved),
{ description: "saved settings feedback" },
);
await waitForSelector(app, "#theme-select", true);
await app.pressShortcut({ key: "\uE00C" });
await waitForSelector(app, "#proxy-type", false);
await app.clickSelector(`[aria-label="${en.rail.profiles}"]`);
const openSort = () =>
@@ -1344,12 +1338,21 @@ test("settings save a RAM-only edit, preserve location, and expose review and di
const initial = await app.invoke("get_app_settings");
const selector = "#keep-decrypted-profiles-in-ram";
await app.clickSelector(selector);
await app.clickSelector(`${slot("settings-feedback")} summary`);
assert.equal(
await app.visibleTextIncludes(en.settings.keepDecryptedProfilesInRam),
true,
await app.clickSelector(slot("settings-review"));
await app.waitFor(
() =>
app.execute(
`return document.querySelector(arguments[0])?.textContent.includes(arguments[1]);`,
[
slot("settings-review-list"),
en.settings.keepDecryptedProfilesInRam,
],
),
{ description: "the review list names the RAM change" },
);
await app.capture("review-ram-change");
await app.pressShortcut({ key: "\uE00C" });
await waitForSelector(app, slot("settings-review-list"), false);
await app.clickSelector(slot("settings-discard"));
assert.equal(
await app.execute(
+58 -19
View File
@@ -1271,6 +1271,26 @@ async function chooseSelectOption(app, triggerSelector, option) {
);
}
async function chooseThemeCard(app, slotName, value) {
const card = `[data-slot="${slotName}"][data-value="${value}"]`;
await app.clickSelector(card);
await app.waitFor(
() =>
app.execute(
`return document.querySelector(arguments[0])?.getAttribute("aria-checked") === "true";`,
[card],
),
{ description: `${value} chosen in ${slotName}` },
);
}
function checkedThemeCard(app, slotName) {
return app.execute(
`return document.querySelector('[data-slot="' + arguments[0] + '"][aria-checked="true"]')?.dataset.value ?? null;`,
[slotName],
);
}
async function saveSettings(app) {
await app.clickText("Save Settings", { roles: ["button"] });
await app.waitFor(
@@ -1376,7 +1396,7 @@ async function dragBackgroundColorPicker(app) {
await app.waitFor(
() =>
app.execute(
`return document.querySelector("#theme-preset-select")?.textContent?.includes("Your Own") === true;`,
`return document.querySelector('[data-slot="settings-theme-preset"][data-value="custom"]')?.getAttribute("aria-checked") === "true";`,
),
{
description: `customized theme to be marked as Your Own after ${JSON.stringify(pointerEvents)}`,
@@ -2368,21 +2388,44 @@ test("first-run onboarding stays recoverable, responsive, and platform-aware", a
);
});
test("interface sounds turn on and off in Appearance and stay as saved", async () => {
test("interface sounds, their pack, volume and kinds stay as saved", async () => {
await withApp("ui-sounds-setting", async (app) => {
const sounds = async () => (await app.invoke("get_app_settings")).ui_sounds;
assert.equal(await sounds(), false, "the harness keeps the suite silent");
await app.clickSelector('[aria-label="Settings"]');
await app.waitForText(en.settings.appearance.soundsDescription);
const toggle = `[role="switch"][aria-label="${en.settings.appearance.sounds}"]`;
await app.waitForText(en.settings.sounds.enabledDescription);
const toggle = `[role="switch"][aria-label="${en.settings.sounds.enabled}"]`;
const pack = '[data-slot="settings-sound-pack-option"][data-value="retro"]';
assert.equal(
await app.execute(
`return Boolean(document.querySelector(arguments[0]));`,
[pack],
),
false,
"the sound options stay hidden while sounds are off",
);
await app.clickSelector(toggle);
await app.clickSelector(pack);
await app.clickSelector(
`[role="switch"][aria-label="${en.settings.sounds.categories.events.label}"]`,
);
await app.execute(
`document.querySelector('[data-slot="settings-sound-volume"] [role="slider"]').focus();`,
);
await app.pressShortcut({ key: "\uE012" });
await saveSettings(app);
assert.equal(await sounds(), true);
const saved = await app.invoke("get_app_settings");
assert.equal(saved.ui_sounds, true);
assert.equal(saved.ui_sound_pack, "retro");
assert.equal(saved.ui_sound_events, false);
assert.equal(saved.ui_sound_volume, 55);
await app.clickSelector(toggle);
await saveSettings(app);
assert.equal(await sounds(), false);
await app.restart();
assert.equal(await sounds(), false, "the choice survives a restart");
const restarted = await app.invoke("get_app_settings");
assert.equal(restarted.ui_sounds, false, "the choice survives a restart");
assert.equal(restarted.ui_sound_pack, "retro");
});
});
@@ -2390,7 +2433,7 @@ test("predefined theme remains rendered across navigation and restart", async ()
await withApp("ui-theme-predefined", async (app) => {
await app.clickSelector('[aria-label="Settings"]');
await app.waitForText("Appearance");
await chooseSelectOption(app, "#theme-select", "Light");
await chooseThemeCard(app, "settings-theme-option", "light");
await saveSettings(app);
const persisted = await app.invoke("get_app_settings");
@@ -2420,8 +2463,8 @@ test("preset and manually customized themes survive navigation and restart", asy
await withApp("ui-theme-custom", async (app) => {
await app.clickSelector('[aria-label="Settings"]');
await app.waitForText("Appearance");
await chooseSelectOption(app, "#theme-select", "Custom");
await chooseSelectOption(app, "#theme-preset-select", "Dracula");
await chooseThemeCard(app, "settings-theme-option", "custom");
await chooseThemeCard(app, "settings-theme-preset", "dracula");
await saveSettings(app);
const presetSettings = await app.invoke("get_app_settings");
@@ -2451,16 +2494,12 @@ test("preset and manually customized themes survive navigation and restart", asy
await app.clickSelector('[aria-label="Settings"]');
await app.waitForText("Appearance");
assert.equal(
await app.execute(
`return document.querySelector("#theme-select")?.textContent?.trim();`,
),
"Custom",
await checkedThemeCard(app, "settings-theme-option"),
"custom",
);
assert.equal(
await app.execute(
`return document.querySelector("#theme-preset-select")?.textContent?.trim();`,
),
"Dracula",
await checkedThemeCard(app, "settings-theme-preset"),
"dracula",
);
await dragBackgroundColorPicker(app);
await saveSettings(app);
@@ -2503,8 +2542,8 @@ test("a light custom preset keeps light component behavior after restart", async
await withApp("ui-theme-custom-light", async (app) => {
await app.clickSelector('[aria-label="Settings"]');
await app.waitForText("Appearance");
await chooseSelectOption(app, "#theme-select", "Custom");
await chooseSelectOption(app, "#theme-preset-select", "Ayu Light");
await chooseThemeCard(app, "settings-theme-option", "custom");
await chooseThemeCard(app, "settings-theme-preset", "ayu-light");
await saveSettings(app);
const selected = await waitForTheme(
+97 -1
View File
@@ -244,9 +244,29 @@ pub struct AppSettings {
/// byte counts only, so nothing on disk lists where a profile went.
#[serde(default = "default_record_traffic_domains")]
pub record_traffic_domains: bool,
/// Soft interface sounds for presses, toggles and results.
/// Soft interface sounds: the master switch over the fields below.
#[serde(default = "default_ui_sounds")]
pub ui_sounds: bool,
/// Interface sound volume, 0 to 100. Out-of-range numbers are clamped as
/// they are read, so a bad value never resets the whole settings file.
#[serde(
default = "default_ui_sound_volume",
deserialize_with = "deserialize_ui_sound_volume"
)]
pub ui_sound_volume: u8,
/// Sounds for presses, toggles and navigation.
#[serde(default = "default_ui_sounds")]
pub ui_sound_interface: bool,
/// Sounds for results: success, error, warning, copy, delete, restore.
#[serde(default = "default_ui_sounds")]
pub ui_sound_feedback: bool,
/// Sounds for events: launch, stop, sync, downloads, notifications.
#[serde(default = "default_ui_sounds")]
pub ui_sound_events: bool,
/// The sound pack id. The frontend plays an unknown id as the default
/// pack and keeps it stored, so a downgrade does not lose the choice.
#[serde(default = "default_ui_sound_pack")]
pub ui_sound_pack: String,
/// The optional MCP toolsets written into clients' endpoint URLs. Empty is
/// every tool.
#[serde(default)]
@@ -287,6 +307,26 @@ fn default_ui_sounds() -> bool {
true
}
pub const DEFAULT_UI_SOUND_VOLUME: u8 = 60;
pub const MAX_UI_SOUND_VOLUME: u8 = 100;
pub const DEFAULT_UI_SOUND_PACK: &str = "soft";
fn default_ui_sound_volume() -> u8 {
DEFAULT_UI_SOUND_VOLUME
}
fn default_ui_sound_pack() -> String {
DEFAULT_UI_SOUND_PACK.to_string()
}
fn deserialize_ui_sound_volume<'de, D>(deserializer: D) -> Result<u8, D::Error>
where
D: serde::Deserializer<'de>,
{
let volume = f64::deserialize(deserializer)?;
Ok(volume.round().clamp(0.0, f64::from(MAX_UI_SOUND_VOLUME)) as u8)
}
/// The automatic tip flow waits a random gap between two tips, drawn from
/// this range (both ends included), so tips arrive now and then instead of on
/// a fixed day and a busy day of restarts never turns into a tip per launch.
@@ -331,6 +371,11 @@ impl Default for AppSettings {
trash_retention_days: DEFAULT_TRASH_RETENTION_DAYS,
record_traffic_domains: true,
ui_sounds: true,
ui_sound_volume: DEFAULT_UI_SOUND_VOLUME,
ui_sound_interface: true,
ui_sound_feedback: true,
ui_sound_events: true,
ui_sound_pack: DEFAULT_UI_SOUND_PACK.to_string(),
mcp_toolsets: Vec::new(),
}
}
@@ -1491,6 +1536,11 @@ mod tests {
trash_retention_days: 7,
record_traffic_domains: false,
ui_sounds: false,
ui_sound_volume: 25,
ui_sound_interface: false,
ui_sound_feedback: true,
ui_sound_events: false,
ui_sound_pack: "retro".to_string(),
mcp_toolsets: vec!["network".to_string()],
};
@@ -1513,6 +1563,11 @@ mod tests {
assert_eq!(loaded_settings.trash_retention_days, 7);
assert!(!loaded_settings.record_traffic_domains);
assert!(!loaded_settings.ui_sounds);
assert_eq!(loaded_settings.ui_sound_volume, 25);
assert!(!loaded_settings.ui_sound_interface);
assert!(loaded_settings.ui_sound_feedback);
assert!(!loaded_settings.ui_sound_events);
assert_eq!(loaded_settings.ui_sound_pack, "retro");
}
#[test]
@@ -1522,6 +1577,47 @@ mod tests {
assert_eq!(settings.trash_retention_days, DEFAULT_TRASH_RETENTION_DAYS);
assert!(settings.record_traffic_domains);
assert!(settings.ui_sounds, "sounds are on until turned off");
assert_eq!(settings.ui_sound_volume, DEFAULT_UI_SOUND_VOLUME);
assert!(settings.ui_sound_interface);
assert!(settings.ui_sound_feedback);
assert!(settings.ui_sound_events);
assert_eq!(settings.ui_sound_pack, DEFAULT_UI_SOUND_PACK);
}
#[test]
fn default_sound_settings_match_a_missing_file() {
let defaults = AppSettings::default();
let parsed: AppSettings = serde_json::from_str("{}").unwrap();
assert_eq!(defaults.ui_sound_volume, 60);
assert_eq!(defaults.ui_sound_pack, "soft");
assert_eq!(parsed.ui_sound_volume, defaults.ui_sound_volume);
assert_eq!(parsed.ui_sound_pack, defaults.ui_sound_pack);
assert_eq!(parsed.ui_sound_interface, defaults.ui_sound_interface);
assert_eq!(parsed.ui_sound_feedback, defaults.ui_sound_feedback);
assert_eq!(parsed.ui_sound_events, defaults.ui_sound_events);
}
#[test]
fn sound_volume_is_clamped_instead_of_resetting_the_settings() {
let volume = |json: &str| {
serde_json::from_str::<AppSettings>(json)
.map(|settings| (settings.ui_sound_volume, settings.theme))
.unwrap()
};
assert_eq!(
volume(r#"{"theme":"dark","ui_sound_volume":250}"#),
(100, "dark".to_string())
);
assert_eq!(
volume(r#"{"theme":"dark","ui_sound_volume":-5}"#),
(0, "dark".to_string())
);
assert_eq!(
volume(r#"{"theme":"dark","ui_sound_volume":42.6}"#),
(43, "dark".to_string())
);
let unknown: AppSettings = serde_json::from_str(r#"{"ui_sound_pack":"future"}"#).unwrap();
assert_eq!(unknown.ui_sound_pack, "future", "an unknown pack is kept");
}
#[test]
+23 -3
View File
@@ -110,6 +110,7 @@ import {
showSyncProgressToast,
showToast,
} from "@/lib/toast-utils";
import { playUiSound } from "@/lib/ui-sounds";
import type {
BrowserProfile,
ConsistencyResult,
@@ -855,6 +856,7 @@ export default function Home() {
return;
}
e.preventDefault();
playUiSound("open");
runShortcut(s.id);
return;
}
@@ -1349,6 +1351,7 @@ export default function Home() {
ackExtensionKeys: [],
});
}
playUiSound("warning");
return new Promise<GateDecision>((resolve) => {
gateRequestSeqRef.current += 1;
gateQueueRef.current.push({
@@ -1541,12 +1544,14 @@ export default function Home() {
console.warn("Pre-launch checks failed, launching anyway:", err);
}
const causedAt = performance.now();
try {
const result = await invoke<BrowserProfile>("launch_browser_profile", {
profile,
consentToken,
});
console.log("Successfully launched profile:", result.name);
playUiSound("launch", { causedAt, batch: opts?.bulkRunId });
return { status: "launched" };
} catch (err: unknown) {
// Tier 2: the enforcing gate measured the exit mid-launch and stopped
@@ -1577,10 +1582,15 @@ export default function Home() {
return { status: "cancelled" };
}
try {
const retriedAt = performance.now();
await invoke<BrowserProfile>("launch_browser_profile", {
profile,
consentToken: parsed.params?.token ?? null,
});
playUiSound("launch", {
causedAt: retriedAt,
batch: opts?.bulkRunId,
});
return { status: "launched" };
} catch (retryErr: unknown) {
showErrorToast(
@@ -1642,6 +1652,7 @@ export default function Home() {
// Attempt to delete the profile
await invoke("delete_profile", { profileId: profile.id });
playUiSound("trash");
console.log("Profile deletion command completed successfully");
// No need to manually reload - useProfileEvents will handle the update
@@ -1675,11 +1686,12 @@ export default function Home() {
);
const handleKillProfile = useCallback(
async (profile: BrowserProfile) => {
async (profile: BrowserProfile, opts?: { batch?: number }) => {
console.log("Starting kill for profile:", profile.name);
try {
await invoke("kill_browser_profile", { profile });
playUiSound("stop", { batch: opts?.batch });
console.log("Successfully killed profile:", profile.name);
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
@@ -1697,6 +1709,7 @@ export default function Home() {
async (profileIds: string[]) => {
try {
await invoke("delete_selected_profiles", { profileIds });
playUiSound("trash");
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
console.error("Failed to delete selected profiles:", err);
@@ -1743,6 +1756,7 @@ export default function Home() {
groupsData.find((group) => group.id === groupId)?.name ??
t("groups.noGroup"),
}),
{ sound: "drop" },
);
return true;
} catch (error) {
@@ -1770,6 +1784,7 @@ export default function Home() {
await invoke("delete_selected_profiles", {
profileIds: selectedProfiles,
});
playUiSound("trash");
// No need to manually reload - useProfileEvents will handle the update
setSelectedProfiles([]);
setShowBulkDeleteConfirmation(false);
@@ -1879,8 +1894,11 @@ export default function Home() {
const executeBulkStop = useCallback(
async (targets: BrowserProfile[]) => {
setIsBulkActing(true);
const batch = performance.now();
try {
await Promise.allSettled(targets.map((p) => handleKillProfile(p)));
await Promise.allSettled(
targets.map((p) => handleKillProfile(p, { batch })),
);
setSelectedProfiles([]);
} finally {
setIsBulkActing(false);
@@ -2003,7 +2021,9 @@ export default function Home() {
dismissToast(toastId);
if (profilesWithTransfer.has(profile_id)) {
profilesWithTransfer.delete(profile_id);
showSuccessToast(t("sync.toast.profileSynced", { name }));
showSuccessToast(t("sync.toast.profileSynced", { name }), {
sound: "synced",
});
}
} else if (status === "error") {
dismissToast(toastId);
+7 -3
View File
@@ -12,7 +12,11 @@ import { WindowDragArea } from "@/components/window-drag-area";
import { InputModalityProvider } from "@/hooks/use-input-modality";
import { LaunchActivityProvider } from "@/hooks/use-launch-activity";
import { setupLogging } from "@/lib/logger";
import { installUiSounds, setUiSoundsEnabled } from "@/lib/ui-sounds";
import {
applySoundSettings,
installUiSounds,
type SoundSettings,
} from "@/lib/ui-sounds";
export function ClientProviders({ children }: { children: React.ReactNode }) {
useEffect(() => {
@@ -20,8 +24,8 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
}, []);
useEffect(() => {
void invoke<{ ui_sounds?: boolean }>("get_app_settings")
.then((settings) => setUiSoundsEnabled(settings.ui_sounds ?? true))
void invoke<SoundSettings>("get_app_settings")
.then(applySoundSettings)
.catch(() => {});
return installUiSounds();
}, []);
+4 -1
View File
@@ -1026,7 +1026,10 @@ export function CookieBotEnrolDialog({
void navigator.clipboard
.writeText(conflict.email)
.then(() => {
showSuccessToast(t("cookieBot.conflict.emailCopied"));
showSuccessToast(
t("cookieBot.conflict.emailCopied"),
{ sound: "copy" },
);
})
.catch(() => {
showErrorToast(t("cookieBot.conflict.copyFailed"));
+2
View File
@@ -19,6 +19,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { playUiSound } from "@/lib/ui-sounds";
import type {
CookieAnalysis,
CookieIssue,
@@ -142,6 +143,7 @@ export function CookiePastePanel({
const file = event.dataTransfer.files[0];
if (!file) return;
event.preventDefault();
playUiSound("drop");
readFile(file);
},
[readFile],
+2
View File
@@ -104,6 +104,7 @@ import { rotateProxyIps } from "@/lib/proxy-rotation";
import type { RemoteHandoffState } from "@/lib/remote-sessions";
import { startPagesFromText } from "@/lib/start-pages";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { playUiSound } from "@/lib/ui-sounds";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
@@ -691,6 +692,7 @@ function ProfileInfoContents({
const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(profile.id);
playUiSound("copy");
setCopied(true);
setTimeout(() => {
setCopied(false);
+2
View File
@@ -23,6 +23,7 @@ import {
import { translateBackendError } from "@/lib/backend-errors";
import { formatRelativeTime } from "@/lib/flag-utils";
import { runProxyCheck, useProxyCheck } from "@/lib/proxy-check-store";
import { playUiSound } from "@/lib/ui-sounds";
import { cn } from "@/lib/utils";
import type { ProxyCheckHistoryEntry, StoredProxy, UdpSupport } from "@/types";
@@ -276,6 +277,7 @@ export function ProxyCheckButton({
} catch {
return;
}
playUiSound("copy");
setCopied(true);
if (copiedTimer.current !== null) {
window.clearTimeout(copiedTimer.current);
+2
View File
@@ -16,6 +16,7 @@ import {
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { playUiSound } from "@/lib/ui-sounds";
import { RippleButton } from "./ui/ripple";
interface ProxyExportDialogProps {
@@ -53,6 +54,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
const handleCopyToClipboard = useCallback(async () => {
try {
await navigator.clipboard.writeText(exportContent);
playUiSound("copy");
setCopied(true);
toast.success(t("toasts.success.copied"));
setTimeout(() => {
+2
View File
@@ -25,6 +25,7 @@ import {
resolveAmbiguousProxyLine,
} from "@/lib/proxy-string";
import { canonicalProxyType } from "@/lib/proxy-type";
import { playUiSound } from "@/lib/ui-sounds";
import type {
ParsedProxyLine,
ProxyImportResult,
@@ -209,6 +210,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
);
if (validFile) {
playUiSound("drop");
handleFileRead(validFile);
} else {
toast.error(t("proxies.importDialog.wrongFileType"));
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,332 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuLock, LuLockKeyhole } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import {
RowIcon,
RuleCheck,
SettingRow,
SettingsCard,
} from "@/components/settings/settings-primitives";
import { AnimatedDisclosureContent } from "@/components/ui/animated-disclosure";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
const MIN_PASSWORD_LENGTH = 8;
/** The sync encryption password: set, check, change or remove it. */
export function EncryptionSettings({
canUseEncryption,
hasPassword,
onHasPasswordChange,
}: {
canUseEncryption: boolean;
hasPassword: boolean;
onHasPasswordChange: (hasPassword: boolean) => void;
}) {
const { t } = useTranslation();
const [changing, setChanging] = useState(false);
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [isVerifyOpen, setIsVerifyOpen] = useState(false);
const [verifyPassword, setVerifyPassword] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const longEnough = password.length >= MIN_PASSWORD_LENGTH;
const matches = password.length > 0 && password === confirm;
const showForm = !hasPassword || changing;
// Every path awaits the rollover so a failed re-sync is shown, not lost.
const rollover = useCallback(async (context: string) => {
try {
await invoke("rollover_encryption_for_all_entities");
} catch (rolloverErr) {
console.error(`Rollover after ${context} failed:`, rolloverErr);
showErrorToast(String(rolloverErr));
}
}, []);
const savePassword = async () => {
if (!longEnough) {
setError(t("settings.encryption.passwordTooShort"));
return;
}
if (!matches) {
setError(t("settings.encryption.passwordMismatch"));
return;
}
setIsSaving(true);
try {
await invoke("set_e2e_password", { password });
onHasPasswordChange(true);
setChanging(false);
setPassword("");
setConfirm("");
await rollover("password set");
showSuccessToast(t("settings.encryption.passwordSaved"));
} catch (err) {
showErrorToast(String(err));
} finally {
setIsSaving(false);
}
};
const removePassword = async () => {
setIsRemoving(true);
try {
await invoke("delete_e2e_password");
onHasPasswordChange(false);
setChanging(false);
await rollover("password removal");
showSuccessToast(t("settings.encryption.removed"));
} catch (err) {
showErrorToast(String(err));
} finally {
setIsRemoving(false);
}
};
const verify = async () => {
setIsVerifying(true);
try {
const ok = await invoke<boolean>("verify_e2e_password", {
password: verifyPassword,
});
if (ok) {
showSuccessToast(t("settings.encryption.validateDialog.matchToast"));
setIsVerifyOpen(false);
setVerifyPassword("");
} else {
showErrorToast(t("settings.encryption.validateDialog.mismatchToast"));
}
} catch (err) {
showErrorToast(String(err));
} finally {
setIsVerifying(false);
}
};
if (!canUseEncryption) {
return (
<SettingsCard>
<SettingRow
icon={
<RowIcon>
<LuLock />
</RowIcon>
}
label={t("settings.encryption.noPassword")}
description={t("settings.encryption.requiresProOrOwner")}
/>
</SettingsCard>
);
}
return (
<>
<SettingsCard>
<SettingRow
slot="settings-encryption-status"
icon={
<RowIcon tone={hasPassword ? "success" : "muted"}>
{hasPassword ? <LuLockKeyhole /> : <LuLock />}
</RowIcon>
}
label={
hasPassword
? t("settings.encryption.passwordSetDescription")
: t("settings.encryption.noPassword")
}
description={t("settings.encryption.description")}
>
{hasPassword && (
<>
<Button
variant="soft"
size="sm"
disabled={isRemoving}
onClick={() => {
setVerifyPassword("");
setIsVerifyOpen(true);
}}
>
{t("settings.encryption.validatePassword")}
</Button>
<Button
variant="soft"
size="sm"
disabled={isRemoving}
aria-expanded={changing}
onClick={() => {
setChanging((open) => !open);
setPassword("");
setConfirm("");
setError("");
}}
>
{t("settings.encryption.changePassword")}
</Button>
<LoadingButton
variant="soft"
size="sm"
className="h-8 text-destructive-text hover:bg-destructive/10"
isLoading={isRemoving}
onClick={() => {
void removePassword();
}}
>
{t("settings.encryption.removePassword")}
</LoadingButton>
</>
)}
</SettingRow>
<AnimatedDisclosureContent open={showForm}>
<form
className="flex flex-col gap-3 px-4 py-3.5"
onSubmit={(event) => {
event.preventDefault();
void savePassword();
}}
>
<div className="grid gap-2 @lg:grid-cols-2">
<Input
type="password"
variant="soft"
autoComplete="new-password"
aria-label={t("settings.encryption.passwordPlaceholder")}
placeholder={t("settings.encryption.passwordPlaceholder")}
value={password}
onChange={(event) => {
setPassword(event.target.value);
setError("");
}}
/>
<Input
type="password"
variant="soft"
autoComplete="new-password"
aria-label={t("settings.encryption.confirmPlaceholder")}
placeholder={t("settings.encryption.confirmPlaceholder")}
value={confirm}
onChange={(event) => {
setConfirm(event.target.value);
setError("");
}}
/>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<ul className="flex flex-wrap gap-x-4 gap-y-1">
<RuleCheck
done={longEnough}
label={t("settings.encryption.ruleLength", {
min: MIN_PASSWORD_LENGTH,
})}
/>
<RuleCheck
done={matches}
label={t("settings.encryption.ruleMatch")}
/>
</ul>
<div className="ml-auto flex items-center gap-2">
{changing && (
<Button
type="button"
variant="subtle"
size="sm"
onClick={() => setChanging(false)}
>
{t("common.buttons.cancel")}
</Button>
)}
<LoadingButton
type="submit"
size="sm"
className="h-8"
isLoading={isSaving}
disabled={!longEnough || !matches}
>
{t("settings.encryption.setPassword")}
</LoadingButton>
</div>
</div>
{error && (
<p role="alert" className="text-xs text-destructive-text">
{error}
</p>
)}
</form>
</AnimatedDisclosureContent>
</SettingsCard>
<Dialog
open={isVerifyOpen}
onOpenChange={(open) => {
if (isVerifying) return;
setIsVerifyOpen(open);
if (!open) setVerifyPassword("");
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("settings.encryption.validateDialog.title")}
</DialogTitle>
<DialogDescription>
{t("settings.encryption.validateDialog.description")}
</DialogDescription>
</DialogHeader>
<form
id="settings-verify-e2e"
onSubmit={(event) => {
event.preventDefault();
if (verifyPassword.length > 0) void verify();
}}
>
<Input
type="password"
placeholder={t("settings.encryption.passwordPlaceholder")}
value={verifyPassword}
autoFocus
onChange={(event) => setVerifyPassword(event.target.value)}
/>
</form>
<DialogFooter>
<Button
variant="outline"
disabled={isVerifying}
onClick={() => {
setIsVerifyOpen(false);
setVerifyPassword("");
}}
>
{t("common.buttons.cancel")}
</Button>
<LoadingButton
type="submit"
form="settings-verify-e2e"
isLoading={isVerifying}
disabled={verifyPassword.length === 0}
>
{t("settings.encryption.validateDialog.submit")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,475 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
createContext,
type KeyboardEvent,
type ReactNode,
useContext,
useId,
} from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import { useInputModality } from "@/hooks/use-input-modality";
import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion";
import { cn } from "@/lib/utils";
/** Layout springs run only for pointer users who allow motion. */
export function useSettingsMotion() {
const reduceMotion = useReducedMotion();
const inputModality = useInputModality();
return !reduceMotion && inputModality === "pointer";
}
/** A nav target: an icon that lights up while the reader is in it, and a title. */
export function SettingsSection({
id,
icon,
title,
active,
hidden,
aside,
children,
}: {
id: string;
icon: ReactNode;
title: string;
active: boolean;
hidden: boolean;
aside?: ReactNode;
children: ReactNode;
}) {
const titleId = `settings-section-${id}`;
return (
<section
data-settings-section={id}
tabIndex={-1}
hidden={hidden}
aria-labelledby={titleId}
className="@container flex scroll-mt-3 flex-col gap-3 focus:outline-none"
>
<header className="flex min-h-8 items-center gap-2.5">
<span
aria-hidden="true"
data-active={active}
className={cn(
"grid size-7 shrink-0 place-items-center rounded-lg transition-colors duration-200 [&_svg]:size-3.5",
active
? "bg-primary/12 text-primary-text"
: "bg-foreground/6 text-muted-foreground",
)}
>
{icon}
</span>
<h2 id={titleId} className="text-sm font-semibold tracking-tight">
{title}
</h2>
{aside && (
<div className="ml-auto flex items-center gap-2">{aside}</div>
)}
</header>
{children}
</section>
);
}
/** A quiet card that holds rows separated by hairlines. */
export function SettingsCard({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"divide-y divide-foreground/6 rounded-xl bg-foreground/3",
className,
)}
>
{children}
</div>
);
}
/** Marks a setting whose draft differs from what is saved. */
export function ChangedDot({ show }: { show: boolean }) {
const { t } = useTranslation();
const animate = useSettingsMotion();
return (
<AnimatePresence initial={false}>
{show && (
<motion.span
role="img"
aria-label={t("settings.changed")}
title={t("settings.changed")}
data-slot="settings-changed-dot"
initial={animate ? { scale: 0, opacity: 0 } : false}
animate={{ scale: 1, opacity: 1 }}
exit={animate ? { scale: 0, opacity: 0 } : { opacity: 0 }}
transition={
animate
? { type: "spring", stiffness: 600, damping: 28 }
: { duration: 0 }
}
className="inline-block size-1.5 shrink-0 rounded-full bg-primary"
/>
)}
</AnimatePresence>
);
}
/**
* One setting: what it is on the left, its control on the right. The control
* drops under the text only when the two cannot share a line.
*/
export function SettingRow({
label,
description,
htmlFor,
changed = false,
icon,
children,
className,
slot,
}: {
label: ReactNode;
description?: ReactNode;
htmlFor?: string;
changed?: boolean;
icon?: ReactNode;
children?: ReactNode;
className?: string;
slot?: string;
}) {
return (
<div
data-slot={slot}
className={cn(
"flex flex-wrap items-center justify-between gap-x-6 gap-y-2.5 px-4 py-3.5",
className,
)}
>
<div className="flex min-w-[min(100%,15rem)] flex-1 items-start gap-3">
{icon}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Label
htmlFor={htmlFor}
className="inline-flex items-center gap-2 text-sm leading-snug font-medium"
>
{label}
<ChangedDot show={changed} />
</Label>
{description && (
<p className="text-xs leading-relaxed text-pretty text-muted-foreground">
{description}
</p>
)}
</div>
</div>
{children && (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{children}
</div>
)}
</div>
);
}
/** A setting whose control needs the full width, under its text. */
export function SettingBlock({
label,
description,
changed = false,
children,
}: {
label: ReactNode;
description?: ReactNode;
changed?: boolean;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-3 px-4 py-3.5">
<div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-2 text-sm leading-snug font-medium">
{label}
<ChangedDot show={changed} />
</span>
{description && (
<p className="text-xs leading-relaxed text-pretty text-muted-foreground">
{description}
</p>
)}
</div>
{children}
</div>
);
}
/** A tinted square for a row's icon; `tone` colors it by state. */
export function RowIcon({
children,
tone = "muted",
}: {
children: ReactNode;
tone?: "muted" | "primary" | "success" | "warning";
}) {
return (
<span
aria-hidden="true"
className={cn(
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg transition-colors duration-200 [&_svg]:size-4",
{
muted: "bg-foreground/6 text-muted-foreground",
primary: "bg-primary/12 text-primary-text",
success: "bg-success/12 text-success-text",
warning: "bg-warning/14 text-warning-text",
}[tone],
)}
>
{children}
</span>
);
}
interface ChoiceContextValue {
value: string;
select: (value: string) => void;
indicatorId: string;
animate: boolean;
}
const ChoiceContext = createContext<ChoiceContextValue | null>(null);
function useChoice() {
const context = useContext(ChoiceContext);
if (!context) throw new Error("ChoiceCard must be inside a ChoiceGroup");
return context;
}
/** Arrow keys move the choice, as in any radio group. */
function moveChoice(event: KeyboardEvent<HTMLElement>) {
const step = (
{
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
} as Record<string, number>
)[event.key];
if (!step) return;
const items = [
...event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[role="radio"]:not(:disabled)',
),
];
const index = items.indexOf(document.activeElement as HTMLButtonElement);
if (index < 0) return;
event.preventDefault();
const next = items[(index + step + items.length) % items.length];
next.focus();
next.click();
}
/**
* A radio group of cards. The selection ring slides from the old card to the
* new one, so the eye follows the change.
*/
export function ChoiceGroup({
value,
onValueChange,
label,
className,
children,
slot,
}: {
value: string;
onValueChange: (value: string) => void;
label: string;
className?: string;
children: ReactNode;
slot?: string;
}) {
const indicatorId = useId();
const animate = useSettingsMotion();
return (
<ChoiceContext.Provider
value={{ value, select: onValueChange, indicatorId, animate }}
>
<div
role="radiogroup"
aria-label={label}
data-slot={slot}
onKeyDown={moveChoice}
className={className}
>
{children}
</div>
</ChoiceContext.Provider>
);
}
export function ChoiceCard({
value,
children,
className,
slot,
label,
}: {
value: string;
children: ReactNode;
className?: string;
slot?: string;
label?: string;
}) {
const { value: selectedValue, select, indicatorId, animate } = useChoice();
const selected = selectedValue === value;
return (
<button
type="button"
role="radio"
aria-checked={selected}
aria-label={label}
tabIndex={selected ? 0 : -1}
data-slot={slot}
data-value={value}
data-state={selected ? "checked" : "unchecked"}
onClick={() => {
if (!selected) select(value);
}}
className={cn(
"group/choice relative isolate cursor-pointer rounded-xl text-left outline-none",
"transition-[background-color,translate] duration-150 ease-[var(--ease-out)]",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
>
{selected && (
<motion.span
aria-hidden="true"
data-slot="settings-choice-indicator"
layoutId={animate ? `choice-${indicatorId}` : undefined}
initial={false}
transition={animate ? MOTION_SPRING_POSITION : { duration: 0 }}
className="pointer-events-none absolute -inset-px -z-10 rounded-[inherit] bg-primary/8 ring-2 ring-primary/70"
/>
)}
{children}
</button>
);
}
/**
* A row of short options with a pill that slides to the chosen one, the same
* motion as the section nav.
*/
export function SegmentedControl<T extends string | number>({
value,
onValueChange,
options,
label,
id,
slot,
}: {
value: T;
onValueChange: (value: T) => void;
options: ReadonlyArray<{ value: T; label: string }>;
label: string;
id?: string;
slot?: string;
}) {
const indicatorId = useId();
const animate = useSettingsMotion();
return (
<div
id={id}
role="radiogroup"
aria-label={label}
data-slot={slot}
onKeyDown={moveChoice}
className="inline-flex max-w-full items-center gap-0.5 overflow-x-auto scrollbar-none rounded-lg bg-foreground/5 p-0.5"
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={String(option.value)}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
data-value={option.value}
onClick={() => {
if (!selected) onValueChange(option.value);
}}
className={cn(
"relative isolate h-7 shrink-0 cursor-pointer rounded-md px-2.5 text-xs font-medium whitespace-nowrap outline-none",
"transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring",
selected
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{selected && (
<motion.span
aria-hidden="true"
layoutId={animate ? `segment-${indicatorId}` : undefined}
initial={false}
transition={animate ? MOTION_SPRING_POSITION : { duration: 0 }}
className="absolute inset-0 -z-10 rounded-md bg-background shadow-sm ring-1 ring-foreground/8"
/>
)}
{option.label}
</button>
);
})}
</div>
);
}
/** A check that draws itself in when `done` turns true. */
export function RuleCheck({ done, label }: { done: boolean; label: string }) {
const animate = useSettingsMotion();
return (
<li
data-done={done}
className={cn(
"flex items-center gap-1.5 text-xs transition-colors duration-200",
done ? "text-foreground" : "text-muted-foreground",
)}
>
<span
aria-hidden="true"
className={cn(
"grid size-4 place-items-center rounded-full transition-colors duration-200",
done ? "bg-success/15 text-success-text" : "bg-foreground/6",
)}
>
<svg
aria-hidden="true"
viewBox="0 0 12 12"
className="size-2.5"
fill="none"
>
<motion.path
d="M2.5 6.2 5 8.5l4.5-5"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: done ? 1 : 0, opacity: done ? 1 : 0 }}
transition={
animate
? { duration: 0.24, ease: MOTION_EASE_OUT }
: { duration: 0 }
}
/>
</svg>
</span>
{label}
</li>
);
}
@@ -0,0 +1,183 @@
"use client";
import { motion } from "motion/react";
import { useTranslation } from "react-i18next";
import { LuChevronUp, LuCornerDownRight } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { useSettingsMotion } from "@/components/settings/settings-primitives";
import { Button } from "@/components/ui/button";
import { ConfirmationMark } from "@/components/ui/confirmation-mark";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export interface PendingChange {
label: string;
section: string;
}
/**
* The page's save state. It tints when there is something to save, and the
* review list jumps to the section of each change.
*/
export function SettingsSaveBar({
pendingChanges,
isLoading,
isSaving,
saved,
saveError,
onSave,
onDiscard,
onJump,
onCancel,
}: {
pendingChanges: PendingChange[];
isLoading: boolean;
isSaving: boolean;
saved: boolean;
saveError: string | null;
onSave: () => void;
onDiscard: () => void;
onJump: (section: string) => void;
/** Shown instead of Discard when Settings is a modal. */
onCancel?: () => void;
}) {
const { t } = useTranslation();
const animate = useSettingsMotion();
const count = pendingChanges.length;
const hasChanges = count > 0;
const status = isLoading
? t("common.buttons.loading")
: isSaving
? t("common.buttons.saving")
: hasChanges
? t("appFeedback.pendingChanges", { count })
: saved
? t("common.buttons.saved")
: t("appFeedback.noChanges");
return (
<div
data-slot="settings-feedback"
data-pending={hasChanges}
className="mx-auto w-full max-w-4xl shrink-0 pt-3"
>
<div
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl py-2 pr-2 pl-3.5 text-xs transition-[background-color,box-shadow] duration-200",
hasChanges
? "bg-primary/8 ring-1 ring-primary/20"
: "bg-foreground/3 ring-1 ring-transparent",
)}
>
{saveError ? (
<p
role="alert"
className="min-w-0 flex-1 break-words text-destructive-text"
>
{saveError}
</p>
) : (
<p
role="status"
className={cn(
"flex min-h-5 items-center gap-2",
hasChanges
? "font-medium text-foreground"
: "text-muted-foreground",
)}
>
{hasChanges ? (
<motion.span
key={count}
aria-hidden="true"
initial={animate ? { scale: 2.2 } : false}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 18 }}
className="size-1.5 rounded-full bg-primary"
/>
) : (
saved && <ConfirmationMark />
)}
{status}
</p>
)}
{hasChanges && !saveError && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="subtle"
size="sm"
data-slot="settings-review"
className="h-7 gap-1 px-2 text-xs"
>
{t("appFeedback.reviewChanges")}
<LuChevronUp className="size-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
sideOffset={8}
className="w-72 p-1.5"
>
<ul
data-slot="settings-review-list"
className="flex max-h-60 flex-col overflow-y-auto"
>
{pendingChanges.map((change) => (
<li key={change.label}>
<button
type="button"
onClick={() => onJump(change.section)}
className="group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none hover:bg-foreground/6 focus-visible:bg-foreground/6"
>
<span className="size-1.5 shrink-0 rounded-full bg-primary" />
<span className="min-w-0 flex-1 truncate">
{change.label}
</span>
<LuCornerDownRight className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
</button>
</li>
))}
</ul>
</PopoverContent>
</Popover>
)}
<div className="ml-auto flex items-center gap-1.5">
{onCancel ? (
<Button variant="subtle" size="sm" onClick={onCancel}>
{t("common.buttons.cancel")}
</Button>
) : (
<Button
variant="subtle"
size="sm"
data-slot="settings-discard"
onClick={onDiscard}
disabled={!hasChanges || isSaving}
>
{t("appFeedback.discardChanges")}
</Button>
)}
<LoadingButton
size="sm"
className="h-8"
data-slot="settings-save"
isLoading={isSaving}
onClick={onSave}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</div>
</div>
</div>
);
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { motion } from "motion/react";
import { Slider as SliderPrimitive } from "radix-ui";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuPlay } from "react-icons/lu";
import {
ChoiceCard,
ChoiceGroup,
SettingBlock,
SettingRow,
useSettingsMotion,
} from "@/components/settings/settings-primitives";
import { AnimatedDisclosureContent } from "@/components/ui/animated-disclosure";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Button } from "@/components/ui/button";
import { MOTION_EASE_OUT } from "@/lib/motion";
import {
DEFAULT_SOUND_PACK,
DEFAULT_SOUND_VOLUME,
previewUiSound,
SOUND_CATEGORIES,
SOUND_PACKS,
type SoundSettings,
type UiSound,
} from "@/lib/ui-sounds";
import { cn } from "@/lib/utils";
/** The sound settings with their saved defaults filled in. */
export function soundDraft(settings: SoundSettings): Required<SoundSettings> {
return {
ui_sounds: settings.ui_sounds ?? true,
ui_sound_volume: settings.ui_sound_volume ?? DEFAULT_SOUND_VOLUME,
ui_sound_interface: settings.ui_sound_interface ?? true,
ui_sound_feedback: settings.ui_sound_feedback ?? true,
ui_sound_events: settings.ui_sound_events ?? true,
ui_sound_pack: settings.ui_sound_pack ?? DEFAULT_SOUND_PACK,
};
}
const CATEGORY_KEYS = {
interface: "ui_sound_interface",
feedback: "ui_sound_feedback",
events: "ui_sound_events",
} as const;
/**
* A speaker whose waves draw out when sound turns on and fold away when it
* turns off, with a cross in their place.
*/
function SpeakerGlyph({ on }: { on: boolean }) {
const animate = useSettingsMotion();
const transition = (delay: number) =>
animate
? { duration: 0.28, delay, ease: MOTION_EASE_OUT }
: { duration: 0 };
return (
<span
aria-hidden="true"
className={cn(
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg transition-colors duration-200",
on
? "bg-primary/12 text-primary-text"
: "bg-foreground/6 text-muted-foreground",
)}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4.7a.7.7 0 0 0-1.2-.5L6.4 7.6A1.4 1.4 0 0 1 5.4 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.4a1.4 1.4 0 0 1 1 .4l3.4 3.4a.7.7 0 0 0 1.2-.5z" />
<motion.path
d="M16 9a5 5 0 0 1 0 6"
initial={false}
animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}
transition={transition(on ? 0 : 0.06)}
/>
<motion.path
d="M19.4 5.6a10 10 0 0 1 0 12.8"
initial={false}
animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}
transition={transition(on ? 0.08 : 0)}
/>
<motion.path
d="m16 9.5 5 5m0-5-5 5"
initial={false}
animate={{ pathLength: on ? 0 : 1, opacity: on ? 0 : 1 }}
transition={transition(on ? 0 : 0.14)}
/>
</svg>
</span>
);
}
/** Four bars that jump once each time `beat` changes, like a level meter. */
function Bars({ beat, active }: { beat: number; active: boolean }) {
const animate = useSettingsMotion();
return (
<span
aria-hidden="true"
className={cn(
"flex h-4 items-end gap-[2px] transition-colors duration-200",
active ? "text-primary-text" : "text-muted-foreground/60",
)}
>
{[0.45, 0.85, 0.6, 0.35].map((rest, index) => (
<motion.span
key={`${index}-${beat}`}
className="block h-full w-[3px] origin-bottom rounded-full bg-current"
initial={{ scaleY: rest }}
animate={
animate && beat > 0
? { scaleY: [rest, 1, rest * 0.6, rest] }
: { scaleY: rest }
}
transition={{
duration: 0.5,
delay: index * 0.04,
ease: MOTION_EASE_OUT,
}}
/>
))}
</span>
);
}
function VolumeSlider({
value,
label,
onChange,
onCommit,
}: {
value: number;
label: string;
onChange: (value: number) => void;
onCommit: (value: number) => void;
}) {
return (
<div className="flex w-56 items-center gap-3">
<SliderPrimitive.Root
data-slot="settings-sound-volume"
aria-label={label}
className="relative flex h-5 flex-1 cursor-pointer touch-none items-center select-none"
min={0}
max={100}
step={5}
value={[value]}
onValueChange={([next]) => onChange(next)}
onValueCommit={([next]) => onCommit(next)}
>
<SliderPrimitive.Track className="relative h-1.5 grow overflow-hidden rounded-full bg-foreground/10">
<SliderPrimitive.Range className="absolute h-full rounded-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
aria-label={label}
className="block size-4 rounded-full bg-background shadow-md ring-1 ring-foreground/15 transition-transform duration-100 outline-none hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring active:scale-95"
/>
</SliderPrimitive.Root>
<span className="w-9 text-right text-xs text-muted-foreground tabular-nums">
{value}%
</span>
</div>
);
}
/** Interface sounds: on or off, how loud, which set, and which kinds. */
export function SoundSettings({
draft,
onChange,
isChanged,
}: {
draft: Required<SoundSettings>;
onChange: (patch: Partial<SoundSettings>) => void;
isChanged: (key: keyof SoundSettings) => boolean;
}) {
const { t } = useTranslation();
const [beats, setBeats] = useState<Record<string, number>>({});
const play = (sound: UiSound, next: Partial<SoundSettings>, beat: string) => {
previewUiSound(sound, { ...draft, ...next });
setBeats((prev) => ({ ...prev, [beat]: (prev[beat] ?? 0) + 1 }));
};
const sample = SOUND_CATEGORIES[0].sample;
return (
<>
<SettingRow
htmlFor="settings-ui-sounds"
label={t("settings.sounds.enabled")}
description={t("settings.sounds.enabledDescription")}
changed={isChanged("ui_sounds")}
icon={<SpeakerGlyph on={draft.ui_sounds} />}
>
<AnimatedSwitch
id="settings-ui-sounds"
data-slot="settings-ui-sounds"
aria-label={t("settings.sounds.enabled")}
checked={draft.ui_sounds}
onCheckedChange={(v) => onChange({ ui_sounds: v === true })}
/>
</SettingRow>
<AnimatedDisclosureContent
open={draft.ui_sounds}
className="divide-y divide-foreground/6"
>
<SettingRow
label={t("settings.sounds.volume")}
changed={isChanged("ui_sound_volume")}
>
<VolumeSlider
value={draft.ui_sound_volume}
label={t("settings.sounds.volume")}
onChange={(value) => onChange({ ui_sound_volume: value })}
onCommit={(value) =>
play(sample, { ui_sound_volume: value }, "volume")
}
/>
</SettingRow>
<SettingBlock
label={t("settings.sounds.pack")}
changed={isChanged("ui_sound_pack")}
>
{/* A pack card plays its own preview, so the press stays silent. */}
<div data-sound="off">
<ChoiceGroup
value={draft.ui_sound_pack}
onValueChange={(id) => {
onChange({ ui_sound_pack: id });
play(sample, { ui_sound_pack: id }, id);
}}
label={t("settings.sounds.pack")}
slot="settings-sound-pack"
className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-1.5"
>
{SOUND_PACKS.map(({ id }) => (
<ChoiceCard
key={id}
value={id}
slot="settings-sound-pack-option"
className="flex items-start gap-3 px-3 py-2.5 hover:bg-foreground/4"
>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{t(`settings.sounds.packs.${id}.label`)}
</span>
<span className="text-xs leading-snug text-muted-foreground">
{t(`settings.sounds.packs.${id}.description`)}
</span>
</span>
<Bars
beat={beats[id] ?? 0}
active={draft.ui_sound_pack === id}
/>
</ChoiceCard>
))}
</ChoiceGroup>
</div>
</SettingBlock>
{SOUND_CATEGORIES.map((category) => {
const key = CATEGORY_KEYS[category.id];
const id = `settings-sound-${category.id}`;
return (
<SettingRow
key={category.id}
htmlFor={id}
label={t(`settings.sounds.categories.${category.id}.label`)}
description={t(
`settings.sounds.categories.${category.id}.description`,
)}
changed={isChanged(key)}
>
<Button
variant="subtle"
size="icon"
className="size-8"
aria-label={t("settings.sounds.preview")}
title={t("settings.sounds.preview")}
data-sound="off"
onClick={() => play(category.sample, {}, category.id)}
>
<LuPlay className="size-3.5" />
</Button>
<AnimatedSwitch
id={id}
aria-label={t(
`settings.sounds.categories.${category.id}.label`,
)}
checked={draft[key]}
onCheckedChange={(v) => onChange({ [key]: v === true })}
/>
</SettingRow>
);
})}
</AnimatedDisclosureContent>
</>
);
}
+292
View File
@@ -0,0 +1,292 @@
"use client";
import Color from "color";
import type { CSSProperties, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { LuMonitor, LuMoon, LuPalette, LuPipette, LuSun } from "react-icons/lu";
import {
ChoiceCard,
ChoiceGroup,
} from "@/components/settings/settings-primitives";
import {
ColorPicker,
ColorPickerAlpha,
ColorPickerEyeDropper,
ColorPickerFormat,
ColorPickerHue,
ColorPickerOutput,
ColorPickerSelection,
} from "@/components/ui/color-picker";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { getThemeByColors, THEME_VARIABLES, THEMES } from "@/lib/themes";
import { cn } from "@/lib/utils";
/**
* A small drawing of the app in one palette: the rail, a header with its
* primary button, and a few profile rows. `scheme` scopes the built-in light
* or dark palette; `colors` scopes a preset's.
*/
export function ThemePreview({
scheme,
colors,
className,
}: {
scheme?: "light" | "dark";
colors?: Record<string, string>;
className?: string;
}) {
return (
<div
aria-hidden="true"
className={cn(
scheme,
"flex aspect-[16/10] w-full overflow-hidden rounded-lg bg-background text-foreground ring-1 ring-foreground/10",
className,
)}
style={colors as CSSProperties | undefined}
>
<div className="flex w-[17%] flex-col items-center gap-[9%] bg-foreground/5 pt-[9%]">
<span className="size-[22%] rounded-full bg-primary" />
<span className="size-[18%] rounded-full bg-foreground/25" />
<span className="size-[18%] rounded-full bg-foreground/25" />
<span className="size-[18%] rounded-full bg-foreground/25" />
</div>
<div className="flex flex-1 flex-col gap-[7%] p-[7%]">
<div className="flex items-center gap-[6%]">
<span className="h-1.5 w-[38%] rounded-full bg-foreground/80" />
<span className="ml-auto h-2.5 w-[24%] rounded-[3px] bg-primary" />
</div>
{[0.55, 0.4, 0.48].map((width, index) => (
<div
key={width}
className="flex items-center gap-[5%] rounded-[3px] bg-card px-[5%] py-[4%] ring-1 ring-border"
>
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
index === 1 ? "bg-warning" : "bg-success",
)}
/>
<span
className="h-1 rounded-full bg-foreground/45"
style={{ width: `${width * 100}%` }}
/>
<span className="ml-auto h-1 w-[14%] rounded-full bg-muted-foreground/40" />
</div>
))}
</div>
</div>
);
}
function SystemPreview() {
return (
<div className="relative">
<ThemePreview scheme="light" />
<ThemePreview
scheme="dark"
className="absolute inset-0 [clip-path:polygon(62%_0,100%_0,100%_100%,38%_100%)]"
/>
</div>
);
}
function CardCaption({
icon,
children,
}: {
icon?: ReactNode;
children: ReactNode;
}) {
return (
<span className="flex min-w-0 items-center gap-1.5 px-1 pt-2 pb-0.5 text-xs font-medium text-muted-foreground transition-colors duration-150 group-hover/choice:text-foreground group-data-[state=checked]/choice:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">
{icon}
<span className="truncate">{children}</span>
</span>
);
}
const CARD_CLASS =
"p-1.5 hover:bg-foreground/4 data-[state=unchecked]:hover:-translate-y-px";
/** Light, Dark, System and Custom, each drawn in its own palette. */
export function ThemeModePicker({
value,
onValueChange,
customColors,
}: {
value: string;
onValueChange: (value: string) => void;
customColors: Record<string, string>;
}) {
const { t } = useTranslation();
return (
<ChoiceGroup
value={value}
onValueChange={onValueChange}
label={t("settings.appearance.theme")}
slot="settings-theme"
className="grid grid-cols-2 gap-2 @lg:grid-cols-4"
>
<ChoiceCard
value="light"
slot="settings-theme-option"
className={CARD_CLASS}
>
<ThemePreview scheme="light" />
<CardCaption icon={<LuSun />}>
{t("settings.appearance.light")}
</CardCaption>
</ChoiceCard>
<ChoiceCard
value="dark"
slot="settings-theme-option"
className={CARD_CLASS}
>
<ThemePreview scheme="dark" />
<CardCaption icon={<LuMoon />}>
{t("settings.appearance.dark")}
</CardCaption>
</ChoiceCard>
<ChoiceCard
value="system"
slot="settings-theme-option"
className={CARD_CLASS}
>
<SystemPreview />
<CardCaption icon={<LuMonitor />}>
{t("settings.appearance.system")}
</CardCaption>
</ChoiceCard>
<ChoiceCard
value="custom"
slot="settings-theme-option"
className={CARD_CLASS}
>
<ThemePreview colors={customColors} />
<CardCaption icon={<LuPalette />}>
{t("common.labels.custom")}
</CardCaption>
</ChoiceCard>
</ChoiceGroup>
);
}
/** Every preset drawn in its own colors, plus the user's own palette. */
export function ThemePresetGallery({
selectedId,
colors,
onSelect,
}: {
selectedId: string | null;
colors: Record<string, string>;
onSelect: (id: string | null) => void;
}) {
const { t } = useTranslation();
return (
<ChoiceGroup
value={selectedId ?? "custom"}
onValueChange={(value) => onSelect(value === "custom" ? null : value)}
label={t("settings.appearance.themePreset")}
slot="settings-theme-presets"
className="grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-1.5"
>
{THEMES.map((theme) => (
<ChoiceCard
key={theme.id}
value={theme.id}
slot="settings-theme-preset"
className={CARD_CLASS}
>
<ThemePreview colors={theme.colors} />
<CardCaption>{theme.name}</CardCaption>
</ChoiceCard>
))}
<ChoiceCard
value="custom"
slot="settings-theme-preset"
className={CARD_CLASS}
>
<ThemePreview colors={colors} />
<CardCaption icon={<LuPipette />}>
{t("settings.appearance.yourOwn")}
</CardCaption>
</ChoiceCard>
</ChoiceGroup>
);
}
/** Every theme color as a chip that opens a picker. */
export function ThemeColorEditor({
colors,
onChange,
}: {
colors: Record<string, string>;
onChange: (next: {
colors: Record<string, string>;
presetId: string | null;
}) => void;
}) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))] gap-1.5">
{THEME_VARIABLES.map(({ key, label }) => {
const value = colors[key] ?? "#000000";
return (
<Popover key={key}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={label}
className="flex min-w-0 cursor-pointer items-center gap-2 rounded-lg bg-foreground/4 p-1.5 pr-2 text-left transition-colors duration-150 outline-none hover:bg-foreground/7 focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-foreground/8"
>
<span
className="size-6 shrink-0 rounded-md shadow-sm ring-1 ring-foreground/15"
style={{ backgroundColor: value }}
/>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium">{label}</span>
<span className="truncate font-mono text-[10px] text-muted-foreground uppercase">
{value}
</span>
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-3" sideOffset={6}>
<ColorPicker
className="rounded-md border bg-background p-3 shadow-sm"
value={value}
onColorChange={([r, g, b, a]) => {
const next = {
...colors,
[key]: Color({ r, g, b }).alpha(a).hexa(),
};
onChange({
colors: next,
presetId: getThemeByColors(next)?.id ?? null,
});
}}
>
<ColorPickerSelection className="h-36 rounded" />
<div className="mt-3 flex items-center gap-3">
<ColorPickerEyeDropper />
<div className="grid w-full gap-1">
<ColorPickerHue />
<ColorPickerAlpha />
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<ColorPickerOutput />
<ColorPickerFormat />
</div>
</ColorPicker>
</PopoverContent>
</Popover>
);
})}
</div>
);
}
+7 -2
View File
@@ -130,6 +130,7 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
showSuccessToast(t("trash.restored", { name: restored.name }), {
description: syncStayedOff ? t("trash.restoredSyncOff") : undefined,
duration: syncStayedOff ? 10000 : undefined,
sound: "restore",
});
} catch (err: unknown) {
console.error("Failed to restore trashed profile:", err);
@@ -149,7 +150,9 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
setBusyId(entry.id);
try {
await invoke("purge_trashed_profile", { profileId: entry.id });
showSuccessToast(t("trash.deletedForever", { name: entry.name }));
showSuccessToast(t("trash.deletedForever", { name: entry.name }), {
sound: "trash",
});
setEntryToPurge(null);
} catch (err: unknown) {
console.error("Failed to purge trashed profile:", err);
@@ -167,7 +170,9 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
setIsEmptying(true);
try {
const removed = await invoke<number>("empty_trash");
showSuccessToast(t("trash.emptied", { count: removed }));
showSuccessToast(t("trash.emptied", { count: removed }), {
sound: "trash",
});
setEmptyConfirmOpen(false);
} catch (err: unknown) {
console.error("Failed to empty trash:", err);
+3 -1
View File
@@ -31,7 +31,9 @@ export function CopyToClipboard({
try {
await navigator.clipboard.writeText(text);
setCopied(true);
showSuccessToast(successMessage ?? t("toasts.success.copied"));
showSuccessToast(successMessage ?? t("toasts.success.copied"), {
sound: "copy",
});
setTimeout(() => {
setCopied(false);
}, 2000);
+2
View File
@@ -21,6 +21,7 @@ import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import { StepTransition } from "@/components/ui/step-transition";
import { getCurrentOS } from "@/lib/browser-utils";
import { playUiSound } from "@/lib/ui-sounds";
import type { VpnImportResult, VpnType } from "@/types";
interface VpnImportDialogProps {
@@ -149,6 +150,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
const files = Array.from(e.dataTransfer.files);
const validFile = files.find((f) => f.name.endsWith(".conf"));
if (validFile) {
playUiSound("drop");
handleFileRead(validFile);
} else {
toast.error(t("vpns.import.wrongFileType"));
+2
View File
@@ -30,6 +30,7 @@ import {
} from "@/lib/agent-console";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showToast } from "@/lib/toast-utils";
import { playUiSound } from "@/lib/ui-sounds";
interface ConsoleData {
loaded: boolean;
@@ -123,6 +124,7 @@ export function useAgentConsole({
onOpenPage: open,
} = optionsRef.current;
const focused = typeof document !== "undefined" && document.hasFocus();
playUiSound("attention");
if (!visible || !focused) {
const session = sessionsRef.current.find(
(entry) => entry.session_id === item.session_id,
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { AppUpdateToast } from "@/components/app-update-toast";
import { translateBackendError } from "@/lib/backend-errors";
import { showToast } from "@/lib/toast-utils";
import { playUiSound } from "@/lib/ui-sounds";
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
export function useAppUpdateNotifications() {
@@ -152,6 +153,7 @@ export function useAppUpdateNotifications() {
const unlistenReady = listen<string>("app-update-ready", (event) => {
console.log("App update ready:", event.payload);
playUiSound("attention");
setUpdateReady(true);
setIsUpdating(false);
setUpdateProgress(null);
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "Aktiv",
"inactive": "Inaktiv",
"running": "Läuft",
"stopped": "Gestoppt",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"granted": "Erteilt",
"notGranted": "Nicht erteilt",
"connected": "Verbunden",
"disconnected": "Getrennt",
"synced": "Synchronisiert",
@@ -116,35 +114,30 @@
"themeDescription": "Wählen Sie Ihr bevorzugtes Design oder übernehmen Sie die Systemeinstellung. Änderungen am eigenen Design werden erst beim Speichern übernommen.",
"themePreset": "Design-Vorlage",
"customColors": "Eigene Farben",
"selectTheme": "Design auswählen",
"selectThemePreset": "Design-Vorlage auswählen",
"yourOwn": "Eigenes",
"light": "Hell",
"dark": "Dunkel",
"system": "System",
"sounds": "Oberflächenklänge",
"soundsDescription": "Leise, sanfte Klänge, wenn Sie eine Schaltfläche drücken, einen Schalter umlegen oder eine Aktion gelingt oder fehlschlägt."
"system": "System"
},
"language": {
"title": "Sprache",
"description": "Wählen Sie die bevorzugte Sprache für die Benutzeroberfläche.",
"systemDefault": "Systemstandard",
"selectLanguage": "Sprache auswählen",
"interface": "Sprache der Oberfläche"
},
"defaultBrowser": {
"title": "Standardbrowser",
"setAsDefault": "Als Standardbrowser festlegen",
"alreadyDefault": "Bereits Standardbrowser",
"description": "Als Standardbrowser öffnet Donut Browser Weblinks und lässt Sie wählen, welches Profil verwendet wird.",
"setSuccess": "Donut Browser ist jetzt Ihr Standardbrowser",
"setFailed": "Der Standardbrowser konnte nicht festgelegt werden",
"finishInSystemSettings": "In den Windows-Einstellungen abschließen",
"finishInSystemSettingsDescription": "Donut Browser ist registriert. Die Windows-Einstellungen sind geöffnet: Wählen Sie unter „Webbrowser“ Donut Browser aus, um den Vorgang abzuschließen."
"finishInSystemSettingsDescription": "Donut Browser ist registriert. Die Windows-Einstellungen sind geöffnet: Wählen Sie unter „Webbrowser“ Donut Browser aus, um den Vorgang abzuschließen.",
"isDefault": "Donut Browser ist Ihr Standardbrowser",
"notDefault": "Donut Browser ist nicht Ihr Standardbrowser"
},
"permissions": {
"title": "Systemberechtigungen",
"loading": "Berechtigungen werden geladen...",
"description": "Diese Berechtigungen erlauben Browsern, die aus Donut Browser gestartet werden, den Zugriff auf Systemressourcen. Jede Website fragt trotzdem einzeln nach Ihrer Erlaubnis.",
"microphone": "Mikrofon",
"microphoneDescription": "Mikrofonzugriff für Browseranwendungen",
@@ -155,12 +148,12 @@
"integrations": {
"title": "Integrationen",
"description": "Konfigurieren Sie die lokale API und MCP (Model Context Protocol) für die Anbindung externer Tools und KI-Assistenten.",
"openSettings": "Integrationseinstellungen öffnen"
"openSettings": "Integrationseinstellungen öffnen",
"label": "Lokale API und MCP"
},
"encryption": {
"title": "Sync-Verschlüsselung",
"description": "Legen Sie ein Passwort fest, um die Ende-zu-Ende-verschlüsselte Synchronisierung zu aktivieren. Wenn Sie dieses Passwort verlieren, lassen sich verschlüsselte Profile nicht wiederherstellen.",
"passwordSet": "Aktiv",
"passwordSetDescription": "E2E-Verschlüsselungspasswort ist festgelegt",
"noPassword": "Kein Passwort festgelegt",
"passwordPlaceholder": "Passwort (mind. 8 Zeichen)",
@@ -180,7 +173,9 @@
"submit": "Prüfen",
"matchToast": "Das Passwort ist korrekt",
"mismatchToast": "Das Passwort stimmt nicht überein"
}
},
"ruleLength": "Mindestens {{min}} Zeichen",
"ruleMatch": "Passwörter stimmen überein"
},
"commercial": {
"title": "Kommerzielle Lizenz",
@@ -199,7 +194,8 @@
"copyLogs": "Protokolle kopieren",
"openLogDir": "Protokollordner öffnen",
"copyLogsSuccess": "Protokolle in die Zwischenablage kopiert",
"copyLogsDescription": "Kopiert ein bereinigtes Paket der aktuellen Protokolle (bis zu 5 MB). Prüfen Sie es vor dem Teilen, da die Bereinigung nicht jede Art personenbezogener Daten erkennen kann."
"copyLogsDescription": "Kopiert ein bereinigtes Paket der aktuellen Protokolle (bis zu 5 MB). Prüfen Sie es vor dem Teilen, da die Bereinigung nicht jede Art personenbezogener Daten erkennen kann.",
"logs": "Protokolle"
},
"disableAutoUpdates": "Automatische App-Updates deaktivieren",
"disableAutoUpdatesDescription": "Verhindert, dass die App automatisch nach Updates für Donut Browser sucht und sie installiert. Browser-Updates sind davon nicht betroffen.",
@@ -250,7 +246,51 @@
},
"trashRetentionOff": "Sofort löschen",
"scrollSectionsLeft": "Abschnitte nach links scrollen",
"scrollSectionsRight": "Abschnitte nach rechts scrollen"
"scrollSectionsRight": "Abschnitte nach rechts scrollen",
"changed": "Geändert",
"open": "Öffnen",
"manage": "Verwalten",
"clear": "Leeren",
"dns": {
"label": "Werbung, Tracker und Malware blockieren"
},
"sounds": {
"title": "Klänge",
"description": "Kurze, leise Klänge, die zeigen, was passiert ist.",
"enabled": "Oberflächenklänge",
"enabledDescription": "Klänge werden nur abgespielt, wenn das Donut-Fenster im Vordergrund ist.",
"volume": "Lautstärke",
"preview": "Anhören",
"pack": "Klangpaket",
"packs": {
"soft": {
"label": "Sanft",
"description": "Warm und rund."
},
"crisp": {
"label": "Klar",
"description": "Kurz, hell und deutlich."
},
"retro": {
"label": "Retro",
"description": "Rechteckwellen wie bei einer alten Spielkonsole."
}
},
"categories": {
"interface": {
"label": "Oberfläche",
"description": "Schaltflächen, Schalter und Seiten, die Sie öffnen oder schließen."
},
"feedback": {
"label": "Ergebnisse",
"description": "Erfolg, Fehler, Warnungen, Kopieren, Löschen und Wiederherstellen."
},
"events": {
"label": "Ereignisse",
"description": "Start und Stopp von Profilen, Synchronisierung, Downloads und Anfragen, die Ihre Aufmerksamkeit brauchen."
}
}
}
},
"header": {
"searchPlaceholder": "Profile durchsuchen...",
@@ -940,7 +980,6 @@
"apiStartFailed": "Der API-Server konnte nicht gestartet werden",
"tokenCopied": "Token kopiert",
"mcpAcceptTermsFirst": "(Akzeptieren Sie zuerst die Wayfern-Bedingungen in den Einstellungen)",
"openSettings": "Integrationseinstellungen öffnen",
"apiRunningOn": "Läuft auf",
"apiExampleRequest": "Beispielanfrage",
"examples": {
@@ -1760,7 +1799,6 @@
"proPlus": "Pro++",
"ultimate": "Ultimate",
"settingsDescription": "DNS-Sperrlisten blockieren Werbung, Tracker und Malware-Domains auf Proxy-Ebene. Die Listen werden alle 12 Stunden automatisch aktualisiert.",
"manageLists": "DNS-Sperrlisten verwalten",
"refreshAll": "Alle Listen aktualisieren",
"refreshFailed": "Die DNS-Sperrlisten konnten nicht aktualisiert werden",
"domains": "Domains",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "Active",
"inactive": "Inactive",
"running": "Running",
"stopped": "Stopped",
"enabled": "Enabled",
"disabled": "Disabled",
"granted": "Granted",
"notGranted": "Not Granted",
"connected": "Connected",
"disconnected": "Disconnected",
"synced": "Synced",
@@ -116,35 +114,30 @@
"themeDescription": "Choose your preferred theme or follow your system settings. Custom theme changes are applied only when you save.",
"themePreset": "Theme Preset",
"customColors": "Custom Colors",
"selectTheme": "Select theme",
"selectThemePreset": "Select a theme preset",
"yourOwn": "Your Own",
"light": "Light",
"dark": "Dark",
"system": "System",
"sounds": "Interface sounds",
"soundsDescription": "Soft, quiet sounds when you press a button, flip a switch, or when an action succeeds or fails."
"system": "System"
},
"language": {
"title": "Language",
"description": "Choose your preferred language for the application interface.",
"systemDefault": "System Default",
"selectLanguage": "Select language",
"interface": "Interface Language"
},
"defaultBrowser": {
"title": "Default Browser",
"setAsDefault": "Set as Default Browser",
"alreadyDefault": "Already Default Browser",
"description": "When set as default, Donut Browser will handle web links and allow you to choose which profile to use.",
"setSuccess": "Donut Browser is now your default browser",
"setFailed": "Could not set the default browser",
"finishInSystemSettings": "Finish in Windows Settings",
"finishInSystemSettingsDescription": "Donut Browser is registered. Windows Settings is open: choose Donut Browser under Web browser to finish."
"finishInSystemSettingsDescription": "Donut Browser is registered. Windows Settings is open: choose Donut Browser under Web browser to finish.",
"isDefault": "Donut Browser is your default browser",
"notDefault": "Donut Browser is not your default browser"
},
"permissions": {
"title": "System Permissions",
"loading": "Loading permissions...",
"description": "These permissions allow browsers launched from Donut Browser to access system resources. Each website will still ask for your permission individually.",
"microphone": "Microphone",
"microphoneDescription": "Access to microphone for browser applications",
@@ -155,12 +148,12 @@
"integrations": {
"title": "Integrations",
"description": "Configure Local API and MCP (Model Context Protocol) for integrating with external tools and AI assistants.",
"openSettings": "Open Integrations Settings"
"openSettings": "Open Integrations Settings",
"label": "Local API and MCP"
},
"encryption": {
"title": "Sync Encryption",
"description": "Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
"passwordSet": "Active",
"passwordSetDescription": "E2E encryption password is set",
"noPassword": "No password set",
"passwordPlaceholder": "Password (min 8 characters)",
@@ -180,7 +173,9 @@
"submit": "Validate",
"matchToast": "Password is correct",
"mismatchToast": "Password does not match"
}
},
"ruleLength": "At least {{min}} characters",
"ruleMatch": "Passwords match"
},
"commercial": {
"title": "Commercial License",
@@ -199,7 +194,8 @@
"copyLogs": "Copy logs",
"openLogDir": "Open log folder",
"copyLogsSuccess": "Logs copied to clipboard",
"copyLogsDescription": "Copies a redacted bundle of recent logs (up to 5 MB). Review it before sharing because redaction cannot identify every kind of personal data."
"copyLogsDescription": "Copies a redacted bundle of recent logs (up to 5 MB). Review it before sharing because redaction cannot identify every kind of personal data.",
"logs": "Logs"
},
"disableAutoUpdates": "Disable App Auto Updates",
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.",
@@ -250,7 +246,51 @@
},
"trashRetentionOff": "Delete at once",
"scrollSectionsLeft": "Scroll sections left",
"scrollSectionsRight": "Scroll sections right"
"scrollSectionsRight": "Scroll sections right",
"changed": "Changed",
"open": "Open",
"manage": "Manage",
"clear": "Clear",
"dns": {
"label": "Block ads, trackers and malware"
},
"sounds": {
"title": "Sounds",
"description": "Short, quiet sounds that tell you what happened.",
"enabled": "Interface sounds",
"enabledDescription": "Sounds play only while the Donut window is in front.",
"volume": "Volume",
"preview": "Preview",
"pack": "Sound pack",
"packs": {
"soft": {
"label": "Soft",
"description": "Warm and round."
},
"crisp": {
"label": "Crisp",
"description": "Short, bright and clear."
},
"retro": {
"label": "Retro",
"description": "Square waves, like an old game console."
}
},
"categories": {
"interface": {
"label": "Interface",
"description": "Buttons, switches, and pages you open or close."
},
"feedback": {
"label": "Results",
"description": "Success, errors, warnings, copy, delete and restore."
},
"events": {
"label": "Events",
"description": "Profile launch and stop, sync, downloads, and requests for your attention."
}
}
}
},
"header": {
"searchPlaceholder": "Search profiles...",
@@ -940,7 +980,6 @@
"apiStartFailed": "Failed to start API server",
"tokenCopied": "Token copied",
"mcpAcceptTermsFirst": "(Accept Wayfern terms in Settings first)",
"openSettings": "Open Integrations Settings",
"apiRunningOn": "Running on",
"apiExampleRequest": "Example request",
"examples": {
@@ -1760,7 +1799,6 @@
"proPlus": "Pro++",
"ultimate": "Ultimate",
"settingsDescription": "DNS blocklists block ads, trackers, and malware domains at the proxy level. Lists are automatically refreshed every 12 hours.",
"manageLists": "Manage DNS Blocklists",
"refreshAll": "Refresh All Lists",
"refreshFailed": "Failed to refresh DNS blocklists",
"domains": "domains",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "Activo",
"inactive": "Inactivo",
"running": "Ejecutando",
"stopped": "Detenido",
"enabled": "Habilitado",
"disabled": "Deshabilitado",
"granted": "Otorgado",
"notGranted": "No Otorgado",
"connected": "Conectado",
"disconnected": "Desconectado",
"synced": "Sincronizado",
@@ -116,35 +114,30 @@
"themeDescription": "Elige tu tema preferido o sigue la configuración del sistema. Los cambios de tema personalizado se aplican solo al guardar.",
"themePreset": "Tema Predefinido",
"customColors": "Colores Personalizados",
"selectTheme": "Seleccionar tema",
"selectThemePreset": "Seleccionar tema predefinido",
"yourOwn": "Tu Propio",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema",
"sounds": "Sonidos de la interfaz",
"soundsDescription": "Sonidos suaves y bajos al pulsar un botón, cambiar un interruptor o cuando una acción sale bien o falla."
"system": "Sistema"
},
"language": {
"title": "Idioma",
"description": "Elige tu idioma preferido para la interfaz de la aplicación.",
"systemDefault": "Predeterminado del Sistema",
"selectLanguage": "Seleccionar idioma",
"interface": "Idioma de la interfaz"
},
"defaultBrowser": {
"title": "Navegador Predeterminado",
"setAsDefault": "Establecer como Navegador Predeterminado",
"alreadyDefault": "Ya es el Navegador Predeterminado",
"description": "Cuando se establece como predeterminado, Donut Browser manejará los enlaces web y te permitirá elegir qué perfil usar.",
"setSuccess": "Donut Browser ya es tu navegador predeterminado",
"setFailed": "No se pudo establecer el navegador predeterminado",
"finishInSystemSettings": "Termina en la Configuración de Windows",
"finishInSystemSettingsDescription": "Donut Browser está registrado. Se abrió la Configuración de Windows: elige Donut Browser en Navegador web para terminar."
"finishInSystemSettingsDescription": "Donut Browser está registrado. Se abrió la Configuración de Windows: elige Donut Browser en Navegador web para terminar.",
"isDefault": "Donut Browser es tu navegador predeterminado",
"notDefault": "Donut Browser no es tu navegador predeterminado"
},
"permissions": {
"title": "Permisos del Sistema",
"loading": "Cargando permisos...",
"description": "Estos permisos permiten que los navegadores iniciados desde Donut Browser accedan a los recursos del sistema. Cada sitio web seguirá pidiendo tu permiso individualmente.",
"microphone": "Micrófono",
"microphoneDescription": "Acceso al micrófono para aplicaciones del navegador",
@@ -155,12 +148,12 @@
"integrations": {
"title": "Integraciones",
"description": "Configura la API Local y MCP (Protocolo de Contexto de Modelo) para integración con herramientas externas y asistentes de IA.",
"openSettings": "Abrir Configuración de Integraciones"
"openSettings": "Abrir Configuración de Integraciones",
"label": "API local y MCP"
},
"encryption": {
"title": "Cifrado de sincronización",
"description": "Establece una contraseña para habilitar la sincronización cifrada E2E. Si pierdes esta contraseña, los perfiles cifrados no podrán recuperarse.",
"passwordSet": "Activo",
"passwordSetDescription": "La contraseña de cifrado E2E está configurada",
"noPassword": "Sin contraseña configurada",
"passwordPlaceholder": "Contraseña (mín. 8 caracteres)",
@@ -180,7 +173,9 @@
"submit": "Validar",
"matchToast": "La contraseña es correcta",
"mismatchToast": "La contraseña no coincide"
}
},
"ruleLength": "Al menos {{min}} caracteres",
"ruleMatch": "Las contraseñas coinciden"
},
"commercial": {
"title": "Licencia Comercial",
@@ -199,7 +194,8 @@
"copyLogs": "Copiar registros",
"openLogDir": "Abrir carpeta de registros",
"copyLogsSuccess": "Registros copiados al portapapeles",
"copyLogsDescription": "Copia un paquete censurado de los registros recientes (hasta 5 MB). Revísalo antes de compartirlo, ya que la censura no puede identificar todos los tipos de datos personales."
"copyLogsDescription": "Copia un paquete censurado de los registros recientes (hasta 5 MB). Revísalo antes de compartirlo, ya que la censura no puede identificar todos los tipos de datos personales.",
"logs": "Registros"
},
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App",
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas.",
@@ -251,7 +247,51 @@
},
"trashRetentionOff": "Eliminar al instante",
"scrollSectionsLeft": "Desplazar secciones a la izquierda",
"scrollSectionsRight": "Desplazar secciones a la derecha"
"scrollSectionsRight": "Desplazar secciones a la derecha",
"changed": "Modificado",
"open": "Abrir",
"manage": "Gestionar",
"clear": "Borrar",
"dns": {
"label": "Bloquear anuncios, rastreadores y malware"
},
"sounds": {
"title": "Sonidos",
"description": "Sonidos cortos y bajos que indican lo que ha pasado.",
"enabled": "Sonidos de la interfaz",
"enabledDescription": "Los sonidos solo suenan cuando la ventana de Donut está delante.",
"volume": "Volumen",
"preview": "Escuchar",
"pack": "Paquete de sonidos",
"packs": {
"soft": {
"label": "Suave",
"description": "Cálido y redondo."
},
"crisp": {
"label": "Nítido",
"description": "Corto, brillante y claro."
},
"retro": {
"label": "Retro",
"description": "Ondas cuadradas, como una consola de videojuegos antigua."
}
},
"categories": {
"interface": {
"label": "Interfaz",
"description": "Botones, interruptores y páginas que abres o cierras."
},
"feedback": {
"label": "Resultados",
"description": "Éxito, errores, avisos, copiar, eliminar y restaurar."
},
"events": {
"label": "Eventos",
"description": "Inicio y parada de perfiles, sincronización, descargas y solicitudes que requieren tu atención."
}
}
}
},
"header": {
"searchPlaceholder": "Buscar perfiles...",
@@ -949,7 +989,6 @@
"apiStartFailed": "Error al iniciar el servidor API",
"tokenCopied": "Token copiado",
"mcpAcceptTermsFirst": "(Acepta primero los términos de Wayfern en Configuración)",
"openSettings": "Abrir configuración de integraciones",
"apiRunningOn": "Ejecutándose en",
"apiExampleRequest": "Solicitud de ejemplo",
"examples": {
@@ -1771,7 +1810,6 @@
"proPlus": "Pro++",
"ultimate": "Definitivo",
"settingsDescription": "Las listas de bloqueo DNS bloquean anuncios, rastreadores y dominios de malware a nivel de proxy. Las listas se actualizan automáticamente cada 12 horas.",
"manageLists": "Gestionar listas de bloqueo DNS",
"refreshAll": "Actualizar todas las listas",
"refreshFailed": "Error al actualizar las listas de bloqueo DNS",
"domains": "dominios",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "Actif",
"inactive": "Inactif",
"running": "En cours",
"stopped": "Arrêté",
"enabled": "Activé",
"disabled": "Désactivé",
"granted": "Accordé",
"notGranted": "Non accordé",
"connected": "Connecté",
"disconnected": "Déconnecté",
"synced": "Synchronisé",
@@ -116,35 +114,30 @@
"themeDescription": "Choisissez votre thème préféré ou suivez les paramètres du système. Les modifications de thème personnalisé ne sont appliquées qu'à l'enregistrement.",
"themePreset": "Thème prédéfini",
"customColors": "Couleurs personnalisées",
"selectTheme": "Sélectionner un thème",
"selectThemePreset": "Sélectionner un thème prédéfini",
"yourOwn": "Le vôtre",
"light": "Clair",
"dark": "Sombre",
"system": "Système",
"sounds": "Sons de l'interface",
"soundsDescription": "Des sons doux et discrets quand vous appuyez sur un bouton, basculez un interrupteur, ou quand une action réussit ou échoue."
"system": "Système"
},
"language": {
"title": "Langue",
"description": "Choisissez votre langue préférée pour l'interface de l'application.",
"systemDefault": "Par défaut du système",
"selectLanguage": "Sélectionner la langue",
"interface": "Langue de l'interface"
},
"defaultBrowser": {
"title": "Navigateur par défaut",
"setAsDefault": "Définir comme navigateur par défaut",
"alreadyDefault": "Déjà le navigateur par défaut",
"description": "Lorsqu'il est défini par défaut, Donut Browser gérera les liens web et vous permettra de choisir quel profil utiliser.",
"setSuccess": "Donut Browser est maintenant votre navigateur par défaut",
"setFailed": "Impossible de définir le navigateur par défaut",
"finishInSystemSettings": "Terminez dans les Paramètres Windows",
"finishInSystemSettingsDescription": "Donut Browser est enregistré. Les Paramètres Windows sont ouverts : choisissez Donut Browser sous Navigateur web pour terminer."
"finishInSystemSettingsDescription": "Donut Browser est enregistré. Les Paramètres Windows sont ouverts : choisissez Donut Browser sous Navigateur web pour terminer.",
"isDefault": "Donut Browser est votre navigateur par défaut",
"notDefault": "Donut Browser n’est pas votre navigateur par défaut"
},
"permissions": {
"title": "Permissions système",
"loading": "Chargement des permissions...",
"description": "Ces permissions permettent aux navigateurs lancés depuis Donut Browser d'accéder aux ressources système. Chaque site web demandera toujours votre permission individuellement.",
"microphone": "Microphone",
"microphoneDescription": "Accès au microphone pour les applications du navigateur",
@@ -155,12 +148,12 @@
"integrations": {
"title": "Intégrations",
"description": "Configurez l'API locale et MCP (Model Context Protocol) pour l'intégration avec des outils externes et des assistants IA.",
"openSettings": "Ouvrir les paramètres d'intégration"
"openSettings": "Ouvrir les paramètres d'intégration",
"label": "API locale et MCP"
},
"encryption": {
"title": "Chiffrement de synchronisation",
"description": "Définissez un mot de passe pour activer la synchronisation chiffrée E2E. Si vous perdez ce mot de passe, les profils chiffrés ne pourront pas être récupérés.",
"passwordSet": "Actif",
"passwordSetDescription": "Le mot de passe de chiffrement E2E est défini",
"noPassword": "Aucun mot de passe défini",
"passwordPlaceholder": "Mot de passe (min. 8 caractères)",
@@ -180,7 +173,9 @@
"submit": "Valider",
"matchToast": "Le mot de passe est correct",
"mismatchToast": "Le mot de passe ne correspond pas"
}
},
"ruleLength": "Au moins {{min}} caractères",
"ruleMatch": "Les mots de passe correspondent"
},
"commercial": {
"title": "Licence commerciale",
@@ -199,7 +194,8 @@
"copyLogs": "Copier les journaux",
"openLogDir": "Ouvrir le dossier des journaux",
"copyLogsSuccess": "Journaux copiés dans le presse-papiers",
"copyLogsDescription": "Copie un lot expurgé des journaux récents (jusqu’à 5 Mo). Vérifiez-le avant de le partager, car l’expurgation ne peut pas identifier tous les types de données personnelles."
"copyLogsDescription": "Copie un lot expurgé des journaux récents (jusqu’à 5 Mo). Vérifiez-le avant de le partager, car l’expurgation ne peut pas identifier tous les types de données personnelles.",
"logs": "Journaux"
},
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées.",
@@ -251,7 +247,51 @@
},
"trashRetentionOff": "Supprimer aussitôt",
"scrollSectionsLeft": "Faire défiler les sections vers la gauche",
"scrollSectionsRight": "Faire défiler les sections vers la droite"
"scrollSectionsRight": "Faire défiler les sections vers la droite",
"changed": "Modifié",
"open": "Ouvrir",
"manage": "Gérer",
"clear": "Effacer",
"dns": {
"label": "Bloquer les publicités, les traqueurs et les logiciels malveillants"
},
"sounds": {
"title": "Sons",
"description": "Des sons courts et discrets qui indiquent ce qui s'est passé.",
"enabled": "Sons de l'interface",
"enabledDescription": "Les sons ne sont joués que lorsque la fenêtre Donut est au premier plan.",
"volume": "Volume",
"preview": "Écouter",
"pack": "Pack de sons",
"packs": {
"soft": {
"label": "Doux",
"description": "Chaud et rond."
},
"crisp": {
"label": "Net",
"description": "Court, brillant et clair."
},
"retro": {
"label": "Rétro",
"description": "Des ondes carrées, comme une ancienne console de jeu."
}
},
"categories": {
"interface": {
"label": "Interface",
"description": "Boutons, interrupteurs et pages que vous ouvrez ou fermez."
},
"feedback": {
"label": "Résultats",
"description": "Succès, erreurs, avertissements, copie, suppression et restauration."
},
"events": {
"label": "Événements",
"description": "Lancement et arrêt des profils, synchronisation, téléchargements et demandes qui requièrent votre attention."
}
}
}
},
"header": {
"searchPlaceholder": "Rechercher des profils...",
@@ -949,7 +989,6 @@
"apiStartFailed": "Échec du démarrage du serveur API",
"tokenCopied": "Jeton copié",
"mcpAcceptTermsFirst": "(Acceptez d'abord les conditions Wayfern dans les Paramètres)",
"openSettings": "Ouvrir les paramètres d'intégrations",
"apiRunningOn": "En cours sur",
"apiExampleRequest": "Exemple de requête",
"examples": {
@@ -1771,7 +1810,6 @@
"proPlus": "Pro++",
"ultimate": "Ultime",
"settingsDescription": "Les listes de blocage DNS bloquent les publicités, les traqueurs et les domaines malveillants au niveau du proxy. Les listes sont automatiquement rafraîchies toutes les 12 heures.",
"manageLists": "Gérer les listes de blocage DNS",
"refreshAll": "Rafraîchir toutes les listes",
"refreshFailed": "Échec du rafraîchissement des listes de blocage DNS",
"domains": "domaines",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "アクティブ",
"inactive": "非アクティブ",
"running": "実行中",
"stopped": "停止",
"enabled": "有効",
"disabled": "無効",
"granted": "許可済み",
"notGranted": "未許可",
"connected": "接続済み",
"disconnected": "切断",
"synced": "同期済み",
@@ -116,35 +114,30 @@
"themeDescription": "お好みのテーマを選択するか、システム設定に従います。カスタムテーマの変更は保存時のみ適用されます。",
"themePreset": "テーマプリセット",
"customColors": "カスタムカラー",
"selectTheme": "テーマを選択",
"selectThemePreset": "テーマプリセットを選択",
"yourOwn": "カスタム",
"light": "ライト",
"dark": "ダーク",
"system": "システム",
"sounds": "インターフェースの効果音",
"soundsDescription": "ボタンを押したとき、スイッチを切り替えたとき、操作が成功または失敗したときに、小さく柔らかい音を鳴らします。"
"system": "システム"
},
"language": {
"title": "言語",
"description": "アプリケーションインターフェースの言語を選択します。",
"systemDefault": "システムデフォルト",
"selectLanguage": "言語を選択",
"interface": "インターフェース言語"
},
"defaultBrowser": {
"title": "デフォルトブラウザ",
"setAsDefault": "デフォルトブラウザに設定",
"alreadyDefault": "既にデフォルトブラウザです",
"description": "デフォルトに設定すると、Donut Browser がウェブリンクを処理し、使用するプロファイルを選択できます。",
"setSuccess": "Donut Browser が既定のブラウザーになりました",
"setFailed": "既定のブラウザーを設定できませんでした",
"finishInSystemSettings": "Windows の設定で完了してください",
"finishInSystemSettingsDescription": "Donut Browser を登録しました。Windows の設定が開いています。「Web ブラウザー」で Donut Browser を選ぶと完了します。"
"finishInSystemSettingsDescription": "Donut Browser を登録しました。Windows の設定が開いています。「Web ブラウザー」で Donut Browser を選ぶと完了します。",
"isDefault": "Donut Browser は既定のブラウザーです",
"notDefault": "Donut Browser は既定のブラウザーではありません"
},
"permissions": {
"title": "システム権限",
"loading": "権限を読み込み中...",
"description": "これらの権限により、Donut Browser から起動したブラウザがシステムリソースにアクセスできます。各ウェブサイトは個別に許可を求めます。",
"microphone": "マイク",
"microphoneDescription": "ブラウザアプリケーションのマイクアクセス",
@@ -155,12 +148,12 @@
"integrations": {
"title": "統合",
"description": "外部ツールやAIアシスタントと統合するためのローカルAPIとMCP(モデルコンテキストプロトコル)を設定します。",
"openSettings": "統合設定を開く"
"openSettings": "統合設定を開く",
"label": "ローカル API と MCP"
},
"encryption": {
"title": "同期暗号化",
"description": "E2E暗号化同期を有効にするためのパスワードを設定してください。このパスワードを紛失すると、暗号化されたプロファイルは復元できません。",
"passwordSet": "有効",
"passwordSetDescription": "E2E暗号化パスワードが設定されています",
"noPassword": "パスワード未設定",
"passwordPlaceholder": "パスワード(8文字以上)",
@@ -180,7 +173,9 @@
"submit": "確認",
"matchToast": "パスワードが一致しました",
"mismatchToast": "パスワードが一致しません"
}
},
"ruleLength": "{{min}} 文字以上",
"ruleMatch": "パスワードが一致"
},
"commercial": {
"title": "商用ライセンス",
@@ -199,7 +194,8 @@
"copyLogs": "ログをコピー",
"openLogDir": "ログフォルダを開く",
"copyLogsSuccess": "ログをクリップボードにコピーしました",
"copyLogsDescription": "最近のログを編集したバンドル(最大 5 MB)をコピーします。編集ではすべての種類の個人データを識別できないため、共有前に内容を確認してください。"
"copyLogsDescription": "最近のログを編集したバンドル(最大 5 MB)をコピーします。編集ではすべての種類の個人データを識別できないため、共有前に内容を確認してください。",
"logs": "ログ"
},
"disableAutoUpdates": "アプリの自動更新を無効にする",
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
@@ -250,7 +246,51 @@
},
"trashRetentionOff": "すぐに削除",
"scrollSectionsLeft": "セクションを左へスクロール",
"scrollSectionsRight": "セクションを右へスクロール"
"scrollSectionsRight": "セクションを右へスクロール",
"changed": "変更あり",
"open": "開く",
"manage": "管理",
"clear": "消去",
"dns": {
"label": "広告、トラッカー、マルウェアをブロック"
},
"sounds": {
"title": "サウンド",
"description": "何が起きたかを伝える、短く小さな効果音です。",
"enabled": "インターフェースの効果音",
"enabledDescription": "効果音は Donut のウィンドウが前面にあるときだけ鳴ります。",
"volume": "音量",
"preview": "試聴",
"pack": "サウンドパック",
"packs": {
"soft": {
"label": "ソフト",
"description": "温かく丸みのある音。"
},
"crisp": {
"label": "クリスプ",
"description": "短く明るく、はっきりした音。"
},
"retro": {
"label": "レトロ",
"description": "昔のゲーム機のような矩形波の音。"
}
},
"categories": {
"interface": {
"label": "インターフェース",
"description": "ボタン、スイッチ、ページの開閉。"
},
"feedback": {
"label": "結果",
"description": "成功、エラー、警告、コピー、削除、復元。"
},
"events": {
"label": "イベント",
"description": "プロファイルの起動と停止、同期、ダウンロード、対応が必要なリクエスト。"
}
}
}
},
"header": {
"searchPlaceholder": "プロファイルを検索...",
@@ -940,7 +980,6 @@
"apiStartFailed": "API サーバーの起動に失敗しました",
"tokenCopied": "トークンをコピーしました",
"mcpAcceptTermsFirst": "(設定で先に Wayfern の規約に同意してください)",
"openSettings": "統合設定を開く",
"apiRunningOn": "実行中",
"apiExampleRequest": "リクエスト例",
"examples": {
@@ -1760,7 +1799,6 @@
"proPlus": "Pro++",
"ultimate": "アルティメット",
"settingsDescription": "DNSブロックリストは、プロキシレベルで広告、トラッカー、マルウェアドメインをブロックします。リストは12時間ごとに自動的に更新されます。",
"manageLists": "DNSブロックリストを管理",
"refreshAll": "すべてのリストを更新",
"refreshFailed": "DNSブロックリストの更新に失敗しました",
"domains": "ドメイン",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "활성",
"inactive": "비활성",
"running": "실행 중",
"stopped": "중지됨",
"enabled": "사용",
"disabled": "사용 안 함",
"granted": "허용됨",
"notGranted": "허용되지 않음",
"connected": "연결됨",
"disconnected": "연결 끊김",
"synced": "동기화됨",
@@ -116,35 +114,30 @@
"themeDescription": "원하는 테마를 선택하거나 시스템 설정을 따르세요. 사용자 지정 테마 변경 사항은 저장 시에만 적용됩니다.",
"themePreset": "테마 프리셋",
"customColors": "사용자 지정 색상",
"selectTheme": "테마 선택",
"selectThemePreset": "테마 프리셋 선택",
"yourOwn": "직접 설정",
"light": "라이트",
"dark": "다크",
"system": "시스템",
"sounds": "인터페이스 소리",
"soundsDescription": "버튼을 누르거나 스위치를 전환하거나 작업이 성공 또는 실패할 때 작고 부드러운 소리를 냅니다."
"system": "시스템"
},
"language": {
"title": "언어",
"description": "애플리케이션 인터페이스에 사용할 언어를 선택하세요.",
"systemDefault": "시스템 기본값",
"selectLanguage": "언어 선택",
"interface": "인터페이스 언어"
},
"defaultBrowser": {
"title": "기본 브라우저",
"setAsDefault": "기본 브라우저로 설정",
"alreadyDefault": "이미 기본 브라우저입니다",
"description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다.",
"setSuccess": "이제 Donut Browser가 기본 브라우저입니다",
"setFailed": "기본 브라우저를 설정하지 못했습니다",
"finishInSystemSettings": "Windows 설정에서 완료하세요",
"finishInSystemSettingsDescription": "Donut Browser가 등록되었습니다. Windows 설정이 열려 있습니다. '웹 브라우저'에서 Donut Browser를 선택하면 완료됩니다."
"finishInSystemSettingsDescription": "Donut Browser가 등록되었습니다. Windows 설정이 열려 있습니다. '웹 브라우저'에서 Donut Browser를 선택하면 완료됩니다.",
"isDefault": "Donut Browser가 기본 브라우저입니다",
"notDefault": "Donut Browser가 기본 브라우저가 아닙니다"
},
"permissions": {
"title": "시스템 권한",
"loading": "권한 불러오는 중...",
"description": "이 권한은 Donut Browser에서 실행된 브라우저가 시스템 리소스에 액세스할 수 있도록 합니다. 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
"microphone": "마이크",
"microphoneDescription": "브라우저 애플리케이션의 마이크 액세스",
@@ -155,12 +148,12 @@
"integrations": {
"title": "통합",
"description": "외부 도구 및 AI 어시스턴트와 통합하기 위해 로컬 API와 MCP(Model Context Protocol)를 구성합니다.",
"openSettings": "통합 설정 열기"
"openSettings": "통합 설정 열기",
"label": "로컬 API 및 MCP"
},
"encryption": {
"title": "동기화 암호화",
"description": "E2E 암호화 동기화를 사용하려면 비밀번호를 설정하세요. 이 비밀번호를 잃어버리면 암호화된 프로필을 복구할 수 없습니다.",
"passwordSet": "활성",
"passwordSetDescription": "E2E 암호화 비밀번호가 설정되어 있습니다",
"noPassword": "비밀번호가 설정되지 않았습니다",
"passwordPlaceholder": "비밀번호 (8자 이상)",
@@ -180,7 +173,9 @@
"submit": "확인",
"matchToast": "비밀번호가 일치합니다",
"mismatchToast": "비밀번호가 일치하지 않습니다"
}
},
"ruleLength": "{{min}}자 이상",
"ruleMatch": "비밀번호 일치"
},
"commercial": {
"title": "상업용 라이선스",
@@ -199,7 +194,8 @@
"copyLogs": "로그 복사",
"openLogDir": "로그 폴더 열기",
"copyLogsSuccess": "로그가 클립보드에 복사되었습니다",
"copyLogsDescription": "최근 로그를 민감 정보가 제거된 묶음으로 복사합니다(최대 5MB). 모든 유형의 개인 데이터를 식별할 수는 없으므로 공유하기 전에 검토하세요."
"copyLogsDescription": "최근 로그를 민감 정보가 제거된 묶음으로 복사합니다(최대 5MB). 모든 유형의 개인 데이터를 식별할 수는 없으므로 공유하기 전에 검토하세요.",
"logs": "로그"
},
"disableAutoUpdates": "앱 자동 업데이트 사용 안 함",
"disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
@@ -250,7 +246,51 @@
},
"trashRetentionOff": "즉시 삭제",
"scrollSectionsLeft": "섹션 왼쪽으로 스크롤",
"scrollSectionsRight": "섹션 오른쪽으로 스크롤"
"scrollSectionsRight": "섹션 오른쪽으로 스크롤",
"changed": "변경됨",
"open": "열기",
"manage": "관리",
"clear": "지우기",
"dns": {
"label": "광고, 추적기, 악성코드 차단"
},
"sounds": {
"title": "소리",
"description": "무슨 일이 일어났는지 알려 주는 짧고 작은 소리입니다.",
"enabled": "인터페이스 소리",
"enabledDescription": "소리는 Donut 창이 맨 앞에 있을 때만 재생됩니다.",
"volume": "음량",
"preview": "미리 듣기",
"pack": "사운드 팩",
"packs": {
"soft": {
"label": "부드러움",
"description": "따뜻하고 둥근 소리."
},
"crisp": {
"label": "선명함",
"description": "짧고 밝고 또렷한 소리."
},
"retro": {
"label": "레트로",
"description": "옛 게임기 같은 사각파 소리."
}
},
"categories": {
"interface": {
"label": "인터페이스",
"description": "버튼, 스위치, 페이지 열기와 닫기."
},
"feedback": {
"label": "결과",
"description": "성공, 오류, 경고, 복사, 삭제, 복원."
},
"events": {
"label": "이벤트",
"description": "프로필 실행과 중지, 동기화, 다운로드, 확인이 필요한 요청."
}
}
}
},
"header": {
"searchPlaceholder": "프로필 검색...",
@@ -940,7 +980,6 @@
"apiStartFailed": "API 서버 시작 실패",
"tokenCopied": "토큰 복사됨",
"mcpAcceptTermsFirst": "(먼저 설정에서 Wayfern 약관에 동의하세요)",
"openSettings": "통합 설정 열기",
"apiRunningOn": "실행 중",
"apiExampleRequest": "예시 요청",
"examples": {
@@ -1760,7 +1799,6 @@
"proPlus": "Pro++",
"ultimate": "얼티밋",
"settingsDescription": "DNS 차단 목록은 프록시 수준에서 광고, 트래커 및 악성코드 도메인을 차단합니다. 목록은 12시간마다 자동으로 새로 고쳐집니다.",
"manageLists": "DNS 차단 목록 관리",
"refreshAll": "모든 목록 새로 고침",
"refreshFailed": "DNS 차단 목록 새로 고침 실패",
"domains": "도메인",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "Ativo",
"inactive": "Inativo",
"running": "Executando",
"stopped": "Parado",
"enabled": "Ativado",
"disabled": "Desativado",
"granted": "Concedido",
"notGranted": "Não Concedido",
"connected": "Conectado",
"disconnected": "Desconectado",
"synced": "Sincronizado",
@@ -116,35 +114,30 @@
"themeDescription": "Escolha seu tema preferido ou siga as configurações do sistema. Alterações de tema personalizado são aplicadas apenas ao salvar.",
"themePreset": "Tema Predefinido",
"customColors": "Cores Personalizadas",
"selectTheme": "Selecionar tema",
"selectThemePreset": "Selecionar tema predefinido",
"yourOwn": "Seu Próprio",
"light": "Claro",
"dark": "Escuro",
"system": "Sistema",
"sounds": "Sons da interface",
"soundsDescription": "Sons suaves e baixos ao pressionar um botão, mudar uma chave ou quando uma ação dá certo ou falha."
"system": "Sistema"
},
"language": {
"title": "Idioma",
"description": "Escolha seu idioma preferido para a interface do aplicativo.",
"systemDefault": "Padrão do Sistema",
"selectLanguage": "Selecionar idioma",
"interface": "Idioma da interface"
},
"defaultBrowser": {
"title": "Navegador Padrão",
"setAsDefault": "Definir como Navegador Padrão",
"alreadyDefault": "Já é o Navegador Padrão",
"description": "Quando definido como padrão, o Donut Browser lidará com links da web e permitirá que você escolha qual perfil usar.",
"setSuccess": "O Donut Browser agora é o seu navegador padrão",
"setFailed": "Não foi possível definir o navegador padrão",
"finishInSystemSettings": "Conclua nas Configurações do Windows",
"finishInSystemSettingsDescription": "O Donut Browser está registrado. As Configurações do Windows foram abertas: escolha o Donut Browser em Navegador da web para concluir."
"finishInSystemSettingsDescription": "O Donut Browser está registrado. As Configurações do Windows foram abertas: escolha o Donut Browser em Navegador da web para concluir.",
"isDefault": "O Donut Browser é o seu navegador padrão",
"notDefault": "O Donut Browser não é o seu navegador padrão"
},
"permissions": {
"title": "Permissões do Sistema",
"loading": "Carregando permissões...",
"description": "Essas permissões permitem que navegadores iniciados pelo Donut Browser acessem recursos do sistema. Cada site ainda pedirá sua permissão individualmente.",
"microphone": "Microfone",
"microphoneDescription": "Acesso ao microfone para aplicativos do navegador",
@@ -155,12 +148,12 @@
"integrations": {
"title": "Integrações",
"description": "Configure a API Local e MCP (Protocolo de Contexto de Modelo) para integração com ferramentas externas e assistentes de IA.",
"openSettings": "Abrir Configurações de Integrações"
"openSettings": "Abrir Configurações de Integrações",
"label": "API local e MCP"
},
"encryption": {
"title": "Criptografia de sincronização",
"description": "Defina uma senha para habilitar a sincronização criptografada E2E. Se você perder esta senha, os perfis criptografados não poderão ser recuperados.",
"passwordSet": "Ativo",
"passwordSetDescription": "A senha de criptografia E2E está definida",
"noPassword": "Sem senha definida",
"passwordPlaceholder": "Senha (mín. 8 caracteres)",
@@ -180,7 +173,9 @@
"submit": "Validar",
"matchToast": "A senha está correta",
"mismatchToast": "A senha não corresponde"
}
},
"ruleLength": "Pelo menos {{min}} caracteres",
"ruleMatch": "As senhas coincidem"
},
"commercial": {
"title": "Licença Comercial",
@@ -199,7 +194,8 @@
"copyLogs": "Copiar logs",
"openLogDir": "Abrir pasta de logs",
"copyLogsSuccess": "Logs copiados para a área de transferência",
"copyLogsDescription": "Copia um pacote editado dos logs recentes (até 5 MB). Revise-o antes de compartilhar, pois a edição não consegue identificar todos os tipos de dados pessoais."
"copyLogsDescription": "Copia um pacote editado dos logs recentes (até 5 MB). Revise-o antes de compartilhar, pois a edição não consegue identificar todos os tipos de dados pessoais.",
"logs": "Logs"
},
"disableAutoUpdates": "Desativar Atualizações Automáticas do App",
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas.",
@@ -251,7 +247,51 @@
},
"trashRetentionOff": "Excluir na hora",
"scrollSectionsLeft": "Rolar seções para a esquerda",
"scrollSectionsRight": "Rolar seções para a direita"
"scrollSectionsRight": "Rolar seções para a direita",
"changed": "Alterado",
"open": "Abrir",
"manage": "Gerenciar",
"clear": "Limpar",
"dns": {
"label": "Bloquear anúncios, rastreadores e malware"
},
"sounds": {
"title": "Sons",
"description": "Sons curtos e baixos que mostram o que aconteceu.",
"enabled": "Sons da interface",
"enabledDescription": "Os sons só tocam quando a janela do Donut está na frente.",
"volume": "Volume",
"preview": "Ouvir",
"pack": "Pacote de sons",
"packs": {
"soft": {
"label": "Suave",
"description": "Quente e arredondado."
},
"crisp": {
"label": "Nítido",
"description": "Curto, brilhante e claro."
},
"retro": {
"label": "Retrô",
"description": "Ondas quadradas, como um console de videogame antigo."
}
},
"categories": {
"interface": {
"label": "Interface",
"description": "Botões, chaves e páginas que você abre ou fecha."
},
"feedback": {
"label": "Resultados",
"description": "Sucesso, erros, avisos, copiar, excluir e restaurar."
},
"events": {
"label": "Eventos",
"description": "Início e parada de perfis, sincronização, downloads e pedidos que precisam da sua atenção."
}
}
}
},
"header": {
"searchPlaceholder": "Pesquisar perfis...",
@@ -949,7 +989,6 @@
"apiStartFailed": "Falha ao iniciar o servidor API",
"tokenCopied": "Token copiado",
"mcpAcceptTermsFirst": "(Aceite primeiro os termos da Wayfern nas Configurações)",
"openSettings": "Abrir configurações de integrações",
"apiRunningOn": "Em execução em",
"apiExampleRequest": "Exemplo de solicitação",
"examples": {
@@ -1771,7 +1810,6 @@
"proPlus": "Pro++",
"ultimate": "Definitivo",
"settingsDescription": "As listas de bloqueio DNS bloqueiam anúncios, rastreadores e domínios de malware no nível do proxy. As listas são atualizadas automaticamente a cada 12 horas.",
"manageLists": "Gerenciar listas de bloqueio DNS",
"refreshAll": "Atualizar todas as listas",
"refreshFailed": "Falha ao atualizar as listas de bloqueio DNS",
"domains": "domínios",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "Активен",
"inactive": "Неактивен",
"running": "Запущен",
"stopped": "Остановлен",
"enabled": "Включено",
"disabled": "Выключено",
"granted": "Разрешено",
"notGranted": "Не разрешено",
"connected": "Подключено",
"disconnected": "Отключено",
"synced": "Синхронизировано",
@@ -116,35 +114,30 @@
"themeDescription": "Выберите предпочитаемую тему или следуйте системным настройкам. Изменения пользовательской темы применяются только при сохранении.",
"themePreset": "Предустановка темы",
"customColors": "Пользовательские цвета",
"selectTheme": "Выберите тему",
"selectThemePreset": "Выберите предустановку темы",
"yourOwn": "Собственная",
"light": "Светлая",
"dark": "Тёмная",
"system": "Системная",
"sounds": "Звуки интерфейса",
"soundsDescription": "Тихие мягкие звуки при нажатии кнопки, переключении переключателя, а также при успехе или ошибке действия."
"system": "Системная"
},
"language": {
"title": "Язык",
"description": "Выберите предпочитаемый язык интерфейса приложения.",
"systemDefault": "Системный по умолчанию",
"selectLanguage": "Выберите язык",
"interface": "Язык интерфейса"
},
"defaultBrowser": {
"title": "Браузер по умолчанию",
"setAsDefault": "Установить браузером по умолчанию",
"alreadyDefault": "Уже браузер по умолчанию",
"description": "При установке по умолчанию Donut Browser будет обрабатывать веб-ссылки и позволит выбрать профиль для использования.",
"setSuccess": "Donut Browser теперь браузер по умолчанию",
"setFailed": "Не удалось назначить браузер по умолчанию",
"finishInSystemSettings": "Завершите в параметрах Windows",
"finishInSystemSettingsDescription": "Donut Browser зарегистрирован. Параметры Windows открыты: выберите Donut Browser в разделе «Веб-браузер», чтобы завершить."
"finishInSystemSettingsDescription": "Donut Browser зарегистрирован. Параметры Windows открыты: выберите Donut Browser в разделе «Веб-браузер», чтобы завершить.",
"isDefault": "Donut Browser — браузер по умолчанию",
"notDefault": "Donut Browser не браузер по умолчанию"
},
"permissions": {
"title": "Системные разрешения",
"loading": "Загрузка разрешений...",
"description": "Эти разрешения позволяют браузерам, запущенным из Donut Browser, получать доступ к системным ресурсам. Каждый сайт всё равно будет запрашивать разрешение отдельно.",
"microphone": "Микрофон",
"microphoneDescription": "Доступ к микрофону для браузерных приложений",
@@ -155,12 +148,12 @@
"integrations": {
"title": "Интеграции",
"description": "Настройте локальный API и MCP (Model Context Protocol) для интеграции с внешними инструментами и AI-ассистентами.",
"openSettings": "Открыть настройки интеграций"
"openSettings": "Открыть настройки интеграций",
"label": "Локальный API и MCP"
},
"encryption": {
"title": "Шифрование синхронизации",
"description": "Установите пароль для включения E2E зашифрованной синхронизации. Если вы потеряете этот пароль, зашифрованные профили не могут быть восстановлены.",
"passwordSet": "Активно",
"passwordSetDescription": "Пароль шифрования E2E установлен",
"noPassword": "Пароль не установлен",
"passwordPlaceholder": "Пароль (мин. 8 символов)",
@@ -180,7 +173,9 @@
"submit": "Проверить",
"matchToast": "Пароль верен",
"mismatchToast": "Пароль не совпадает"
}
},
"ruleLength": "Не менее {{min}} символов",
"ruleMatch": "Пароли совпадают"
},
"commercial": {
"title": "Коммерческая лицензия",
@@ -199,7 +194,8 @@
"copyLogs": "Скопировать логи",
"openLogDir": "Открыть папку логов",
"copyLogsSuccess": "Логи скопированы в буфер обмена",
"copyLogsDescription": "Копирует отредактированный набор последних логов (до 5 МБ). Проверьте его перед отправкой: редактирование не может выявить все виды персональных данных."
"copyLogsDescription": "Копирует отредактированный набор последних логов (до 5 МБ). Проверьте его перед отправкой: редактирование не может выявить все виды персональных данных.",
"logs": "Журналы"
},
"disableAutoUpdates": "Отключить автообновление приложения",
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
@@ -252,7 +248,51 @@
},
"trashRetentionOff": "Удалять сразу",
"scrollSectionsLeft": "Прокрутить разделы влево",
"scrollSectionsRight": "Прокрутить разделы вправо"
"scrollSectionsRight": "Прокрутить разделы вправо",
"changed": "Изменено",
"open": "Открыть",
"manage": "Управлять",
"clear": "Очистить",
"dns": {
"label": "Блокировать рекламу, трекеры и вредоносные сайты"
},
"sounds": {
"title": "Звуки",
"description": "Короткие тихие звуки, которые сообщают, что произошло.",
"enabled": "Звуки интерфейса",
"enabledDescription": "Звуки играют, только когда окно Donut на переднем плане.",
"volume": "Громкость",
"preview": "Прослушать",
"pack": "Набор звуков",
"packs": {
"soft": {
"label": "Мягкий",
"description": "Тёплый и округлый."
},
"crisp": {
"label": "Чёткий",
"description": "Короткий, яркий и ясный."
},
"retro": {
"label": "Ретро",
"description": "Прямоугольные волны, как в старой игровой приставке."
}
},
"categories": {
"interface": {
"label": "Интерфейс",
"description": "Кнопки, переключатели, открытие и закрытие страниц."
},
"feedback": {
"label": "Результаты",
"description": "Успех, ошибки, предупреждения, копирование, удаление и восстановление."
},
"events": {
"label": "События",
"description": "Запуск и остановка профилей, синхронизация, загрузки и запросы, которым нужно ваше внимание."
}
}
}
},
"header": {
"searchPlaceholder": "Поиск профилей...",
@@ -958,7 +998,6 @@
"apiStartFailed": "Не удалось запустить API сервер",
"tokenCopied": "Токен скопирован",
"mcpAcceptTermsFirst": "(Сначала примите условия Wayfern в Настройках)",
"openSettings": "Открыть настройки интеграций",
"apiRunningOn": "Запущен на",
"apiExampleRequest": "Пример запроса",
"examples": {
@@ -1782,7 +1821,6 @@
"proPlus": "Pro++",
"ultimate": "Максимум",
"settingsDescription": "Списки блокировки DNS блокируют рекламу, трекеры и вредоносные домены на уровне прокси. Списки автоматически обновляются каждые 12 часов.",
"manageLists": "Управление списками блокировки DNS",
"refreshAll": "Обновить все списки",
"refreshFailed": "Не удалось обновить списки блокировки DNS",
"domains": "доменов",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "Etkin",
"inactive": "Devre Dışı",
"running": "Çalışıyor",
"stopped": "Durduruldu",
"enabled": "Etkin",
"disabled": "Devre Dışı",
"granted": "İzin Verildi",
"notGranted": "İzin Verilmedi",
"connected": "Bağlı",
"disconnected": "Bağlantı Kesildi",
"synced": "Eşitlendi",
@@ -116,35 +114,30 @@
"themeDescription": "Tercih ettiğiniz temayı seçin veya sistem ayarlarınızı takip edin. Özel tema değişiklikleri yalnızca kaydettiğinizde uygulanır.",
"themePreset": "Tema Ön Ayarı",
"customColors": "Özel Renkler",
"selectTheme": "Tema seçin",
"selectThemePreset": "Bir tema ön ayarı seçin",
"yourOwn": "Kendi Temanız",
"light": "Açık",
"dark": "Koyu",
"system": "Sistem",
"sounds": "Arayüz sesleri",
"soundsDescription": "Bir düğmeye bastığınızda, bir anahtarı değiştirdiğinizde veya bir işlem başarılı olduğunda ya da başarısız olduğunda yumuşak ve sessiz sesler."
"system": "Sistem"
},
"language": {
"title": "Dil",
"description": "Uygulama arayüzü için tercih ettiğiniz dili seçin.",
"systemDefault": "Sistem Varsayılanı",
"selectLanguage": "Dil seçin",
"interface": "Arayüz Dili"
},
"defaultBrowser": {
"title": "Varsayılan Tarayıcı",
"setAsDefault": "Varsayılan Tarayıcı Olarak Ayarla",
"alreadyDefault": "Zaten Varsayılan Tarayıcı",
"description": "Varsayılan olarak ayarlandığında, Donut Browser web bağlantılarını yönetir ve hangi profilin kullanılacağını seçmenize olanak tanır.",
"setSuccess": "Donut Browser artık varsayılan tarayıcınız",
"setFailed": "Varsayılan tarayıcı ayarlanamadı",
"finishInSystemSettings": "Windows Ayarları'nda tamamlayın",
"finishInSystemSettingsDescription": "Donut Browser kaydedildi. Windows Ayarları açıldı: tamamlamak için Web tarayıcısı bölümünden Donut Browser'ı seçin."
"finishInSystemSettingsDescription": "Donut Browser kaydedildi. Windows Ayarları açıldı: tamamlamak için Web tarayıcısı bölümünden Donut Browser'ı seçin.",
"isDefault": "Donut Browser varsayılan tarayıcınız",
"notDefault": "Donut Browser varsayılan tarayıcınız değil"
},
"permissions": {
"title": "Sistem İzinleri",
"loading": "İzinler yükleniyor...",
"description": "Bu izinler, Donut Browser'dan başlatılan tarayıcıların sistem kaynaklarına erişmesine olanak tanır. Her web sitesi yine de sizden ayrı ayrı izin isteyecektir.",
"microphone": "Mikrofon",
"microphoneDescription": "Tarayıcı uygulamaları için mikrofon erişimi",
@@ -155,12 +148,12 @@
"integrations": {
"title": "Entegrasyonlar",
"description": "Harici araçlar ve yapay zekâ asistanlarıyla entegrasyon için Yerel API ve MCP (Model Context Protocol) yapılandırın.",
"openSettings": "Entegrasyon Ayarlarını Aç"
"openSettings": "Entegrasyon Ayarlarını Aç",
"label": "Yerel API ve MCP"
},
"encryption": {
"title": "Eşitleme Şifrelemesi",
"description": "Uçtan uca şifreli eşitlemeyi etkinleştirmek için bir parola belirleyin. Bu parolayı kaybederseniz şifrelenmiş profiller kurtarılamaz.",
"passwordSet": "Etkin",
"passwordSetDescription": "Uçtan uca şifreleme parolası ayarlandı",
"noPassword": "Parola ayarlanmadı",
"passwordPlaceholder": "Parola (en az 8 karakter)",
@@ -180,7 +173,9 @@
"submit": "Doğrula",
"matchToast": "Parola doğru",
"mismatchToast": "Parola eşleşmiyor"
}
},
"ruleLength": "En az {{min}} karakter",
"ruleMatch": "Parolalar eşleşiyor"
},
"commercial": {
"title": "Ticari Lisans",
@@ -199,7 +194,8 @@
"copyLogs": "Günlükleri kopyala",
"openLogDir": "Günlük klasörünü aç",
"copyLogsSuccess": "Günlükler panoya kopyalandı",
"copyLogsDescription": "Son günlüklerin hassas verileri ayıklanmış bir paketini kopyalar (en fazla 5 MB). Ayıklama her tür kişisel veriyi belirleyemeyeceğinden paylaşmadan önce inceleyin."
"copyLogsDescription": "Son günlüklerin hassas verileri ayıklanmış bir paketini kopyalar (en fazla 5 MB). Ayıklama her tür kişisel veriyi belirleyemeyeceğinden paylaşmadan önce inceleyin.",
"logs": "Günlükler"
},
"disableAutoUpdates": "Uygulama Otomatik Güncellemelerini Devre Dışı Bırak",
"disableAutoUpdatesDescription": "Uygulamanın Donut Browser güncellemelerini otomatik olarak denetlemesini ve yüklemesini engelleyin. Tarayıcı güncellemeleri bundan etkilenmez.",
@@ -250,7 +246,51 @@
},
"trashRetentionOff": "Hemen sil",
"scrollSectionsLeft": "Bölümleri sola kaydır",
"scrollSectionsRight": "Bölümleri sağa kaydır"
"scrollSectionsRight": "Bölümleri sağa kaydır",
"changed": "Değiştirildi",
"open": "Aç",
"manage": "Yönet",
"clear": "Temizle",
"dns": {
"label": "Reklamları, izleyicileri ve kötü amaçlı yazılımları engelle"
},
"sounds": {
"title": "Sesler",
"description": "Ne olduğunu bildiren kısa ve sessiz sesler.",
"enabled": "Arayüz sesleri",
"enabledDescription": "Sesler yalnızca Donut penceresi öndeyken çalar.",
"volume": "Ses düzeyi",
"preview": "Dinle",
"pack": "Ses paketi",
"packs": {
"soft": {
"label": "Yumuşak",
"description": "Sıcak ve yuvarlak."
},
"crisp": {
"label": "Net",
"description": "Kısa, parlak ve net."
},
"retro": {
"label": "Retro",
"description": "Eski bir oyun konsolu gibi kare dalgalar."
}
},
"categories": {
"interface": {
"label": "Arayüz",
"description": "Düğmeler, anahtarlar ve açıp kapattığınız sayfalar."
},
"feedback": {
"label": "Sonuçlar",
"description": "Başarı, hatalar, uyarılar, kopyalama, silme ve geri yükleme."
},
"events": {
"label": "Olaylar",
"description": "Profil başlatma ve durdurma, eşitleme, indirmeler ve ilginizi gerektiren istekler."
}
}
}
},
"header": {
"searchPlaceholder": "Profillerde ara...",
@@ -940,7 +980,6 @@
"apiStartFailed": "API sunucusu başlatılamadı",
"tokenCopied": "Jeton kopyalandı",
"mcpAcceptTermsFirst": "(Önce Ayarlar'dan Wayfern koşullarını kabul edin)",
"openSettings": "Entegrasyon Ayarlarını Aç",
"apiRunningOn": "Çalıştığı adres",
"apiExampleRequest": "Örnek istek",
"examples": {
@@ -1760,7 +1799,6 @@
"proPlus": "Pro++",
"ultimate": "Ultimate",
"settingsDescription": "DNS engel listeleri; reklamları, izleyicileri ve kötü amaçlı yazılım alan adlarını proxy düzeyinde engeller. Listeler her 12 saatte bir otomatik olarak yenilenir.",
"manageLists": "DNS Engel Listelerini Yönet",
"refreshAll": "Tüm Listeleri Yenile",
"refreshFailed": "DNS engel listeleri yenilenemedi",
"domains": "alan adı",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "Đang hoạt động",
"inactive": "Không hoạt động",
"running": "Đang chạy",
"stopped": "Đã dừng",
"enabled": "Đã bật",
"disabled": "Đã tắt",
"granted": "Đã cấp",
"notGranted": "Chưa cấp",
"connected": "Đã kết nối",
"disconnected": "Đã ngắt kết nối",
"synced": "Đã đồng bộ",
@@ -116,35 +114,30 @@
"themeDescription": "Chọn chủ đề bạn muốn hoặc theo cài đặt hệ thống. Thay đổi chủ đề tùy chỉnh chỉ được áp dụng khi bạn lưu.",
"themePreset": "Mẫu chủ đề",
"customColors": "Tùy chỉnh màu sắc",
"selectTheme": "Chọn chủ đề",
"selectThemePreset": "Chọn mẫu chủ đề",
"yourOwn": "Của riêng bạn",
"light": "Sáng",
"dark": "Tối",
"system": "Theo hệ thống",
"sounds": "Âm thanh giao diện",
"soundsDescription": "Âm thanh nhỏ, nhẹ khi bạn nhấn nút, bật tắt công tắc, hoặc khi một thao tác thành công hay thất bại."
"system": "Theo hệ thống"
},
"language": {
"title": "Ngôn ngữ",
"description": "Chọn ngôn ngữ hiển thị cho giao diện ứng dụng.",
"systemDefault": "Mặc định hệ thống",
"selectLanguage": "Chọn ngôn ngữ",
"interface": "Ngôn ngữ giao diện"
},
"defaultBrowser": {
"title": "Trình duyệt mặc định",
"setAsDefault": "Đặt làm trình duyệt mặc định",
"alreadyDefault": "Đã là trình duyệt mặc định",
"description": "Khi được đặt làm mặc định, Donut Browser sẽ xử lý các liên kết web và cho phép bạn chọn hồ sơ để sử dụng.",
"setSuccess": "Donut Browser hiện là trình duyệt mặc định của bạn",
"setFailed": "Không thể đặt trình duyệt mặc định",
"finishInSystemSettings": "Hoàn tất trong Cài đặt Windows",
"finishInSystemSettingsDescription": "Donut Browser đã được đăng ký. Cài đặt Windows đang mở: chọn Donut Browser trong mục Trình duyệt web để hoàn tất."
"finishInSystemSettingsDescription": "Donut Browser đã được đăng ký. Cài đặt Windows đang mở: chọn Donut Browser trong mục Trình duyệt web để hoàn tất.",
"isDefault": "Donut Browser là trình duyệt mặc định của bạn",
"notDefault": "Donut Browser không phải là trình duyệt mặc định của bạn"
},
"permissions": {
"title": "Quyền hệ thống",
"loading": "Đang tải quyền...",
"description": "Các quyền này cho phép trình duyệt được khởi chạy từ Donut Browser truy cập tài nguyên hệ thống. Mỗi trang web vẫn sẽ yêu cầu quyền riêng lẻ.",
"microphone": "Micro",
"microphoneDescription": "Quyền truy cập micro cho ứng dụng trình duyệt",
@@ -155,12 +148,12 @@
"integrations": {
"title": "Tích hợp",
"description": "Cấu hình Local API và MCP (Model Context Protocol) để tích hợp với công cụ bên ngoài và trợ lý AI.",
"openSettings": "Mở cài đặt tích hợp"
"openSettings": "Mở cài đặt tích hợp",
"label": "API cục bộ và MCP"
},
"encryption": {
"title": "Mã hóa đồng bộ",
"description": "Đặt mật khẩu để bật mã hóa đầu cuối (E2E) cho đồng bộ. Nếu mất mật khẩu này, các hồ sơ đã mã hóa sẽ không thể khôi phục.",
"passwordSet": "Đang hoạt động",
"passwordSetDescription": "Mật khẩu mã hóa E2E đã được thiết lập",
"noPassword": "Chưa đặt mật khẩu",
"passwordPlaceholder": "Mật khẩu (tối thiểu 8 ký tự)",
@@ -180,7 +173,9 @@
"submit": "Xác thực",
"matchToast": "Mật khẩu chính xác",
"mismatchToast": "Mật khẩu không khớp"
}
},
"ruleLength": "Ít nhất {{min}} ký tự",
"ruleMatch": "Mật khẩu khớp"
},
"commercial": {
"title": "Giấy phép thương mại",
@@ -199,7 +194,8 @@
"copyLogs": "Sao chép nhật ký",
"openLogDir": "Mở thư mục nhật ký",
"copyLogsSuccess": "Đã sao chép nhật ký vào clipboard",
"copyLogsDescription": "Sao chép gói nhật ký gần đây đã được che thông tin nhạy cảm (tối đa 5 MB). Hãy xem lại trước khi chia sẻ vì việc che dữ liệu không thể nhận diện mọi loại dữ liệu cá nhân."
"copyLogsDescription": "Sao chép gói nhật ký gần đây đã được che thông tin nhạy cảm (tối đa 5 MB). Hãy xem lại trước khi chia sẻ vì việc che dữ liệu không thể nhận diện mọi loại dữ liệu cá nhân.",
"logs": "Nhật ký"
},
"disableAutoUpdates": "Tắt tự động cập nhật ứng dụng",
"disableAutoUpdatesDescription": "Ngăn ứng dụng tự động kiểm tra và cài đặt bản cập nhật Donut Browser. Cập nhật trình duyệt không bị ảnh hưởng.",
@@ -250,7 +246,51 @@
},
"trashRetentionOff": "Xóa ngay",
"scrollSectionsLeft": "Cuộn các mục sang trái",
"scrollSectionsRight": "Cuộn các mục sang phải"
"scrollSectionsRight": "Cuộn các mục sang phải",
"changed": "Đã thay đổi",
"open": "Mở",
"manage": "Quản lý",
"clear": "Xóa",
"dns": {
"label": "Chặn quảng cáo, trình theo dõi và phần mềm độc hại"
},
"sounds": {
"title": "Âm thanh",
"description": "Âm thanh ngắn, nhỏ cho bạn biết điều gì vừa xảy ra.",
"enabled": "Âm thanh giao diện",
"enabledDescription": "Âm thanh chỉ phát khi cửa sổ Donut ở phía trước.",
"volume": "Âm lượng",
"preview": "Nghe thử",
"pack": "Gói âm thanh",
"packs": {
"soft": {
"label": "Êm dịu",
"description": "Ấm và tròn."
},
"crisp": {
"label": "Sắc nét",
"description": "Ngắn, sáng và rõ."
},
"retro": {
"label": "Cổ điển",
"description": "Sóng vuông, như máy chơi game đời cũ."
}
},
"categories": {
"interface": {
"label": "Giao diện",
"description": "Nút, công tắc và các trang bạn mở hoặc đóng."
},
"feedback": {
"label": "Kết quả",
"description": "Thành công, lỗi, cảnh báo, sao chép, xóa và khôi phục."
},
"events": {
"label": "Sự kiện",
"description": "Khởi chạy và dừng hồ sơ, đồng bộ, tải xuống và các yêu cầu cần bạn chú ý."
}
}
}
},
"header": {
"searchPlaceholder": "Tìm kiếm hồ sơ...",
@@ -940,7 +980,6 @@
"apiStartFailed": "Khởi động máy chủ API thất bại",
"tokenCopied": "Đã sao chép token",
"mcpAcceptTermsFirst": "(Chấp nhận điều khoản Wayfern trong Cài đặt trước)",
"openSettings": "Mở cài đặt tích hợp",
"apiRunningOn": "Đang chạy trên",
"apiExampleRequest": "Ví dụ yêu cầu",
"examples": {
@@ -1760,7 +1799,6 @@
"proPlus": "Pro++",
"ultimate": "Tối đa",
"settingsDescription": "Danh sách chặn DNS chặn quảng cáo, trình theo dõi và tên miền độc hại ở cấp proxy. Danh sách được tự động làm mới mỗi 12 giờ.",
"manageLists": "Quản lý danh sách chặn DNS",
"refreshAll": "Làm mới tất cả danh sách",
"refreshFailed": "Làm mới danh sách chặn DNS thất bại",
"domains": "tên miền",
+56 -18
View File
@@ -38,13 +38,11 @@
},
"status": {
"active": "活跃",
"inactive": "未活跃",
"running": "运行中",
"stopped": "已停止",
"enabled": "已启用",
"disabled": "已禁用",
"granted": "已授权",
"notGranted": "未授权",
"connected": "已连接",
"disconnected": "已断开",
"synced": "已同步",
@@ -116,35 +114,30 @@
"themeDescription": "选择您喜欢的主题或跟随系统设置。自定义主题更改仅在保存时应用。",
"themePreset": "主题预设",
"customColors": "自定义颜色",
"selectTheme": "选择主题",
"selectThemePreset": "选择主题预设",
"yourOwn": "自定义",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"sounds": "界面音效",
"soundsDescription": "按下按钮、切换开关,或操作成功或失败时,播放轻柔安静的声音。"
"system": "跟随系统"
},
"language": {
"title": "语言",
"description": "选择应用程序界面的首选语言。",
"systemDefault": "系统默认",
"selectLanguage": "选择语言",
"interface": "界面语言"
},
"defaultBrowser": {
"title": "默认浏览器",
"setAsDefault": "设为默认浏览器",
"alreadyDefault": "已是默认浏览器",
"description": "设为默认后,Donut Browser 将处理网页链接并允许您选择使用哪个配置文件。",
"setSuccess": "Donut Browser 现在是您的默认浏览器",
"setFailed": "无法设置默认浏览器",
"finishInSystemSettings": "请在 Windows 设置中完成",
"finishInSystemSettingsDescription": "Donut Browser 已注册。Windows 设置已打开:在“Web 浏览器”中选择 Donut Browser 即可完成。"
"finishInSystemSettingsDescription": "Donut Browser 已注册。Windows 设置已打开:在“Web 浏览器”中选择 Donut Browser 即可完成。",
"isDefault": "Donut Browser 是你的默认浏览器",
"notDefault": "Donut Browser 不是你的默认浏览器"
},
"permissions": {
"title": "系统权限",
"loading": "加载权限中...",
"description": "这些权限允许从 Donut Browser 启动的浏览器访问系统资源。每个网站仍会单独请求您的权限。",
"microphone": "麦克风",
"microphoneDescription": "浏览器应用程序的麦克风访问权限",
@@ -155,12 +148,12 @@
"integrations": {
"title": "集成",
"description": "配置本地 API 和 MCP(模型上下文协议)以与外部工具和 AI 助手集成。",
"openSettings": "打开集成设置"
"openSettings": "打开集成设置",
"label": "本地 API 和 MCP"
},
"encryption": {
"title": "同步加密",
"description": "设置密码以启用E2E加密同步。如果您丢失此密码,加密的配置文件将无法恢复。",
"passwordSet": "已启用",
"passwordSetDescription": "E2E加密密码已设置",
"noPassword": "未设置密码",
"passwordPlaceholder": "密码(至少8个字符)",
@@ -180,7 +173,9 @@
"submit": "验证",
"matchToast": "密码正确",
"mismatchToast": "密码不匹配"
}
},
"ruleLength": "至少 {{min}} 个字符",
"ruleMatch": "密码一致"
},
"commercial": {
"title": "商业许可",
@@ -199,7 +194,8 @@
"copyLogs": "复制日志",
"openLogDir": "打开日志文件夹",
"copyLogsSuccess": "日志已复制到剪贴板",
"copyLogsDescription": "复制经过脱敏的近期日志包(最多 5 MB)。脱敏无法识别所有类型的个人数据,请在分享前检查内容。"
"copyLogsDescription": "复制经过脱敏的近期日志包(最多 5 MB)。脱敏无法识别所有类型的个人数据,请在分享前检查内容。",
"logs": "日志"
},
"disableAutoUpdates": "禁用应用自动更新",
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
@@ -250,7 +246,51 @@
},
"trashRetentionOff": "立即删除",
"scrollSectionsLeft": "向左滚动分区",
"scrollSectionsRight": "向右滚动分区"
"scrollSectionsRight": "向右滚动分区",
"changed": "已更改",
"open": "打开",
"manage": "管理",
"clear": "清除",
"dns": {
"label": "拦截广告、跟踪器和恶意软件"
},
"sounds": {
"title": "声音",
"description": "简短、安静的声音,告诉你发生了什么。",
"enabled": "界面音效",
"enabledDescription": "只有 Donut 窗口在前台时才会播放声音。",
"volume": "音量",
"preview": "试听",
"pack": "音效包",
"packs": {
"soft": {
"label": "柔和",
"description": "温暖圆润。"
},
"crisp": {
"label": "清脆",
"description": "短促、明亮、清晰。"
},
"retro": {
"label": "复古",
"description": "方波音色,像老式游戏机。"
}
},
"categories": {
"interface": {
"label": "界面",
"description": "按钮、开关,以及打开或关闭页面。"
},
"feedback": {
"label": "结果",
"description": "成功、错误、警告、复制、删除和恢复。"
},
"events": {
"label": "事件",
"description": "配置文件启动和停止、同步、下载,以及需要你处理的请求。"
}
}
}
},
"header": {
"searchPlaceholder": "搜索配置文件...",
@@ -940,7 +980,6 @@
"apiStartFailed": "启动 API 服务器失败",
"tokenCopied": "令牌已复制",
"mcpAcceptTermsFirst": "(请先在设置中接受 Wayfern 条款)",
"openSettings": "打开集成设置",
"apiRunningOn": "运行于",
"apiExampleRequest": "示例请求",
"examples": {
@@ -1760,7 +1799,6 @@
"proPlus": "Pro++",
"ultimate": "终极",
"settingsDescription": "DNS 拦截列表在代理级别拦截广告、跟踪器和恶意软件域名。列表每 12 小时自动刷新一次。",
"manageLists": "管理 DNS 拦截列表",
"refreshAll": "刷新所有列表",
"refreshFailed": "刷新 DNS 拦截列表失败",
"domains": "个域名",
+15 -13
View File
@@ -3,7 +3,7 @@ import React from "react";
import { type ExternalToast, toast as sonnerToast } from "sonner";
import { UnifiedToast } from "@/components/custom-toast";
import i18n from "@/i18n";
import { playUiSound } from "@/lib/ui-sounds";
import { playUiSound, type UiSound } from "@/lib/ui-sounds";
interface BaseToastProps {
id?: string;
@@ -181,6 +181,8 @@ export function showDownloadToast(
return;
}
if (stage === "completed") playUiSound("download");
// Only show cancel button during active downloading, not for completed/extracting/verifying
const showCancel = stage === "downloading";
@@ -194,15 +196,19 @@ export function showDownloadToast(
});
}
interface ResultToastOptions {
id?: string;
description?: string;
duration?: number;
/** The sound to play instead of the toast's own; null plays none. */
sound?: UiSound | null;
}
export function showSuccessToast(
title: string,
options?: {
id?: string;
description?: string;
duration?: number;
},
{ sound = "success", ...options }: ResultToastOptions = {},
) {
playUiSound("success");
if (sound) playUiSound(sound);
return showToast({
type: "success",
title,
@@ -212,13 +218,9 @@ export function showSuccessToast(
export function showErrorToast(
title: string,
options?: {
id?: string;
description?: string;
duration?: number;
},
{ sound = "error", ...options }: ResultToastOptions = {},
) {
playUiSound("error");
if (sound) playUiSound(sound);
return showToast({
type: "error",
title,
+128 -16
View File
@@ -1,38 +1,150 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clampVolume,
compileSound,
DEFAULT_SOUND_PACK,
DEFAULT_SOUND_VOLUME,
noteFrequency,
planSound,
resolvePack,
SOUND_CATEGORIES,
SOUND_CATEGORY,
SOUND_CONTROL_SELECTOR,
SOUND_GAP_MS,
SOUNDS,
SOUND_PACKS,
soundForControl,
soundLength,
UI_SOUNDS,
volumeGain,
} from "./ui-sounds.ts";
/**
* What is pinned here is "never annoying": every sound is short and quiet,
* starts without a click, can not repeat faster than its gap, and only the
* controls a person deliberately used make one.
* What is pinned here is "never annoying": every sound in every pack is
* short and quiet, starts without a click, can not repeat faster than its
* gap, only the controls a person deliberately used make one, and every
* setting that turns a sound off does turn it off.
*/
const length = (voices) =>
Math.max(...voices.map((v) => v.delay + v.attack + v.decay));
test("every sound is short, quiet and starts softly", () => {
for (const [name, voices] of Object.entries(SOUNDS)) {
assert.ok(voices.length > 0, name);
assert.ok(length(voices) <= 0.3, `${name} lasts ${length(voices)} s`);
for (const voice of voices) {
assert.ok(voice.peak > 0 && voice.peak <= 0.1, `${name} peak`);
assert.ok(voice.attack >= 0.001, `${name} must not start with a click`);
test("every sound exists in every pack, short, quiet and starting softly", () => {
assert.ok(UI_SOUNDS.length >= 15);
assert.deepEqual(
SOUND_PACKS.map((pack) => pack.id),
["soft", "crisp", "retro"],
);
for (const { id: pack } of SOUND_PACKS) {
for (const sound of UI_SOUNDS) {
const name = `${pack}/${sound}`;
const compiled = compileSound(sound, pack);
assert.ok(compiled.voices.length > 0, name);
assert.ok(
soundLength(compiled) <= 0.45,
`${name} lasts ${soundLength(compiled)} s`,
);
for (const voice of compiled.voices) {
assert.ok(voice.peak > 0 && voice.peak <= 0.1, `${name} peak`);
assert.ok(voice.attack >= 0.001, `${name} must not start with a click`);
assert.ok(voice.pan >= -1 && voice.pan <= 1, `${name} pan`);
if (voice.wave !== "noise") {
assert.ok(
voice.frequency >= 150 && voice.frequency <= 7000,
`${name} pitch ${voice.frequency}`,
);
}
}
assert.ok(compiled.lowpass > 0 && compiled.room >= 0, name);
assert.ok(SOUND_GAP_MS[sound] > 0, `${sound} needs a gap`);
}
assert.ok(SOUND_GAP_MS[name] > 0, `${name} needs a gap`);
assert.ok(
soundLength(compileSound("tap", pack)) <= 0.04,
`a ${pack} tap is a tick, not a tone`,
);
}
assert.ok(length(SOUNDS.tap) <= 0.04, "a tap is a tick, not a tone");
assert.ok(
SOUND_GAP_MS.error >= 1000 && SOUND_GAP_MS.success >= 1000,
"a run of results makes one sound, not a burst",
);
});
test("the packs sound different from each other", () => {
const taps = SOUND_PACKS.map(({ id }) => compileSound("tap", id));
const waves = new Set(taps.map((tap) => tap.voices[0].wave));
const pitches = new Set(taps.map((tap) => tap.voices[0].frequency));
assert.ok(waves.size > 1 || pitches.size > 1);
assert.ok(new Set(taps.map((tap) => tap.lowpass)).size === taps.length);
});
test("the notes stay on one pentatonic scale", () => {
assert.equal(noteFrequency(440, 0), 440);
assert.equal(noteFrequency(440, 5), 880);
assert.equal(noteFrequency(440, -5), 220);
assert.ok(Math.abs(noteFrequency(440, 3) - 440 * 2 ** (7 / 12)) < 1e-9);
});
test("an unknown pack falls back to the default", () => {
assert.equal(DEFAULT_SOUND_PACK, "soft");
assert.equal(resolvePack("nope"), "soft");
assert.equal(resolvePack(undefined), "soft");
assert.equal(resolvePack("retro"), "retro");
assert.deepEqual(compileSound("success", "nope"), compileSound("success"));
assert.deepEqual(
planSound("tap", { ui_sound_pack: "gone" })?.compiled,
compileSound("tap", "soft"),
);
});
test("every sound belongs to a category, and each category has a sample", () => {
const ids = SOUND_CATEGORIES.map((category) => category.id);
assert.deepEqual(ids, ["interface", "feedback", "events"]);
for (const sound of UI_SOUNDS) {
assert.ok(ids.includes(SOUND_CATEGORY[sound]), sound);
}
for (const { id, sample } of SOUND_CATEGORIES) {
assert.equal(SOUND_CATEGORY[sample], id);
}
});
test("the switches turn sounds off", () => {
assert.ok(planSound("tap", {}), "everything is on by default");
assert.equal(planSound("tap", { ui_sounds: false }), null);
assert.equal(planSound("launch", { ui_sounds: false }), null);
const noInterface = { ui_sound_interface: false };
assert.equal(planSound("tap", noInterface), null);
assert.equal(planSound("toggleOn", noInterface), null);
assert.ok(planSound("success", noInterface));
assert.ok(planSound("launch", noInterface));
const noFeedback = { ui_sound_feedback: false };
assert.equal(planSound("error", noFeedback), null);
assert.equal(planSound("copy", noFeedback), null);
assert.ok(planSound("tap", noFeedback));
const noEvents = { ui_sound_events: false };
assert.equal(planSound("launch", noEvents), null);
assert.equal(planSound("attention", noEvents), null);
assert.ok(planSound("success", noEvents));
});
test("volume 0 is silent, and the default is the tuned level", () => {
assert.equal(DEFAULT_SOUND_VOLUME, 60);
assert.equal(volumeGain(0), 0);
assert.equal(planSound("success", { ui_sound_volume: 0 }), null);
assert.equal(planSound("success", { ui_sound_volume: -20 }), null);
assert.equal(volumeGain(DEFAULT_SOUND_VOLUME), 0.6);
assert.ok(volumeGain(30) < volumeGain(60));
assert.ok(volumeGain(100) > volumeGain(60));
assert.equal(volumeGain(500), volumeGain(100));
assert.equal(clampVolume(undefined), DEFAULT_SOUND_VOLUME);
assert.equal(clampVolume(Number.NaN), DEFAULT_SOUND_VOLUME);
assert.equal(clampVolume(42.6), 43);
assert.equal(clampVolume(140), 100);
assert.equal(
planSound("tap", { ui_sound_volume: 100 })?.gain,
volumeGain(100),
);
});
const control = (overrides) => ({
role: null,
tag: "button",
+650 -144
View File
@@ -2,133 +2,496 @@
* Interface sounds: short, quiet, synthesized at play time. Nothing is
* recorded or loaded, and a sound only plays while the window is in front.
*
* Each sound is a few voices: a sine, triangle or noise burst with a soft
* attack (no click at the start) and an exponential decay, like a small
* physical switch. Every play shifts the pitch and level a little, so a run
* of presses never sounds like a machine repeating one sample.
* Every sound is a short phrase on one major pentatonic scale, so two sounds
* that overlap never clash. A pack gives the phrases their timbre: the wave,
* an overtone, how fast a note starts and fades, how bright it is, and how
* much short room it has. The phrases are the same in every pack, so a sound
* keeps its meaning when the pack changes. Every play shifts the pitch and
* level a little, so a run of presses never sounds like one sample repeated.
*/
export type UiSound = "tap" | "toggleOn" | "toggleOff" | "success" | "error";
export type UiSound =
| "tap"
| "toggleOn"
| "toggleOff"
| "open"
| "close"
| "drop"
| "success"
| "error"
| "warning"
| "copy"
| "trash"
| "restore"
| "launch"
| "stop"
| "synced"
| "download"
| "attention";
export type SoundCategory = "interface" | "feedback" | "events";
/** Which switch in Settings turns a sound on or off. */
export const SOUND_CATEGORY: Record<UiSound, SoundCategory> = {
tap: "interface",
toggleOn: "interface",
toggleOff: "interface",
open: "interface",
close: "interface",
drop: "interface",
success: "feedback",
error: "feedback",
warning: "feedback",
copy: "feedback",
trash: "feedback",
restore: "feedback",
launch: "events",
stop: "events",
synced: "events",
download: "events",
attention: "events",
};
export const UI_SOUNDS = Object.keys(SOUND_CATEGORY) as UiSound[];
/** The categories in display order, each with the sound its preview plays. */
export const SOUND_CATEGORIES: readonly {
id: SoundCategory;
sample: UiSound;
}[] = [
{ id: "interface", sample: "toggleOn" },
{ id: "feedback", sample: "success" },
{ id: "events", sample: "launch" },
];
export type SoundPackId = "soft" | "crisp" | "retro";
/** The packs in display order. The first one is the default. */
export const SOUND_PACKS: readonly { id: SoundPackId }[] = [
{ id: "soft" },
{ id: "crisp" },
{ id: "retro" },
];
export const DEFAULT_SOUND_PACK: SoundPackId = "soft";
export const DEFAULT_SOUND_VOLUME = 60;
/** The sound fields of the app settings. A missing field is its default. */
export interface SoundSettings {
ui_sounds?: boolean;
ui_sound_volume?: number;
ui_sound_interface?: boolean;
ui_sound_feedback?: boolean;
ui_sound_events?: boolean;
ui_sound_pack?: string;
}
export interface SoundVoice {
wave: "sine" | "triangle" | "noise";
wave: "sine" | "triangle" | "square" | "noise";
/** Hz at the start. Ignored for noise. */
frequency: number;
/** Hz at the end of the decay, for a slight glide. */
/** Hz at the end of the decay, for a slide. */
endFrequency?: number;
/** Seconds after the sound starts. */
delay: number;
attack: number;
decay: number;
/** Peak level before the master level. */
/** Peak level before the volume. */
peak: number;
/** A band-pass that shapes a noise burst into a soft tick. */
/** A band-pass that shapes a noise burst into a tick or a swish. */
bandPass?: { frequency: number; q: number };
/** -1 is left, 1 is right. */
pan: number;
}
export const SOUNDS: Record<UiSound, SoundVoice[]> = {
tap: [
{
wave: "sine",
frequency: 1750,
delay: 0,
attack: 0.002,
decay: 0.03,
peak: 0.07,
},
{
/** One sound in one pack, ready to schedule. */
export interface CompiledSound {
voices: SoundVoice[];
/** Cut-off of the low-pass that the whole sound goes through. */
lowpass: number;
/** Level sent into the short room. */
room: number;
/** How far one play may shift the pitch, as a ratio either way. */
jitter: number;
}
interface Note {
/** Steps on the pack's pentatonic scale: 0 is the root, 5 an octave up. */
step: number;
/** Seconds after the sound starts. */
at: number;
/** Seconds the note takes to fade. */
length: number;
/** 0 to 1, before the pack level. */
level: number;
/** Semitones the pitch slides over the note. */
slide?: number;
}
interface Phrase {
notes: Note[];
/** A noise tick at the start, 0 to 1: the feel of a key going down. */
touch?: number;
/** A band of noise that swells and fades under the notes: a swish. */
air?: { length: number; level: number; frequency: number };
/** Pitch variation per play. A tick varies more than a tune. */
jitter?: number;
}
/**
* The phrases. Each sound has its own contour, so it is recognized without
* looking: a press is one falling tick, a result goes up (success) or down
* (error) a short way, a launch climbs four notes and a stop comes back down,
* a warning repeats one unresolved note, and a copy repeats one high note.
*/
const PHRASES: Record<UiSound, Phrase> = {
tap: {
notes: [{ step: 4, at: 0, length: 0.026, level: 0.75, slide: -2 }],
touch: 0.9,
jitter: 0.035,
},
toggleOn: {
notes: [
{ step: 4, at: 0, length: 0.03, level: 0.7 },
{ step: 7, at: 0.045, length: 0.045, level: 0.65 },
],
touch: 0.5,
},
toggleOff: {
notes: [
{ step: 7, at: 0, length: 0.03, level: 0.65 },
{ step: 4, at: 0.045, length: 0.045, level: 0.6 },
],
touch: 0.5,
},
open: {
notes: [{ step: 3, at: 0, length: 0.07, level: 0.3, slide: 2 }],
air: { length: 0.07, level: 0.3, frequency: 1800 },
},
close: {
notes: [{ step: 3, at: 0, length: 0.06, level: 0.28, slide: -2 }],
air: { length: 0.06, level: 0.28, frequency: 1400 },
},
drop: {
notes: [
{ step: -3, at: 0, length: 0.07, level: 0.9, slide: -3 },
{ step: 2, at: 0, length: 0.03, level: 0.25 },
],
touch: 0.7,
jitter: 0.02,
},
success: {
notes: [
{ step: 3, at: 0, length: 0.08, level: 0.65 },
{ step: 5, at: 0.06, length: 0.09, level: 0.65 },
{ step: 7, at: 0.12, length: 0.18, level: 0.6 },
],
},
error: {
notes: [
{ step: -2, at: 0, length: 0.11, level: 0.9, slide: -0.6 },
{ step: -3, at: 0.1, length: 0.17, level: 0.85, slide: -0.8 },
],
},
warning: {
notes: [
{ step: 1, at: 0, length: 0.09, level: 0.7 },
{ step: 1, at: 0.12, length: 0.12, level: 0.65 },
],
},
copy: {
notes: [
{ step: 7, at: 0, length: 0.03, level: 0.5 },
{ step: 7, at: 0.055, length: 0.04, level: 0.45 },
],
touch: 0.35,
},
trash: {
notes: [{ step: 0, at: 0, length: 0.12, level: 0.5, slide: -5 }],
air: { length: 0.09, level: 0.5, frequency: 1100 },
},
restore: {
notes: [
{ step: 0, at: 0, length: 0.1, level: 0.45, slide: 5 },
{ step: 7, at: 0.08, length: 0.12, level: 0.4 },
],
air: { length: 0.07, level: 0.3, frequency: 1600 },
},
launch: {
notes: [
{ step: 0, at: 0, length: 0.06, level: 0.5 },
{ step: 3, at: 0.04, length: 0.06, level: 0.55 },
{ step: 5, at: 0.08, length: 0.07, level: 0.55 },
{ step: 8, at: 0.12, length: 0.2, level: 0.5 },
],
},
stop: {
notes: [
{ step: 5, at: 0, length: 0.07, level: 0.5 },
{ step: 3, at: 0.05, length: 0.08, level: 0.5 },
{ step: 0, at: 0.1, length: 0.16, level: 0.5 },
],
},
synced: {
notes: [
{ step: 5, at: 0, length: 0.08, level: 0.5 },
{ step: 3, at: 0.07, length: 0.08, level: 0.45 },
{ step: 7, at: 0.14, length: 0.18, level: 0.5 },
],
},
download: {
notes: [
{ step: 8, at: 0, length: 0.08, level: 0.45 },
{ step: 5, at: 0.08, length: 0.24, level: 0.6 },
],
touch: 0.25,
},
attention: {
notes: [
{ step: 5, at: 0, length: 0.12, level: 0.55 },
{ step: 9, at: 0.11, length: 0.26, level: 0.55 },
],
},
};
interface Pack {
/** Hz of step 0. */
root: number;
wave: "sine" | "triangle" | "square";
/** One more sine on every note: its ratio to the note, its level, and how much sooner it fades. */
partial: { ratio: number; level: number; length: number } | null;
attack: number;
/** Multiplies every note length. */
length: number;
/** Multiplies every note start, so a pack can play a phrase tighter. */
tempo: number;
/** Multiplies every peak: a square sounds louder than a sine at one peak. */
level: number;
touch: { frequency: number; q: number; level: number; length: number };
lowpass: number;
room: number;
/** How far notes spread left (low) and right (high), 0 to 1. */
width: number;
}
/**
* Soft is round and warm: sines with a quiet octave, slow starts, a dark
* low-pass and a little room. Crisp is a tone higher, short and glassy: a
* bell-like overtone, quick starts, no low-pass to speak of and almost dry.
* Retro is an old game console: square waves, filtered so they never buzz,
* tight timing, centered and dry.
*/
const PACKS: Record<SoundPackId, Pack> = {
soft: {
root: 587.33,
wave: "sine",
partial: { ratio: 2, level: 0.2, length: 0.45 },
attack: 0.004,
length: 1,
tempo: 1,
level: 1,
touch: { frequency: 2000, q: 0.8, level: 1, length: 0.012 },
lowpass: 5200,
room: 0.2,
width: 0.35,
},
crisp: {
root: 659.25,
wave: "sine",
partial: { ratio: 2.76, level: 0.3, length: 0.3 },
attack: 0.0015,
length: 0.7,
tempo: 0.85,
level: 0.8,
touch: { frequency: 4200, q: 1.6, level: 1.1, length: 0.007 },
lowpass: 11000,
room: 0.1,
width: 0.2,
},
retro: {
root: 587.33,
wave: "square",
partial: null,
attack: 0.002,
length: 0.85,
tempo: 0.9,
level: 0.45,
touch: { frequency: 5200, q: 0.6, level: 0.6, length: 0.008 },
lowpass: 3000,
room: 0,
width: 0,
},
};
const NOTE_PEAK = 0.08;
const TOUCH_PEAK = 0.045;
const AIR_PEAK = 0.06;
const PENTATONIC = [0, 2, 4, 7, 9];
function semitones(step: number) {
const octave = Math.floor(step / 5);
return octave * 12 + PENTATONIC[step - octave * 5];
}
/** The pitch of `step` on the major pentatonic scale of `root`. */
export function noteFrequency(root: number, step: number) {
return root * 2 ** (semitones(step) / 12);
}
/** A known pack id, or the default pack. */
export function resolvePack(id: string | undefined): SoundPackId {
return SOUND_PACKS.find((pack) => pack.id === id)?.id ?? DEFAULT_SOUND_PACK;
}
export function compileSound(sound: UiSound, packId?: string): CompiledSound {
const pack = PACKS[resolvePack(packId)];
const phrase = PHRASES[sound];
const voices: SoundVoice[] = [];
for (const note of phrase.notes) {
const frequency = noteFrequency(pack.root, note.step);
const slide = note.slide ? 2 ** (note.slide / 12) : null;
const delay = note.at * pack.tempo;
const decay = note.length * pack.length;
const peak = NOTE_PEAK * note.level * pack.level;
const pan = Math.max(-1, Math.min(1, (note.step - 4) / 8)) * pack.width;
voices.push({
wave: pack.wave,
frequency,
...(slide ? { endFrequency: frequency * slide } : {}),
delay,
attack: pack.attack,
decay,
peak,
pan,
});
if (pack.partial) {
const { ratio, level, length } = pack.partial;
voices.push({
wave: "sine",
frequency: frequency * ratio,
...(slide ? { endFrequency: frequency * ratio * slide } : {}),
delay,
attack: pack.attack,
decay: decay * length,
peak: peak * level,
pan,
});
}
}
if (phrase.touch) {
voices.push({
wave: "noise",
frequency: 0,
delay: 0,
attack: 0.001,
decay: 0.012,
peak: 0.035,
bandPass: { frequency: 3600, q: 1.1 },
},
],
toggleOn: [
{
wave: "sine",
frequency: 1100,
decay: pack.touch.length,
peak: TOUCH_PEAK * phrase.touch * pack.touch.level,
bandPass: { frequency: pack.touch.frequency, q: pack.touch.q },
pan: 0,
});
}
if (phrase.air) {
const { length, level, frequency } = phrase.air;
voices.push({
wave: "noise",
frequency: 0,
delay: 0,
attack: 0.002,
decay: 0.035,
peak: 0.06,
attack: length * 0.35,
decay: length * 0.65,
peak: AIR_PEAK * level,
bandPass: { frequency, q: 0.7 },
pan: 0,
});
}
return {
voices,
lowpass: pack.lowpass,
room: pack.room,
jitter: phrase.jitter ?? 0.008,
};
}
/** Seconds from the start of a sound to the end of its last voice. */
export function soundLength(compiled: CompiledSound) {
return Math.max(
...compiled.voices.map((voice) => voice.delay + voice.attack + voice.decay),
);
}
/** 0 to 100, whole numbers. Anything else is the default. */
export function clampVolume(volume: number | undefined) {
if (typeof volume !== "number" || !Number.isFinite(volume)) {
return DEFAULT_SOUND_VOLUME;
}
return Math.round(Math.max(0, Math.min(100, volume)));
}
/**
* The output level for a volume. Loudness follows the square of the
* setting, so the low half of the slider is usable, and the default is the
* level the sounds were tuned at.
*/
export function volumeGain(volume: number) {
const ratio = clampVolume(volume) / DEFAULT_SOUND_VOLUME;
return 0.6 * ratio * ratio;
}
export interface ResolvedSoundSettings {
enabled: boolean;
volume: number;
pack: SoundPackId;
categories: Record<SoundCategory, boolean>;
}
export function resolveSoundSettings(
settings: SoundSettings,
): ResolvedSoundSettings {
return {
enabled: settings.ui_sounds ?? true,
volume: clampVolume(settings.ui_sound_volume),
pack: resolvePack(settings.ui_sound_pack),
categories: {
interface: settings.ui_sound_interface ?? true,
feedback: settings.ui_sound_feedback ?? true,
events: settings.ui_sound_events ?? true,
},
{
wave: "sine",
frequency: 1650,
delay: 0.045,
attack: 0.002,
decay: 0.05,
peak: 0.06,
},
],
toggleOff: [
{
wave: "sine",
frequency: 1650,
delay: 0,
attack: 0.002,
decay: 0.035,
peak: 0.055,
},
{
wave: "sine",
frequency: 1100,
delay: 0.045,
attack: 0.002,
decay: 0.05,
peak: 0.055,
},
],
success: [
{
wave: "sine",
frequency: 880,
delay: 0,
attack: 0.004,
decay: 0.16,
peak: 0.05,
},
{
wave: "sine",
frequency: 1318.5,
delay: 0.07,
attack: 0.004,
decay: 0.22,
peak: 0.045,
},
],
error: [
{
wave: "triangle",
frequency: 330,
endFrequency: 247,
delay: 0,
attack: 0.004,
decay: 0.14,
peak: 0.08,
},
{
wave: "triangle",
frequency: 262,
endFrequency: 196,
delay: 0.09,
attack: 0.004,
decay: 0.16,
peak: 0.06,
},
],
};
};
}
/** What one play of `sound` would be, or null when the settings keep it silent. */
export function planSound(
sound: UiSound,
settings: SoundSettings | ResolvedSoundSettings,
): { compiled: CompiledSound; gain: number } | null {
const resolved =
"categories" in settings ? settings : resolveSoundSettings(settings);
if (!resolved.enabled || !resolved.categories[SOUND_CATEGORY[sound]]) {
return null;
}
const gain = volumeGain(resolved.volume);
if (gain <= 0) return null;
return { compiled: compileSound(sound, resolved.pack), gain };
}
/** The shortest time between two plays of one sound, in ms. */
export const SOUND_GAP_MS: Record<UiSound, number> = {
tap: 35,
toggleOn: 60,
toggleOff: 60,
open: 120,
close: 120,
drop: 150,
copy: 250,
trash: 400,
restore: 400,
success: 1200,
error: 1200,
warning: 1200,
launch: 1500,
stop: 1500,
synced: 2000,
download: 2000,
attention: 3000,
};
/** What the sound picker needs to know about the control that was used. */
@@ -197,65 +560,103 @@ export const SOUND_CONTROL_SELECTOR = [
...[...PRESSABLE_ROLES, ...TOGGLE_ROLES].map((role) => `[role="${role}"]`),
].join(",");
let enabled = true;
let context: AudioContext | null = null;
let output: GainNode | null = null;
let noise: AudioBuffer | null = null;
const lastPlayed = new Map<UiSound, number>();
/**
* How long after the window lost focus a sound the person caused may still
* play: a launch hands the focus to the browser it opened before it returns.
*/
const HANDOFF_MS = 3000;
const PREVIEW_GAP_MS = 80;
export function setUiSoundsEnabled(on: boolean) {
enabled = on;
let active = resolveSoundSettings({});
let lastBlurAt = Number.NEGATIVE_INFINITY;
let lastPreviewAt = Number.NEGATIVE_INFINITY;
const lastPlayed = new Map<UiSound, number>();
const lastBatch = new Map<UiSound, string | number>();
interface Engine {
context: AudioContext;
output: AudioNode;
room: AudioNode | null;
noise: AudioBuffer;
}
let engine: Engine | null = null;
/** Use the sound fields of the app settings from now on. */
export function applySoundSettings(settings: SoundSettings) {
active = resolveSoundSettings(settings);
}
function audio(): AudioContext | null {
if (context) return context;
/** A short, dark room: decaying noise, different in each ear so it is wide. */
function roomImpulse(context: AudioContext) {
const length = Math.ceil(context.sampleRate * 0.4);
const buffer = context.createBuffer(2, length, context.sampleRate);
for (let channel = 0; channel < 2; channel++) {
const samples = buffer.getChannelData(channel);
let smooth = 0;
for (let i = 0; i < length; i++) {
smooth += 0.25 * (Math.random() * 2 - 1 - smooth);
samples[i] = smooth * Math.exp(-i / context.sampleRate / 0.085);
}
}
return buffer;
}
function audio(): Engine | null {
if (engine) return engine;
const Context = window.AudioContext;
if (!Context) return null;
context = new Context({ latencyHint: "interactive" });
// A gentle low-pass takes the edge off every voice; the master level keeps
// the whole set well under anything else a computer plays.
const soften = context.createBiquadFilter();
soften.type = "lowpass";
soften.frequency.value = 7000;
output = context.createGain();
output.gain.value = 0.6;
output.connect(soften);
soften.connect(context.destination);
noise = context.createBuffer(
const context = new Context({ latencyHint: "interactive" });
// A limiter keeps overlapping sounds at full volume from clipping.
const limiter = context.createDynamicsCompressor();
limiter.threshold.value = -6;
limiter.knee.value = 6;
limiter.ratio.value = 12;
limiter.attack.value = 0.002;
limiter.release.value = 0.12;
limiter.connect(context.destination);
let room: AudioNode | null = null;
try {
const convolver = context.createConvolver();
convolver.buffer = roomImpulse(context);
convolver.connect(limiter);
room = convolver;
} catch {
room = null;
}
const noise = context.createBuffer(
1,
Math.ceil(context.sampleRate * 0.05),
Math.ceil(context.sampleRate * 0.25),
context.sampleRate,
);
const samples = noise.getChannelData(0);
for (let i = 0; i < samples.length; i++) samples[i] = Math.random() * 2 - 1;
return context;
engine = { context, output: limiter, room, noise };
return engine;
}
function schedule(
ctx: AudioContext,
{ context, noise }: Engine,
voice: SoundVoice,
at: number,
pitch: number,
level: number,
bus: AudioNode,
) {
if (!output) return;
const start = at + voice.delay;
const end = start + voice.attack + voice.decay;
const envelope = ctx.createGain();
const envelope = context.createGain();
envelope.gain.setValueAtTime(0, start);
envelope.gain.linearRampToValueAtTime(
voice.peak * level,
start + voice.attack,
);
envelope.gain.linearRampToValueAtTime(voice.peak, start + voice.attack);
envelope.gain.exponentialRampToValueAtTime(0.0001, end);
let source: AudioScheduledSourceNode;
if (voice.wave === "noise") {
const buffer = ctx.createBufferSource();
const buffer = context.createBufferSource();
buffer.buffer = noise;
// A different stretch of the noise each play.
buffer.start(start, Math.random() * 0.1);
source = buffer;
} else {
const oscillator = ctx.createOscillator();
const oscillator = context.createOscillator();
oscillator.type = voice.wave;
oscillator.frequency.setValueAtTime(voice.frequency * pitch, start);
if (voice.endFrequency) {
@@ -264,11 +665,12 @@ function schedule(
end,
);
}
oscillator.start(start);
source = oscillator;
}
let last: AudioNode = source;
if (voice.bandPass) {
const band = ctx.createBiquadFilter();
const band = context.createBiquadFilter();
band.type = "bandpass";
band.frequency.value = voice.bandPass.frequency * pitch;
band.Q.value = voice.bandPass.q;
@@ -276,14 +678,74 @@ function schedule(
last = band;
}
last.connect(envelope);
envelope.connect(output);
source.start(start);
last = envelope;
if (voice.pan !== 0 && typeof context.createStereoPanner === "function") {
const panner = context.createStereoPanner();
panner.pan.value = voice.pan;
last.connect(panner);
last = panner;
}
last.connect(bus);
source.stop(end + 0.02);
}
export function playUiSound(sound: UiSound) {
if (!enabled || typeof window === "undefined") return;
if (document.visibilityState !== "visible" || !document.hasFocus()) return;
function render(compiled: CompiledSound, gain: number) {
const sound = audio();
if (!sound) return;
const { context } = sound;
if (context.state === "suspended") void context.resume();
const pitch = 1 + (Math.random() - 0.5) * 2 * compiled.jitter;
const at = context.currentTime + 0.004;
const bus = context.createGain();
bus.gain.value = gain * (1 + (Math.random() - 0.5) * 0.16);
const tone = context.createBiquadFilter();
tone.type = "lowpass";
tone.frequency.value = compiled.lowpass;
tone.Q.value = 0.5;
bus.connect(tone);
tone.connect(sound.output);
if (sound.room && compiled.room > 0) {
const send = context.createGain();
send.gain.value = compiled.room;
tone.connect(send);
send.connect(sound.room);
}
for (const voice of compiled.voices) {
schedule(sound, voice, at, pitch, bus);
}
window.setTimeout(
() => tone.disconnect(),
(soundLength(compiled) + 0.5) * 1000,
);
}
function inFront(causedAt: number | undefined) {
if (document.visibilityState !== "visible") return false;
if (document.hasFocus()) return true;
return (
causedAt !== undefined &&
lastBlurAt >= causedAt &&
performance.now() - lastBlurAt < HANDOFF_MS
);
}
export interface PlayOptions {
/**
* `performance.now()` when the person started the action. The sound still
* plays when the action itself took the focus away a moment ago, as a
* launch does when its browser window opens.
*/
causedAt?: number;
/** One id per bulk run: only the first play of a sound in a run sounds. */
batch?: string | number;
}
export function playUiSound(sound: UiSound, options: PlayOptions = {}) {
if (typeof window === "undefined") return;
const plan = planSound(sound, active);
if (!plan || !inFront(options.causedAt)) return;
const { batch } = options;
if (batch !== undefined && lastBatch.get(sound) === batch) return;
const now = performance.now();
if (
now - (lastPlayed.get(sound) ?? Number.NEGATIVE_INFINITY) <
@@ -292,19 +754,34 @@ export function playUiSound(sound: UiSound) {
return;
}
lastPlayed.set(sound, now);
if (batch !== undefined) lastBatch.set(sound, batch);
try {
const ctx = audio();
if (!ctx) return;
if (ctx.state === "suspended") void ctx.resume();
const pitch = 1 + (Math.random() - 0.5) * 0.08;
const level = 1 + (Math.random() - 0.5) * 0.2;
const at = ctx.currentTime + 0.004;
for (const voice of SOUNDS[sound]) schedule(ctx, voice, at, pitch, level);
render(plan.compiled, plan.gain);
} catch (error) {
console.debug("UI sound not played:", error);
}
}
/**
* Plays `sound` with the draft's pack and volume, ignoring the master switch
* and categories. Used by Settings previews.
*/
export function previewUiSound(sound: UiSound, draft: SoundSettings) {
if (typeof window === "undefined") return;
if (document.visibilityState !== "visible" || !document.hasFocus()) return;
const settings = resolveSoundSettings(draft);
const gain = volumeGain(settings.volume);
if (gain <= 0) return;
const now = performance.now();
if (now - lastPreviewAt < PREVIEW_GAP_MS) return;
lastPreviewAt = now;
try {
render(compileSound(sound, settings.pack), gain);
} catch (error) {
console.debug("UI sound preview not played:", error);
}
}
function controlInfo(element: Element): ControlInfo {
return {
role: element.getAttribute("role"),
@@ -318,7 +795,10 @@ function controlInfo(element: Element): ControlInfo {
};
}
/** Listen for presses and toggles across the whole window. */
/**
* Listen across the whole window for presses, toggles, and Escape closing a
* dialog or page.
*/
export function installUiSounds(): () => void {
const control = (target: EventTarget | null) =>
target instanceof Element ? target.closest(SOUND_CONTROL_SELECTOR) : null;
@@ -345,14 +825,40 @@ export function installUiSounds(): () => void {
if (sound) playUiSound(sound);
});
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.repeat) return;
const dialog =
event.target instanceof Element
? event.target.closest('[role="dialog"],[role="alertdialog"]')
: null;
if (!dialog) return;
requestAnimationFrame(() => {
if (
!dialog.isConnected ||
dialog.getAttribute("data-state") === "closed"
) {
playUiSound("close");
}
});
};
const onBlur = (event: FocusEvent) => {
if (event.target === window) lastBlurAt = performance.now();
};
window.addEventListener("pointerdown", onPointerDown, {
capture: true,
passive: true,
});
window.addEventListener("click", onClick, { capture: true, passive: true });
window.addEventListener("keydown", onKeyDown, {
capture: true,
passive: true,
});
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("pointerdown", onPointerDown, { capture: true });
window.removeEventListener("click", onClick, { capture: true });
window.removeEventListener("keydown", onKeyDown, { capture: true });
window.removeEventListener("blur", onBlur);
};
}
+3 -1
View File
@@ -66,7 +66,9 @@
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
/* `.light` scopes the light palette to one element, for theme previews. */
:root,
.light {
--radius: 0.625rem;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--background: oklch(1 0 0);