mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
feat: v1.33.0 — Fullscreen Player redesign, Norwegian, configurable preload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="public/logo-psysonic.png" alt="Psysonic Logo" width="200"/>
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="200"/>
|
||||
<h1>Psysonic</h1>
|
||||
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.32.0",
|
||||
"version": "1.33.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
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')
|
||||
|
||||
Generated
+1
-1
@@ -3493,7 +3493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.32.0"
|
||||
version = "1.33.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.32.0"
|
||||
version = "1.33.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
|
||||
@@ -7,5 +7,14 @@
|
||||
(triggered by CoreAudio enumerating input devices during init). -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
|
||||
|
||||
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
|
||||
Navidrome servers running without HTTPS can load media in WKWebView.
|
||||
Without this, macOS App Transport Security blocks HTTP audio src. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+162
-61
@@ -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<HashMap<String, String>>;
|
||||
|
||||
/// 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<Option<TrayIcon>>;
|
||||
|
||||
/// 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<Option<souvlaki::MediaControls>>;
|
||||
@@ -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<u8>,
|
||||
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<TrayIcon> {
|
||||
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<TrayState>,
|
||||
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::<audio::AudioEngine>();
|
||||
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::<TrayState>().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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+18
-7
@@ -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<vo
|
||||
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_playlist_cover', {
|
||||
serverUrl: baseUrl,
|
||||
playlistId: id,
|
||||
@@ -453,6 +453,23 @@ export async function uploadPlaylistCoverArt(id: string, file: File): Promise<vo
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadArtistImage(id: string, file: File): Promise<void> {
|
||||
// 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<void> {
|
||||
await api('deletePlaylist.view', { id });
|
||||
}
|
||||
@@ -527,7 +544,6 @@ export async function uploadRadioCoverArt(id: string, file: File): Promise<void>
|
||||
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<void> {
|
||||
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<Record<string, string>>): RadioBro
|
||||
export const RADIO_PAGE_SIZE = 25;
|
||||
|
||||
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
|
||||
return parseRadioBrowserStations(raw);
|
||||
}
|
||||
|
||||
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<Array<Record<string, string>>>('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 });
|
||||
}
|
||||
|
||||
@@ -199,11 +199,10 @@ export default function AlbumHeader({
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
|
||||
@@ -252,10 +252,9 @@ export default function AlbumTrackList({
|
||||
return (
|
||||
<div key="favorite" className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`}
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
>
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
+121
-122
@@ -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<HTMLDivElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(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 (
|
||||
<div ref={containerRef} className="fs-title-wrap">
|
||||
<span
|
||||
ref={textRef}
|
||||
className={scrollAmount > 0 ? 'fs-title-marquee' : ''}
|
||||
style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
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 (
|
||||
<>
|
||||
<div className="fs-portrait-wrap" aria-hidden="true">
|
||||
{layers.map(layer => (
|
||||
<div
|
||||
<img
|
||||
key={layer.id}
|
||||
className="fs-bg"
|
||||
style={{ backgroundImage: `url(${layer.url})`, opacity: layer.visible ? 1 : 0 }}
|
||||
aria-hidden="true"
|
||||
src={layer.url}
|
||||
className="fs-portrait"
|
||||
style={{ opacity: layer.visible ? 1 : 0 }}
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
alt=""
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
|
||||
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
|
||||
// ─── Full-width seekbar (isolated — re-renders every tick) ────────────────────
|
||||
const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
@@ -112,21 +77,22 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
|
||||
const buf = Math.max(pct, buffered * 100);
|
||||
|
||||
return (
|
||||
<div className="fs-progress-wrap">
|
||||
<span className="fs-time">{formatTime(currentTime)}</span>
|
||||
<div className="fs-progress-bar">
|
||||
<div className="fs-seekbar-wrap">
|
||||
<div className="fs-seekbar-times">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
<div className="fs-seekbar">
|
||||
<div className="fs-seekbar-bg" />
|
||||
<div className="fs-seekbar-buf" style={{ width: `${buf}%` }} />
|
||||
<div className="fs-seekbar-played" style={{ width: `${pct}%` }} />
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={{
|
||||
'--pct': `${pct}%`,
|
||||
'--buf': `${buf}%`,
|
||||
} as React.CSSProperties}
|
||||
aria-label="progress"
|
||||
aria-label="seek"
|
||||
/>
|
||||
</div>
|
||||
<span className="fs-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -150,28 +116,40 @@ interface FullscreenPlayerProps {
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
const nextVal = !isStarred;
|
||||
setStarredOverride(currentTrack.id, nextVal);
|
||||
try {
|
||||
if (nextVal) await star(currentTrack.id, 'song');
|
||||
else await unstar(currentTrack.id, 'song');
|
||||
} catch {
|
||||
setStarredOverride(currentTrack.id, !nextVal);
|
||||
}
|
||||
}, [currentTrack, isStarred, setStarredOverride]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized
|
||||
// to prevent useCachedUrl from re-fetching on every progress re-render (100 ms).
|
||||
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
|
||||
// No fetchUrl fallback for the background — we only want stable blob URLs
|
||||
// to avoid a double crossfade (fetchUrl → blobUrl for the same image).
|
||||
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized.
|
||||
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
|
||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
// Fetch artist image for background — fall back to cover art if unavailable
|
||||
// Artist image → portrait on right. Falls back to cover art.
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
useEffect(() => {
|
||||
setArtistBgUrl('');
|
||||
@@ -184,7 +162,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.artistId]);
|
||||
|
||||
const bgUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
@@ -192,84 +170,105 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const metaParts = [
|
||||
currentTrack?.album,
|
||||
currentTrack?.year?.toString(),
|
||||
currentTrack?.suffix?.toUpperCase(),
|
||||
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
|
||||
{/* Layer 1 — blurred artist image */}
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
<div className="fs-mesh-bg" aria-hidden="true">
|
||||
<div className="fs-mesh-blob fs-mesh-blob-a" />
|
||||
<div className="fs-mesh-blob fs-mesh-blob-b" />
|
||||
</div>
|
||||
|
||||
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
|
||||
<FsPortrait url={portraitUrl} />
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
<div className="fs-scrim" aria-hidden="true" />
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
|
||||
{/* Center stage — everything vertically + horizontally centered */}
|
||||
<div className="fs-stage">
|
||||
|
||||
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
<div className="fs-cover-wrap">
|
||||
{/* Album art */}
|
||||
<div className="fs-art-wrap">
|
||||
{coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverKey}
|
||||
alt={`${currentTrack?.album} Cover`}
|
||||
className="fs-cover"
|
||||
className="fs-art"
|
||||
/>
|
||||
) : (
|
||||
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
|
||||
<div className="fs-art fs-art-placeholder"><Music size={40} /></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fs-track-info">
|
||||
<MarqueeTitle title={currentTrack?.title ?? '—'} />
|
||||
<p className="fs-album">
|
||||
{currentTrack?.album ?? ''}
|
||||
{currentTrack?.year ? ` · ${currentTrack.year}` : ''}
|
||||
</p>
|
||||
{(currentTrack?.bitRate || currentTrack?.suffix) && (
|
||||
<span className="fs-codec">
|
||||
{[
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''
|
||||
].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Artist — massive statement */}
|
||||
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
<FsProgress duration={duration} />
|
||||
{/* Track title — accent, light weight */}
|
||||
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
|
||||
|
||||
{/* Metadata row */}
|
||||
{metaParts.length > 0 && (
|
||||
<div className="fs-meta">
|
||||
{metaParts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="fs-meta-dot">·</span>}
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="fs-controls">
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
|
||||
<Square size={14} fill="currentColor" />
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
|
||||
<Square size={13} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
|
||||
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
|
||||
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
|
||||
aria-label={t('player.lyrics')}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Layer 4 — full-width seekbar, bottom edge */}
|
||||
<FsSeekbar duration={duration} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,11 +141,11 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
{currentTrack && !isRadio && (
|
||||
<button
|
||||
className="player-btn player-btn-sm player-star-btn"
|
||||
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
@@ -404,13 +404,11 @@ export default function QueuePanel() {
|
||||
<div className="queue-current-info">
|
||||
<h3 className="truncate">{currentTrack.title}</h3>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
className={`queue-current-sub truncate${currentTrack.artistId ? ' is-link' : ''}`}
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
>{currentTrack.artist}</div>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>{currentTrack.album}</div>
|
||||
{currentTrack.year && (
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
|
||||
@@ -164,29 +164,10 @@ export default function ThemePicker({ value, onChange }: Props) {
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
className="theme-card-btn"
|
||||
onClick={() => onChange(t.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '46px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
position: 'relative',
|
||||
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
|
||||
transition: 'outline-color 0.15s, box-shadow 0.15s',
|
||||
}}>
|
||||
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
|
||||
<div style={{ background: t.bg, height: '55%' }} />
|
||||
<div style={{ background: t.card, height: '20%' }} />
|
||||
<div style={{ background: t.accent, height: '25%' }} />
|
||||
@@ -208,14 +189,7 @@ export default function ThemePicker({ value, onChange }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
+58
@@ -172,6 +172,8 @@ const enTranslation = {
|
||||
cacheOffline: 'Save discography offline',
|
||||
offlineCached: 'Discography cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
uploadImage: 'Upload artist image',
|
||||
uploadImageError: 'Failed to upload image',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
@@ -414,6 +416,8 @@ const enTranslation = {
|
||||
offlineDirHint: 'New downloads will use this location. Existing downloads remain at their original path.',
|
||||
showArtistImages: 'Show Artist Images',
|
||||
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
|
||||
showTrayIcon: 'Show Tray Icon',
|
||||
showTrayIconDesc: 'Display the Psysonic icon in the system notification area / menu bar.',
|
||||
minimizeToTray: 'Minimize to Tray',
|
||||
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
@@ -498,6 +502,12 @@ const enTranslation = {
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
preloadMode: 'Preload Next Track',
|
||||
preloadModeDesc: 'When to start buffering the next track in the queue',
|
||||
preloadBalanced: 'Balanced (30 s before end)',
|
||||
preloadEarly: 'Early (after 5 s of playback)',
|
||||
preloadCustom: 'Custom',
|
||||
preloadCustomSeconds: 'Seconds before end: {{n}}',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
@@ -951,6 +961,8 @@ const deTranslation = {
|
||||
cacheOffline: 'Diskografie offline speichern',
|
||||
offlineCached: 'Diskografie gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
uploadImage: 'Künstlerbild hochladen',
|
||||
uploadImageError: 'Bild konnte nicht hochgeladen werden',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
@@ -1193,6 +1205,8 @@ const deTranslation = {
|
||||
offlineDirHint: 'Neue Downloads werden an diesem Ort gespeichert. Bestehende Downloads verbleiben am ursprünglichen Pfad.',
|
||||
showArtistImages: 'Künstlerbilder anzeigen',
|
||||
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
|
||||
showTrayIcon: 'Tray-Icon anzeigen',
|
||||
showTrayIconDesc: 'Psysonic-Icon im System-Tray / in der Menüleiste anzeigen.',
|
||||
minimizeToTray: 'Im Tray minimieren',
|
||||
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
@@ -1277,6 +1291,12 @@ const deTranslation = {
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
preloadMode: 'Nächsten Track vorpuffern',
|
||||
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
|
||||
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
|
||||
preloadEarly: 'Früh (nach 5 s Wiedergabe)',
|
||||
preloadCustom: 'Benutzerdefiniert',
|
||||
preloadCustomSeconds: 'Sekunden vor Ende: {{n}}',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||
experimental: 'Experimentell',
|
||||
@@ -1730,6 +1750,8 @@ const frTranslation = {
|
||||
cacheOffline: 'Enregistrer la discographie hors ligne',
|
||||
offlineCached: 'Discographie en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
uploadImage: "Téléverser l'image de l'artiste",
|
||||
uploadImageError: "Échec du téléversement de l'image",
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoris',
|
||||
@@ -1972,6 +1994,8 @@ const frTranslation = {
|
||||
offlineDirHint: 'Les nouveaux téléchargements utiliseront cet emplacement. Les téléchargements existants restent à leur emplacement d\'origine.',
|
||||
showArtistImages: 'Afficher les images d\'artistes',
|
||||
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
|
||||
showTrayIcon: 'Afficher l\'icône dans la barre système',
|
||||
showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.',
|
||||
minimizeToTray: 'Réduire dans la barre système',
|
||||
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
@@ -2056,6 +2080,12 @@ const frTranslation = {
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
preloadMode: 'Précharger la piste suivante',
|
||||
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
|
||||
preloadBalanced: 'Équilibré (30 s avant la fin)',
|
||||
preloadEarly: 'Tôt (après 5 s de lecture)',
|
||||
preloadCustom: 'Personnalisé',
|
||||
preloadCustomSeconds: 'Secondes avant la fin : {{n}}',
|
||||
infiniteQueue: 'File infinie',
|
||||
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||
experimental: 'Expérimental',
|
||||
@@ -2509,6 +2539,8 @@ const nlTranslation = {
|
||||
cacheOffline: 'Discografie offline opslaan',
|
||||
offlineCached: 'Discografie gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
uploadImage: 'Artiestafbeelding uploaden',
|
||||
uploadImageError: 'Uploaden van afbeelding mislukt',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorieten',
|
||||
@@ -2751,6 +2783,8 @@ const nlTranslation = {
|
||||
offlineDirHint: 'Nieuwe downloads worden op deze locatie opgeslagen. Bestaande downloads blijven op hun oorspronkelijke locatie.',
|
||||
showArtistImages: 'Artiestafbeeldingen weergeven',
|
||||
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
|
||||
showTrayIcon: 'Tray-pictogram weergeven',
|
||||
showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.',
|
||||
minimizeToTray: 'Minimaliseren naar systeemvak',
|
||||
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
@@ -2835,6 +2869,12 @@ const nlTranslation = {
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
preloadMode: 'Volgend nummer vooraf laden',
|
||||
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
|
||||
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
|
||||
preloadEarly: 'Vroeg (na 5 s afspelen)',
|
||||
preloadCustom: 'Aangepast',
|
||||
preloadCustomSeconds: 'Seconden voor einde: {{n}}',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||
experimental: 'Experimenteel',
|
||||
@@ -3288,6 +3328,8 @@ const zhTranslation = {
|
||||
cacheOffline: '离线保存全部专辑',
|
||||
offlineCached: '全部专辑已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
uploadImage: '上传艺术家图片',
|
||||
uploadImageError: '图片上传失败',
|
||||
},
|
||||
favorites: {
|
||||
title: '收藏夹',
|
||||
@@ -3526,6 +3568,8 @@ const zhTranslation = {
|
||||
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
|
||||
showArtistImages: '显示艺术家图片',
|
||||
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
||||
showTrayIcon: '显示托盘图标',
|
||||
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
@@ -3610,6 +3654,12 @@ const zhTranslation = {
|
||||
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
||||
gapless: '无缝播放',
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
preloadMode: '预加载下一曲目',
|
||||
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
|
||||
preloadBalanced: '均衡(结束前30秒)',
|
||||
preloadEarly: '提前(播放5秒后)',
|
||||
preloadCustom: '自定义',
|
||||
preloadCustomSeconds: '结束前秒数:{{n}}',
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
@@ -4063,6 +4113,8 @@ const nbTranslation = {
|
||||
cacheOffline: 'Lagre diskografi som frakoblet',
|
||||
offlineCached: 'Diskografi bufret',
|
||||
offlineDownloading: 'Bufrer… ({{done}}/{{total}} album)',
|
||||
uploadImage: 'Last opp artistbilde',
|
||||
uploadImageError: 'Kunne ikke laste opp bildet',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoritter',
|
||||
@@ -4393,6 +4445,12 @@ const nbTranslation = {
|
||||
infiniteQueue: 'Uendelig kø',
|
||||
infiniteQueueDesc: 'Legg automatisk til tilfeldige spor når køen går tom',
|
||||
experimental: 'Eksperimentell',
|
||||
preloadMode: 'Forhåndslast neste spor',
|
||||
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
|
||||
preloadBalanced: 'Balansert (30 s før slutt)',
|
||||
preloadEarly: 'Tidlig (etter 5 s avspilling)',
|
||||
preloadCustom: 'Egendefinert',
|
||||
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "Nyheter",
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function Albums() {
|
||||
const clearYear = () => { setYearFrom(''); setYearTo(''); };
|
||||
|
||||
const sortOptions: { value: SortType; label: string }[] = [
|
||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||
];
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ const CONTRIBUTORS = [
|
||||
'Human-readable audio error messages (PR #44)',
|
||||
],
|
||||
},
|
||||
{
|
||||
github: 'zz5zz',
|
||||
since: '1.32.0',
|
||||
contributions: [
|
||||
'Norwegian (Bokmål) translation (PR #101)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SPECIAL_THANKS = [
|
||||
@@ -450,6 +457,41 @@ export default function Settings() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* Preload mode */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{(['balanced', 'early', 'custom'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setPreloadMode(mode)}
|
||||
>
|
||||
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{auth.preloadMode === 'custom' && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={5} max={120} step={5}
|
||||
value={auth.preloadCustomSeconds}
|
||||
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
@@ -467,6 +509,17 @@ export default function Settings() {
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showTrayIcon')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showTrayIconDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showTrayIcon')}>
|
||||
<input type="checkbox" checked={auth.showTrayIcon} onChange={e => auth.setShowTrayIcon(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||||
|
||||
@@ -34,8 +34,11 @@ interface AuthState {
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
preloadMode: 'balanced' | 'early' | 'custom';
|
||||
preloadCustomSeconds: number;
|
||||
infiniteQueueEnabled: boolean;
|
||||
showArtistImages: boolean;
|
||||
showTrayIcon: boolean;
|
||||
minimizeToTray: boolean;
|
||||
discordRichPresence: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
@@ -71,8 +74,11 @@ interface AuthState {
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => void;
|
||||
setPreloadCustomSeconds: (v: number) => void;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
setShowArtistImages: (v: boolean) => void;
|
||||
setShowTrayIcon: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setDiscordRichPresence: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
@@ -109,8 +115,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
preloadMode: 'balanced',
|
||||
preloadCustomSeconds: 30,
|
||||
infiniteQueueEnabled: false,
|
||||
showArtistImages: false,
|
||||
showTrayIcon: true,
|
||||
minimizeToTray: false,
|
||||
discordRichPresence: false,
|
||||
nowPlayingEnabled: false,
|
||||
@@ -179,8 +188,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
|
||||
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
|
||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
|
||||
@@ -166,15 +166,44 @@ let togglePlayLock = false;
|
||||
const radioAudio = new Audio();
|
||||
radioAudio.preload = 'none';
|
||||
let radioStopping = false;
|
||||
// Pending reconnect timer for stalled streams — null when no reconnect is scheduled.
|
||||
let radioReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearRadioReconnectTimer() {
|
||||
if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; }
|
||||
}
|
||||
|
||||
radioAudio.addEventListener('ended', () => {
|
||||
// Stream disconnected unexpectedly — clear radio state.
|
||||
clearRadioReconnectTimer();
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
|
||||
});
|
||||
radioAudio.addEventListener('error', () => {
|
||||
clearRadioReconnectTimer();
|
||||
if (radioStopping) { radioStopping = false; return; }
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
|
||||
showToast('Radio stream error', 3000, 'error');
|
||||
});
|
||||
// Stalled: stream stopped delivering data — try to reconnect after 4 s.
|
||||
radioAudio.addEventListener('stalled', () => {
|
||||
if (radioReconnectTimer) return; // already scheduled
|
||||
radioReconnectTimer = setTimeout(() => {
|
||||
radioReconnectTimer = null;
|
||||
if (!usePlayerStore.getState().currentRadio) return;
|
||||
// Re-assign src to force a fresh connection, then resume playback.
|
||||
const src = radioAudio.src;
|
||||
radioAudio.src = src;
|
||||
radioAudio.play().catch(console.error);
|
||||
}, 4000);
|
||||
});
|
||||
// Waiting: browser is rebuffering — normal for live streams, no action needed.
|
||||
radioAudio.addEventListener('waiting', () => {
|
||||
console.debug('[psysonic] radio: buffering');
|
||||
});
|
||||
// Suspend: browser paused loading (sufficient buffer) — cancel any stale reconnect.
|
||||
radioAudio.addEventListener('suspend', () => {
|
||||
clearRadioReconnectTimer();
|
||||
});
|
||||
|
||||
// Timestamp of the last gapless auto-advance (from audio:track_switched).
|
||||
// Used to suppress ghost-commands from stale IPC arriving after the switch.
|
||||
@@ -235,9 +264,15 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-buffer / pre-chain next track when 30 s remain.
|
||||
const { gaplessEnabled } = useAuthStore.getState();
|
||||
if (dur - current_time < 30 && dur - current_time > 0) {
|
||||
// Pre-buffer / pre-chain next track based on preload mode.
|
||||
const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState();
|
||||
const remaining = dur - current_time;
|
||||
const shouldPreload = preloadMode === 'early'
|
||||
? current_time >= 5
|
||||
: preloadMode === 'custom'
|
||||
? remaining < preloadCustomSeconds && remaining > 0
|
||||
: remaining < 30 && remaining > 0; // balanced (default)
|
||||
if (shouldPreload) {
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
const nextTrack = repeatMode === 'one'
|
||||
@@ -598,6 +633,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
// ── stop ────────────────────────────────────────────────────────────────
|
||||
stop: () => {
|
||||
if (get().currentRadio) {
|
||||
clearRadioReconnectTimer();
|
||||
radioStopping = true;
|
||||
radioAudio.pause();
|
||||
radioAudio.src = '';
|
||||
@@ -614,6 +650,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const { volume } = get();
|
||||
++playGeneration;
|
||||
isAudioPaused = false;
|
||||
clearRadioReconnectTimer();
|
||||
gaplessPreloadingId = null;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
// Stop Rust engine in case a regular track was playing.
|
||||
@@ -623,6 +660,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
radioAudio.volume = volume;
|
||||
radioAudio.play().catch((err: unknown) => {
|
||||
console.error('[psysonic] radio HTML5 play failed:', err);
|
||||
showToast('Radio stream error', 3000, 'error');
|
||||
set({ isPlaying: false, currentRadio: null });
|
||||
});
|
||||
set({
|
||||
@@ -654,6 +692,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
// If a radio stream is active, stop it before the new track starts so
|
||||
// the PlayerBar clears radio mode immediately and the stream is released.
|
||||
if (get().currentRadio) {
|
||||
clearRadioReconnectTimer();
|
||||
radioStopping = true;
|
||||
radioAudio.pause();
|
||||
radioAudio.src = '';
|
||||
|
||||
+345
-190
@@ -1628,10 +1628,17 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-artist-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.track-artist-link:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.track-artist-sep {
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.track-codec {
|
||||
display: block;
|
||||
@@ -1684,6 +1691,26 @@
|
||||
padding: 4px;
|
||||
height: auto;
|
||||
min-height: unset;
|
||||
color: var(--text-muted);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.track-star-btn:hover,
|
||||
.track-star-btn.is-starred {
|
||||
color: var(--color-star-active, var(--accent));
|
||||
}
|
||||
|
||||
.album-detail-star-btn {
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast) !important;
|
||||
}
|
||||
|
||||
.album-detail-star-btn:hover {
|
||||
color: var(--color-star-active, var(--accent)) !important;
|
||||
}
|
||||
|
||||
.album-detail-star-btn.is-starred {
|
||||
color: var(--color-star-active, var(--accent)) !important;
|
||||
border: 1px solid var(--color-star-active, var(--accent)) !important;
|
||||
}
|
||||
|
||||
/* ── Random Mix — clickable artist name ── */
|
||||
@@ -2271,6 +2298,70 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Theme Card (individual theme preview button) ───────────────────────── */
|
||||
|
||||
.theme-card-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-card-preview {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
position: relative;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
transition: outline-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
/* Hover: dim accent ring + subtle lift */
|
||||
.theme-card-btn:hover .theme-card-preview {
|
||||
outline-color: var(--accent-glow);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* Active: full accent ring + glow */
|
||||
.theme-card-preview.is-active {
|
||||
outline-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow, rgba(0,0,0,0.2));
|
||||
}
|
||||
|
||||
/* Active + hover: keep strong ring, keep lift */
|
||||
.theme-card-btn:hover .theme-card-preview.is-active {
|
||||
outline-color: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow, rgba(0,0,0,0.3));
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.theme-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
transition: color var(--transition-fast), font-weight 0s;
|
||||
}
|
||||
|
||||
.theme-card-btn:hover .theme-card-label {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.theme-card-label.is-active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Queue Tab Bar ────────────────────────────────────────────────────────── */
|
||||
|
||||
.queue-tab-bar {
|
||||
@@ -2715,82 +2806,147 @@
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Fullscreen Player — Ambient Stage
|
||||
Fullscreen Player — Adaptive Portrait
|
||||
──────────────────────────────────────────── */
|
||||
.fs-player {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
contain: layout style paint;
|
||||
animation: fsIn 280ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
background: #0e0e1a;
|
||||
animation: fsIn 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
background: #06060e;
|
||||
/* Promote to own compositor layer immediately on mount */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes fsIn {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Cover breathing ── */
|
||||
@keyframes ambilight-pulse {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 1.0; }
|
||||
/* ── Animated dark mesh — GPU-only: only transform3d, no scale, real divs for will-change ── */
|
||||
@keyframes mesh-aura-a {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
33% { transform: translate3d(5%, -6%, 0); }
|
||||
66% { transform: translate3d(-3%, 2%, 0); }
|
||||
100% { transform: translate3d(0, 0, 0); }
|
||||
}
|
||||
|
||||
@keyframes cover-breathe {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.018);
|
||||
}
|
||||
@keyframes mesh-aura-b {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
50% { transform: translate3d(-6%, 5%, 0); }
|
||||
100% { transform: translate3d(0, 0, 0); }
|
||||
}
|
||||
|
||||
@keyframes ken-burns {
|
||||
0% { transform: scale(1.05) translate(-4%, -1%); }
|
||||
25% { transform: scale(1.08) translate( 4%, -1.5%); }
|
||||
50% { transform: scale(1.06) translate( 3%, 1%); }
|
||||
75% { transform: scale(1.09) translate(-3%, 1.5%); }
|
||||
100% { transform: scale(1.05) translate(-4%, -1%); }
|
||||
@keyframes portrait-drift {
|
||||
0%, 100% { transform: translate3d(0, 0, 0); }
|
||||
50% { transform: translate3d(0, -10px, 0); }
|
||||
}
|
||||
|
||||
/* ── Blurred background ── */
|
||||
.fs-bg {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
background-size: cover;
|
||||
background-position: center 20%;
|
||||
filter: blur(4px) brightness(0.25) saturate(1.5);
|
||||
animation: ken-burns 120s linear infinite;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
transition: opacity 700ms ease;
|
||||
}
|
||||
|
||||
.fs-bg-overlay {
|
||||
.fs-mesh-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 0;
|
||||
background: #06060e;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Blobs are real divs so will-change: transform applies (pseudo-elements can't have it) */
|
||||
.fs-mesh-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.fs-mesh-blob-a {
|
||||
/* Oversized so panning never exposes the dark base underneath */
|
||||
width: 130%;
|
||||
height: 130%;
|
||||
left: -35%;
|
||||
bottom: -35%;
|
||||
background: radial-gradient(ellipse, var(--accent-glow, rgba(120, 80, 255, 0.14)) 0%, transparent 65%);
|
||||
filter: blur(55px);
|
||||
animation: mesh-aura-a 26s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
}
|
||||
|
||||
.fs-mesh-blob-b {
|
||||
width: 100%;
|
||||
height: 110%;
|
||||
right: -25%;
|
||||
top: -25%;
|
||||
background: radial-gradient(ellipse, var(--accent-dim, rgba(120, 80, 255, 0.08)) 0%, transparent 65%);
|
||||
filter: blur(65px);
|
||||
animation: mesh-aura-b 20s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
}
|
||||
|
||||
/* ── Artist portrait — right half, object-fit: contain ── */
|
||||
.fs-portrait-wrap {
|
||||
position: absolute;
|
||||
right: 4vw;
|
||||
top: 4vw;
|
||||
bottom: 8vw;
|
||||
width: 46%;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transform: translateZ(0);
|
||||
/* Fade left edge to blend into the dark mesh */
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.45) 18%,
|
||||
rgba(0, 0, 0, 0.85) 40%,
|
||||
#000 60%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.45) 18%,
|
||||
rgba(0, 0, 0, 0.85) 40%,
|
||||
#000 60%
|
||||
);
|
||||
}
|
||||
|
||||
.fs-portrait {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
filter: drop-shadow(-24px 0 48px rgba(0, 0, 0, 0.85));
|
||||
transition: opacity 1000ms ease-in-out;
|
||||
will-change: transform;
|
||||
animation: portrait-drift 22s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Horizontal scrim — dark left edge for text legibility ── */
|
||||
.fs-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(6, 6, 14, 0.88) 0%,
|
||||
rgba(6, 6, 14, 0.65) 32%,
|
||||
rgba(6, 6, 14, 0.18) 58%,
|
||||
transparent 78%
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Close button ── */
|
||||
.fs-close {
|
||||
position: absolute;
|
||||
@@ -2811,179 +2967,102 @@
|
||||
}
|
||||
|
||||
.fs-close:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Center stage ── */
|
||||
.fs-stage {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* ── Info cluster — bottom-left quadrant ── */
|
||||
.fs-cluster {
|
||||
position: absolute;
|
||||
bottom: 72px;
|
||||
left: clamp(28px, 4vw, 64px);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
width: min(440px, 88vw);
|
||||
padding: 16px 0;
|
||||
will-change: transform;
|
||||
gap: 12px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
|
||||
/* Artist name — above cover */
|
||||
.fs-artist {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Cover — breathes slowly */
|
||||
.fs-cover-wrap {
|
||||
width: clamp(300px, 30vw, 480px);
|
||||
max-height: calc(100vh - 300px);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-xl);
|
||||
/* Album art — small, rounded, glowing */
|
||||
.fs-art-wrap {
|
||||
width: clamp(120px, 10vw, 180px);
|
||||
height: clamp(120px, 10vw, 180px);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
flex-shrink: 0;
|
||||
animation: cover-breathe 9s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.7),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.07),
|
||||
0 0 28px var(--accent-glow, var(--accent));
|
||||
}
|
||||
|
||||
.fs-cover {
|
||||
.fs-art {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fs-cover-placeholder {
|
||||
.fs-art-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--ctp-surface0);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Track info — below cover */
|
||||
.fs-track-info {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fs-title-wrap {
|
||||
/* Artist name — massive, font-black, uppercase, wraps gracefully */
|
||||
.fs-artist-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(20px, 3vw, 32px);
|
||||
font-weight: 800;
|
||||
font-size: clamp(28px, 4.5vw, 68px);
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
color: #ffffff;
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fs-title-marquee {
|
||||
display: inline-block;
|
||||
animation: marquee-scroll 14s linear infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes marquee-scroll {
|
||||
|
||||
0%,
|
||||
15% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
85%,
|
||||
100% {
|
||||
transform: translateX(var(--scroll-amount, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
.fs-album {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.05;
|
||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.7);
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.fs-codec {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin-top: 8px;
|
||||
/* Track title — large, light, accent color, wraps gracefully */
|
||||
.fs-track-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(18px, 2.6vw, 40px);
|
||||
font-weight: 300;
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.fs-progress-wrap {
|
||||
/* Metadata row */
|
||||
.fs-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: clamp(11px, 1vw, 13px);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.fs-time {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
.fs-meta-dot {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.fs-progress-bar {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fs-progress-bar input[type="range"] {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(to right,
|
||||
rgba(255, 255, 255, 0.85) var(--pct, 0%),
|
||||
rgba(255, 255, 255, 0.32) var(--pct, 0%),
|
||||
rgba(255, 255, 255, 0.32) var(--buf, 0%),
|
||||
rgba(255, 255, 255, 0.15) var(--buf, 0%));
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.fs-progress-bar input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
/* Transport controls */
|
||||
/* Controls row — left-aligned */
|
||||
.fs-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.fs-btn {
|
||||
@@ -2993,7 +3072,7 @@
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
background: transparent;
|
||||
@@ -3002,6 +3081,7 @@
|
||||
.fs-btn:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.fs-btn.active {
|
||||
@@ -3014,19 +3094,94 @@
|
||||
}
|
||||
|
||||
.fs-btn-play {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow, var(--accent));
|
||||
}
|
||||
|
||||
.fs-btn-play:hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
transform: scale(1.07);
|
||||
transform: scale(1.1);
|
||||
filter: brightness(1.12);
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6), 0 0 28px var(--accent-glow, var(--accent));
|
||||
}
|
||||
|
||||
.fs-btn-heart:hover,
|
||||
.fs-btn-heart.active {
|
||||
color: var(--color-star-active, var(--accent));
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ── Full-width seekbar — pinned to bottom edge ── */
|
||||
.fs-seekbar-wrap {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.fs-seekbar-times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px 5px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fs-seekbar {
|
||||
position: relative;
|
||||
height: 3px;
|
||||
cursor: pointer;
|
||||
transition: height 120ms ease;
|
||||
}
|
||||
|
||||
.fs-seekbar:hover {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.fs-seekbar-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fs-seekbar-buf {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fs-seekbar-played {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 15px var(--accent-glow, var(--accent));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fs-seekbar input[type="range"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
|
||||
+22
-1
@@ -735,6 +735,13 @@
|
||||
/* Star + Last.fm heart — visually separated from track title */
|
||||
.player-star-btn {
|
||||
margin: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.player-star-btn:hover,
|
||||
.player-star-btn.is-starred {
|
||||
color: var(--color-star-active, var(--accent));
|
||||
}
|
||||
|
||||
.player-btn-primary {
|
||||
@@ -1025,7 +1032,7 @@
|
||||
}
|
||||
.queue-round-btn:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
color: var(--text-primary);
|
||||
color: var(--accent);
|
||||
}
|
||||
.queue-round-btn:disabled {
|
||||
opacity: 0.35;
|
||||
@@ -1093,6 +1100,14 @@
|
||||
.queue-current-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.queue-current-sub.is-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.queue-current-sub.is-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.queue-current-tech {
|
||||
@@ -1168,6 +1183,12 @@
|
||||
.queue-item-artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.queue-item:hover .queue-item-artist,
|
||||
.queue-item.context-active .queue-item-artist {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.queue-item-duration {
|
||||
|
||||
@@ -171,6 +171,26 @@ export async function getImageCacheSize(): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a single cache entry from both in-memory and IndexedDB caches. */
|
||||
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
|
||||
const existing = objectUrlCache.get(cacheKey);
|
||||
if (existing) {
|
||||
URL.revokeObjectURL(existing);
|
||||
objectUrlCache.delete(cacheKey);
|
||||
}
|
||||
try {
|
||||
const database = await openDB();
|
||||
await new Promise<void>(resolve => {
|
||||
const tx = database.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).delete(cacheKey);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears all entries from IndexedDB and revokes all in-memory object URLs. */
|
||||
export async function clearImageCache(): Promise<void> {
|
||||
for (const url of objectUrlCache.values()) {
|
||||
|
||||
Reference in New Issue
Block a user