Compare commits

...

4 Commits

Author SHA1 Message Date
Psychotoxical 8cd4cdcd64 feat: v1.33.0 — Fullscreen Player redesign, Norwegian, configurable preload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 02:00:48 +02:00
Psychotoxical a32ca64792 Merge pull request #101: Added Norwegian language
Added Norwegian language
2026-04-06 01:42:56 +02:00
zz55zz 939abace35 Added Norwegian language
Added Norwegian language
2026-04-05 13:15:22 -08:00
Psychotoxical 4f7236e986 Update CHANGELOG.md 2026-04-05 20:07:17 +02:00
26 changed files with 1725 additions and 435 deletions
+29
View File
@@ -5,10 +5,39 @@ 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
- **Custom Offline Storage Directory (#95)**: You can now specify a custom directory for the offline library in Settings → Storage & Downloads. This is perfect for offloading your internal drive to an SD card or external HDD.
- **Robust Volume Handling**: The app now automatically detects if a configured external storage medium is missing and provides a clear "Volume not found" notification instead of failing silently or attempting to download to a non-existent path.
- **Internet Radio — full release**: The Radio page is now accessible from the sidebar. Complete UI rewrite to a card-based layout (cover art, name, edit/homepage buttons) consistent with the Playlists look. Covers can be uploaded or removed via a hover menu directly on the card.
- **Internet Radio — Edit Modal**: A dedicated modal lets you change station name, stream URL, and homepage URL, and upload or remove cover art.
- **Internet Radio — Radio Browser directory** *(via [radio-browser.info](https://www.radio-browser.info))*: Discover new stations directly inside Psysonic. Top stations by vote are shown as suggestions; a debounced search finds stations by name. Favicon images can be imported as cover art in one click.
+23 -4
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.32.0",
"version": "1.33.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -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')
+1 -1
View File
@@ -3493,7 +3493,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.32.0"
version = "1.33.0"
dependencies = [
"biquad",
"discord-rich-presence",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.32.0"
version = "1.33.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+9
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+7
View File
@@ -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
View File
@@ -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 });
}
+1 -2
View File
@@ -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')}
+1 -2
View File
@@ -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
View File
@@ -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 1blurred artist image */}
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Layer 0animated 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>
);
}
+2 -2
View File
@@ -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>
+2 -4
View File
@@ -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 && (
+1 -1
View File
@@ -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' },
};
+3 -29
View File
@@ -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>
+845 -1
View File
@@ -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',
@@ -341,6 +343,7 @@ const enTranslation = {
languageFr: 'French',
languageNl: 'Dutch',
languageZh: 'Chinese',
languageNb: 'Norwegian',
font: 'Font',
theme: 'Theme',
appearance: 'Appearance',
@@ -413,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',
@@ -497,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',
@@ -950,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',
@@ -1119,6 +1132,7 @@ const deTranslation = {
languageFr: 'Französisch',
languageNl: 'Niederländisch',
languageZh: 'Chinesisch',
languageNb: 'Norwegisch',
font: 'Schriftart',
theme: 'Design',
appearance: 'Darstellung',
@@ -1191,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',
@@ -1275,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',
@@ -1728,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',
@@ -1897,6 +1921,7 @@ const frTranslation = {
languageFr: 'Français',
languageNl: 'Néerlandais',
languageZh: 'Chinois',
languageNb: 'Norvégien',
font: 'Police',
theme: 'Thème',
appearance: 'Apparence',
@@ -1969,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',
@@ -2053,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',
@@ -2506,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',
@@ -2675,6 +2710,7 @@ const nlTranslation = {
languageFr: 'Frans',
languageNl: 'Nederlands',
languageZh: 'Chinees',
languageNb: 'Noors',
font: 'Lettertype',
theme: 'Thema',
appearance: 'Weergave',
@@ -2747,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',
@@ -2831,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',
@@ -3284,6 +3328,8 @@ const zhTranslation = {
cacheOffline: '离线保存全部专辑',
offlineCached: '全部专辑已缓存',
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
uploadImage: '上传艺术家图片',
uploadImageError: '图片上传失败',
},
favorites: {
title: '收藏夹',
@@ -3449,6 +3495,7 @@ const zhTranslation = {
languageFr: '法语',
languageNl: '荷兰语',
languageZh: '中文',
languageNb: '挪威',
font: '字体',
theme: '主题',
appearance: '外观',
@@ -3521,6 +3568,8 @@ const zhTranslation = {
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
showArtistImages: '显示艺术家图片',
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
showTrayIcon: '显示托盘图标',
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
discordRichPresence: 'Discord Rich Presence',
@@ -3605,6 +3654,12 @@ const zhTranslation = {
notWithCrossfade: '交叉淡入淡出开启时不可用',
gapless: '无缝播放',
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
preloadMode: '预加载下一曲目',
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
preloadBalanced: '均衡(结束前30秒)',
preloadEarly: '提前(播放5秒后)',
preloadCustom: '自定义',
preloadCustomSeconds: '结束前秒数:{{n}}',
infiniteQueue: '无限队列',
infiniteQueueDesc: '队列播完时自动追加随机曲目',
experimental: '实验性',
@@ -3887,6 +3942,794 @@ const zhTranslation = {
}
};
// Norwegian Translations
const nbTranslation = {
sidebar: {
library: 'Bibliotek',
mainstage: 'Hovedscene',
newReleases: 'Nye utgivelser',
allAlbums: 'Alle album',
randomAlbums: 'Tilfeldige album',
artists: 'Artister',
randomMix: 'Tilfeldig miks',
favorites: 'Favoritter',
nowPlaying: 'Spilles nå',
system: 'System',
statistics: 'Statistikk',
settings: 'Innstillinger',
help: 'Hjelp',
expand: 'Utvid sidefelt',
collapse: 'Skjul sidefelt',
updateAvailable: 'Ny versjon er tilgjengelig',
updateReady: '{{version}} er klar',
updateLink: 'Hent nyeste versjon →',
downloadingTracks: 'Bufre {{n}} spor…',
offlineLibrary: 'Frakoblet bibliotek',
genres: 'Sjangere',
playlists: 'Spillelister',
radio: 'Internettradio',
},
home: {
hero: 'Utvalgt',
starred: 'Personlige favoritter',
recent: 'Nylig lagt til',
mostPlayed: 'Mest spilt',
recentlyPlayed: 'Nylig spilt',
discover: 'Oppdag',
loadMore: 'Last inn flere',
discoverMore: 'Oppdag flere',
discoverArtists: 'Oppdag artister',
discoverArtistsMore: 'Alle artister'
},
hero: {
eyebrow: 'Utvalgt album',
playAlbum: 'Spill av album',
enqueue: 'Legg til i kø',
enqueueTooltip: 'Legg til hele albumet i køen',
},
search: {
placeholder: 'Søk etter artist, album eller sang…',
noResults: 'Ingen resultater funnet for "{{query}}"',
artists: 'Artister',
albums: 'Album',
songs: 'Sanger',
clearLabel: 'Fjern søk',
title: 'Søk',
resultsFor: 'Resultater for "{{query}}"',
album: 'Album',
advanced: 'Avansert søk',
advancedSearchTerm: 'Søkeord',
advancedSearchPlaceholder: 'Tittel, album, artist…',
advancedGenre: 'Sjanger',
advancedAllGenres: 'Alle sjangre',
advancedYear: 'År',
advancedYearFrom: 'fra',
advancedYearTo: 'til',
advancedAll: 'Alle',
advancedSearch: 'Søk',
advancedEmpty: 'Skriv inn et søkeord eller velg ett filter for å begynne.',
advancedNoResults: 'Ingen resultater funnet.',
advancedGenreNote: 'Sanger er tilfeldig valgt fra denne sjangeren.',
},
nowPlaying: {
tooltip: 'Hvem lytter?',
title: 'Hvem lytter?',
loading: 'Laster…',
nobody: 'Ingen lyttere for øyeblikket.',
minutesAgo: '{{n}}m siden',
nothingPlaying: 'Ingenting spiller akkurat å. Start et spor!',
aboutArtist: 'Om artisten',
fromAlbum: 'Fra dette albumet',
viewAlbum: 'Se album',
goToArtist: 'Gå til artist',
readMore: 'Les mer',
showLess: 'Vis mindre',
genreInfo: 'Sjanger',
trackInfo: 'Sporinfo',
},
contextMenu: {
playNow: 'Spill nå',
playNext: 'Spill neste',
addToQueue: 'Legg til i kø',
enqueueAlbum: 'Legg albumet i kø',
startRadio: 'Start radio',
lfmLove: 'Lik på Last.fm',
lfmUnlove: 'Fjern fra likte på Last.fm',
favorite: 'Favoritt',
favoriteArtist: 'Favorittartist',
favoriteAlbum: 'Favorittalbum',
unfavorite: 'Fjern fra favoritter',
unfavoriteArtist: 'Fjern artist fra favoritter',
unfavoriteAlbum: 'Fjern album fra favoritter',
removeFromQueue: 'Fjern fra kø',
openAlbum: 'Åpne album',
goToArtist: 'Gå til artist',
download: 'Last ned (ZIP)',
addToPlaylist: 'Legg til i spilleliste',
songInfo: 'Sanginfo',
},
albumDetail: {
back: 'Tilbake',
playAll: 'Spill alt',
enqueue: 'Legg i kø',
enqueueTooltip: 'Legg hele albumet i kø',
artistBio: 'Artistbiografi',
download: 'Last ned (ZIP)',
downloading: 'Laster…',
cacheOffline: 'Gjør tilgjengelig som frakoblet',
offlineCached: 'Tilgjengelig frakoblet',
offlineDownloading: 'Bufrer… ({{n}}/{{total}})',
removeOffline: 'Fjern frakoblet hurtigbuffer',
offlineStorageFull: 'Frakoblet lagring er full (lagringsgrense: {{mb}} MB). Frigjør plass, ved å slette et album fra ditt frakoblede bibliotek, eller øk lagringsgrensen i Innstillinger.',
offlineStorageGoToLibrary: 'Frakoblet bibliotek',
offlineStorageGoToSettings: 'Innstillinger',
favoriteAdd: 'Legg til i favoritter',
favoriteRemove: 'Fjern fra favoritter',
favorite: 'Favoritt',
noBio: 'Ingen biografi er tilgjengelig.',
moreByArtist: 'Mer av {{artist}}',
tracksCount: '{{n}} spor',
goToArtist: 'Gå til {{artist}}',
moreLabelAlbums: 'Flere album på {{label}}',
trackTitle: 'Tittel',
trackArtist: 'Artist',
trackGenre: 'Sjanger',
trackFormat: 'Format',
trackFavorite: 'Favoritt',
trackRating: 'Vurdering',
trackDuration: 'Varighet',
trackTotal: 'Totalt',
columns: 'Kolonner',
notFound: 'Album ble ikke funnet.',
bioModal: 'Artistbiografi',
bioClose: 'Lukk',
ratingLabel: 'Vurdering',
enlargeCover: 'Forstørr',
},
artistDetail: {
back: 'Tilbake',
albums: 'Album',
album: 'Album',
playAll: 'Spill alt',
shuffle: 'Tilfeldig rekkefølge',
radio: 'Radio',
loading: 'Laster…',
noRadio: 'Ingen lignende spor funnet for denne artisten.',
notFound: 'Artisten ble ikke funnet.',
albumsBy: 'Album fra {{name}}',
topTracks: 'Toppspor',
noAlbums: 'Ingen album funnet.',
trackTitle: 'Tittel',
trackAlbum: 'Album',
trackDuration: 'Varighet',
favoriteAdd: 'Legg til i favoritter',
favoriteRemove: 'Fjern fra favoritter',
favorite: 'Favoritt',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} album',
openedInBrowser: 'Åpnet i nettleser',
featuredOn: 'Også en del av',
similarArtists: 'Lignende artister',
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',
empty: "Du har ikke lagret noen favoritter ennå.",
artists: 'Artister',
albums: 'Album',
songs: 'Sanger',
enqueueAll: 'Legg alle i kø',
playAll: 'Spill alle',
removeSong: 'Fjern fra favoritter',
stations: 'Radiostasjoner',
},
randomAlbums: {
title: 'Tilfeldige album',
refresh: 'Oppdater',
},
genres: {
title: 'Sjangre',
genreCount: 'Sjangre',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} album',
loading: 'Laster sjangre…',
empty: 'Ingen sjangre funnet.',
albumsLoading: 'Laster album…',
albumsEmpty: 'Ingen album funnet for denne sjangeren.',
loadMore: 'Last mer',
back: 'Tilbake',
},
randomMix: {
title: 'Tilfeldig miks',
remix: 'Remiks',
remixTooltip: 'Last inn nye tilfeldige sanger',
playAll: 'Spill alt',
trackTitle: 'Tittel',
trackArtist: 'Artist',
trackAlbum: 'Album',
trackFavorite: 'Favoritt',
trackDuration: 'Varighet',
favoriteAdd: 'Legg til i favoritter',
favoriteRemove: 'Fjern fra favoritter',
play: 'Spill',
trackGenre: 'Sjanger',
excludeAudiobooks: 'Ekskluder lydbøker og hørespill',
excludeAudiobooksDesc: 'Samsvarer nøkkelord mot sjanger, tittel, album og artist - f.eks. Hørespill, Lydbok, Spoken Word, …',
genreBlocked: 'Nøkkelord blokkert',
genreAddedToBlacklist: 'Lagt til i filterliste',
genreAlreadyBlocked: 'Allerede blokkert',
artistBlocked: 'Artisten er blokkert',
artistAddedToBlacklist: 'Artist er lagt til i filterliste',
artistClickHint: 'Klikk for å blokkere denne artisten',
blacklistToggle: 'Nøkkelordfilter',
genreMixTitle: 'Sjangermiks',
genreMixDesc: 'Topp 20 sjangre etter antall sanger - klikk for å laste en tilfeldig miks',
genreMixLoadMore: 'Last 10 til',
genreMixNoGenres: 'Ingen sjangre funnet på tjeneren.',
shuffleGenres: 'Vis andre sjangre',
filterPanelTitle: 'Filtre',
filterPanelDesc: 'Klikk på en sjanger-tag eller på artistnavnet i sporlisten nedenfor, for å blokkere den fra fremtidige mikser.',
genreClickHint: 'Klikk på en sjanger-tag for å legge den til\nsom et filternøkkelord.\nSamsvarer med sjanger, tittel, album og artist.',
},
albums: {
title: 'Alle album',
sortByName: 'A–Å (Album)',
sortByArtist: 'A–Å (Artist)',
sortNewest: 'Vis nyeste først',
sortRandom: 'Tilfeldig',
yearFrom: 'Fra',
yearTo: 'Til',
yearFilterClear: 'Tøm år filteret',
yearFilterLabel: 'År',
},
artists: {
title: 'Artister',
search: 'Søk…',
all: 'Alle',
gridView: 'Rutenettvisning',
listView: 'Listevisning',
imagesOn: 'Artistbilder på - kan øke belastningen på nettverket og applikasjonen',
imagesOff: 'Artistbilder av - viser kun initialer',
loadMore: 'Last flere',
notFound: 'Ingen artister funnet.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} album',
},
login: {
subtitle: 'Din Navidrome-mediaspiller',
serverName: 'Tjenernavn (valgfritt)',
serverNamePlaceholder: 'Mitt mediabibliotek',
serverUrl: 'Tjener-URL',
serverUrlPlaceholder: '192.168.1.100:4533 eller feks. musikk.eksempel.com',
username: 'Brukernavn',
usernamePlaceholder: 'admin',
password: 'Passord',
showPassword: 'Vis passord',
hidePassword: 'Skjul passord',
connect: 'Koble til',
connecting: 'Kobler til…',
connected: 'Tilkoblet!',
error: 'Tilkoblingen mislyktes vennligst sjekk instillingene.',
urlRequired: 'Vennligst skriv inn en tjeneer-URL.',
savedServers: 'Lagrede servere',
addNew: 'Eller legg til en ny tjener',
},
connection: {
connected: 'Tilkoblet',
connectedTo: 'Tilkoblet til {{server}}',
disconnected: 'Frakoblet',
disconnectedFrom: 'Kan ikke nå {{server}} - klikk for å sjekke innstillinger',
checking: 'Kobler til…',
extern: 'Ekstern',
offlineTitle: 'Ingen tjenertilkobling',
offlineSubtitle: 'Kan ikke nå {{server}}. Sjekk nettverket eller tjeneren din.',
offlineModeBanner: 'Frakoblet modus - spiller fra lokal hurtigbuffer',
offlineLibraryTitle: 'Frakoblet bibliotek',
offlineLibraryEmpty: 'Ingen album bufret ennå. Kobl deg til nettverket, åpne et album og klikk "Gjør tilgjengelig frakoblet".',
offlineAlbumCount: '{{n}} album',
offlineAlbumCount_plural: '{{n}} album',
offlineFilterAll: 'Alle',
offlineFilterAlbums: 'Album',
offlineFilterPlaylists: 'Spillelister',
offlineFilterArtists: 'Diskografier',
retry: 'Prøv igjen',
lastfmConnected: 'Last.fm tilkoblet som bruker @{{user}}',
lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt',
},
common: {
albums: 'Album',
album: 'Album',
loading: 'Laster…',
loadingMore: 'Laster…',
loadingPlaylists: 'Laster spillelister…',
noAlbums: 'Ingen album funnet.',
downloading: 'Laster ned…',
downloadZip: 'Last ned (ZIP)',
back: 'Tilbake',
cancel: 'Avbryt',
save: 'Lagre',
delete: 'Slett',
use: 'Bruk',
add: 'Legg til',
active: 'Aktiv',
download: 'Last ned',
chooseDownloadFolder: 'Velg nedlastingsmappe',
noFolderSelected: 'Ingen mappe valgt',
rememberDownloadFolder: 'Husk denne mappen',
filterGenre: 'Sjangerfilter',
filterSearchGenres: 'Søk i sjangre…',
filterNoGenres: 'Ingen sjangre samsvarer',
filterClear: 'Tøm',
bulkSelected: '{{count}} valgt',
bulkAddToPlaylist: 'Legg til i spilleliste',
bulkRemoveFromPlaylist: 'Fjern fra spilleliste',
bulkClear: 'Tøm utvalg',
updaterAvailable: 'Ny versjon er tilgjengelig',
updaterVersion: 'v{{version}} er lansert',
updaterInstall: 'Installer og start applikasjonen på nytt',
updaterDownloading: 'Laster ned…',
updaterInstalling: 'Installerer…',
updaterDownload: 'Last ned fra GitHub',
updaterExperimentalHint: 'Auto-oppdatering er fortsatt under utvikling',
},
settings: {
title: 'Innstillinger',
language: 'Språk',
languageEn: 'Engelsk',
languageDe: 'Tysk',
languageFr: 'Fransk',
languageNl: 'Nederlandsk',
languageZh: 'Kinesisk',
languageNb: 'Norsk',
font: 'Skrifttype',
theme: 'Tema',
appearance: 'Utseende',
servers: 'Tjenere',
serverName: 'Tjenernavn',
serverUrl: 'Tjener-URL',
serverUsername: 'Brukernavn',
serverPassword: 'Passord',
addServer: 'Legg til tjener',
addServerTitle: 'Legg til ny tjener',
useServer: 'Bruk',
deleteServer: 'Slett',
noServers: 'Ingen tjenere lagret.',
serverActive: 'Aktiv',
confirmDeleteServer: 'Slett tjener "{{name}}"?',
serverConnecting: 'Kobler til…',
serverConnected: 'Tilkoblet!',
serverFailed: 'Tilkobling mislyktes.',
testBtn: 'Test tilkobling',
testingBtn: 'Tester…',
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
connected: 'Tilkoblet',
failed: 'Mislyktes',
eqTitle: 'Jevnstiller',
eqEnabled: 'Aktiver jevnstiller',
eqPreset: 'Forhåndsinnstilling',
eqPresetCustom: 'Egendefinert',
eqPresetBuiltin: 'Innebygde forhåndsinnstillinger',
eqPresetCustomGroup: 'Mine forhåndsinnstillinger',
eqSavePreset: 'Lagre som forhåndsinnstilling',
eqPresetName: 'Navn på forhåndsinnstilling…',
eqDeletePreset: 'Slett forhåndsinnstilling',
eqResetBands: 'Tilbakestill til standard',
eqPreGain: 'Forforsterkning',
eqResetPreGain: 'Tilbakestill forforsterkning',
eqAutoEqTitle: 'AutoEQ hodetelefonoppslag',
eqAutoEqPlaceholder: 'Søk etter hodetelefon- / IEM-modell…',
eqAutoEqSearching: 'Søker…',
eqAutoEqNoResults: 'Ingen resultater funnet',
eqAutoEqError: 'Søk mislyktes',
eqAutoEqRateLimit: 'GitHub-hastighetsgrense nådd - prøv igjen om ett minutt',
eqAutoEqFetchError: 'Kunne ikke hente EQ-profil',
lfmTitle: 'Last.fm',
lfmConnect: 'Koble til med Last.fm',
lfmConnecting: 'Venter på autorisasjon…',
lfmConfirm: 'Jeg har autorisert appen',
lfmConnected: 'Tilkoblet som',
lfmDisconnect: 'Koble fra',
lfmConnectDesc: 'Koble til din Last.fm-konto for å aktivere scrobbling og få "Nå spiller"-oppdateringer direkte fra Psysonic - ingen Navidrome-konfigurasjon kreves.',
lfmOpenBrowser: 'Et nettleservindu vil åpne seg. Autoriser Psysonic via Last.fm, og klikk deretter på knappen nedenfor.',
lfmScrobbles: '{{n}} scrobbles',
lfmMemberSince: 'Medlem siden {{year}}',
scrobbleEnabled: 'Scrobbling er aktivert',
scrobbleDesc: 'Send sanger til Last.fm etter 50 % avspilling',
behavior: 'App-oppførsel',
cacheTitle: 'Maks. lagringsstørrelse',
cacheDesc: 'Plateomslag og artistbilder. Når den er full, fjernes de eldste oppføringene automatisk. Frakoblede album fjernes ikke automatisk, men slettes når hurtigbufferen tømmes manuelt.',
cacheUsed: 'Brukt: {{images}} bilder · {{offline}} frakoblede spor',
cacheUsedImages: 'Bilder:',
cacheUsedOffline: 'Frakoblede spor:',
cacheMaxLabel: 'Maks størrelse',
cacheClearBtn: 'Tøm hurtigbuffer',
cacheClearWarning: 'Dette vil også fjerne alle frakoblede album fra biblioteket.',
cacheClearConfirm: 'Tøm alt',
cacheClearCancel: 'Avbryt',
offlineDirTitle: 'Frakoblet bibliotek (In-App)',
offlineDirDesc: 'Lagringsplassering for spor som du gjør tilgjengelige som frakoblet i Psysonic.',
offlineDirDefault: 'Standard (App-data)',
offlineDirChange: 'Endre katalog',
offlineDirClear: 'Tilbakestill til standard',
offlineDirHint: 'Nye nedlastinger vil bruke denne plasseringen. Eksisterende nedlastinger forblir på sin opprinnelige lokasjon.',
showArtistImages: 'Vis artistbilder',
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
minimizeToTray: 'Minimer til oppgavelinjen',
minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.',
nowPlayingEnabled: 'Vis i "Nå spiller"',
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
downloadsTitle: 'ZIP Eksport & Arkivering',
downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.',
downloadsDefault: 'Standard nedlastingsmappe',
pickFolder: 'Velg',
pickFolderTitle: 'Velg nedlastingsmappe',
clearFolder: 'Tøm nedlastingsmappe',
logout: 'Logg ut',
aboutTitle: 'Om Psysonic',
aboutDesc: 'En moderne musikkspiller for Subsonic-kompatible tjenere (Navidrome, Gonic og andre). Bygget på Tauri v2 med en innebygd Rust-lydmotor - lett og rask, men fullpakket med funksjoner: bølgeform-søkelinje, synkroniserte sangtekster, Last.fm-integrasjon, 10-bånds jevnstiller, crossfade, gapless-avspilling, Replay Gain, sjangerlesing og et stort bibliotek med temaer.',
aboutFeatures: 'Multi-tjener · Last.fm scrobbling og liker · Fullskjerm Ambient Stage · Bølgeformsøk · Synkroniserte tekster · 10-bånds jevnstiller · Crossfade og gapless · Replay Gain · Sjangre · 63 temaer · 6 språk',
aboutLicense: 'Lisens',
aboutLicenseText: 'GNU GPL v3 - gratis å bruke, endre og distribuere under samme lisens.',
aboutRepo: 'Kildekode på GitHub',
aboutVersion: 'Versjon',
aboutBuiltWith: 'Bygget med Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Utviklet med støtte fra Claude Code laget av Anthropic',
aboutContributorsLabel: 'Bidragsytere',
aboutSpecialThanksLabel: 'Spesiell takk',
changelog: 'Endringslogg',
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
showChangelogOnUpdateDesc: "Vis automatisk hva som er nytt når en ny versjon lanseres for første gang.",
randomMixTitle: 'Tilfeldig miks',
randomMixBlacklistTitle: 'Egendefinerte filternøkkelord',
randomMixBlacklistDesc: 'Sanger ekskluderes når et nøkkelord samsvarer med sjanger, tittel, album eller artist (aktiv når avkrysningsboksen ovenfor er på).',
randomMixBlacklistPlaceholder: 'Legg til nøkkelord…',
randomMixBlacklistAdd: 'Legg til',
randomMixBlacklistEmpty: 'Ingen egendefinerte nøkkelord lagt til ennå.',
randomMixHardcodedTitle: 'Innebygde nøkkelord (aktiv når avkrysningsboksen er på)',
tabPlayback: 'Avspilling',
tabLibrary: 'Bibliotek',
tabAppearance: 'Utseende',
homeCustomizerTitle: 'Hjemmeside',
sidebarTitle: 'Sidefelt',
sidebarReset: 'Tilbakestill til standard',
sidebarDrag: 'Dra for å endre rekkefølge',
sidebarFixed: 'Alltid synlig',
tabShortcuts: 'Snarveier',
tabServer: 'Tjener',
tabSystem: 'System',
tabGeneral: 'Generelt',
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
backupExport: 'Eksporter innstillinger',
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst hold filen sikker.',
backupImport: 'Importer innstillinger',
backupImportDesc: 'Gjenoppretter innstillinger fra en .psybkp-fil. Applikasjonen starter på nytt etter import.',
backupImportConfirm: 'Dette vil overskrive alle gjeldende innstillinger. Vil du fortsette?',
backupSuccess: 'Sikkerhetskopiering lagret',
backupImportSuccess: 'Innstillinger gjenopprettet laster inn data på nytt…',
backupImportError: 'Ugyldig eller ødelagt fil for sikkerhetskopi.',
shortcutsReset: 'Tilbakestill til standardinnstillinger',
shortcutListening: 'Trykk på en tast…',
shortcutUnbound: '-',
globalShortcutsTitle: 'Globale snarveier',
globalShortcutsNote: 'Arbeid systemomfattende selv når Psysonic er i bakgrunnen. Krever Ctrl, Alt eller Super som modifikator.',
shortcutClear: 'Fjern',
shortcutPlayPause: 'Spill av / Pause',
shortcutNext: 'Neste spor',
shortcutPrev: 'Forrige spor',
shortcutVolumeUp: 'Volum opp',
shortcutVolumeDown: 'Volum ned',
shortcutSeekForward: 'Søk fremover 10 sekunder',
shortcutSeekBackward: 'Søk bakover 10 sekunder',
shortcutToggleQueue: 'Veksle kø',
shortcutFullscreenPlayer: 'Fullskjermsspiller',
shortcutNativeFullscreen: 'Naturlig fullskjerm',
playbackTitle: 'Avspilling',
replayGain: 'Replay Gain',
replayGainDesc: 'Normaliser sporvolumet ved hjelp av ReplayGain-metadata',
replayGainMode: 'Modus',
replayGainTrack: 'Spor',
replayGainAlbum: 'Album',
crossfade: 'Crossfade',
crossfadeDesc: 'Tone mellom spor',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Ikke tilgjengelig mens Gapless er aktiv',
notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv',
gapless: 'Gapless avspilling',
gaplessDesc: 'Forhåndsbuffer neste spor for å eliminere mellomrom mellom sanger',
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",
dontShowAgain: "Ikke vis igjen",
close: 'Forstått',
},
help: {
title: 'Hjelp',
s1: 'Komme i gang',
q1: 'Hvilke servere er kompatible?',
a1: 'Psysonic fungerer med alle Subsonic-kompatible servere: Navidrome, Gonic, Subsonic, Airsonic og andre. Navidrome er det anbefalte valget.',
q2: 'Hvordan kobler jeg til serveren min?',
a2: 'Åpne Innstillinger og klikk på "Legg til server". Skriv inn server-URL-en (f.eks. 192.168.1.100:4533), brukernavn og passord. Psysonic tester tilkoblingen før den lagrer ingenting lagres hvis tilkoblingen mislykkes.',
q3: 'Kan jeg bruke flere servere?',
a3: 'Ja. Du kan legge til så mange servere du vil i Innstillinger og bytte mellom dem når som helst. Bare én server er aktiv om gangen.',
s2: 'Avspilling',
q4: 'Hvordan spiller jeg musikk?',
a4: 'Dobbeltklikk på et spor for å spille det av. På album- og artistsider bruker du "Spill av alle" for å starte hele albumet. Du kan også dra spor inn i køpanelet.',
q5: 'Hvilke hurtigtaster er tilgjengelige?',
a5: 'Mellomrom = Spill av / Pause · Escape = Lukk fullskjermsspiller. Medietaster (Spill av / Pause, Neste, Forrige) fungerer på alle plattformer. På Linux bruker du knappene i spillerlinjen for medietaster. Snarveier i appen og systemomfattende globale snarveier kan tilpasses i Innstillinger → Tastatursnarveier / Globale snarveier.',
q6: 'Hva er køen?',
a6: 'Køen viser alle kommende spor. Åpne den med panelikonet øverst til høyre (ved siden av Spilles nå-indikatoren). Du kan endre rekkefølgen på sporene ved å dra, spille av i tilfeldig rekkefølge med tilfeldig-knappen og lagre køen som en spilleliste.',
q7: 'Hvordan åpner jeg fullskjermsspilleren?',
a7: 'Klikk på miniatyrbildet av albumcoveret i spillerlinjen nederst, eller på utvid-ikonet ved siden av. Trykk på Escape for å lukke det igjen.',
q8: 'Hvordan fungerer gjentakelse?',
a8: 'Klikk på gjentakelsesknappen i spillerlinjen for å bla gjennom: Av → Gjenta alle → Gjenta én.',
s3: 'Bibliotek',
q9: 'Hvordan laster jeg ned et album?',
a9: 'Åpne detaljsiden for et album og klikk på "Last ned (ZIP)". Serveren komprimerer albumet til en ZIP-fil først for store album eller filer uten tap (FLAC/WAV) kan dette ta et øyeblikk før nedlastingen faktisk starter. Dette er normalt: fremdriftslinjen vises når serveren er ferdig med å zippe og overføringen starter.',
q10: 'Hvordan stjernemerker/favoritterer jeg spor og album?',
a10: 'Klikk på stjerneikonet på en hvilken som helst sporrad eller i albumoverskriften. Stjernemerkede elementer vises i Favoritter-delen i sidefeltet.',
q11: 'Hva er hovedkarusellen på hjemmesiden?',
a11: 'Banneret øverst på hjemmesiden velger tilfeldig album fra biblioteket ditt og roterer gjennom dem hvert 10. sekund. Klikk på prikkene for å hoppe til et bestemt album, eller klikk på banneret for å åpne albumet.',
s4: 'Innstillinger',
q12: 'Hvordan endrer jeg temaet?',
a12: 'Innstillinger → Tema. Velg fra et stort utvalg av temaer på tvers av 8 grupper: Psysonic-temaer, mediaspiller, operativsystemer, spill, filmer, serier, sosiale medier og åpen kildekode-klassikere (Catppuccin, Nord, Gruvbox).',
q13: 'Hvordan endrer jeg språket?',
a13: 'Innstillinger → Språk. Engelsk, tysk, fransk, nederlandsk og kinesisk støttes.',
q15: 'Hvordan angir jeg en nedlastingsmappe?',
a15: 'Innstillinger → App-oppførsel → Nedlastingsmappe. Velg en hvilken som helst mappe nedlastede album lagres der som ZIP-filer. Uten en tilpasset mappe brukes nettleserens standard nedlastingsplassering.',
s5: 'Scrobbling',
q16: 'Hvordan fungerer scrobbling?',
a16: 'Psysonic scrobbler direkte til Last.fm - ingen Navidrome-konfigurasjon nødvendig. Koble til Last.fm-kontoen din i Innstillinger → Last.fm og aktiver scrobbling der.',
q17: 'Når sendes en scrobble?',
a17: 'En scrobble sendes etter at du har lyttet til 50 % av et spor.',
q22: 'Hva er bølgeformen i avspillerlinjen?',
a22: 'Bølgeformsøkelinjen erstatter den klassiske fremdriftsglidebryteren. Klikk hvor som helst på den for å hoppe til den posisjonen, eller dra for å skrubbe. Den avspilte delen lyser med en blå-til-lilla gradient, det bufrede området vises litt lysere, og den uavspilte delen er falmet.',
q24: 'Kan jeg blande køen?',
a24: 'Ja. Åpne køpanelet og klikk på blandeikonet i køoverskriften. Sporet som spilles av for øyeblikket forblir på posisjon 1 alle gjenværende spor blir tilfeldig omorganisert.',
q25: 'Åpnes Last.fm- og Wikipedia-lenker på artistsider i en nettleser?',
a25: 'Ja de åpnes i standardnettleseren din. Knappen viser kort en bekreftelsesetikett når du klikker på den.',
s7: 'Tilfeldig miks',
q26: 'Hva er tilfeldig miks?',
a26: 'Tilfeldig miks bygger en spilleliste med tilfeldige spor fra hele biblioteket ditt. Åpne den via "Tilfeldig miks" i sidefeltet og klikk på "Ny miks". Du kan justere antall spor før generering.',
q27: 'Hva er nøkkelordfilteret?',
a27: 'Nøkkelordfilteret ekskluderer spor der sjanger, tittel eller albumnavn inneholder spesifikke ord. Lydbøker filtreres automatisk. Legg til egendefinerte nøkkelord i Innstillinger → Tilfeldig miks, eller klikk på en hvilken som helst sjangertagg i sporlisten for å legge den til umiddelbart.',
q28: 'Hva er Super Genre Mix?',
a28: 'Super Genre Mix grupperer biblioteket ditt i brede kategorier (rock, metal, elektronisk, jazz, klassisk osv.) og bygger en fokusert miks fra den stilen. Velg en kategoribrikke under sporlisten. Resultatene vises gradvis etter hvert som hver undersjanger hentes.',
s6: 'Feilsøking',
q18: 'Omslagsbilder og artistbilder lastes sakte.',
a18: 'Bilder hentes fra serverens disk ved første innlasting og bufres deretter lokalt i 30 dager. Hvis serverens lagring er treg, kan det ta et øyeblikk før det første besøket på en side. Senere besøk vil være umiddelbare.',
q19: 'Tilkoblingstesten mislykkes.',
a19: 'Sjekk URL-en inkludert porten (f.eks. http://192.168.1.100:4533). Sørg for at ingen brannmur blokkerer tilkoblingen. Prøv http:// i stedet for https:// på et lokalt nettverk. Bekreft også at brukernavnet og passordet ditt er riktig.',
q20: 'Ingen lyd på Linux.',
a20: 'Psysonic er tilgjengelig som .deb (Ubuntu/Debian), .rpm (Fedora/RHEL), og via AUR på Arch/CachyOS (yay -S psysonic). Det finnes ingen AppImage. Hvis lyd mangler, sørg for at PipeWire eller PulseAudio kjører.',
q21: 'Appen viser en svart skjerm på Linux.',
a21: 'Dette er vanligvis et GPU/EGL-driverproblem i WebKitGTK. Start med GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. AUR-pakken og offisielle .deb/.rpm-installasjonsprogrammer setter disse automatisk.',
q29: 'Hva er Crossfade og Gapless Playback?',
a29: 'Begge er eksperimentelle lydfunksjoner i Innstillinger → Lyd. Gapless forhåndsbuffer neste spor for å eliminere stillhet mellom sanger. Crossfade fader ut gjeldende spor mens det fader inn det neste juster varigheten (110 s) etter smak.',
q30: 'Viser Psysonic sangtekst?',
a30: 'Ja. Klikk på mikrofonikonet i spillerlinjen, for å åpne Sangtekst-fanen i køpanelet. Psysonic henter tekst automatisk fra LRCLIB. Hvis synkroniserte tekster er tilgjengelige, utheves den aktive linjen og ruller i sanntid mens sangen spilles av. Hvis bare ren tekst er tilgjengelig, vises fullteksten statisk.',
q31: 'Kan jeg tilpasse hurtigtaster?',
a31: 'Ja. Innstillinger → Tastatursnarveier lar deg tilordne handlinger i appen (Spill av/pause, Neste, Forrige, Volum opp/ned, Fullskjerm og mer) til en hvilken som helst tast. Innstillinger → Globale snarveier lar deg tilordne systemomfattende snarveier som utløses selv når Psysonic er i bakgrunnen.',
q32: 'Kan jeg endre skrifttypen?',
a32: 'Ja. Innstillinger → Skrifttype lar deg velge mellom 10 skrifttyper, inkludert IBM Plex Mono, Fira Code, JetBrains Mono, Courier Prime og andre. Den valgte skrifttypen gjelder for hele brukergrensesnittet.',
s8: 'Frakoblet modus',
q34: 'Hva er frakoblet modus?',
a34: 'Frakoblet modus (beta) lar deg mellomlagre album til enheten din, slik at du kan lytte uten en aktiv servertilkobling. Bufrede spor lagres lokalt og spilles av direkte fra harddisken ingen nettverksforespørsel gjøres under avspilling.',
q35: 'Hvordan bufrer jeg et album for å bruke det offline?',
a35: 'Åpne et hvilket som helst album og klikk på nedlastingsikonet i albumoverskriften. Psysonic laster ned alle spor i bakgrunnen. Fremdrift vises på knappen. Når bufren er lagret, blir ikonet grønt. Du kan vise og fjerne bufrede album på siden Offline bibliotek (sidefelt).',
q36: 'Hvor mye lagringsplass kan frakoblet mellomlagring bruke?',
a36: 'Du kan angi en maksimal mellomlagringsstørrelse i Innstillinger → Bibliotek. Når grensen er nådd, vises et advarselsbanner på albumsiden. Du kan slette individuelle album fra frakoblet bibliotek for å frigjøre plass.',
},
queue: {
title: 'Kø',
savePlaylist: 'Lagre spilleliste',
updatePlaylist: 'Oppdater spilleliste',
filterPlaylists: 'Filtrer spillelister…',
playlistName: 'Spillelistenavn',
cancel: 'Avbryt',
save: 'Lagre',
loadPlaylist: 'Last inn spilleliste',
loading: 'Laster…',
noPlaylists: 'Ingen spillelister funnet.',
load: 'Erstatt kø og spill av',
appendToQueue: 'Legg til i kø',
delete: 'Slett',
deleteConfirm: 'Slett spillelisten "{{name}}"?',
clear: 'Fjern',
shuffle: 'Bland kø',
gapless: 'Uten mellomrom',
crossfade: 'Crossfade',
infiniteQueue: 'Uendelig kø',
autoAdded: '- Lagt til automatisk -',
radioAdded: '- Radio -',
hide: 'Skjul',
close: 'Lukk',
nextTracks: 'Neste spor',
emptyQueue: 'Køen er tom.',
trackSingular: 'spor',
trackPlural: 'spor',
showRemaining: 'Vis gjenværende tid',
showTotal: 'Vis total tid',
},
statistics: {
title: 'Statistikk',
recentlyPlayed: 'Nylig spilt',
mostPlayed: 'Mest spilte album',
highestRated: 'Høyest rangerte album',
genreDistribution: 'Sjangerfordeling (Topp 20)',
loadMore: 'Last inn mer',
statArtists: 'Artister',
statAlbums: 'Album',
statSongs: 'Sanger',
statGenres: 'Sjangere',
statPlaytime: 'Total spilletid',
genreInsights: 'Sjangerinnsikt',
formatDistribution: 'Formatdistribusjon',
formatSample: 'Utvalg av {{n}} spor',
computing: 'Utregner…',
genreSongs: '{{count}} Sanger',
genreAlbums: '{{count}} Album',
recentlyAdded: 'Nylig lagt til',
decadeDistribution: 'Album etter tiår',
decadeAlbums_one: '{{count}} Album',
decadeAlbums_other: '{{count}} Album',
decadeUnknown: 'Ukjent',
lfmTitle: 'Last.fm Statistikk',
lfmTopArtists: 'Toppartister',
lfmTopAlbums: 'Toppalbum',
lfmTopTracks: 'Toppspor',
lfmPlays: '{{count}} avspillinger',
lfmPeriodOverall: 'Alltid',
lfmPeriod7day: '7 dager',
lfmPeriod1month: '1 måned',
lfmPeriod3month: '3 måneder',
lfmPeriod6month: '6 måneder',
lfmPeriod12month: '12 måneder',
lfmNotConnected: 'Koble til Last.fm i Innstillinger for å se statistikken din.',
lfmRecentTracks: 'Siste Scrobble-innslag',
lfmNowPlaying: 'Spilles nå',
lfmJustNow: 'akkurat nå',
lfmMinutesAgo: 'For {{n}}m siden',
lfmHoursAgo: 'For {{n}}t siden',
lfmDaysAgo: 'For {{n}}d siden',
},
player: {
regionLabel: 'Musikkspiller',
openFullscreen: 'Åpne fullskjermsspiller',
fullscreen: 'Fullskjermsspiller',
closeFullscreen: 'Lukk fullskjerm',
closeTooltip: 'Lukk (Esc)',
noTitle: 'Ingen tittel',
stop: 'Stopp',
prev: 'Forrige spor',
play: 'Spill av',
pause: 'Pause',
next: 'Neste spor',
repeat: 'Gjenta',
repeatOff: 'Av',
repeatAll: 'Alle',
repeatOne: 'Én',
progress: 'Sangfremdrift',
volume: 'Volum',
toggleQueue: 'Veksle kø',
lyrics: 'Sangtekst',
lyricsLoading: 'Laster sangtekst…',
lyricsNotFound: 'Ingen sangtekst funnet for dette sporet',
},
songInfo: {
title: 'Sanginfo',
songTitle: 'Tittel',
artist: 'Artist',
album: 'Album',
albumArtist: 'Albumartist',
year: 'År',
genre: 'Sjanger',
duration: 'Varighet',
track: 'Spor',
format: 'Format',
bitrate: 'Bitrate',
sampleRate: 'Samplingfrekvens',
bitDepth: 'Bitdybde',
channels: 'Kanaler',
fileSize: 'Filstørrelse',
path: 'Sti',
replayGainTrack: 'RG-sporforsterkning',
replayGainAlbum: 'RG-albumforsterkning',
replayGainPeak: 'RG-sportopp',
mono: 'Mono',
stereo: 'Stereo',
},
playlists: {
title: 'Spillelister',
newPlaylist: 'Ny spilleliste',
unnamed: 'Navnløs spilleliste',
createName: 'Spillelistenavn…',
create: 'Opprett',
cancel: 'Avbryt',
empty: 'Ingen spillelister ennå.',
emptyPlaylist: 'Denne spillelisten er tom.',
addFirstSong: 'Legg til din første sang',
notFound: 'Spillelisten ble ikke funnet.',
songs: '{{n}} sanger',
playAll: 'Spill alle',
shuffle: 'Bland',
addToQueue: 'Legg til i kø',
back: 'Tilbake til spillelister',
deletePlaylist: 'Slett',
confirmDelete: 'Klikk igjen for å bekrefte',
removeSong: 'Fjern fra spilleliste',
addSongs: 'Legg til sanger',
searchPlaceholder: 'Søk i biblioteket ditt…',
noResults: 'Ingen resultater.',
suggestions: 'Foreslåtte sanger',
noSuggestions: 'Ingen forslag tilgjengelig.',
titleBadge: 'Spilleliste',
refreshSuggestions: 'Nye forslag',
addSong: 'Legg til i spilleliste',
cacheOffline: 'Bufre spilleliste offline',
offlineCached: 'Spilleliste bufret',
offlineDownloading: 'Bufre… ({{done}}/{{total}} album)',
publicLabel: 'Offentlig',
privateLabel: 'Privat',
editMeta: 'Rediger spilleliste',
editNamePlaceholder: 'Spillelistenavn…',
editCommentPlaceholder: 'Legg til en beskrivelse…',
editPublic: 'Offentlig spilleliste',
editSave: 'Lagre',
editCancel: 'Avbryt',
changeCover: 'Endre omslagsbilde',
changeCoverLabel: 'Endre bilde',
removeCover: 'Fjern bilde',
coverUpdated: 'Omslag oppdatert',
metaSaved: 'Spillelisten er oppdatert',
},
radio: {
title: 'Internettradio',
empty: 'Ingen radiostasjoner konfigurert.',
addStation: 'Legg til radio stasjon',
editStation: 'Rediger',
deleteStation: 'Slett stasjon',
confirmDelete: 'Klikk igjen for å bekrefte',
stationName: 'Stasjonsnavn…',
streamUrl: 'Strøm-URL…',
homepageUrl: 'Hjemmeside-URL (valgfritt)',
save: 'Lagre',
cancel: 'Avbryt',
live: 'LIVE',
liveStream: 'Internettradio',
openHomepage: 'Åpne hjemmesiden',
changeCoverLabel: 'Endre omslag',
removeCover: 'Fjern omslag',
browseDirectory: 'Søk i katalogen',
directoryPlaceholder: 'Søk i stasjoner…',
noResults: 'Ingen stasjoner funnet.',
stationAdded: 'Stasjon lagt til',
filterAll: 'Alle',
filterFavorites: 'Favoritter',
sortManual: 'Manuell',
sortAZ: 'A → Å',
sortZA: 'Å → A',
sortNewest: 'Nyeste',
favorite: 'Legg til i favoritter',
unfavorite: 'Fjern fra favoritter',
noFavorites: 'Ingen favorittstasjoner.',
}
};
// Retrieve language from local storage or default to 'en'
const savedLanguage = localStorage.getItem('psysonic_language') || 'en';
@@ -3898,7 +4741,8 @@ i18n
de: { translation: deTranslation },
fr: { translation: frTranslation },
nl: { translation: nlTranslation },
zh: { translation: zhTranslation }
zh: { translation: zhTranslation },
nb: { translation: nbTranslation }
},
lng: savedLanguage,
fallbackLng: 'en',
+1 -1
View File
@@ -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') },
];
+54
View File
@@ -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>
@@ -757,6 +810,7 @@ export default function Settings() {
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'de', label: t('settings.languageDe') },
{ value: 'zh', label: t('settings.languageZh') },
{ value: 'nb', label: t('settings.languageNb') },
]}
/>
</div>
+12
View File
@@ -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 }),
+42 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+20
View File
@@ -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()) {