diff --git a/CHANGELOG.md b/CHANGELOG.md index c0effd41..3679c346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- Keep playing when closed is now Keep running in the background, and the tray icon, the menu bar + on macOS, only shows while it is on. A new Show in the system tray switch under it lets you hide + the icon anyway. Settings > General > Window + - An artist page opens as soon as their profile and popular tracks arrive, and fills its releases in behind that, so an artist with a thousand of them no longer leaves the page blank for half a minute. diff --git a/CLAUDE.md b/CLAUDE.md index 5542b887..6707ad94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -902,18 +902,24 @@ Edit and Window menus there alone. **The tray outlives the window.** `sonora/src/tray.rs` owns one `Tray` entity driven by two backends: `tray/native.rs` (`tray-icon`, macOS and Windows) and `tray/sni.rs` (`ksni`, Linux over -D-Bus, no gtk). Both expose the same `Icon::new(sender) -> Option` / `Icon::show(&Shown)` -pair; the entity turns tray events into `Playback` calls the way `state::remote` does and rebuilds -the labels from `t!` on every playback change, so they follow the language. `install` returns -`false` when no tray can be placed — no StatusNotifierWatcher on the bus, say — and -`actions::register` then keeps the old quit-on-last-window behaviour, so a headless Sonora never -lingers unreachable. With a tray and `close_to_tray` on, the last window closing only flips -`dock::show(false)` (Accessory policy on macOS; a no-op elsewhere) and `show_window` in `main.rs` -brings it back from the tray, a Dock relaunch (`on_reopen`) or a `spotify:` link. `ksni` must stay -on `async-io`: `gpui_linux` already drives `zbus` on that executor, and mixing in `zbus/tokio` -panics at runtime. The icons come from `assets/tray/`, which `scripts/generate-icons.py` derives -from the master like every other artefact — a template glyph for the macOS menu bar, the round -one for Windows and Linux. +D-Bus, no gtk). Both expose the same `Icon::new(sender) -> Option` / `Icon::show(&Shown)` / +`Icon::placed(bool)` triple; the entity turns tray events into `Playback` calls the way +`state::remote` does and rebuilds the labels from `t!` on every playback change, so they follow the +language. `install` returns `false` when no tray can be placed — no StatusNotifierWatcher on the +bus, say — and `actions::register` then keeps the old quit-on-last-window behaviour, so a headless +Sonora never lingers unreachable. `tray_icon` is a setting of its own, apart from `close_to_tray`, +so the app can keep running without an icon: `Tray::place` follows it, but only while +`close_to_tray` is on, and Settings draws no row for it otherwise, so an icon never shows for an app +that quits with its last window. A Sonora with no window and no icon is still reachable everywhere: +starting it again hands over to the running instance through the socket, and `show_window` opens the +window. `tray-icon` hides the status item and `ksni` leaves the bus altogether, since a status +notifier host draws whatever is registered. With a tray and `close_to_tray` on, the last window +closing only flips `dock::show(false)` (Accessory policy on macOS; a no-op elsewhere) and +`show_window` in `main.rs` brings it back from the tray, a Dock relaunch (`on_reopen`) or a +`spotify:` link. `ksni` must stay on `async-io`: `gpui_linux` already drives `zbus` on that +executor, and mixing in `zbus/tokio` panics at runtime. The icons come from `assets/tray/`, which +`scripts/generate-icons.py` derives from the master like every other artefact — a template glyph for +the macOS menu bar, the round one for Windows and Linux. **Assets.** `crates/sonora/src/assets.rs` answers GPUI for both icons and fonts: icons come from the `icons` crate, fonts from a `FONTS` table its build script writes by walking `assets/fonts`. diff --git a/README.md b/README.md index a879d788..92190283 100644 --- a/README.md +++ b/README.md @@ -187,19 +187,19 @@ AI-assisted proofreading and translation of human-written text are permitted. | Language | Translated | Coverage | | --- | --- | --- | -| English (`en-US`) | 720/720 | 100% | -| Deutsch (`de`) | 631/720 | 88% | -| Español (`es`) | 691/720 | 96% | -| Français (`fr`) | 631/720 | 88% | -| Italiano (`it`) | 609/720 | 85% | -| Bahasa Indonesia (`id`) | 609/720 | 85% | -| 日本語 (`ja`) | 609/720 | 85% | -| Русский (`ru`) | 711/720 | 99% | -| Українська (`uk`) | 711/720 | 99% | -| Polski (`pl`) | 711/720 | 99% | -| Português (Brasil) (`pt-BR`) | 609/720 | 85% | -| 简体中文 (`zh-CN`) | 609/720 | 85% | -| Türkçe (`tr`) | 609/720 | 85% | +| English (`en-US`) | 722/722 | 100% | +| Deutsch (`de`) | 631/722 | 87% | +| Español (`es`) | 693/722 | 96% | +| Français (`fr`) | 631/722 | 87% | +| Italiano (`it`) | 609/722 | 84% | +| Bahasa Indonesia (`id`) | 609/722 | 84% | +| 日本語 (`ja`) | 609/722 | 84% | +| Русский (`ru`) | 711/722 | 98% | +| Українська (`uk`) | 711/722 | 98% | +| Polski (`pl`) | 711/722 | 98% | +| Português (Brasil) (`pt-BR`) | 609/722 | 84% | +| 简体中文 (`zh-CN`) | 609/722 | 84% | +| Türkçe (`tr`) | 609/722 | 84% | diff --git a/assets/i18n/en-US/main.ftl b/assets/i18n/en-US/main.ftl index 1533dde2..46b89911 100644 --- a/assets/i18n/en-US/main.ftl +++ b/assets/i18n/en-US/main.ftl @@ -544,8 +544,10 @@ settings-window-rounding = Window corners settings-window-rounding-detail = How rounded the window's own corners are settings-controls-side = Controls side settings-controls-side-detail = Which end of the title bar the controls sit on -settings-close-to-tray = Keep playing when closed -settings-close-to-tray-detail = Keep Sonora in the system tray and continue playing after its window closes +settings-close-to-tray = Keep running in the background +settings-close-to-tray-detail = Keep Sonora running and playing after its window closes +settings-tray-icon = Show in the system tray +settings-tray-icon-detail = Put Sonora's icon in the system tray, or the menu bar on macOS settings-discord = Show on Discord settings-discord-detail = Put the track you are playing on your Discord profile settings-discord-name = Status name diff --git a/assets/i18n/es/main.ftl b/assets/i18n/es/main.ftl index ba6c6859..c17c5db0 100644 --- a/assets/i18n/es/main.ftl +++ b/assets/i18n/es/main.ftl @@ -525,8 +525,10 @@ settings-window-rounding = Esquinas de la ventana settings-window-rounding-detail = El grado de redondeo de las esquinas de la ventana settings-controls-side = Lado de los controles settings-controls-side-detail = El extremo de la barra de título donde se sitúan los controles -settings-close-to-tray = Reproducir mientras esta en segundo plano -settings-close-to-tray-detail = Mantiene a Sonora en segundo plano y continúa reproduciendo después de cerrar su ventana +settings-close-to-tray = Mantener en segundo plano +settings-close-to-tray-detail = Mantiene a Sonora en ejecución y reproduciendo después de cerrar su ventana +settings-tray-icon = Mostrar en la bandeja del sistema +settings-tray-icon-detail = Muestra el icono de Sonora en la bandeja del sistema, o en la barra de menús en macOS settings-discord = Mostrar en Discord settings-discord-detail = Muestra la pista que estás escuchando en tu perfil de Discord settings-discord-name = Nombre del estado diff --git a/crates/sonora/src/tray.rs b/crates/sonora/src/tray.rs index efdd9ea6..9f75f2c6 100644 --- a/crates/sonora/src/tray.rs +++ b/crates/sonora/src/tray.rs @@ -98,6 +98,7 @@ pub fn install(show: impl Fn(&mut App) + 'static, cx: &mut App) -> bool { pub struct Tray { icon: Icon, shown: Shown, + placed: bool, /// The cover the art below was loaded from, so a repeat of the same track loads nothing. cover: Option, art: Option, @@ -146,6 +147,10 @@ impl Tray { .detach(); let queue = Sonora::global(cx).queue.clone(); cx.observe(&queue, |this, _, cx| this.publish(cx)).detach(); + // only `place` here: settings notifies on every window move, and rebuilding `Shown` + // allocates the caption and clones the cover each time + let settings = Sonora::global(cx).settings.clone(); + cx.observe(&settings, |this, _, cx| this.place(cx)).detach(); let shown = shown(None, cx); icon.show(&shown); @@ -153,15 +158,38 @@ impl Tray { let mut tray = Self { icon, shown, + placed: true, cover: None, art: None, artwork: None, _events, }; + tray.place(cx); tray.follow(cx); tray } + /// Puts the icon in the tray, or takes it out. It follows `tray_icon`, but only while + /// `close_to_tray` is on, which keeps the choice for when the app runs in the background again. + fn place(&mut self, cx: &mut Context) { + let settings = Sonora::global(cx).settings.read(cx); + let placed = settings.close_to_tray() && settings.tray_icon(); + if placed == self.placed { + return; + } + self.placed = placed; + self.icon.placed(placed); + if !placed { + return; + } + // the cover of whatever is playing went unfetched while the icon was out, so forget + // the one `follow` last saw and let it load again + if self.art.is_none() { + self.cover = None; + } + self.publish(cx); + } + fn publish(&mut self, cx: &mut Context) { self.follow(cx); let shown = shown(self.art.clone(), cx); @@ -189,7 +217,11 @@ impl Tray { self.cover = cover.clone(); self.art = None; - self.artwork = cover.map(|cover| self.load(cover, cx)); + // only the tray menu draws the cover, so an icon that is out of the tray fetches none + self.artwork = match self.placed { + true => cover.map(|cover| self.load(cover, cx)), + false => None, + }; } fn load(&self, cover: String, cx: &mut Context) -> Task<()> { diff --git a/crates/sonora/src/tray/native.rs b/crates/sonora/src/tray/native.rs index d90fe81e..24dcee27 100644 --- a/crates/sonora/src/tray/native.rs +++ b/crates/sonora/src/tray/native.rs @@ -120,6 +120,13 @@ impl Icon { }) } + /// The status item stays alive either way, so the menu and its handlers survive a round trip. + pub fn placed(&mut self, placed: bool) { + if let Err(error) = self.icon.set_visible(placed) { + log::warn!("tray: cannot place the tray icon: {error:#}"); + } + } + pub fn show(&mut self, shown: &Shown) { // the status notifier hosts read the caption off the tooltip themselves; here it has to // be pushed, or hovering the icon only ever says Sonora diff --git a/crates/sonora/src/tray/sni.rs b/crates/sonora/src/tray/sni.rs index 5fd7a8c5..829d8d9a 100644 --- a/crates/sonora/src/tray/sni.rs +++ b/crates/sonora/src/tray/sni.rs @@ -12,7 +12,10 @@ const PNG: &[u8] = include_bytes!("../../../../assets/tray/sonora.png"); const FLATPAK_INFO: &str = "/.flatpak-info"; pub struct Icon { - handle: Handle, + /// What a fresh service is spawned from when the icon comes back. It only follows `show` + /// while the icon is out, since `Tray::place` publishes again right after the spawn. + item: Item, + handle: Option>, } impl Icon { @@ -41,25 +44,51 @@ impl Icon { pixmap, shown: None, }; - // A sandbox cannot own `org.kde.StatusNotifierItem--`, and a manifest cannot - // grant it: flatpak's own-name wildcard only matches a `.*` suffix. The watcher - // accepts the unique bus name instead. - let sandboxed = std::path::Path::new(FLATPAK_INFO).exists(); - match item.disable_dbus_name(sandboxed).spawn() { - Ok(handle) => Some(Self { handle }), - Err(error) => { - log::warn!("tray: cannot reach the status notifier host: {error}"); - None - } + let handle = spawn(item.clone())?; + Some(Self { + item, + handle: Some(handle), + }) + } + + /// A host draws every item that is registered, so leaving the bus is the only way out. + pub fn placed(&mut self, placed: bool) { + if placed == self.handle.is_some() { + return; + } + match self.handle.take() { + // the request is sent, not awaited: the item leaves the bus either way + Some(handle) => drop(handle.shutdown()), + None => self.handle = spawn(self.item.clone()), } } pub fn show(&mut self, shown: &Shown) { let shown = shown.clone(); - self.handle.update(|item| item.shown = Some(shown)); + match &self.handle { + Some(handle) => { + handle.update(|item| item.shown = Some(shown)); + } + None => self.item.shown = Some(shown), + } + } +} + +fn spawn(item: Item) -> Option> { + // A sandbox cannot own `org.kde.StatusNotifierItem--`, and a manifest cannot + // grant it: flatpak's own-name wildcard only matches a `.*` suffix. The watcher + // accepts the unique bus name instead. + let sandboxed = std::path::Path::new(FLATPAK_INFO).exists(); + match item.disable_dbus_name(sandboxed).spawn() { + Ok(handle) => Some(handle), + Err(error) => { + log::warn!("tray: cannot reach the status notifier host: {error}"); + None + } } } +#[derive(Clone)] struct Item { sender: UnboundedSender, pixmap: Vec, diff --git a/crates/state/src/settings.rs b/crates/state/src/settings.rs index 302958e4..4346a91f 100644 --- a/crates/state/src/settings.rs +++ b/crates/state/src/settings.rs @@ -292,6 +292,7 @@ struct Values { adaptive_menu: bool, check_updates: bool, close_to_tray: bool, + tray_icon: bool, language: String, #[serde(default = "system_font")] font: String, @@ -377,6 +378,7 @@ impl Default for Values { adaptive_menu: false, check_updates: cfg!(target_os = "windows"), close_to_tray: true, + tray_icon: true, language: i18n::AUTO.to_owned(), font: system_font(), startup: DEFAULT_STARTUP.to_owned(), @@ -711,6 +713,10 @@ impl AppSettings { self.values.close_to_tray } + pub fn tray_icon(&self) -> bool { + self.values.tray_icon + } + /// Every linked scrobbling account, keyed by its service slug. pub fn scrobbling(&self) -> &BTreeMap { &self.values.scrobbling @@ -1072,6 +1078,11 @@ impl AppSettings { self.schedule_save(cx); } + pub fn set_tray_icon(&mut self, tray_icon: bool, cx: &mut Context) { + self.values.tray_icon = tray_icon; + self.schedule_save(cx); + } + /// Stores a linked account, or forgets the service when the account carries no session. pub fn set_account(&mut self, service: &str, account: Account, cx: &mut Context) { match account.linked() { diff --git a/crates/views/src/screens/settings.rs b/crates/views/src/screens/settings.rs index 48f78c85..add74be4 100644 --- a/crates/views/src/screens/settings.rs +++ b/crates/views/src/screens/settings.rs @@ -126,6 +126,7 @@ enum Slot { Entries, Language, Tray, + TrayIcon, Accounts, LocalFolder, Theme, @@ -520,17 +521,25 @@ impl SettingsView { fn tab_slots(&self, tab: SettingsTab, cx: &App) -> Vec { match tab { - SettingsTab::General => vec![ - Slot::Startup, - Slot::Entries, - Slot::Language, - Slot::Title("settings-group-window"), - Slot::Tray, - Slot::Title("settings-group-accounts"), - Slot::Accounts, - Slot::Title("settings-group-library"), - Slot::LocalFolder, - ], + SettingsTab::General => { + let mut slots = vec![ + Slot::Startup, + Slot::Entries, + Slot::Language, + Slot::Title("settings-group-window"), + Slot::Tray, + ]; + if self.settings.read(cx).close_to_tray() { + slots.push(Slot::TrayIcon); + } + slots.extend([ + Slot::Title("settings-group-accounts"), + Slot::Accounts, + Slot::Title("settings-group-library"), + Slot::LocalFolder, + ]); + slots + } SettingsTab::Appearance => vec![ Slot::Title("settings-tab-general"), Slot::Theme, @@ -630,6 +639,7 @@ impl SettingsView { t!("settings-close-to-tray"), t!("settings-close-to-tray-detail"), ), + Slot::TrayIcon => (t!("settings-tray-icon"), t!("settings-tray-icon-detail")), Slot::Accounts => { let detail = t!("settings-accounts-detail"); let names = self.account_words(cx); @@ -860,6 +870,7 @@ impl SettingsView { Slot::Entries => self.entries_row(cx).element, Slot::Language => self.language_row(cx).element, Slot::Tray => self.tray_row(cx).element, + Slot::TrayIcon => self.tray_icon_row(cx).element, Slot::Accounts => self.accounts_row(cx).element, Slot::LocalFolder => self.local_folder_row(cx).element, Slot::Theme => self.theme_row(cx).element, @@ -1952,6 +1963,26 @@ impl SettingsView { ) } + fn tray_icon_row(&self, cx: &mut Context) -> Setting { + let theme = *cx.theme(); + let muted = theme.muted_foreground; + let small = theme.text(Text::Small); + let on = self.settings.read(cx).tray_icon(); + + self.row( + t!("settings-tray-icon"), + t!("settings-tray-icon-detail"), + muted, + small, + Switch::new("tray-icon", on) + .on_click(cx.listener(move |this, _, _, cx| { + this.settings + .update(cx, |settings, cx| settings.set_tray_icon(!on, cx)); + })) + .into_any_element(), + ) + } + fn gapless_row(&self, cx: &mut Context) -> Setting { let theme = *cx.theme(); let muted = theme.muted_foreground;