Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 18 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>` / `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>` / `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`.
Expand Down
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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% |

<!-- i18n:end -->

Expand Down
6 changes: 4 additions & 2 deletions assets/i18n/en-US/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions assets/i18n/es/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion crates/sonora/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
art: Option<Art>,
Expand Down Expand Up @@ -146,22 +147,49 @@ 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);
crate::dock::menu(&shown, cx);
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<Self>) {
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>) {
self.follow(cx);
let shown = shown(self.art.clone(), cx);
Expand Down Expand Up @@ -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<Self>) -> Task<()> {
Expand Down
7 changes: 7 additions & 0 deletions crates/sonora/src/tray/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 41 additions & 12 deletions crates/sonora/src/tray/sni.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ const PNG: &[u8] = include_bytes!("../../../../assets/tray/sonora.png");
const FLATPAK_INFO: &str = "/.flatpak-info";

pub struct Icon {
handle: Handle<Item>,
/// 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<Handle<Item>>,
}

impl Icon {
Expand Down Expand Up @@ -41,25 +44,51 @@ impl Icon {
pixmap,
shown: None,
};
// A sandbox cannot own `org.kde.StatusNotifierItem-<pid>-<n>`, 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<Handle<Item>> {
// A sandbox cannot own `org.kde.StatusNotifierItem-<pid>-<n>`, 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<Event>,
pixmap: Vec<ksni::Icon>,
Expand Down
11 changes: 11 additions & 0 deletions crates/state/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<String, Account> {
&self.values.scrobbling
Expand Down Expand Up @@ -1072,6 +1078,11 @@ impl AppSettings {
self.schedule_save(cx);
}

pub fn set_tray_icon(&mut self, tray_icon: bool, cx: &mut Context<Self>) {
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<Self>) {
match account.linked() {
Expand Down
53 changes: 42 additions & 11 deletions crates/views/src/screens/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ enum Slot {
Entries,
Language,
Tray,
TrayIcon,
Accounts,
LocalFolder,
Theme,
Expand Down Expand Up @@ -520,17 +521,25 @@ impl SettingsView {

fn tab_slots(&self, tab: SettingsTab, cx: &App) -> Vec<Slot> {
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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1952,6 +1963,26 @@ impl SettingsView {
)
}

fn tray_icon_row(&self, cx: &mut Context<Self>) -> 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<Self>) -> Setting {
let theme = *cx.theme();
let muted = theme.muted_foreground;
Expand Down
Loading