Skip to content
Merged
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
2 changes: 2 additions & 0 deletions changelog/v0.1.0-beta3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ MUSIC
- Flow reopens the music player on the song you stopped at, paused at the same point, with its queue, shuffle order and radio. "Restore the last queue" in Settings turns this off.
- History and recommendations count the time a song actually played, so skipping or dragging to the end no longer counts as a full listen, and a short replay no longer replaces a longer one.
- A new "Resume long tracks" setting, off by default, starts mixes, podcasts and episodes of 10 minutes or more where you left them.
- Music controls in the menu bar or system tray: the current song on top, then Play or Pause, Previous, Next and Show Flow. Turn it off under Integrations with Show music controls in the tray.
- On Linux the tray is off by default and needs the libayatana-appindicator3 library, which the .deb, .rpm and AppImage now bring along. Without it the switch stays off instead of stopping Flow from starting.

AUDIO
- Equalizer boosts no longer distort: the music equalizer turns the whole curve down by its highest boost before the filters, and a soft limiter bends any peak that still goes past full scale.
Expand Down
1 change: 1 addition & 0 deletions packaging/aur/flow-desktop-bin/.SRCINFO
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pkgbase = flow-desktop-bin
depends = nodejs
depends = webkit2gtk-4.1
optdepends = gst-plugins-ugly: more audio and video codecs
optdepends = libayatana-appindicator: music controls in the system tray
provides = flow-desktop
conflicts = flow-desktop
options = !strip
Expand Down
3 changes: 2 additions & 1 deletion packaging/aur/flow-desktop-bin/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ depends=(
'nodejs'
'webkit2gtk-4.1'
)
optdepends=('gst-plugins-ugly: more audio and video codecs')
optdepends=('gst-plugins-ugly: more audio and video codecs'
'libayatana-appindicator: music controls in the system tray')
provides=("$_pkgname")
conflicts=("$_pkgname")
options=('!strip' '!debug')
Expand Down
2 changes: 2 additions & 0 deletions packaging/aur/flow-desktop/.SRCINFO
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pkgbase = flow-desktop
arch = x86_64
arch = aarch64
license = GPL-3.0-only
makedepends = libayatana-appindicator
makedepends = pnpm
makedepends = rust
depends = cairo
Expand All @@ -24,6 +25,7 @@ pkgbase = flow-desktop
depends = nodejs
depends = webkit2gtk-4.1
optdepends = gst-plugins-ugly: more audio and video codecs
optdepends = libayatana-appindicator: music controls in the system tray
conflicts = flow-desktop-bin
options = !debug
source = flow-desktop-0.1.0beta2.tar.gz::https://github.com/Flow-Tube/Flow-Desktop/archive/refs/tags/v0.1.0-beta2.tar.gz
Expand Down
5 changes: 3 additions & 2 deletions packaging/aur/flow-desktop/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ depends=(
'nodejs'
'webkit2gtk-4.1'
)
makedepends=('pnpm' 'rust')
optdepends=('gst-plugins-ugly: more audio and video codecs')
makedepends=('libayatana-appindicator' 'pnpm' 'rust')
optdepends=('gst-plugins-ugly: more audio and video codecs'
'libayatana-appindicator: music controls in the system tray')
conflicts=("$pkgname-bin")
# The release profile already strips symbols, so a debug package would be empty.
options=('!debug')
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }

