diff --git a/CHANGELOG.md b/CHANGELOG.md index afb7c9d2..00f6d97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.33.0] - 2026-04-06 + +### Added + +- **Norwegian (Bokmål) translation** *(PR [#101](https://github.com/Psychotoxical/psysonic/pull/101) by [@zz5zz](https://github.com/zz5zz))*: Psysonic is now fully translated into Norwegian Bokmål — selectable in Settings → Appearance. +- **Configurable next-track preload** *(Issue [#102](https://github.com/Psychotoxical/psysonic/issues/102))*: A new setting in Settings → Playback controls when Psysonic starts buffering the next track. Three modes available: + - **Balanced** (default) — begins buffering 30 s before the end of the current track (previous behaviour). + - **Early** — begins buffering after just 5 s of playback, maximising reliability on slow connections. + - **Custom** — set the exact threshold (5 – 120 s before the end) via a slider. +- **Tray icon visibility toggle**: A new toggle in Settings → App Behavior lets you show or hide the system tray icon. When disabled, the icon is fully removed from the notification area / menu bar. + +### Changed + +- **Fullscreen Player — complete redesign**: The Ambient Stage has been rebuilt from the ground up. + - **Animated mesh background**: A GPU-only animated dark gradient mesh replaces the static blurred cover art background — smooth, performant, no layout repaints. + - **Artist portrait**: The right half of the screen now shows the artist's image (loaded from the server), crossfading smoothly on every track change. Falls back to the album cover if no artist image is available. + - **Bottom seekbar**: The seekbar is now pinned to the very bottom edge, spanning the full width, with elapsed and remaining timestamps above it. + - **Heart button**: You can now star/unstar the currently playing track directly from the Fullscreen Player without leaving the view. + - Removed the marquee-scrolling title in favour of a large, wrapping typographic layout. +- **Star buttons** — all star/favourite buttons across the app (Player Bar, Album Header, Album Tracklist, Queue Panel) now use the CSS class `.is-starred` instead of inline color overrides, making them trivially themeable. + +### Fixed + +- **macOS — HTTP audio streams**: Added `NSAppTransportSecurity` / `NSAllowsArbitraryLoads` to `Info.plist`. Without this, App Transport Security silently blocked HTTP radio streams and non-HTTPS Navidrome servers from loading audio in WKWebView on macOS. + +--- + ## [1.32.0] - 2026-04-05 — *The Big Easter Update* 🐣 ### Added diff --git a/README.md b/README.md index 96ffa938..e63e0d4d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- Psysonic Logo + Psysonic Logo

Psysonic

A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).

