sound settings
This commit is contained in:
parent
54e23ef0f1
commit
da1fdc25f8
|
|
@ -0,0 +1,38 @@
|
|||
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::state::app_preferences::settings::app_sound_preferences::AppSoundEvent;
|
||||
use log::{error, info, warn};
|
||||
use tauri::ipc::Response;
|
||||
use tauri::State;
|
||||
|
||||
/// The bytes of the custom `.wav` configured for an event, for the frontend
|
||||
/// to play. The path comes from the saved preference — the frontend never
|
||||
/// names a file — so this can only read files the user picked in Settings.
|
||||
///
|
||||
/// Errors (returned as a plain message, logged at `warn`) when the event has
|
||||
/// no custom sound or the file has since gone missing; the frontend treats
|
||||
/// that as "play nothing".
|
||||
#[tauri::command]
|
||||
pub async fn load_custom_sound_command(
|
||||
event: AppSoundEvent,
|
||||
app_prefs: State<'_, AppPreferencesManager>,
|
||||
) -> Result<Response, String> {
|
||||
info!("load_custom_sound_command called for {:?}", event);
|
||||
|
||||
let prefs = app_prefs.get().map_err(|err| {
|
||||
error!("Error reading app preferences: {:?}", err);
|
||||
format!("Error reading app preferences: {:?}", err)
|
||||
})?;
|
||||
|
||||
let path = prefs
|
||||
.sounds
|
||||
.get(event)
|
||||
.and_then(|sound| sound.custom_wav_path())
|
||||
.ok_or_else(|| format!("No custom sound is configured for {:?}", event))?;
|
||||
|
||||
let bytes = std::fs::read(path).map_err(|err| {
|
||||
warn!("Custom sound for {:?} could not be read from {}: {}", event, path.display(), err);
|
||||
format!("Custom sound file could not be read: {}", path.display())
|
||||
})?;
|
||||
|
||||
Ok(Response::new(bytes))
|
||||
}
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
pub mod get_app_preferences_command;
|
||||
pub mod update_app_preference_command;
|
||||
pub mod load_custom_sound_command;
|
||||
pub mod update_app_preference_command;
|
||||
pub mod update_sound_preference_command;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
use crate::state::app_preferences::app_preferences::AppPreferences;
|
||||
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::state::app_preferences::settings::app_sound_file::{optional_sound, AppSoundFile};
|
||||
use crate::state::app_preferences::settings::preferred_download_directory::PreferredDownloadDirectory;
|
||||
use crate::state::app_preferences::settings::preferred_download_filename::PreferredDownloadFilename;
|
||||
use anyhow::anyhow;
|
||||
|
|
@ -10,19 +9,16 @@ use serde_derive::{Deserialize, Serialize};
|
|||
use tauri::State;
|
||||
|
||||
/// One preference change, typed per preference. The frontend sends
|
||||
/// `{ "preference": "<name>", "value": <value> }`; a sound preference with a
|
||||
/// missing, `null`, or `"none"` value means "silent".
|
||||
/// `{ "preference": "<name>", "value": <value> }`.
|
||||
///
|
||||
/// Per-event sounds have their own command (`update_sound_preference_command`),
|
||||
/// since custom files need validating.
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "preference", content = "value", rename_all = "snake_case")]
|
||||
pub enum UpdateAppPreferencesRequest {
|
||||
PreferredDownloadDirectory(PreferredDownloadDirectory),
|
||||
PreferredDownloadFilename(PreferredDownloadFilename),
|
||||
PlaySounds(bool),
|
||||
DeleteFileSound(#[serde(with = "optional_sound")] Option<AppSoundFile>),
|
||||
EnqueueSuccessSound(#[serde(with = "optional_sound")] Option<AppSoundFile>),
|
||||
EnqueueFailureSound(#[serde(with = "optional_sound")] Option<AppSoundFile>),
|
||||
GenerationSuccessSound(#[serde(with = "optional_sound")] Option<AppSoundFile>),
|
||||
GenerationFailureSound(#[serde(with = "optional_sound")] Option<AppSoundFile>),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -67,11 +63,6 @@ fn apply(request: UpdateAppPreferencesRequest, prefs: &mut AppPreferences) {
|
|||
PreferredDownloadDirectory(directory) => prefs.downloads.preferred_download_directory = directory,
|
||||
PreferredDownloadFilename(filename) => prefs.downloads.preferred_download_filename = filename,
|
||||
PlaySounds(enabled) => prefs.sounds.play_sounds = enabled,
|
||||
DeleteFileSound(sound) => prefs.sounds.delete_file = sound,
|
||||
EnqueueSuccessSound(sound) => prefs.sounds.enqueue_success = sound,
|
||||
EnqueueFailureSound(sound) => prefs.sounds.enqueue_failure = sound,
|
||||
GenerationSuccessSound(sound) => prefs.sounds.generation_success = sound,
|
||||
GenerationFailureSound(sound) => prefs.sounds.generation_failure = sound,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,39 +97,13 @@ mod tests {
|
|||
decode(r#"{"preference":"preferred_download_directory","value":{"system":"downloads"}}"#),
|
||||
UpdateAppPreferencesRequest::PreferredDownloadDirectory(PreferredDownloadDirectory::System(_)),
|
||||
));
|
||||
assert!(matches!(
|
||||
decode(r#"{"preference":"enqueue_success_sound","value":"done"}"#),
|
||||
UpdateAppPreferencesRequest::EnqueueSuccessSound(Some(AppSoundFile::Done)),
|
||||
));
|
||||
assert!(matches!(
|
||||
decode(r#"{"preference":"generation_failure_sound","value":{"custom_wav":"/tmp/x.wav"}}"#),
|
||||
UpdateAppPreferencesRequest::GenerationFailureSound(Some(AppSoundFile::CustomWav(_))),
|
||||
));
|
||||
}
|
||||
|
||||
/// "None (Silent)" in the UI sends `value: undefined` (key absent) — and
|
||||
/// `null` must work too.
|
||||
#[test]
|
||||
fn silent_sound_decodes_from_missing_or_null_value() {
|
||||
assert!(matches!(
|
||||
decode(r#"{"preference":"delete_file_sound"}"#),
|
||||
UpdateAppPreferencesRequest::DeleteFileSound(None),
|
||||
));
|
||||
assert!(matches!(
|
||||
decode(r#"{"preference":"delete_file_sound","value":null}"#),
|
||||
UpdateAppPreferencesRequest::DeleteFileSound(None),
|
||||
));
|
||||
assert!(matches!(
|
||||
decode(r#"{"preference":"delete_file_sound","value":"none"}"#),
|
||||
UpdateAppPreferencesRequest::DeleteFileSound(None),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_value_type_is_rejected() {
|
||||
assert!(serde_json::from_str::<UpdateAppPreferencesRequest>(r#"{"preference":"play_sounds","value":"yes"}"#).is_err());
|
||||
assert!(serde_json::from_str::<UpdateAppPreferencesRequest>(r#"{"preference":"enqueue_success_sound","value":"not_a_sound"}"#).is_err());
|
||||
assert!(serde_json::from_str::<UpdateAppPreferencesRequest>(r#"{"preference":"generation_enqueue_sound","value":"done"}"#).is_err());
|
||||
// Per-event sounds moved to `update_sound_preference_command`.
|
||||
assert!(serde_json::from_str::<UpdateAppPreferencesRequest>(r#"{"preference":"enqueue_success_sound","value":"done"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -153,10 +118,8 @@ mod tests {
|
|||
fn apply_writes_into_the_right_group() {
|
||||
let mut prefs = AppPreferences::default();
|
||||
apply(decode(r#"{"preference":"play_sounds","value":false}"#), &mut prefs);
|
||||
apply(decode(r#"{"preference":"generation_success_sound"}"#), &mut prefs);
|
||||
apply(decode(r#"{"preference":"preferred_download_directory","value":{"custom":"/tmp/out"}}"#), &mut prefs);
|
||||
assert!(!prefs.sounds.play_sounds);
|
||||
assert_eq!(prefs.sounds.generation_success, None);
|
||||
assert_eq!(prefs.downloads.preferred_download_directory, PreferredDownloadDirectory::Custom("/tmp/out".into()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::state::app_preferences::settings::app_sound_file::{optional_sound, AppSoundFile};
|
||||
use crate::state::app_preferences::settings::app_sound_preferences::{AppSoundEvent, AppSoundPreferences};
|
||||
use log::{error, info, warn};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
/// Change the sound for one event.
|
||||
///
|
||||
/// - `set`: `sound` is a catalog key (e.g. `"done"`), `{ "custom_wav":
|
||||
/// "/abs/path.wav" }`, or `"none"` / `null` / absent for silent. A custom
|
||||
/// file is checked to exist (and be a `.wav`) before it's accepted.
|
||||
/// - `reset_to_default`: back to the out-of-the-box sound for the event
|
||||
/// (whatever `AppSoundPreferences::default()` says).
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum UpdateSoundPreferenceRequest {
|
||||
Set {
|
||||
event: AppSoundEvent,
|
||||
#[serde(default, with = "optional_sound")]
|
||||
sound: Option<AppSoundFile>,
|
||||
},
|
||||
ResetToDefault {
|
||||
event: AppSoundEvent,
|
||||
},
|
||||
}
|
||||
|
||||
/// The full sound preferences after the change, so the UI can re-render
|
||||
/// without a second round trip.
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateSoundPreferenceResponse {
|
||||
pub sounds: AppSoundPreferences,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_sound_preference_command(
|
||||
request: UpdateSoundPreferenceRequest,
|
||||
app_prefs: State<'_, AppPreferencesManager>,
|
||||
) -> Result<UpdateSoundPreferenceResponse, String> {
|
||||
info!("update_sound_preference_command called: {:?}", request);
|
||||
|
||||
if let UpdateSoundPreferenceRequest::Set { event, sound: Some(sound) } = &request {
|
||||
sound.validate().map_err(|reason| {
|
||||
warn!("Rejected sound for {:?}: {}", event, reason);
|
||||
reason
|
||||
})?;
|
||||
}
|
||||
|
||||
let prefs = app_prefs
|
||||
.update(|prefs| match request {
|
||||
UpdateSoundPreferenceRequest::Set { event, sound } => prefs.sounds.set(event, sound),
|
||||
UpdateSoundPreferenceRequest::ResetToDefault { event } => prefs.sounds.reset_to_default(event),
|
||||
})
|
||||
.map_err(|err| {
|
||||
error!("Error updating sound preference: {:?}", err);
|
||||
format!("Error updating sound preference: {:?}", err)
|
||||
})?;
|
||||
|
||||
Ok(UpdateSoundPreferenceResponse { sounds: prefs.sounds })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decode(json: &str) -> UpdateSoundPreferenceRequest {
|
||||
serde_json::from_str(json).unwrap()
|
||||
}
|
||||
|
||||
fn set_sound(request: UpdateSoundPreferenceRequest) -> (AppSoundEvent, Option<AppSoundFile>) {
|
||||
match request {
|
||||
UpdateSoundPreferenceRequest::Set { event, sound } => (event, sound),
|
||||
other => panic!("expected a set action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin the wire format the frontend sends (`UpdateSoundPreference.ts`).
|
||||
#[test]
|
||||
fn decodes_catalog_custom_and_silent() {
|
||||
let (event, sound) = set_sound(decode(r#"{"action":"set","event":"enqueue_success","sound":"done"}"#));
|
||||
assert_eq!(event, AppSoundEvent::EnqueueSuccess);
|
||||
assert_eq!(sound, Some(AppSoundFile::Done));
|
||||
|
||||
let (event, sound) = set_sound(decode(r#"{"action":"set","event":"generation_failure","sound":{"custom_wav":"/tmp/x.wav"}}"#));
|
||||
assert_eq!(event, AppSoundEvent::GenerationFailure);
|
||||
assert_eq!(sound, Some(AppSoundFile::CustomWav("/tmp/x.wav".into())));
|
||||
|
||||
assert_eq!(set_sound(decode(r#"{"action":"set","event":"delete_file","sound":"none"}"#)).1, None);
|
||||
assert_eq!(set_sound(decode(r#"{"action":"set","event":"delete_file","sound":null}"#)).1, None);
|
||||
assert_eq!(set_sound(decode(r#"{"action":"set","event":"delete_file"}"#)).1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_reset() {
|
||||
assert!(matches!(
|
||||
decode(r#"{"action":"reset_to_default","event":"generation_success"}"#),
|
||||
UpdateSoundPreferenceRequest::ResetToDefault { event: AppSoundEvent::GenerationSuccess },
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_action_event_or_sound() {
|
||||
assert!(serde_json::from_str::<UpdateSoundPreferenceRequest>(r#"{"event":"delete_file","sound":"done"}"#).is_err());
|
||||
assert!(serde_json::from_str::<UpdateSoundPreferenceRequest>(r#"{"action":"set","event":"launch","sound":"done"}"#).is_err());
|
||||
assert!(serde_json::from_str::<UpdateSoundPreferenceRequest>(r#"{"action":"set","event":"delete_file","sound":"bogus"}"#).is_err());
|
||||
}
|
||||
|
||||
/// Resetting reads the default from the `Default` impl, so changing a
|
||||
/// default there is the only place it needs changing.
|
||||
#[test]
|
||||
fn reset_restores_the_default_impl_value() {
|
||||
let mut sounds = AppSoundPreferences::default();
|
||||
sounds.set(AppSoundEvent::EnqueueFailure, Some(AppSoundFile::CustomWav("/tmp/x.wav".into())));
|
||||
sounds.set(AppSoundEvent::DeleteFile, None);
|
||||
|
||||
sounds.reset_to_default(AppSoundEvent::EnqueueFailure);
|
||||
sounds.reset_to_default(AppSoundEvent::DeleteFile);
|
||||
|
||||
let defaults = AppSoundPreferences::default();
|
||||
assert_eq!(sounds.enqueue_failure, defaults.enqueue_failure);
|
||||
assert_eq!(sounds.delete_file, defaults.delete_file);
|
||||
assert_eq!(sounds, defaults);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,9 @@ use crate::commands::credentials::list_credentials_command::list_credentials_com
|
|||
use crate::commands::credentials::open_web_login_command::open_web_login_command;
|
||||
use crate::commands::credentials::refresh_grok_statsig_command::refresh_grok_statsig_command;
|
||||
use crate::commands::service::app_preferences::get_app_preferences_command::get_app_preferences_command;
|
||||
use crate::commands::service::app_preferences::load_custom_sound_command::load_custom_sound_command;
|
||||
use crate::commands::service::app_preferences::update_app_preference_command::update_app_preferences_command;
|
||||
use crate::commands::service::app_preferences::update_sound_preference_command::update_sound_preference_command;
|
||||
use crate::commands::cost_estimate::estimate_audio_cost_command::estimate_audio_cost_command;
|
||||
use crate::commands::cost_estimate::estimate_image_cost_command::estimate_image_cost_command;
|
||||
use crate::commands::cost_estimate::estimate_mesh_cost_command::estimate_mesh_cost_command;
|
||||
|
|
@ -220,11 +222,13 @@ pub fn run() {
|
|||
get_app_info_command,
|
||||
get_app_preferences_command,
|
||||
get_task_queue_command,
|
||||
load_custom_sound_command,
|
||||
load_without_cors_command,
|
||||
mark_task_as_dismissed_command,
|
||||
platform_info_command,
|
||||
tasks_nuke_all_command,
|
||||
update_app_preferences_command,
|
||||
update_sound_preference_command,
|
||||
]);
|
||||
|
||||
builder.run(tauri::generate_context!("tauri.conf.json"))
|
||||
|
|
|
|||
|
|
@ -60,6 +60,37 @@ pub enum AppSoundFile {
|
|||
CustomWav(PathBuf),
|
||||
}
|
||||
|
||||
impl AppSoundFile {
|
||||
/// Whether this sound can be saved: catalog sounds always can; a custom file
|
||||
/// must be an absolute path to an existing `.wav` file. Returns a
|
||||
/// user-facing reason when it can't.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let Self::CustomWav(path) = self else {
|
||||
return Ok(());
|
||||
};
|
||||
if !path.is_absolute() {
|
||||
return Err(format!("Sound file path must be absolute: {}", path.display()));
|
||||
}
|
||||
let is_wav = path.extension()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("wav"))
|
||||
.unwrap_or(false);
|
||||
if !is_wav {
|
||||
return Err(format!("Sound file must be a .wav file: {}", path.display()));
|
||||
}
|
||||
if !path.is_file() {
|
||||
return Err(format!("Sound file does not exist: {}", path.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn custom_wav_path(&self) -> Option<&std::path::Path> {
|
||||
match self {
|
||||
Self::CustomWav(path) => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serde helper for `Option<AppSoundFile>` fields: `None` (silent) is written
|
||||
/// as the string `"none"` — TOML has no null, and the frontend already uses
|
||||
/// `"none"` for "None (Silent)". Reading accepts `"none"`, `null`, or a sound.
|
||||
|
|
@ -125,6 +156,28 @@ mod tests {
|
|||
assert!(serde_json::from_str::<Holder>("{\"sound\":\"bogus\"}").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_requires_an_existing_absolute_wav() {
|
||||
assert!(AppSoundFile::Done.validate().is_ok());
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wav = dir.path().join("ding.wav");
|
||||
std::fs::write(&wav, b"RIFF").unwrap();
|
||||
let upper = dir.path().join("DING.WAV");
|
||||
std::fs::write(&upper, b"RIFF").unwrap();
|
||||
let mp3 = dir.path().join("ding.mp3");
|
||||
std::fs::write(&mp3, b"ID3").unwrap();
|
||||
|
||||
assert!(AppSoundFile::CustomWav(wav).validate().is_ok());
|
||||
assert!(AppSoundFile::CustomWav(upper).validate().is_ok());
|
||||
assert!(AppSoundFile::CustomWav(mp3).validate().unwrap_err().contains(".wav"));
|
||||
assert!(AppSoundFile::CustomWav(dir.path().join("missing.wav")).validate().unwrap_err().contains("does not exist"));
|
||||
let dir_named_wav = dir.path().join("folder.wav");
|
||||
std::fs::create_dir(&dir_named_wav).unwrap();
|
||||
assert!(AppSoundFile::CustomWav(dir_named_wav).validate().unwrap_err().contains("does not exist"));
|
||||
assert!(AppSoundFile::CustomWav("relative/ding.wav".into()).validate().unwrap_err().contains("absolute"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_wav_serializes_as_tagged_path() {
|
||||
let sound = AppSoundFile::CustomWav("/tmp/ding.wav".into());
|
||||
|
|
|
|||
|
|
@ -33,6 +33,52 @@ pub struct AppSoundPreferences {
|
|||
pub generation_failure: Option<AppSoundFile>,
|
||||
}
|
||||
|
||||
/// The app events that can play a sound; one per field of
|
||||
/// [`AppSoundPreferences`]. Serialized as the field names.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AppSoundEvent {
|
||||
DeleteFile,
|
||||
EnqueueSuccess,
|
||||
EnqueueFailure,
|
||||
GenerationSuccess,
|
||||
GenerationFailure,
|
||||
}
|
||||
|
||||
impl AppSoundPreferences {
|
||||
pub fn get(&self, event: AppSoundEvent) -> Option<&AppSoundFile> {
|
||||
match event {
|
||||
AppSoundEvent::DeleteFile => self.delete_file.as_ref(),
|
||||
AppSoundEvent::EnqueueSuccess => self.enqueue_success.as_ref(),
|
||||
AppSoundEvent::EnqueueFailure => self.enqueue_failure.as_ref(),
|
||||
AppSoundEvent::GenerationSuccess => self.generation_success.as_ref(),
|
||||
AppSoundEvent::GenerationFailure => self.generation_failure.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The out-of-the-box sound for an event (from the `Default` impl).
|
||||
pub fn default_for(event: AppSoundEvent) -> Option<AppSoundFile> {
|
||||
Self::default().get(event).cloned()
|
||||
}
|
||||
|
||||
/// Put the event back to its out-of-the-box sound.
|
||||
pub fn reset_to_default(&mut self, event: AppSoundEvent) {
|
||||
self.set(event, Self::default_for(event));
|
||||
}
|
||||
|
||||
/// `None` silences the event.
|
||||
pub fn set(&mut self, event: AppSoundEvent, sound: Option<AppSoundFile>) {
|
||||
let slot = match event {
|
||||
AppSoundEvent::DeleteFile => &mut self.delete_file,
|
||||
AppSoundEvent::EnqueueSuccess => &mut self.enqueue_success,
|
||||
AppSoundEvent::EnqueueFailure => &mut self.enqueue_failure,
|
||||
AppSoundEvent::GenerationSuccess => &mut self.generation_success,
|
||||
AppSoundEvent::GenerationFailure => &mut self.generation_failure,
|
||||
};
|
||||
*slot = sound;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppSoundPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
|
|||
|
|
@ -1,109 +1,55 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { SoundManager } from "@storyteller/soundboard";
|
||||
import { SoundManager, SoundOption } from "@storyteller/soundboard";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Play } from "lucide-react";
|
||||
import { FolderOpen, Play, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
AppPreferencesPayload,
|
||||
AppSoundEvent,
|
||||
AppSoundFile,
|
||||
AppSoundPreferences,
|
||||
GetAppPreferences,
|
||||
PreferenceName,
|
||||
ResetSoundPreference,
|
||||
SILENT_SOUND,
|
||||
UpdateAppPreferences,
|
||||
UpdateSoundPreference,
|
||||
isCustomWavSound,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Select, SelectValue } from "@storyteller/ui-select";
|
||||
import { Switch } from "@storyteller/ui-switch";
|
||||
import { Label } from "@storyteller/ui-label";
|
||||
|
||||
interface AudioSettingsPaneProps {}
|
||||
|
||||
// Rows in display order.
|
||||
const SOUND_EVENTS: { event: AppSoundEvent; label: string }[] = [
|
||||
{ event: "delete_file", label: "Delete File Sound" },
|
||||
{ event: "enqueue_success", label: "Enqueue Success Sound" },
|
||||
{ event: "enqueue_failure", label: "Enqueue Failure Sound" },
|
||||
{ event: "generation_success", label: "Generation Success Sound" },
|
||||
{ event: "generation_failure", label: "Generation Failure Sound" },
|
||||
];
|
||||
|
||||
export const AudioSettingsPane = (args: AudioSettingsPaneProps) => {
|
||||
const [preferences, setPreferences] = useState<
|
||||
AppPreferencesPayload | undefined
|
||||
>(undefined);
|
||||
const [sounds, setSounds] = useState<AppSoundPreferences | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const prefs = await GetAppPreferences();
|
||||
setPreferences(prefs.preferences);
|
||||
setSounds(prefs.preferences.sounds);
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const sounds = preferences?.sounds;
|
||||
const playSounds = sounds?.play_sounds || false;
|
||||
|
||||
const deleteFileSound = orNone(sounds?.delete_file);
|
||||
const enqueueSuccessSound = orNone(sounds?.enqueue_success);
|
||||
const enqueueFailureSound = orNone(sounds?.enqueue_failure);
|
||||
const generationSuccessSound = orNone(sounds?.generation_success);
|
||||
const generationFailureSound = orNone(sounds?.generation_failure);
|
||||
|
||||
const reloadPreferences = async () => {
|
||||
const prefs = await GetAppPreferences();
|
||||
setPreferences(prefs.preferences);
|
||||
};
|
||||
|
||||
const setPlaySounds = async (checked: boolean) => {
|
||||
//const value = checked ? "true" : "false";
|
||||
await UpdateAppPreferences({
|
||||
preference: PreferenceName.PlaySounds,
|
||||
value: checked,
|
||||
});
|
||||
await reloadPreferences();
|
||||
};
|
||||
|
||||
const setDeleteFileSound = async (val: string) => {
|
||||
let sendVal = val === "none" ? undefined : val;
|
||||
await UpdateAppPreferences({
|
||||
preference: PreferenceName.DeleteFileSound,
|
||||
value: sendVal,
|
||||
});
|
||||
SoundManager.playPreview(val);
|
||||
await reloadPreferences();
|
||||
};
|
||||
|
||||
const setEnqueueSuccessSound = async (val: string) => {
|
||||
let sendVal = val === "none" ? undefined : val;
|
||||
await UpdateAppPreferences({
|
||||
preference: PreferenceName.EnqueueSuccessSound,
|
||||
value: sendVal,
|
||||
});
|
||||
SoundManager.playPreview(val);
|
||||
await reloadPreferences();
|
||||
};
|
||||
|
||||
const setEnqueueFailureSound = async (val: string) => {
|
||||
let sendVal = val === "none" ? undefined : val;
|
||||
await UpdateAppPreferences({
|
||||
preference: PreferenceName.EnqueueFailureSound,
|
||||
value: sendVal,
|
||||
});
|
||||
SoundManager.playPreview(val);
|
||||
await reloadPreferences();
|
||||
};
|
||||
|
||||
const setSuccessSound = async (val: string) => {
|
||||
let sendVal = val === "none" ? undefined : val;
|
||||
await UpdateAppPreferences({
|
||||
preference: PreferenceName.GenerationSuccessSound,
|
||||
value: sendVal,
|
||||
});
|
||||
SoundManager.playPreview(val);
|
||||
await reloadPreferences();
|
||||
};
|
||||
|
||||
const setFailureSound = async (val: string) => {
|
||||
let sendVal = val === "none" ? undefined : val;
|
||||
await UpdateAppPreferences({
|
||||
preference: PreferenceName.GenerationFailureSound,
|
||||
value: sendVal,
|
||||
});
|
||||
SoundManager.playPreview(val);
|
||||
await reloadPreferences();
|
||||
};
|
||||
|
||||
const playSound = (val?: string) => {
|
||||
if (val !== undefined && val !== "none") {
|
||||
SoundManager.playPreview(val);
|
||||
}
|
||||
const prefs = await GetAppPreferences();
|
||||
setSounds(prefs.preferences.sounds);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -116,111 +62,126 @@ export const AudioSettingsPane = (args: AudioSettingsPaneProps) => {
|
|||
<Switch enabled={playSounds} setEnabled={setPlaySounds} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="success-sound">Delete File Sound</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
id="success-sound"
|
||||
value={deleteFileSound}
|
||||
onChange={(val: SelectValue) => setDeleteFileSound(val as string)}
|
||||
options={SoundManager.OPTIONS}
|
||||
className="grow"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[40px] h-[40px]"
|
||||
icon={Play}
|
||||
onClick={() => playSound(deleteFileSound)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="success-sound">Enqueue Success Sound</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
id="success-sound"
|
||||
value={enqueueSuccessSound}
|
||||
onChange={(val: SelectValue) => setEnqueueSuccessSound(val as string)}
|
||||
options={SoundManager.OPTIONS}
|
||||
className="grow"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[40px] h-[40px]"
|
||||
icon={Play}
|
||||
onClick={() => playSound(enqueueSuccessSound)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="success-sound">Enqueue Failure Sound</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
id="success-sound"
|
||||
value={enqueueFailureSound}
|
||||
onChange={(val: SelectValue) => setEnqueueFailureSound(val as string)}
|
||||
options={SoundManager.OPTIONS}
|
||||
className="grow"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[40px] h-[40px]"
|
||||
icon={Play}
|
||||
onClick={() => playSound(enqueueFailureSound)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="success-sound">Generation Success Sound</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
id="success-sound"
|
||||
value={generationSuccessSound}
|
||||
onChange={(val: SelectValue) => setSuccessSound(val as string)}
|
||||
options={SoundManager.OPTIONS}
|
||||
className="grow"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[40px] h-[40px]"
|
||||
icon={Play}
|
||||
onClick={() => playSound(generationSuccessSound)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="failure-sound">Generation Failure Sound</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
id="failure-sound"
|
||||
value={generationFailureSound}
|
||||
onChange={(val: SelectValue) => setFailureSound(val as string)}
|
||||
options={SoundManager.OPTIONS}
|
||||
className="grow"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[40px] h-[40px]"
|
||||
icon={Play}
|
||||
onClick={() => playSound(generationFailureSound)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{SOUND_EVENTS.map(({ event, label }) => (
|
||||
<SoundEventRow
|
||||
key={event}
|
||||
event={event}
|
||||
label={label}
|
||||
sound={sounds?.[event]}
|
||||
onSoundsChanged={setSounds}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// The dropdown only knows catalog keys; anything else (silent, or a custom
|
||||
// .wav the UI can't pick yet) shows as "None (Silent)".
|
||||
const orNone = (val: AppSoundFile | undefined | null): string => {
|
||||
if (!val || typeof val !== "string") {
|
||||
return "none";
|
||||
}
|
||||
return val;
|
||||
// ── One event's sound picker ──
|
||||
|
||||
interface SoundEventRowProps {
|
||||
event: AppSoundEvent;
|
||||
label: string;
|
||||
sound: AppSoundFile | undefined;
|
||||
onSoundsChanged: (sounds: AppSoundPreferences) => void;
|
||||
}
|
||||
|
||||
const SoundEventRow = ({ event, label, sound, onSoundsChanged }: SoundEventRowProps) => {
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// The dropdown lists the presets; when a custom file is set, it also shows
|
||||
// that file's full path as the selected entry.
|
||||
const customPath = isCustomWavSound(sound) ? sound.custom_wav : undefined;
|
||||
const selectedValue = customPath ?? presetValue(sound);
|
||||
const options: SoundOption[] = customPath
|
||||
? [{ value: customPath, label: customPath }, ...SoundManager.OPTIONS]
|
||||
: SoundManager.OPTIONS;
|
||||
|
||||
const save = async (next: AppSoundFile | undefined) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const result = await UpdateSoundPreference({ event, sound: next });
|
||||
onSoundsChanged(result.sounds);
|
||||
SoundManager.playPreview(next, event);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefault = async () => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const result = await ResetSoundPreference(event);
|
||||
onSoundsChanged(result.sounds);
|
||||
SoundManager.playPreview(result.sounds[event], event);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Picking a preset or "None" from the dropdown replaces a custom file too.
|
||||
const onSelect = async (val: SelectValue) => {
|
||||
const value = String(val);
|
||||
if (value === customPath) return; // Re-selected the current custom file.
|
||||
await save(value === SILENT_SOUND ? undefined : value);
|
||||
};
|
||||
|
||||
const chooseCustomFile = async () => {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
title: `Choose a .wav file for: ${label}`,
|
||||
filters: [{ name: "WAV audio", extensions: ["wav"] }],
|
||||
});
|
||||
if (selected === null) {
|
||||
return; // User dismissed the dialog.
|
||||
}
|
||||
await save({ custom_wav: selected });
|
||||
};
|
||||
|
||||
const inputId = `${event}-sound`;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={inputId}>{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
id={inputId}
|
||||
value={selectedValue}
|
||||
onChange={onSelect}
|
||||
options={options}
|
||||
className="grow min-w-0"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[40px] h-[40px]"
|
||||
icon={Play}
|
||||
title="Preview"
|
||||
onClick={() => SoundManager.playPreview(sound, event)}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-[40px] h-[40px]"
|
||||
icon={FolderOpen}
|
||||
title="Use a custom .wav file"
|
||||
onClick={chooseCustomFile}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-[40px] h-[40px]"
|
||||
icon={RotateCcw}
|
||||
title="Reset to default"
|
||||
onClick={resetToDefault}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// The dropdown value for a preset (or silent) sound.
|
||||
const presetValue = (sound: AppSoundFile | undefined): string => {
|
||||
if (!sound || typeof sound !== "string") {
|
||||
return SILENT_SOUND;
|
||||
}
|
||||
return sound;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
// Internal to @storyteller/soundboard. Consumers go through SoundManager.
|
||||
//
|
||||
// Plays the user's own .wav files. The bytes come from the backend (which
|
||||
// reads the path saved in preferences) and are wrapped in a blob URL for
|
||||
// Howler; decoded sounds are cached per path so a file is only loaded once.
|
||||
// Every failure is logged and swallowed — a missing file must never break
|
||||
// the event that triggered the sound.
|
||||
import { Howl } from "howler";
|
||||
import { AppSoundEvent, LoadCustomSound } from "@storyteller/tauri-api";
|
||||
|
||||
type CachedSound = { path: string; howl: Howl };
|
||||
|
||||
const DEFAULT_VOLUME = 0.5;
|
||||
|
||||
export class CustomSoundPlayer {
|
||||
// Keyed by event; invalidated when the configured path changes.
|
||||
private static readonly cache = new Map<AppSoundEvent, CachedSound>();
|
||||
|
||||
// Play the custom sound configured for `event` (whose current path is
|
||||
// `path`). Resolves once playback starts or the load failed.
|
||||
static async play(event: AppSoundEvent, path: string): Promise<void> {
|
||||
const howl = await this.load(event, path);
|
||||
howl?.play();
|
||||
}
|
||||
|
||||
private static async load(event: AppSoundEvent, path: string): Promise<Howl | undefined> {
|
||||
const cached = this.cache.get(event);
|
||||
if (cached && cached.path === path) {
|
||||
return cached.howl;
|
||||
}
|
||||
if (cached) {
|
||||
cached.howl.unload();
|
||||
this.cache.delete(event);
|
||||
}
|
||||
|
||||
let bytes: ArrayBuffer;
|
||||
try {
|
||||
bytes = await LoadCustomSound(event);
|
||||
} catch (err) {
|
||||
console.warn(`Custom sound for ${event} could not be loaded (${path}):`, err);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: "audio/wav" }));
|
||||
const howl = new Howl({
|
||||
src: [url],
|
||||
// Blob URLs carry no extension, so tell Howler the codec.
|
||||
format: ["wav"],
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
volume: DEFAULT_VOLUME,
|
||||
onloaderror: (_id, err) => {
|
||||
console.warn(`Custom sound for ${event} could not be decoded (${path}):`, err);
|
||||
},
|
||||
onplayerror: (_id, err) => {
|
||||
console.warn(`Custom sound for ${event} could not be played (${path}):`, err);
|
||||
},
|
||||
});
|
||||
this.cache.set(event, { path, howl });
|
||||
return howl;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
import { GetAppPreferences } from "@storyteller/tauri-api";
|
||||
import {
|
||||
AppSoundEvent,
|
||||
AppSoundFile,
|
||||
GetAppPreferences,
|
||||
SILENT_SOUND,
|
||||
isCustomWavSound,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { CustomSoundPlayer } from "./CustomSoundPlayer";
|
||||
import { SoundEffect } from "./SoundEffect";
|
||||
import { SoundRegistry } from "./SoundRegistry";
|
||||
|
||||
|
|
@ -54,18 +61,11 @@ const SOUNDS: readonly SoundDef[] = [
|
|||
|
||||
export type SoundOption = { value: string; label: string };
|
||||
|
||||
type SoundPrefKey =
|
||||
| "delete_file"
|
||||
| "enqueue_success"
|
||||
| "enqueue_failure"
|
||||
| "generation_success"
|
||||
| "generation_failure";
|
||||
|
||||
export class SoundManager {
|
||||
// Dropdown options for AudioSettingsPane.
|
||||
// "None (Silent)" first, then user-selectable sounds A→Z by label.
|
||||
static readonly OPTIONS: SoundOption[] = [
|
||||
{ value: "none", label: "None (Silent)" },
|
||||
{ value: SILENT_SOUND, label: "None (Silent)" },
|
||||
...SOUNDS
|
||||
.filter((s): s is SoundDef & { label: string } => !!s.label)
|
||||
.map((s) => ({ value: s.key, label: s.label }))
|
||||
|
|
@ -84,10 +84,10 @@ export class SoundManager {
|
|||
this.installed = true;
|
||||
}
|
||||
|
||||
// Settings preview — fires regardless of the master toggle.
|
||||
static playPreview(soundName: string) {
|
||||
if (!soundName || soundName === "none") return;
|
||||
SoundRegistry.getInstance().playSound(soundName);
|
||||
// Settings preview — fires regardless of the master toggle. `event` is
|
||||
// needed to play a custom file (the backend serves it by event).
|
||||
static async playPreview(sound: AppSoundFile | undefined, event: AppSoundEvent) {
|
||||
await this.playSound(sound, event);
|
||||
}
|
||||
|
||||
// Event-driven playback — gated on `play_sounds`.
|
||||
|
|
@ -97,13 +97,35 @@ export class SoundManager {
|
|||
static async playGenerationSuccess() { await this.playEvent("generation_success"); }
|
||||
static async playGenerationFailure() { await this.playEvent("generation_failure"); }
|
||||
|
||||
private static async playEvent(prefKey: SoundPrefKey) {
|
||||
const sounds = (await GetAppPreferences()).preferences?.sounds;
|
||||
private static async playEvent(event: AppSoundEvent) {
|
||||
let sounds;
|
||||
try {
|
||||
sounds = (await GetAppPreferences()).preferences?.sounds;
|
||||
} catch (err) {
|
||||
console.warn("Could not read sound preferences; not playing a sound:", err);
|
||||
return;
|
||||
}
|
||||
if (!sounds?.play_sounds) return;
|
||||
const sound = sounds[prefKey];
|
||||
// Only catalog keys are playable here; "none" is silent and custom .wav
|
||||
// files aren't supported by the registry yet.
|
||||
if (!sound || sound === "none" || typeof sound !== "string") return;
|
||||
SoundRegistry.getInstance().playSound(sound);
|
||||
await this.playSound(sounds[event], event);
|
||||
}
|
||||
|
||||
// Never throws: a bad or missing sound is logged and skipped.
|
||||
private static async playSound(sound: AppSoundFile | undefined, event: AppSoundEvent) {
|
||||
try {
|
||||
if (!sound || sound === SILENT_SOUND) return;
|
||||
if (isCustomWavSound(sound)) {
|
||||
await CustomSoundPlayer.play(event, sound.custom_wav);
|
||||
return;
|
||||
}
|
||||
if (typeof sound !== "string") return;
|
||||
const registry = SoundRegistry.getInstance();
|
||||
if (!registry.hasSound(sound)) {
|
||||
console.warn(`Unknown sound key "${sound}" for ${event}; not playing.`);
|
||||
return;
|
||||
}
|
||||
registry.playSound(sound);
|
||||
} catch (err) {
|
||||
console.warn(`Could not play sound for ${event}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export * from "./lib/opener/OpenUrl.js";
|
|||
export * from "./lib/providers/Provider.js";
|
||||
export * from "./lib/settings/GetAppPreferences.js";
|
||||
export * from "./lib/settings/UpdateAppPreference.js";
|
||||
export * from "./lib/settings/UpdateSoundPreference.js";
|
||||
export * from "./lib/settings/LoadCustomSound.js";
|
||||
export * from "./lib/sora/CheckSoraSession.js";
|
||||
export * from "./lib/sora/LogoutSoraSession.js";
|
||||
export * from "./lib/sora/SoraGetCredentialInfo.js";
|
||||
|
|
|
|||
|
|
@ -27,9 +27,15 @@ export interface AppSoundPreferences {
|
|||
export type AppSoundFile = string | CustomWavSound;
|
||||
|
||||
export interface CustomWavSound {
|
||||
// Absolute path to the user's .wav file.
|
||||
custom_wav: string,
|
||||
}
|
||||
|
||||
export const SILENT_SOUND = "none";
|
||||
|
||||
export const isCustomWavSound = (sound: AppSoundFile | undefined | null): sound is CustomWavSound =>
|
||||
typeof sound === "object" && sound !== null && typeof sound.custom_wav === "string";
|
||||
|
||||
export interface AppDownloadPreferences {
|
||||
// Preferred download directory
|
||||
preferred_download_directory: PreferredDownloadDirectory,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AppSoundEvent } from "./UpdateSoundPreference";
|
||||
|
||||
// The bytes of the custom .wav configured for an event. The backend reads the
|
||||
// path from the saved preference, so only files picked in Settings are
|
||||
// readable. Throws when the event has no custom sound or the file is missing
|
||||
// (the backend logs a warning).
|
||||
export const LoadCustomSound = async (event: AppSoundEvent) : Promise<ArrayBuffer> => {
|
||||
let result = await invoke("load_custom_sound_command", { event });
|
||||
return (result as ArrayBuffer);
|
||||
}
|
||||
|
|
@ -1,23 +1,17 @@
|
|||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AppSoundFile, PreferredDownloadDirectory, PreferredDownloadFilename } from "./GetAppPreferences";
|
||||
import { PreferredDownloadDirectory, PreferredDownloadFilename } from "./GetAppPreferences";
|
||||
|
||||
// One preference change. For the sound preferences, `undefined` or "none"
|
||||
// means silent.
|
||||
// One preference change. Per-event sounds go through UpdateSoundPreference.
|
||||
export interface UpdateAppPreferencesRequest {
|
||||
preference: PreferenceName,
|
||||
value: undefined | boolean | AppSoundFile | PreferredDownloadDirectory | PreferredDownloadFilename,
|
||||
value: boolean | PreferredDownloadDirectory | PreferredDownloadFilename,
|
||||
}
|
||||
|
||||
export enum PreferenceName {
|
||||
PreferredDownloadDirectory = "preferred_download_directory",
|
||||
PreferredDownloadFilename = "preferred_download_filename",
|
||||
PlaySounds = "play_sounds",
|
||||
DeleteFileSound = "delete_file_sound",
|
||||
EnqueueSuccessSound = "enqueue_success_sound",
|
||||
EnqueueFailureSound = "enqueue_failure_sound",
|
||||
GenerationSuccessSound = "generation_success_sound",
|
||||
GenerationFailureSound = "generation_failure_sound",
|
||||
PlaySounds = "play_sounds",
|
||||
}
|
||||
|
||||
export interface UpdateAppPreferencesResult {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AppSoundFile, AppSoundPreferences } from "./GetAppPreferences";
|
||||
|
||||
// The app events that can play a sound (one per field of AppSoundPreferences).
|
||||
export type AppSoundEvent =
|
||||
| "delete_file"
|
||||
| "enqueue_success"
|
||||
| "enqueue_failure"
|
||||
| "generation_success"
|
||||
| "generation_failure";
|
||||
|
||||
export interface UpdateSoundPreferenceRequest {
|
||||
event: AppSoundEvent,
|
||||
// A catalog key, a custom .wav, or undefined / "none" for silent.
|
||||
sound: AppSoundFile | undefined,
|
||||
}
|
||||
|
||||
export interface UpdateSoundPreferenceResult {
|
||||
// The full sound preferences after the change.
|
||||
sounds: AppSoundPreferences,
|
||||
}
|
||||
|
||||
// Change the sound for one event. Rejects (throws the reason) when a custom
|
||||
// file doesn't exist or isn't a .wav.
|
||||
export const UpdateSoundPreference = async (request: UpdateSoundPreferenceRequest) : Promise<UpdateSoundPreferenceResult> => {
|
||||
let result = await invoke("update_sound_preference_command", {
|
||||
request: {
|
||||
action: "set",
|
||||
event: request.event,
|
||||
sound: request.sound,
|
||||
}
|
||||
});
|
||||
return (result as UpdateSoundPreferenceResult);
|
||||
}
|
||||
|
||||
// Put one event's sound back to the app default (defined on the backend).
|
||||
export const ResetSoundPreference = async (event: AppSoundEvent) : Promise<UpdateSoundPreferenceResult> => {
|
||||
let result = await invoke("update_sound_preference_command", {
|
||||
request: {
|
||||
action: "reset_to_default",
|
||||
event,
|
||||
}
|
||||
});
|
||||
return (result as UpdateSoundPreferenceResult);
|
||||
}
|
||||
Loading…
Reference in New Issue