[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-os = "2"
Expand Down Expand Up @@ -107,6 +107,8 @@ tauri-plugin-window-state = "2"
rust-ini = "=0.21.1"
# The version wry uses, for the web-process-terminated signal (src/web_process.rs).
webkit2gtk = { version = "=2.0.2", features = ["v2_20"] }
# Probes for the AppIndicator library before building the tray (src/services/music_tray.rs).
libloading = "0.7"

[profile.release]
# Keep production binaries compact without sacrificing the normal release optimizer.
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ pub mod pip;
pub mod recommendation;
pub mod shorts;
pub mod sync;
pub mod tray;
pub mod window;
pub mod youtube;
31 changes: 31 additions & 0 deletions src-tauri/src/commands/tray.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use tauri::{AppHandle, State};

use crate::errors::{AppError, ErrorResponse};
use crate::services::music_tray::{MusicTray, TrayUpdate};

/// Whether the system can show tray icons (false on Linux without an `AppIndicator` library).
#[tauri::command]
pub async fn music_tray_supported() -> bool {
MusicTray::supported()
}

/// Show the music tray with the current song and play state.
#[tauri::command]
pub async fn music_tray_update(
app: AppHandle,
update: TrayUpdate,
tray: State<'_, MusicTray>,
) -> Result<(), ErrorResponse> {
tray.update(&app, &update)
.map_err(|error| ErrorResponse::from(AppError::Internal(error.to_string())))
}

/// Remove the music tray icon.
#[tauri::command]
pub async fn music_tray_hide(
app: AppHandle,
tray: State<'_, MusicTray>,
) -> Result<(), ErrorResponse> {
tray.hide(&app)
.map_err(|error| ErrorResponse::from(AppError::Internal(error.to_string())))
}
8 changes: 7 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ use commands::sync::{
sync_cancel, sync_device_info, sync_host_receive, sync_respond_consent, sync_scan_join,
sync_start_host, sync_status,
};
use commands::tray::{music_tray_hide, music_tray_supported, music_tray_update};
use commands::window::{PlayerFullscreenState, set_player_fullscreen};
use commands::youtube::{
fetch_subtitles, get_channel_about, get_channel_details, get_channel_page, get_channel_tab,
Expand Down Expand Up @@ -392,6 +393,7 @@ pub fn run() {
app.manage(PlayerFullscreenState::default());
app.manage(PipState::default());
app.manage(DiscordPresence::new());
app.manage(services::music_tray::MusicTray::default());

// Initialize and manage streaming proxy
let (streaming_manager, proxy_listeners) = streaming::proxy::StreamingManager::new();
Expand Down Expand Up @@ -643,7 +645,11 @@ pub fn run() {
check_subscriptions_now,
// --- Discord Rich Presence ---
set_discord_presence,
clear_discord_presence
clear_discord_presence,
// --- Music tray ---
music_tray_supported,
music_tray_update,
music_tray_hide
])
.build(context)
.expect("error while building Flow Desktop")
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod discord_presence;
pub mod home_engine;
pub mod home_feed;
pub mod music_service;
pub mod music_tray;
pub mod notification_service;
pub mod recommendation_service;
pub mod shorts_engine;
Expand Down
230 changes: 230 additions & 0 deletions src-tauri/src/services/music_tray.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
//! Music controls in the system tray. The frontend owns playback, so the tray only shows what it
//! is told and hands its clicks back to the main window as events.

use std::sync::{Mutex, PoisonError};

use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{TrayIcon, TrayIconBuilder};
use tauri::{AppHandle, Emitter, Manager};

/// Event the main window listens on; the payload is one of the action ids below.
pub const ACTION_EVENT: &str = "music-tray-action";

const TRAY_ID: &str = "flow-music";
const ID_TOGGLE: &str = "toggle";
const ID_PREVIOUS: &str = "previous";
const ID_NEXT: &str = "next";
const ID_SHOW: &str = "show";
const TITLE_MAX_CHARS: usize = 64;

/// Menu text in the UI language, sent with every update so the backend holds no strings.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrayLabels {
pub play: String,
pub pause: String,
pub previous: String,
pub next: String,
pub show: String,
}

#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrayUpdate {
pub now_playing: String,
pub has_track: bool,
pub playing: bool,
pub labels: TrayLabels,
}

struct TrayParts {
tray: TrayIcon,
now_playing: MenuItem<tauri::Wry>,
toggle: MenuItem<tauri::Wry>,
previous: MenuItem<tauri::Wry>,
next: MenuItem<tauri::Wry>,
show: MenuItem<tauri::Wry>,
}

#[derive(Default)]
pub struct MusicTray {
parts: Mutex<Option<TrayParts>>,
}