@@ -80,10 +80,29 @@ Designed specifically for users hosting their own music via Navidrome or other S Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system. -- **Windows**: `.exe` (NSIS installer) +### 🐧 Linux + +- **Ubuntu / Debian**: `.deb` from GitHub Releases +- **Fedora / RHEL**: `.rpm` from GitHub Releases + +### 🍎 macOS + - **macOS**: `.dmg` (Universal or Apple Silicon) -- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases -- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases + +> [!WARNING] +> **Gatekeeper Note:** +> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder: +> ```sh +> xattr -cr /Applications/Psysonic.app +> ``` + +### 🪟 Windows + +- **Windows**: `.exe` (NSIS installer) + +> [!WARNING] +> **SmartScreen Note:** +> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**. ## 📦 Installation (Arch Linux / AUR) diff --git a/package.json b/package.json index 95fb5617..90a2da82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.32.0", + "version": "1.33.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index 9d930278..53af4b8d 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.31.0 +pkgver=1.33.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b5ccba4a..cfe1fb74 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3493,7 +3493,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.32.0" +version = "1.33.0" dependencies = [ "biquad", "discord-rich-presence", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 03b39eba..96eae087 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.32.0" +version = "1.33.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 5bd183a1..7b541e8b 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -7,5 +7,14 @@ (triggered by CoreAudio enumerating input devices during init). --> NSMicrophoneUsageDescription Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices. + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1b403763..5212bdf1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,10 +6,11 @@ mod discord; use std::collections::HashMap; use std::sync::Mutex; +use std::sync::atomic::Ordering; use tauri::{ menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}, Emitter, Manager, }; @@ -17,6 +18,10 @@ use tauri::{ /// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode). type ShortcutMap = Mutex>; +/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed. +/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms. +type TrayState = Mutex>; + /// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows). /// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux). type MprisControls = Mutex>; @@ -145,6 +150,33 @@ async fn upload_radio_cover( Ok(()) } +#[tauri::command] +async fn upload_artist_image( + server_url: String, + artist_id: String, + username: String, + password: String, + file_bytes: Vec, + mime_type: String, +) -> Result<(), String> { + let token = navidrome_token(&server_url, &username, &password).await?; + let part = reqwest::multipart::Part::bytes(file_bytes) + .file_name("cover.jpg") + .mime_str(&mime_type) + .map_err(|e| e.to_string())?; + let form = reqwest::multipart::Form::new().part("image", part); + reqwest::Client::new() + .post(format!("{}/api/artist/{}/image", server_url, artist_id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] async fn delete_radio_cover( server_url: String, @@ -546,6 +578,113 @@ async fn delete_offline_track( Ok(()) } +/// Builds and returns a new system-tray icon with all menu items and event handlers. +/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation). +fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { + let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?; + let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?; + let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?; + let sep1 = PredefinedMenuItem::separator(app)?; + let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?; + let sep2 = PredefinedMenuItem::separator(app)?; + let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?; + + let menu = MenuBuilder::new(app) + .item(&play_pause) + .item(&previous) + .item(&next) + .item(&sep1) + .item(&show_hide) + .item(&sep2) + .item(&quit) + .build()?; + + TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .tooltip("Psysonic") + .on_menu_event(|app, event| match event.id.as_ref() { + "play_pause" => { let _ = app.emit("tray:play-pause", ()); } + "next" => { let _ = app.emit("tray:next", ()); } + "previous" => { let _ = app.emit("tray:previous", ()); } + "show_hide" => { + if let Some(win) = app.get_webview_window("main") { + if win.is_visible().unwrap_or(false) { + let _ = win.hide(); + } else { + let _ = win.show(); + let _ = win.set_focus(); + } + } + } + "quit" => { stop_audio_engine(app); app.exit(0); } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event { + let app = tray.app_handle(); + if let Some(win) = app.get_webview_window("main") { + if win.is_visible().unwrap_or(false) { + let _ = win.hide(); + } else { + let _ = win.show(); + let _ = win.set_focus(); + } + } + } + }) + .build(app) +} + +/// Show (`true`) or fully remove (`false`) the system-tray icon. +/// +/// The command is strictly idempotent: +/// - `show=true` when the icon is already present → no-op (prevents duplicate icons). +/// - `show=false` when the icon is already absent → no-op. +/// +/// For removal, `set_visible(false)` is called explicitly before the handle is +/// dropped because some platforms (Windows notification area, certain Linux DEs) +/// process the OS removal asynchronously — hiding first prevents a brief "ghost" +/// icon from appearing alongside a freshly created one. +#[tauri::command] +fn toggle_tray_icon( + app: tauri::AppHandle, + tray_state: tauri::State, + show: bool, +) -> Result<(), String> { + let mut guard = tray_state.lock().unwrap(); + + if show { + // Early-return when already shown — never build a second icon. + if guard.is_some() { + return Ok(()); + } + *guard = Some(build_tray_icon(&app).map_err(|e| e.to_string())?); + } else if let Some(tray) = guard.take() { + // Hide synchronously before dropping so the OS processes the removal + // before any subsequent show=true call can create a new icon. + let _ = tray.set_visible(false); + // `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem). + } + + Ok(()) +} + +/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`). +/// Called before process exit on macOS to ensure audio stops immediately. +fn stop_audio_engine(app: &tauri::AppHandle) { + let engine = app.state::(); + engine.generation.fetch_add(1, Ordering::SeqCst); + *engine.chained_info.lock().unwrap() = None; + drop(engine.radio_state.lock().unwrap().take()); + let mut cur = engine.current.lock().unwrap(); + if let Some(sink) = cur.sink.take() { sink.stop(); } +} + pub fn run() { let (audio_engine, _audio_thread) = audio::create_engine(); @@ -553,6 +692,7 @@ pub fn run() { .manage(audio_engine) .manage(ShortcutMap::default()) .manage(discord::DiscordState::new()) + .manage(TrayState::default()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) @@ -570,65 +710,11 @@ pub fn run() { .setup(|app| { // ── System tray ─────────────────────────────────────────────── + // Always build on startup; the frontend calls toggle_tray_icon(false) + // immediately after load if the user has disabled the tray icon. { - let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?; - let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?; - let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?; - let sep1 = PredefinedMenuItem::separator(app)?; - let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?; - let sep2 = PredefinedMenuItem::separator(app)?; - let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?; - - let menu = MenuBuilder::new(app) - .item(&play_pause) - .item(&previous) - .item(&next) - .item(&sep1) - .item(&show_hide) - .item(&sep2) - .item(&quit) - .build()?; - - TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&menu) - .tooltip("Psysonic") - .on_menu_event(|app, event| match event.id.as_ref() { - "play_pause" => { let _ = app.emit("tray:play-pause", ()); } - "next" => { let _ = app.emit("tray:next", ()); } - "previous" => { let _ = app.emit("tray:previous", ()); } - "show_hide" => { - if let Some(win) = app.get_webview_window("main") { - if win.is_visible().unwrap_or(false) { - let _ = win.hide(); - } else { - let _ = win.show(); - let _ = win.set_focus(); - } - } - } - "quit" => { std::process::exit(0); } - _ => {} - }) - .on_tray_icon_event(|tray, event| { - // Left-click: toggle window visibility - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event { - let app = tray.app_handle(); - if let Some(win) = app.get_webview_window("main") { - if win.is_visible().unwrap_or(false) { - let _ = win.hide(); - } else { - let _ = win.show(); - let _ = win.set_focus(); - } - } - } - }) - .build(app)?; + let tray = build_tray_icon(app.handle())?; + *app.state::().lock().unwrap() = Some(tray); } // ── MPRIS2 / OS media controls via souvlaki ────────────────── @@ -726,9 +812,22 @@ pub fn run() { .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { if window.label() == "main" { - // Let JS decide: minimize to tray or exit, based on user setting. api.prevent_close(); - let _ = window.emit("window:close-requested", ()); + + #[cfg(target_os = "macos")] + { + // On macOS the red close button quits the app entirely. + // Stop the audio engine first so sound cuts immediately. + let app = window.app_handle(); + stop_audio_engine(app); + app.exit(0); + } + + #[cfg(not(target_os = "macos"))] + { + // Let JS decide: minimize to tray or exit, based on user setting. + let _ = window.emit("window:close-requested", ()); + } } } }) @@ -759,6 +858,7 @@ pub fn run() { lastfm_request, upload_playlist_cover, upload_radio_cover, + upload_artist_image, delete_radio_cover, search_radio_browser, get_top_radio_stations, @@ -767,6 +867,7 @@ pub fn run() { delete_offline_track, get_offline_cache_size, relaunch_after_update, + toggle_tray_icon, ]) .run(tauri::generate_context!()) .expect("error while running Psysonic"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d359f90c..d249dffc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.32.0", + "version": "1.33.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index cf94f292..ab306041 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -314,6 +314,13 @@ function TauriEventBridge() { const next = usePlayerStore(s => s.next); const previous = usePlayerStore(s => s.previous); + // Sync tray-icon visibility with the user's stored setting. + // Runs once on mount (initial sync) and again whenever the setting changes. + const showTrayIcon = useAuthStore(s => s.showTrayIcon); + useEffect(() => { + invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error); + }, [showTrayIcon]); + // Configurable keybindings useEffect(() => { const onKey = (e: KeyboardEvent) => { diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index f9b20e53..86ae4896 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import md5 from 'md5'; +import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '../store/authStore'; import { version } from '../../package.json'; @@ -442,7 +443,6 @@ export async function uploadPlaylistCoverArt(id: string, file: File): Promise { + // Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions. + const { getBaseUrl, getActiveServer } = useAuthStore.getState(); + const server = getActiveServer(); + const baseUrl = getBaseUrl(); + const buffer = await file.arrayBuffer(); + const fileBytes = Array.from(new Uint8Array(buffer)); + await invoke('upload_artist_image', { + serverUrl: baseUrl, + artistId: id, + username: server?.username ?? '', + password: server?.password ?? '', + fileBytes, + mimeType: file.type || 'image/jpeg', + }); +} + export async function deletePlaylist(id: string): Promise { await api('deletePlaylist.view', { id }); } @@ -527,7 +544,6 @@ export async function uploadRadioCoverArt(id: string, file: File): Promise const baseUrl = getBaseUrl(); const buffer = await file.arrayBuffer(); const fileBytes = Array.from(new Uint8Array(buffer)); - const { invoke } = await import('@tauri-apps/api/core'); await invoke('upload_radio_cover', { serverUrl: baseUrl, radioId: id, @@ -543,7 +559,6 @@ export async function deleteRadioCoverArt(id: string): Promise { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); const baseUrl = getBaseUrl(); - const { invoke } = await import('@tauri-apps/api/core'); await invoke('delete_radio_cover', { serverUrl: baseUrl, radioId: id, @@ -556,7 +571,6 @@ export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); const baseUrl = getBaseUrl(); - const { invoke } = await import('@tauri-apps/api/core'); await invoke('upload_radio_cover', { serverUrl: baseUrl, radioId: id, @@ -580,18 +594,15 @@ function parseRadioBrowserStations(raw: Array>): RadioBro export const RADIO_PAGE_SIZE = 25; export async function searchRadioBrowser(query: string, offset = 0): Promise { - const { invoke } = await import('@tauri-apps/api/core'); const raw = await invoke>>('search_radio_browser', { query, offset }); return parseRadioBrowserStations(raw); } export async function getTopRadioStations(offset = 0): Promise { - const { invoke } = await import('@tauri-apps/api/core'); const raw = await invoke>>('get_top_radio_stations', { offset }); return parseRadioBrowserStations(raw); } export async function fetchUrlBytes(url: string): Promise<[number[], string]> { - const { invoke } = await import('@tauri-apps/api/core'); return invoke<[number[], string]>('fetch_url_bytes', { url }); } diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 9d0c1014..b85a43e1 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -199,11 +199,10 @@ export default function AlbumHeader({
diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 17464484..be671933 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,11 +1,10 @@ import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react'; import { Play, Pause, SkipBack, SkipForward, - ChevronDown, Repeat, Repeat1, Square, Music, MicVocal + ChevronDown, Repeat, Repeat1, Square, Music, Heart } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; -import { useLyricsStore } from '../store/lyricsStore'; -import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic'; import CachedImage, { useCachedUrl } from './CachedImage'; import { useTranslation } from 'react-i18next'; @@ -16,45 +15,8 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } -function MarqueeTitle({ title }: { title: string }) { - const containerRef = useRef(null); - const textRef = useRef(null); - const [scrollAmount, setScrollAmount] = useState(0); - - const measure = useCallback(() => { - const container = containerRef.current; - const text = textRef.current; - if (!container || !text) return; - // Temporarily make span inline-block to get its natural width - text.style.display = 'inline-block'; - const textWidth = text.getBoundingClientRect().width; - text.style.display = ''; - const overflow = textWidth - container.clientWidth; - setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0); - }, []); - - useEffect(() => { - measure(); - const ro = new ResizeObserver(measure); - if (containerRef.current) ro.observe(containerRef.current); - return () => ro.disconnect(); - }, [title, measure]); - - return ( -
- 0 ? 'fs-title-marquee' : ''} - style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}} - > - {title} - -
- ); -} - -// ─── Crossfading blurred background ─────────────────────────────────────────── -const FsBg = memo(function FsBg({ url }: { url: string }) { +// ─── Artist portrait — right half, crossfades on track change ───────────────── +const FsPortrait = memo(function FsPortrait({ url }: { url: string }) { const [layers, setLayers] = useState>(() => url ? [{ url, id: 0, visible: true }] : [] ); @@ -64,8 +26,6 @@ const FsBg = memo(function FsBg({ url }: { url: string }) { if (!url) return; let cancelled = false; const id = counterRef.current++; - // Preload the image before starting the crossfade — prevents a blank flash - // between the old and new layer while the browser decodes the image. const img = new Image(); img.onload = img.onerror = () => { if (cancelled) return; @@ -75,29 +35,34 @@ const FsBg = memo(function FsBg({ url }: { url: string }) { setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))); setTimeout(() => { if (!cancelled) setLayers(prev => prev.filter(l => l.id === id)); - }, 800); + }, 1000); }); }; img.src = url; return () => { cancelled = true; }; }, [url]); + if (layers.length === 0) return null; + return ( - <> +