diff --git a/changelog/v0.1.0-beta3.txt b/changelog/v0.1.0-beta3.txt index 7e94b3d3..6f030fa6 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -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. diff --git a/packaging/aur/flow-desktop-bin/.SRCINFO b/packaging/aur/flow-desktop-bin/.SRCINFO index 260aacb1..b60f06b9 100644 --- a/packaging/aur/flow-desktop-bin/.SRCINFO +++ b/packaging/aur/flow-desktop-bin/.SRCINFO @@ -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 diff --git a/packaging/aur/flow-desktop-bin/PKGBUILD b/packaging/aur/flow-desktop-bin/PKGBUILD index b0ee89d6..0813d3df 100644 --- a/packaging/aur/flow-desktop-bin/PKGBUILD +++ b/packaging/aur/flow-desktop-bin/PKGBUILD @@ -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') diff --git a/packaging/aur/flow-desktop/.SRCINFO b/packaging/aur/flow-desktop/.SRCINFO index e8b3df07..0a6f3620 100644 --- a/packaging/aur/flow-desktop/.SRCINFO +++ b/packaging/aur/flow-desktop/.SRCINFO @@ -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 @@ -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 diff --git a/packaging/aur/flow-desktop/PKGBUILD b/packaging/aur/flow-desktop/PKGBUILD index 20451fa3..1903fea4 100644 --- a/packaging/aur/flow-desktop/PKGBUILD +++ b/packaging/aur/flow-desktop/PKGBUILD @@ -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') diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5bc21904..69e0a99b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1479,6 +1479,7 @@ dependencies = [ "hmac", "icu_properties", "icu_segmenter", + "libloading", "local-ip-address", "lofty", "mp4", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ac91ef71..13aa6d26 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" @@ -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. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9c889d37..f321cd30 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/tray.rs b/src-tauri/src/commands/tray.rs new file mode 100644 index 00000000..6cb937b5 --- /dev/null +++ b/src-tauri/src/commands/tray.rs @@ -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()))) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b3857619..9bc1c9b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, @@ -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(); @@ -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") diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 97cecaf7..da50c7f4 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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; diff --git a/src-tauri/src/services/music_tray.rs b/src-tauri/src/services/music_tray.rs new file mode 100644 index 00000000..fb467a8f --- /dev/null +++ b/src-tauri/src/services/music_tray.rs @@ -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, + toggle: MenuItem, + previous: MenuItem, + next: MenuItem, + show: MenuItem, +} + +#[derive(Default)] +pub struct MusicTray { + parts: Mutex>, +} + +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 { + 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::>().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 = 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}')); + } +} diff --git a/src/App.tsx b/src/App.tsx index 83e74367..0cbd04cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,6 +63,7 @@ import { AddToPlaylistModal } from "./components/playlist/AddToPlaylistModal"; import { DeepFlowController } from "./components/deep-flow/DeepFlowController"; import { DeepLinkController } from "./components/handoff/DeepLinkController"; import { DiscordPresenceController } from "./components/discord/DiscordPresenceController"; +import { MusicTrayController } from "./components/tray/MusicTrayController"; import { DownloadDialog } from "./components/downloads/DownloadDialog"; import { DownloadActivity } from "./components/downloads/DownloadActivity"; import { DonationPromptHost } from "./components/donations/DonationPrompt"; @@ -270,6 +271,7 @@ function App() { + diff --git a/src/components/settings/MusicTraySetting.tsx b/src/components/settings/MusicTraySetting.tsx new file mode 100644 index 00000000..5cc9183c --- /dev/null +++ b/src/components/settings/MusicTraySetting.tsx @@ -0,0 +1,22 @@ +import { SettingItem } from "./SettingItem"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { getString } from "../../lib/i18n/index"; +import { SETTINGS } from "../../lib/settings/schema"; +import { useBoolPref } from "../../lib/usePreference"; +import { useMusicTraySupported } from "../../lib/useMusicTray"; + +export function MusicTraySetting() { + const [enabled, setEnabled] = useBoolPref(SETTINGS.MUSIC_TRAY_ENABLED); + const supported = useMusicTraySupported(); + const unavailable = supported === false; + + return ( + + + + ); +} diff --git a/src/components/tray/MusicTrayController.tsx b/src/components/tray/MusicTrayController.tsx new file mode 100644 index 00000000..bb0e0d36 --- /dev/null +++ b/src/components/tray/MusicTrayController.tsx @@ -0,0 +1,7 @@ +import { useMusicTray } from "../../lib/useMusicTray"; + +// Headless controller that keeps the system tray in step with the music player. +export function MusicTrayController() { + useMusicTray(); + return null; +} diff --git a/src/lib/api/tray.ts b/src/lib/api/tray.ts new file mode 100644 index 00000000..aa70d97f --- /dev/null +++ b/src/lib/api/tray.ts @@ -0,0 +1,42 @@ +import { invokeBackend } from "./errors"; +import { isTauriEnv } from "./env"; + +/** Event the backend emits when a tray menu item is clicked. */ +export const MUSIC_TRAY_ACTION_EVENT = "music-tray-action"; + +export type MusicTrayAction = "toggle" | "previous" | "next"; + +// Mirrors the backend TrayUpdate (serde camelCase). +export interface MusicTrayUpdate { + nowPlaying: string; + hasTrack: boolean; + playing: boolean; + labels: { play: string; pause: string; previous: string; next: string; show: string }; +} + +export async function musicTraySupported(): Promise { + if (!(await isTauriEnv())) return false; + try { + return await invokeBackend("music_tray_supported"); + } catch { + return false; + } +} + +export async function updateMusicTray(update: MusicTrayUpdate): Promise { + if (!(await isTauriEnv())) return; + try { + await invokeBackend("music_tray_update", { update }); + } catch (error) { + console.warn("Failed to update the music tray", error); + } +} + +export async function hideMusicTray(): Promise { + if (!(await isTauriEnv())) return; + try { + await invokeBackend("music_tray_hide"); + } catch (error) { + console.warn("Failed to hide the music tray", error); + } +} diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json index 0bbcdb58..6d32c089 100644 --- a/src/lib/i18n/strings.json +++ b/src/lib/i18n/strings.json @@ -1080,6 +1080,11 @@ "music_pause": "Pause", "music_next": "Next", "music_previous": "Previous", + "music_tray_nothing_playing": "Nothing playing", + "music_tray_show": "Show Flow", + "settings_music_tray": "Show music controls in the tray", + "settings_music_tray_desc": "Play, pause and skip songs from the menu bar or system tray, with the current song on top.", + "settings_music_tray_unavailable": "Needs the libayatana-appindicator3 library and a desktop that shows tray icons.", "music_queue": "Queue", "music_lyrics": "Lyrics", "music_equalizer": "Equalizer", diff --git a/src/lib/settings/schema.ts b/src/lib/settings/schema.ts index 6cdba62b..96a1fec2 100644 --- a/src/lib/settings/schema.ts +++ b/src/lib/settings/schema.ts @@ -1,4 +1,5 @@ import type { StringKey } from "../i18n/index"; +import { IS_LINUX_RUNTIME } from "../platform"; export type SettingValueType = "boolean" | "number" | "string" | "json"; export type SettingArea = @@ -150,6 +151,7 @@ export const SETTINGS = { DEARROW_BADGE_ENABLED: "dearrow_badge_enabled", RYTD_ENABLED: "rytd_enabled", DISCORD_RPC_MODE: "discord_rpc_mode", + MUSIC_TRAY_ENABLED: "music_tray_enabled", SB_SUBMIT_ENABLED: "sb_submit_enabled", SPONSORBLOCK_USER_ID: "sponsorblock_user_id", SPONSORBLOCK_SERVER: "sponsorblock_server", @@ -371,6 +373,8 @@ export const SETTING_DEFINITIONS = [ bool(SETTINGS.DEARROW_BADGE_ENABLED, "extensions", true, "wired"), bool(SETTINGS.RYTD_ENABLED, "extensions", true, "wired"), str(SETTINGS.DISCORD_RPC_MODE, "extensions", "off", "wired", ["off", "music", "videos", "musicAndVideos"]), + // Linux trays need an AppIndicator library and a desktop that shows them, so it is opt-in there. + bool(SETTINGS.MUSIC_TRAY_ENABLED, "extensions", !IS_LINUX_RUNTIME, "wired"), bool(SETTINGS.SB_SUBMIT_ENABLED, "extensions", false, "wired"), str(SETTINGS.SPONSORBLOCK_USER_ID, "extensions", "", "persisted-only", undefined, "visible", "private"), str(SETTINGS.SPONSORBLOCK_SERVER, "extensions", "https://sponsor.ajay.app", "wired"), diff --git a/src/lib/useMusicTray.test.ts b/src/lib/useMusicTray.test.ts new file mode 100644 index 00000000..b07fd048 --- /dev/null +++ b/src/lib/useMusicTray.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const player = vi.hoisted(() => ({ + currentTrack: null as unknown, + togglePlay: vi.fn(), + next: vi.fn(), + previous: vi.fn(), +})); + +vi.mock("../store/useMusicPlayerStore", () => ({ + useMusicPlayerStore: Object.assign(() => undefined, { getState: () => player }), +})); + +import { runTrayAction, trayNowPlaying } from "./useMusicTray"; +import type { SongItem } from "../types/music"; + +const song = (artists: string[]): SongItem => ({ + id: "s1", + title: "Song", + artists: artists.map((name) => ({ name, id: null })), + album: null, + duration: 200, + musicVideoType: null, + thumbnail: "", + explicit: false, + videoId: "s1", + playlistId: null, + params: null, +}); + +describe("music tray", () => { + beforeEach(() => { + player.currentTrack = null; + vi.clearAllMocks(); + }); + + it("titles the tray with the song and its artists", () => { + expect(trayNowPlaying(song(["A", "B"]), "Nothing")).toBe("Song · A, B"); + expect(trayNowPlaying(song([]), "Nothing")).toBe("Song"); + expect(trayNowPlaying(null, "Nothing")).toBe("Nothing"); + }); + + it("sends each tray action to the music player", () => { + player.currentTrack = song(["A"]); + runTrayAction("toggle"); + runTrayAction("next"); + runTrayAction("previous"); + expect(player.togglePlay).toHaveBeenCalledOnce(); + expect(player.next).toHaveBeenCalledOnce(); + expect(player.previous).toHaveBeenCalledOnce(); + }); + + it("ignores tray clicks with nothing loaded", () => { + runTrayAction("toggle"); + expect(player.togglePlay).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/useMusicTray.ts b/src/lib/useMusicTray.ts new file mode 100644 index 00000000..3829fdcb --- /dev/null +++ b/src/lib/useMusicTray.ts @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; + +import { useAppSettingsStore } from "../store/useAppSettingsStore"; +import { useMusicPlayerStore } from "../store/useMusicPlayerStore"; +import type { SongItem } from "../types/music"; +import { + MUSIC_TRAY_ACTION_EVENT, + hideMusicTray, + musicTraySupported, + updateMusicTray, + type MusicTrayAction, +} from "./api/tray"; +import { isTauriEnv } from "./api/env"; +import { getString } from "./i18n/index"; +import { SETTINGS } from "./settings/schema"; +import { useBoolPref } from "./usePreference"; + +/** The tray's first line: the song and its artists, or a placeholder when nothing is loaded. */ +export function trayNowPlaying(track: SongItem | null, nothingPlaying: string): string { + if (!track) return nothingPlaying; + const artists = track.artists.map((artist) => artist.name).filter(Boolean).join(", "); + return artists ? `${track.title} · ${artists}` : track.title; +} + +export function runTrayAction(action: MusicTrayAction): void { + const player = useMusicPlayerStore.getState(); + if (!player.currentTrack) return; + if (action === "toggle") player.togglePlay(); + else if (action === "next") player.next(); + else if (action === "previous") player.previous(); +} + +/** Null until the backend answers; false on Linux without an AppIndicator library. */ +export function useMusicTraySupported(): boolean | null { + const [supported, setSupported] = useState(null); + useEffect(() => { + let disposed = false; + void musicTraySupported().then((value) => { + if (!disposed) setSupported(value); + }); + return () => { + disposed = true; + }; + }, []); + return supported; +} + +// Mirrors the music player into the system tray and plays back the tray's clicks. +export function useMusicTray(): void { + const [enabledPref] = useBoolPref(SETTINGS.MUSIC_TRAY_ENABLED); + // Until the saved value arrives the default shows, which would build a tray only to remove it. + const loaded = useAppSettingsStore((s) => s.loaded); + const enabled = loaded && enabledPref; + const currentTrack = useMusicPlayerStore((s) => s.currentTrack); + const isPlaying = useMusicPlayerStore((s) => s.isPlaying); + + useEffect(() => { + if (!loaded) return; + if (!enabled) { + void hideMusicTray(); + return; + } + void updateMusicTray({ + nowPlaying: trayNowPlaying(currentTrack, getString("music_tray_nothing_playing")), + hasTrack: !!currentTrack, + playing: isPlaying, + labels: { + play: getString("music_play"), + pause: getString("music_pause"), + previous: getString("music_previous"), + next: getString("music_next"), + show: getString("music_tray_show"), + }, + }); + }, [loaded, enabled, currentTrack, isPlaying]); + + useEffect(() => { + if (!enabled) return; + let disposed = false; + let unlisten: (() => void) | null = null; + void isTauriEnv().then(async (inTauri) => { + if (!inTauri || disposed) return; + const stop = await listen(MUSIC_TRAY_ACTION_EVENT, (event) => runTrayAction(event.payload)); + if (disposed) stop(); + else unlisten = stop; + }).catch((error) => console.warn("Failed to listen for tray actions", error)); + return () => { + disposed = true; + unlisten?.(); + }; + }, [enabled]); +} diff --git a/src/pages/ExtensionsPage.tsx b/src/pages/ExtensionsPage.tsx index 660a1bed..3ac057f8 100644 --- a/src/pages/ExtensionsPage.tsx +++ b/src/pages/ExtensionsPage.tsx @@ -8,6 +8,7 @@ import { } from "../store/useSettingsStore"; import { SettingsGroup } from "../components/settings/SettingsGroup"; import { SettingItem } from "../components/settings/SettingItem"; +import { MusicTraySetting } from "../components/settings/MusicTraySetting"; import { ToggleSwitch } from "../components/ui/ToggleSwitch"; import { Select } from "../components/ui/Select"; import { Button } from "../components/ui/Button"; @@ -165,6 +166,7 @@ export const ExtensionsPage: React.FC = () => { ]} /> +