impl MusicTray {
/// Whether this system can show a tray icon at all.
pub fn supported() -> bool {
indicator_library_present()
}

/// Shows the tray with this state, creating it on first use.
pub fn update(&self, app: &AppHandle, update: &TrayUpdate) -> tauri::Result<()> {
if !Self::supported() {
return Ok(());
}
let mut guard = self.parts.lock().unwrap_or_else(PoisonError::into_inner);
if guard.is_none() {
*guard = Some(build(app, update)?);
}
let parts = guard.as_ref().expect("tray built above");
let title = menu_title(&update.now_playing);
parts.now_playing.set_text(&title)?;
parts.toggle.set_text(if update.playing {
&update.labels.pause
} else {
&update.labels.play
})?;
parts.previous.set_text(&update.labels.previous)?;
parts.next.set_text(&update.labels.next)?;
parts.show.set_text(&update.labels.show)?;
for item in [&parts.toggle, &parts.previous, &parts.next] {
item.set_enabled(update.has_track)?;
}
parts.tray.set_tooltip(Some(&update.now_playing))?;
Ok(())
}

/// Removes the tray icon, for when the setting is off.
pub fn hide(&self, app: &AppHandle) -> tauri::Result<()> {
let Some(parts) = self
.parts
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
else {
return Ok(());
};
// Dropping the last handle removes the NSStatusItem, and AppKit traps when that happens
// off the main thread, so both the removal and the drop run there.
let handle = app.clone();
app.run_on_main_thread(move || {
let removed = handle.remove_tray_by_id(TRAY_ID);
drop(parts);
drop(removed);
})
}
}

fn build(app: &AppHandle, update: &TrayUpdate) -> tauri::Result<TrayParts> {
let now_playing = MenuItem::with_id(
app,
"now-playing",
menu_title(&update.now_playing),
false,
None::<&str>,
)?;
let toggle = MenuItem::with_id(app, ID_TOGGLE, &update.labels.play, true, None::<&str>)?;
let previous = MenuItem::with_id(
app,
ID_PREVIOUS,
&update.labels.previous,
true,
None::<&str>,
)?;
let next = MenuItem::with_id(app, ID_NEXT, &update.labels.next, true, None::<&str>)?;
let show = MenuItem::with_id(app, ID_SHOW, &update.labels.show, true, None::<&str>)?;
let menu = Menu::with_items(
app,
&[
&now_playing,
&PredefinedMenuItem::separator(app)?,
&toggle,
&previous,
&next,
&PredefinedMenuItem::separator(app)?,
&show,
],
)?;

let mut builder = TrayIconBuilder::with_id(TRAY_ID)
.menu(&menu)
.show_menu_on_left_click(true)
.on_menu_event(|app, event| match event.id().as_ref() {
ID_SHOW => show_main_window(app),
action @ (ID_TOGGLE | ID_PREVIOUS | ID_NEXT) => {
if let Err(error) = app.emit_to("main", ACTION_EVENT, action) {
tracing::warn!(%error, "Could not send a tray action to the player");
}
}
_ => {}
});
if let Some(icon) = app.default_window_icon() {
builder = builder.icon(icon.clone());
}
let tray = builder.build(app)?;
Ok(TrayParts {
tray,
now_playing,
toggle,
previous,
next,
show,
})
}

fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main")
&& let Err(error) = window
.unminimize()
.and_then(|()| window.show())
.and_then(|()| window.set_focus())
{
tracing::warn!(%error, "Could not show the main window from the tray");
}
}

/// A single line short enough for a menu, with `&` doubled so menus don't read it as a mnemonic.
fn menu_title(text: &str) -> String {
let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
let clipped: String = if line.chars().count() > TITLE_MAX_CHARS {
let mut cut: String = line.chars().take(TITLE_MAX_CHARS - 1).collect();
cut.push('\u{2026}');
cut
} else {
line
};
clipped.replace('&', "&&")
}

/// Tauri loads the AppIndicator library lazily and panics when it is missing, which the release
/// profile turns into an abort, so probe for it before building a tray on Linux.
#[cfg(target_os = "linux")]
fn indicator_library_present() -> bool {
use std::sync::OnceLock;
static PRESENT: OnceLock<bool> = OnceLock::new();
*PRESENT.get_or_init(|| {
let present = ["libayatana-appindicator3.so.1", "libappindicator3.so.1"]
.iter()
// SAFETY: both are plain shared libraries with no load-time side effects we rely on.
.any(|name| unsafe { libloading::Library::new(*name) }.is_ok());
if !present {
tracing::info!("No AppIndicator library found; music tray controls are unavailable");
}
present
})
}

#[cfg(not(target_os = "linux"))]
fn indicator_library_present() -> bool {
true
}

#[cfg(test)]
mod tests {
use super::menu_title;

#[test]
fn titles_are_one_escaped_line() {
assert_eq!(
menu_title("Simon & Garfunkel\nLive"),
"Simon && Garfunkel Live"
);
}

#[test]
fn long_titles_are_clipped_with_an_ellipsis() {
let title = menu_title(&"a".repeat(100));
assert_eq!(title.chars().count(), 64);
assert!(title.ends_with('\u{2026}'));
}
}
Loading
Loading