diff --git a/src/app/facade.rs b/src/app/facade.rs index 7ce82e2..8d65c9e 100644 --- a/src/app/facade.rs +++ b/src/app/facade.rs @@ -2,7 +2,8 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::config::{ - AppConfig, CachedReceiver, ConfigError, default_config_path, load_config, save_config, + AppConfig, CachedReceiver, ConfigError, TrayLanguagePreference, default_config_path, + load_config, save_config, }; use crate::discovery::{DiscoveryService, MdnsDiscoveryService}; use crate::error::RairstreamError; @@ -190,6 +191,15 @@ where Ok(()) } + pub fn set_tray_language( + &mut self, + language: TrayLanguagePreference, + ) -> Result<(), RairstreamError> { + self.config.set_tray_language(language); + self.persist_config()?; + Ok(()) + } + pub fn paired_forget( &mut self, selector_text: &str, @@ -657,6 +667,33 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn set_tray_language_persists() { + let path = temp_config_path(); + let mut facade = AppFacade::with_config_path( + FixedDiscoveryService { + receivers: Vec::new(), + }, + path.clone(), + ) + .unwrap(); + + facade + .set_tray_language(crate::config::TrayLanguagePreference::ZhCn) + .unwrap(); + + assert_eq!( + facade.config().tray_language, + crate::config::TrayLanguagePreference::ZhCn + ); + let reloaded = crate::config::load_config(&path).unwrap(); + assert_eq!( + reloaded.tray_language, + crate::config::TrayLanguagePreference::ZhCn + ); + let _ = std::fs::remove_file(path); + } + #[test] fn play_file_resets_state_after_failure() { let path = temp_config_path(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 4977d9b..a593e85 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -4,4 +4,5 @@ mod model; pub use file::{ConfigError, default_config_path, load_config, save_config}; pub use model::{ AppConfig, CachedReceiver, DEFAULT_SENDER_VOLUME_PERCENT, MAX_SENDER_VOLUME_PERCENT, + TrayLanguagePreference, }; diff --git a/src/config/model.rs b/src/config/model.rs index aba0eea..29b208f 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -31,6 +31,8 @@ pub struct AppConfig { pub receiver_cache: HashMap, #[serde(default)] pub tray_selected_receiver_ids: Vec, + #[serde(default)] + pub tray_language: TrayLanguagePreference, } impl Default for AppConfig { @@ -40,6 +42,7 @@ impl Default for AppConfig { paired_receivers: HashMap::new(), receiver_cache: HashMap::new(), tray_selected_receiver_ids: Vec::new(), + tray_language: TrayLanguagePreference::default(), } } } @@ -69,8 +72,21 @@ impl AppConfig { pub fn set_tray_selected_receiver_ids(&mut self, receiver_ids: Vec) { self.tray_selected_receiver_ids = receiver_ids; } + + pub fn set_tray_language(&mut self, language: TrayLanguagePreference) { + self.tray_language = language; + } } const fn default_sender_volume_percent() -> u16 { DEFAULT_SENDER_VOLUME_PERCENT } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TrayLanguagePreference { + #[default] + System, + EnUs, + ZhCn, +} diff --git a/src/ui/tray/i18n.rs b/src/ui/tray/i18n.rs new file mode 100644 index 0000000..4ba7d8b --- /dev/null +++ b/src/ui/tray/i18n.rs @@ -0,0 +1,220 @@ +use crate::config::TrayLanguagePreference; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayLocale { + EnUs, + ZhCn, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayText { + StatusStarting, + StatusIdle, + StatusWaitingForPin, + StatusStreamingToDevices, + RefreshDevices, + PlaybackTargets, + PairDevice, + ForgetPairing, + StartStreaming, + StopStreaming, + StartAtLogin, + Language, + LanguageSystem, + LanguageEnglish, + LanguageChinese, + Quit, + NoDevicesAvailable, + NoSavedPairings, + PairedSuffix, + SavedPairingFor, + RemovedPairingFor, + SelectPlaybackTarget, + EnterPinShownOn, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TrayI18n { + locale: TrayLocale, +} + +impl TrayI18n { + #[must_use] + pub fn new(preference: TrayLanguagePreference) -> Self { + Self { + locale: resolve_locale(preference, detected_system_locale().as_deref()), + } + } + + #[must_use] + pub fn with_system_locale( + preference: TrayLanguagePreference, + system_locale: Option<&str>, + ) -> Self { + Self { + locale: resolve_locale(preference, system_locale), + } + } + + #[must_use] + pub fn text(&self, key: TrayText) -> &'static str { + text_for(self.locale, key) + } + + #[must_use] + pub fn status_waiting_for_pin(&self, label: &str) -> String { + format!("{} - {label}", self.text(TrayText::StatusWaitingForPin)) + } + + #[must_use] + pub fn status_streaming_to_devices(&self, count: usize) -> String { + format!( + "{} {count} device(s)", + self.text(TrayText::StatusStreamingToDevices) + ) + } + + #[must_use] + pub fn saved_pairing_for(&self, label: &str) -> String { + format!("{} {label}", self.text(TrayText::SavedPairingFor)) + } + + #[must_use] + pub fn removed_pairing_for(&self, label: &str) -> String { + format!("{} {label}", self.text(TrayText::RemovedPairingFor)) + } + + #[must_use] + pub fn enter_pin_shown_on(&self, display_name: &str) -> String { + format!("{} {display_name}:", self.text(TrayText::EnterPinShownOn)) + } +} + +#[must_use] +pub fn resolve_locale( + preference: TrayLanguagePreference, + system_locale: Option<&str>, +) -> TrayLocale { + match preference { + TrayLanguagePreference::EnUs => TrayLocale::EnUs, + TrayLanguagePreference::ZhCn => TrayLocale::ZhCn, + TrayLanguagePreference::System => system_locale + .and_then(locale_from_tag) + .unwrap_or(TrayLocale::EnUs), + } +} + +fn detected_system_locale() -> Option { + ["LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"] + .into_iter() + .find_map(|key| { + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} + +fn locale_from_tag(tag: &str) -> Option { + let normalized = tag + .split('.') + .next() + .unwrap_or(tag) + .replace('_', "-") + .to_ascii_lowercase(); + + if normalized == "zh-cn" || normalized.starts_with("zh-hans") { + return Some(TrayLocale::ZhCn); + } + if normalized == "en-us" || normalized.starts_with("en") { + return Some(TrayLocale::EnUs); + } + + None +} + +fn text_for(locale: TrayLocale, key: TrayText) -> &'static str { + match (locale, key) { + (TrayLocale::ZhCn, TrayText::StatusStarting) => "状态:正在启动...", + (TrayLocale::ZhCn, TrayText::StatusIdle) => "状态:空闲", + (TrayLocale::ZhCn, TrayText::StatusWaitingForPin) => "状态:等待 PIN", + (TrayLocale::ZhCn, TrayText::StatusStreamingToDevices) => "状态:正在串流到", + (TrayLocale::ZhCn, TrayText::RefreshDevices) => "刷新设备", + (TrayLocale::ZhCn, TrayText::PlaybackTargets) => "播放目标", + (TrayLocale::ZhCn, TrayText::PairDevice) => "配对设备", + (TrayLocale::ZhCn, TrayText::ForgetPairing) => "忘记配对", + (TrayLocale::ZhCn, TrayText::StartStreaming) => "开始串流", + (TrayLocale::ZhCn, TrayText::StopStreaming) => "停止串流", + (TrayLocale::ZhCn, TrayText::StartAtLogin) => "登录时启动", + (TrayLocale::ZhCn, TrayText::Language) => "语言", + (TrayLocale::ZhCn, TrayText::LanguageSystem) => "跟随系统", + (TrayLocale::ZhCn, TrayText::LanguageEnglish) => "英语", + (TrayLocale::ZhCn, TrayText::LanguageChinese) => "简体中文", + (TrayLocale::ZhCn, TrayText::Quit) => "退出", + (TrayLocale::ZhCn, TrayText::NoDevicesAvailable) => "没有可用设备", + (TrayLocale::ZhCn, TrayText::NoSavedPairings) => "没有已保存的配对", + (TrayLocale::ZhCn, TrayText::PairedSuffix) => "已配对", + (TrayLocale::ZhCn, TrayText::SavedPairingFor) => "已保存配对:", + (TrayLocale::ZhCn, TrayText::RemovedPairingFor) => "已移除配对:", + (TrayLocale::ZhCn, TrayText::SelectPlaybackTarget) => "请至少选择一个播放目标", + (TrayLocale::ZhCn, TrayText::EnterPinShownOn) => "输入此设备上显示的 PIN:", + (_, TrayText::StatusStarting) => "Status: Starting...", + (_, TrayText::StatusIdle) => "Status: Idle", + (_, TrayText::StatusWaitingForPin) => "Status: Waiting for PIN", + (_, TrayText::StatusStreamingToDevices) => "Status: Streaming to", + (_, TrayText::RefreshDevices) => "Refresh Devices", + (_, TrayText::PlaybackTargets) => "Playback Targets", + (_, TrayText::PairDevice) => "Pair Device", + (_, TrayText::ForgetPairing) => "Forget Pairing", + (_, TrayText::StartStreaming) => "Start Streaming", + (_, TrayText::StopStreaming) => "Stop Streaming", + (_, TrayText::StartAtLogin) => "Start at login", + (_, TrayText::Language) => "Language", + (_, TrayText::LanguageSystem) => "Follow System", + (_, TrayText::LanguageEnglish) => "English", + (_, TrayText::LanguageChinese) => "Chinese (Simplified)", + (_, TrayText::Quit) => "Quit", + (_, TrayText::NoDevicesAvailable) => "No devices available", + (_, TrayText::NoSavedPairings) => "No saved pairings", + (_, TrayText::PairedSuffix) => "paired", + (_, TrayText::SavedPairingFor) => "saved pairing for", + (_, TrayText::RemovedPairingFor) => "removed pairing for", + (_, TrayText::SelectPlaybackTarget) => "select at least one playback target", + (_, TrayText::EnterPinShownOn) => "Enter the PIN shown on", + } +} + +#[cfg(test)] +mod tests { + use crate::config::TrayLanguagePreference; + + use super::{TrayI18n, TrayLocale, TrayText, resolve_locale}; + + #[test] + fn resolves_system_locale_with_english_fallback() { + assert_eq!( + resolve_locale(TrayLanguagePreference::System, Some("zh_CN.UTF-8")), + TrayLocale::ZhCn + ); + assert_eq!( + resolve_locale(TrayLanguagePreference::System, Some("fr-FR")), + TrayLocale::EnUs + ); + assert_eq!( + resolve_locale(TrayLanguagePreference::EnUs, Some("zh-CN")), + TrayLocale::EnUs + ); + } + + #[test] + fn maps_key_tray_texts_for_supported_languages() { + let english = TrayI18n::with_system_locale(TrayLanguagePreference::EnUs, None); + let chinese = TrayI18n::with_system_locale(TrayLanguagePreference::ZhCn, None); + + assert_eq!(english.text(TrayText::RefreshDevices), "Refresh Devices"); + assert_eq!(chinese.text(TrayText::RefreshDevices), "刷新设备"); + assert_eq!( + chinese.status_waiting_for_pin("Living Room"), + "状态:等待 PIN - Living Room" + ); + } +} diff --git a/src/ui/tray/mod.rs b/src/ui/tray/mod.rs index 33351d5..841ec86 100644 --- a/src/ui/tray/mod.rs +++ b/src/ui/tray/mod.rs @@ -1,8 +1,11 @@ use crate::app::{AppFacade, TrayReceiverEntry}; +use crate::config::TrayLanguagePreference; use crate::discovery::DiscoveryService; use crate::error::RairstreamError; use crate::session::PlaybackSession; +pub mod i18n; + #[cfg(target_os = "windows")] mod windows; @@ -31,6 +34,7 @@ pub enum TrayPhase { pub struct TraySnapshot { pub phase: TrayPhase, pub receivers: Vec, + pub language: TrayLanguagePreference, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -43,6 +47,7 @@ pub enum TrayCommand { ForgetPairing { receiver_id: String }, StartStreaming, StopStreaming, + SetLanguage { language: TrayLanguagePreference }, Quit, } @@ -135,6 +140,7 @@ where TraySnapshot { phase: self.runtime.phase().clone(), receivers: self.facade.tray_receivers(), + language: self.facade.config().tray_language, } } @@ -158,6 +164,7 @@ where TrayCommand::ForgetPairing { receiver_id } => self.forget_pairing(&receiver_id), TrayCommand::StartStreaming => self.start_streaming(), TrayCommand::StopStreaming => self.stop_streaming(), + TrayCommand::SetLanguage { language } => self.set_language(language), TrayCommand::Quit => self.quit(), } } @@ -224,10 +231,10 @@ where self.runtime.cancel_waiting_for_pin(); match self.facade.pair(receiver_id, pin) { Ok(entry) => vec![ - TrayEvent::Info(format!( - "saved pairing for {}", - entry.display_name.unwrap_or(entry.receiver_id) - )), + TrayEvent::Info( + self.i18n() + .saved_pairing_for(&entry.display_name.unwrap_or(entry.receiver_id)), + ), self.snapshot_event(), ], Err(error) => vec![TrayEvent::Error(error.to_string()), self.snapshot_event()], @@ -237,10 +244,10 @@ where fn forget_pairing(&mut self, receiver_id: &str) -> Vec { match self.facade.paired_forget(receiver_id) { Ok(entry) => vec![ - TrayEvent::Info(format!( - "removed pairing for {}", - entry.display_name.unwrap_or(entry.receiver_id) - )), + TrayEvent::Info( + self.i18n() + .removed_pairing_for(&entry.display_name.unwrap_or(entry.receiver_id)), + ), self.snapshot_event(), ], Err(error) => vec![TrayEvent::Error(error.to_string()), self.snapshot_event()], @@ -251,7 +258,11 @@ where let selected_ids = self.selected_receiver_ids(); if selected_ids.is_empty() { return vec![ - TrayEvent::Error(String::from("select at least one playback target")), + TrayEvent::Error( + self.i18n() + .text(i18n::TrayText::SelectPlaybackTarget) + .to_string(), + ), self.snapshot_event(), ]; } @@ -332,6 +343,15 @@ where fn snapshot_event(&self) -> TrayEvent { TrayEvent::SnapshotUpdated(self.snapshot()) } + + fn set_language(&mut self, language: TrayLanguagePreference) -> Vec { + let result = self.facade.set_tray_language(language); + self.finish_config_write(result) + } + + fn i18n(&self) -> i18n::TrayI18n { + i18n::TrayI18n::new(self.facade.config().tray_language) + } } #[must_use] @@ -350,7 +370,7 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use crate::app::AppFacade; - use crate::config::{AppConfig, save_config}; + use crate::config::{AppConfig, TrayLanguagePreference, save_config}; use crate::pairing::{ReceiverAuthFlow, ReceiverCredentials}; use crate::receiver::{ AirPlayGeneration, AuthMethod, DeviceSupport, Receiver, ReceiverCapabilities, ReceiverKind, @@ -568,4 +588,23 @@ mod tests { )); let _ = std::fs::remove_file(path); } + + #[test] + fn worker_persists_language_selection() { + let config = AppConfig::default(); + let (mut worker, path) = build_worker(&config, Vec::new()); + + let events = worker.handle_command(TrayCommand::SetLanguage { + language: TrayLanguagePreference::ZhCn, + }); + + assert!(matches!( + &events[..], + [TrayEvent::SnapshotUpdated(snapshot)] + if snapshot.language == TrayLanguagePreference::ZhCn + )); + let reloaded = crate::config::load_config(&path).unwrap(); + assert_eq!(reloaded.tray_language, TrayLanguagePreference::ZhCn); + let _ = std::fs::remove_file(path); + } } diff --git a/src/ui/tray/windows.rs b/src/ui/tray/windows.rs index 86c541a..c1be1f2 100644 --- a/src/ui/tray/windows.rs +++ b/src/ui/tray/windows.rs @@ -15,9 +15,11 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy} use winit::window::WindowId; use crate::app::AppFacade; +use crate::config::TrayLanguagePreference; use crate::discovery::MdnsDiscoveryService; use crate::platform; +use super::i18n::{TrayI18n, TrayText}; use super::{TrayCommand, TrayEvent, TrayPhase, TraySnapshot, TrayWorker, tray_receiver_label}; const APP_TITLE: &str = "Rairstream"; @@ -28,6 +30,9 @@ const MENU_ID_REFRESH: &str = "refresh"; const MENU_ID_START_STREAMING: &str = "start-streaming"; const MENU_ID_STOP_STREAMING: &str = "stop-streaming"; const MENU_ID_START_AT_LOGIN: &str = "start-at-login"; +const MENU_ID_LANGUAGE_SYSTEM: &str = "language:system"; +const MENU_ID_LANGUAGE_EN_US: &str = "language:en-us"; +const MENU_ID_LANGUAGE_ZH_CN: &str = "language:zh-cn"; const MENU_ID_QUIT: &str = "quit"; const MENU_ID_TARGET_PREFIX: &str = "target:"; const MENU_ID_PAIR_PREFIX: &str = "pair:"; @@ -45,6 +50,7 @@ enum MenuAction { StartStreaming, StopStreaming, ToggleStartAtLogin, + SetLanguage(TrayLanguagePreference), Quit, ToggleReceiver { receiver_id: String }, RequestPairing { receiver_id: String }, @@ -130,6 +136,7 @@ impl TrayApp { } return; } + MenuAction::SetLanguage(language) => TrayCommand::SetLanguage { language }, MenuAction::Quit => TrayCommand::Quit, MenuAction::ToggleReceiver { receiver_id } => { let Some(selected) = menu.checked_state(&receiver_id) else { @@ -163,26 +170,33 @@ impl TrayApp { TrayEvent::PromptForPin { receiver_id, display_name, - } => match prompt_for_pin(&display_name) { - Ok(Some(pin)) => { - if let Err(error) = - self.send_command(TrayCommand::SubmitPairingPin { receiver_id, pin }) - { - self.fail_and_exit(event_loop, error); + } => { + let i18n = self.menu.as_ref().map_or_else( + || TrayI18n::new(TrayLanguagePreference::System), + TrayMenu::i18n, + ); + match prompt_for_pin(&display_name, i18n) { + Ok(Some(pin)) => { + if let Err(error) = + self.send_command(TrayCommand::SubmitPairingPin { receiver_id, pin }) + { + self.fail_and_exit(event_loop, error); + } } - } - Ok(None) => { - if let Err(error) = self.send_command(TrayCommand::CancelPairingPrompt) { - self.fail_and_exit(event_loop, error); + Ok(None) => { + if let Err(error) = self.send_command(TrayCommand::CancelPairingPrompt) { + self.fail_and_exit(event_loop, error); + } } - } - Err(error) => { - show_alert(MessageLevel::Error, &error); - if let Err(send_error) = self.send_command(TrayCommand::CancelPairingPrompt) { - self.fail_and_exit(event_loop, send_error); + Err(error) => { + show_alert(MessageLevel::Error, &error); + if let Err(send_error) = self.send_command(TrayCommand::CancelPairingPrompt) + { + self.fail_and_exit(event_loop, send_error); + } } } - }, + } TrayEvent::Info(message) => show_alert(MessageLevel::Info, &message), TrayEvent::Error(message) => show_alert(MessageLevel::Error, &message), TrayEvent::ExitRequested => event_loop.exit(), @@ -244,30 +258,57 @@ struct TrayMenu { start_streaming: MenuItem, stop_streaming: MenuItem, start_at_login: CheckMenuItem, + language: Submenu, + language_system: CheckMenuItem, + language_en_us: CheckMenuItem, + language_zh_cn: CheckMenuItem, quit: MenuItem, target_items: HashMap, + language_preference: TrayLanguagePreference, +} + +struct TrayLanguageMenu { + submenu: Submenu, + system: CheckMenuItem, + en_us: CheckMenuItem, + zh_cn: CheckMenuItem, } impl TrayMenu { fn build() -> Result { + let i18n = TrayI18n::new(TrayLanguagePreference::System); let root = Menu::new(); - let status = MenuItem::new("Status: Starting...", false, None); - let refresh = MenuItem::with_id(MENU_ID_REFRESH, "Refresh Devices", true, None); - let playback_targets = Submenu::new("Playback Targets", true); - let pair_device = Submenu::new("Pair Device", true); - let forget_pairing = Submenu::new("Forget Pairing", true); - let start_streaming = - MenuItem::with_id(MENU_ID_START_STREAMING, "Start Streaming", false, None); - let stop_streaming = - MenuItem::with_id(MENU_ID_STOP_STREAMING, "Stop Streaming", false, None); + let status = MenuItem::new(i18n.text(TrayText::StatusStarting), false, None); + let refresh = MenuItem::with_id( + MENU_ID_REFRESH, + i18n.text(TrayText::RefreshDevices), + true, + None, + ); + let playback_targets = Submenu::new(i18n.text(TrayText::PlaybackTargets), true); + let pair_device = Submenu::new(i18n.text(TrayText::PairDevice), true); + let forget_pairing = Submenu::new(i18n.text(TrayText::ForgetPairing), true); + let start_streaming = MenuItem::with_id( + MENU_ID_START_STREAMING, + i18n.text(TrayText::StartStreaming), + false, + None, + ); + let stop_streaming = MenuItem::with_id( + MENU_ID_STOP_STREAMING, + i18n.text(TrayText::StopStreaming), + false, + None, + ); let start_at_login = CheckMenuItem::with_id( MENU_ID_START_AT_LOGIN, - "Start at login", + i18n.text(TrayText::StartAtLogin), true, platform::is_start_at_login_enabled().unwrap_or(false), None, ); - let quit = MenuItem::with_id(MENU_ID_QUIT, "Quit", true, None); + let language_menu = build_language_menu(i18n)?; + let quit = MenuItem::with_id(MENU_ID_QUIT, i18n.text(TrayText::Quit), true, None); let separator_a = PredefinedMenuItem::separator(); let separator_b = PredefinedMenuItem::separator(); @@ -285,6 +326,7 @@ impl TrayMenu { &stop_streaming, &separator_c, &start_at_login, + &language_menu.submenu, &separator_d, &quit, ]) @@ -300,18 +342,27 @@ impl TrayMenu { start_streaming, stop_streaming, start_at_login, + language: language_menu.submenu, + language_system: language_menu.system, + language_en_us: language_menu.en_us, + language_zh_cn: language_menu.zh_cn, quit, target_items: HashMap::new(), + language_preference: TrayLanguagePreference::System, }; menu.apply_snapshot(&TraySnapshot { phase: TrayPhase::Idle, receivers: Vec::new(), + language: TrayLanguagePreference::System, })?; Ok(menu) } fn apply_snapshot(&mut self, snapshot: &TraySnapshot) -> Result<(), String> { + self.language_preference = snapshot.language; + self.apply_static_text(); self.status.set_text(format_status(snapshot)); + self.sync_language_checks(); self.rebuild_targets(snapshot)?; self.rebuild_pair_devices(snapshot)?; self.rebuild_forget_pairing(snapshot)?; @@ -328,6 +379,43 @@ impl TrayMenu { Ok(()) } + fn i18n(&self) -> TrayI18n { + TrayI18n::new(self.language_preference) + } + + fn apply_static_text(&self) { + let i18n = self.i18n(); + self.refresh.set_text(i18n.text(TrayText::RefreshDevices)); + self.playback_targets + .set_text(i18n.text(TrayText::PlaybackTargets)); + self.pair_device.set_text(i18n.text(TrayText::PairDevice)); + self.forget_pairing + .set_text(i18n.text(TrayText::ForgetPairing)); + self.start_streaming + .set_text(i18n.text(TrayText::StartStreaming)); + self.stop_streaming + .set_text(i18n.text(TrayText::StopStreaming)); + self.start_at_login + .set_text(i18n.text(TrayText::StartAtLogin)); + self.language.set_text(i18n.text(TrayText::Language)); + self.language_system + .set_text(i18n.text(TrayText::LanguageSystem)); + self.language_en_us + .set_text(i18n.text(TrayText::LanguageEnglish)); + self.language_zh_cn + .set_text(i18n.text(TrayText::LanguageChinese)); + self.quit.set_text(i18n.text(TrayText::Quit)); + } + + fn sync_language_checks(&self) { + self.language_system + .set_checked(self.language_preference == TrayLanguagePreference::System); + self.language_en_us + .set_checked(self.language_preference == TrayLanguagePreference::EnUs); + self.language_zh_cn + .set_checked(self.language_preference == TrayLanguagePreference::ZhCn); + } + fn checked_state(&self, receiver_id: &str) -> Option { self.target_items .get(receiver_id) @@ -339,7 +427,10 @@ impl TrayMenu { self.target_items.clear(); if snapshot.receivers.is_empty() { - append_placeholder(&self.playback_targets, "No devices available")?; + append_placeholder( + &self.playback_targets, + self.i18n().text(TrayText::NoDevicesAvailable), + )?; return Ok(()); } @@ -365,13 +456,20 @@ impl TrayMenu { clear_submenu(&self.pair_device); if snapshot.receivers.is_empty() { - append_placeholder(&self.pair_device, "No devices available")?; + append_placeholder( + &self.pair_device, + self.i18n().text(TrayText::NoDevicesAvailable), + )?; return Ok(()); } for entry in &snapshot.receivers { let label = if entry.is_paired { - format!("{} (paired)", tray_receiver_label(entry)) + format!( + "{} ({})", + tray_receiver_label(entry), + self.i18n().text(TrayText::PairedSuffix) + ) } else { tray_receiver_label(entry) }; @@ -398,7 +496,10 @@ impl TrayMenu { .filter(|entry| entry.is_paired) .collect(); if paired_entries.is_empty() { - append_placeholder(&self.forget_pairing, "No saved pairings")?; + append_placeholder( + &self.forget_pairing, + self.i18n().text(TrayText::NoSavedPairings), + )?; return Ok(()); } @@ -491,6 +592,41 @@ fn append_placeholder(submenu: &Submenu, text: &str) -> Result<(), String> { .map_err(|error| format!("failed to update placeholder menu item: {error}")) } +fn build_language_menu(i18n: TrayI18n) -> Result { + let submenu = Submenu::new(i18n.text(TrayText::Language), true); + let system = CheckMenuItem::with_id( + MENU_ID_LANGUAGE_SYSTEM, + i18n.text(TrayText::LanguageSystem), + true, + true, + None, + ); + let en_us = CheckMenuItem::with_id( + MENU_ID_LANGUAGE_EN_US, + i18n.text(TrayText::LanguageEnglish), + true, + false, + None, + ); + let zh_cn = CheckMenuItem::with_id( + MENU_ID_LANGUAGE_ZH_CN, + i18n.text(TrayText::LanguageChinese), + true, + false, + None, + ); + submenu + .append_items(&[&system, &en_us, &zh_cn]) + .map_err(|error| format!("failed to build language menu: {error}"))?; + + Ok(TrayLanguageMenu { + submenu, + system, + en_us, + zh_cn, + }) +} + fn build_target_label(entry: &crate::app::TrayReceiverEntry) -> String { if entry.host.is_empty() { tray_receiver_label(entry) @@ -500,18 +636,19 @@ fn build_target_label(entry: &crate::app::TrayReceiverEntry) -> String { } fn format_status(snapshot: &TraySnapshot) -> String { + let i18n = TrayI18n::new(snapshot.language); match &snapshot.phase { - TrayPhase::Idle => String::from("Status: Idle"), + TrayPhase::Idle => i18n.text(TrayText::StatusIdle).to_string(), TrayPhase::WaitingForPin { receiver_id } => { let label = snapshot .receivers .iter() .find(|entry| &entry.receiver_id == receiver_id) .map_or_else(|| receiver_id.clone(), tray_receiver_label); - format!("Status: Waiting for PIN - {label}") + i18n.status_waiting_for_pin(&label) } TrayPhase::Streaming { receiver_ids } => { - format!("Status: Streaming to {} device(s)", receiver_ids.len()) + i18n.status_streaming_to_devices(receiver_ids.len()) } } } @@ -525,8 +662,8 @@ fn show_alert(level: MessageLevel, message: &str) { .show(); } -fn prompt_for_pin(display_name: &str) -> Result, String> { - let prompt = powershell_single_quoted(&format!("Enter the PIN shown on {display_name}:")); +fn prompt_for_pin(display_name: &str, i18n: TrayI18n) -> Result, String> { + let prompt = powershell_single_quoted(&i18n.enter_pin_shown_on(display_name)); let title = powershell_single_quoted(APP_TITLE); let script = format!( "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Add-Type -AssemblyName Microsoft.VisualBasic; $pin = [Microsoft.VisualBasic.Interaction]::InputBox('{prompt}', '{title}', ''); [Console]::Out.Write($pin)" @@ -579,6 +716,15 @@ fn parse_menu_action(menu_id: &MenuId) -> Option { if menu_id == MENU_ID_START_AT_LOGIN { return Some(MenuAction::ToggleStartAtLogin); } + if menu_id == MENU_ID_LANGUAGE_SYSTEM { + return Some(MenuAction::SetLanguage(TrayLanguagePreference::System)); + } + if menu_id == MENU_ID_LANGUAGE_EN_US { + return Some(MenuAction::SetLanguage(TrayLanguagePreference::EnUs)); + } + if menu_id == MENU_ID_LANGUAGE_ZH_CN { + return Some(MenuAction::SetLanguage(TrayLanguagePreference::ZhCn)); + } if menu_id == MENU_ID_QUIT { return Some(MenuAction::Quit); } @@ -605,6 +751,8 @@ fn parse_menu_action(menu_id: &MenuId) -> Option { mod tests { use tray_icon::menu::MenuId; + use crate::config::TrayLanguagePreference; + use super::{MenuAction, parse_menu_action}; #[test] @@ -625,6 +773,10 @@ mod tests { parse_menu_action(&MenuId::new("start-at-login")), Some(MenuAction::ToggleStartAtLogin) ); + assert_eq!( + parse_menu_action(&MenuId::new("language:zh-cn")), + Some(MenuAction::SetLanguage(TrayLanguagePreference::ZhCn)) + ); } #[test]