diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 86ae4896..1dc007ef 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -606,3 +606,40 @@ export async function getTopRadioStations(offset = 0): Promise { return invoke<[number[], string]>('fetch_url_bytes', { url }); } + +// ─── Structured Lyrics (OpenSubsonic / getLyricsBySongId) ──────────────────── + +export interface SubsonicLyricLine { + start?: number; // milliseconds — absent when unsynced + value: string; +} + +export interface SubsonicStructuredLyrics { + issynced: boolean; + lang?: string; + offset?: number; + displayArtist?: string; + displayTitle?: string; + line: SubsonicLyricLine[]; +} + +/** + * Fetches structured lyrics from the server's embedded tags via the + * OpenSubsonic `getLyricsBySongId` endpoint. Returns null when the + * server doesn't support the endpoint or the track has no embedded lyrics. + * Prefers synced lyrics over plain when both are present. + */ +export async function getLyricsBySongId(id: string): Promise { + try { + const data = await api<{ lyricsList: { structuredLyrics?: SubsonicStructuredLyrics[] } }>( + 'getLyricsBySongId.view', + { id }, + ); + const list = data.lyricsList?.structuredLyrics; + if (!list || list.length === 0) return null; + return list.find(l => l.issynced) ?? list[0]; + } catch { + // Server doesn't support the endpoint or track has no embedded lyrics + return null; + } +} diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index be671933..89695a36 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,12 +1,17 @@ import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react'; import { Play, Pause, SkipBack, SkipForward, - ChevronDown, Repeat, Repeat1, Square, Music, Heart + ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic'; -import CachedImage, { useCachedUrl } from './CachedImage'; +import { useCachedUrl } from './CachedImage'; +import { getCachedUrl } from '../utils/imageCache'; import { useTranslation } from 'react-i18next'; +import { useLyrics } from '../hooks/useLyrics'; +import { useAuthStore } from '../store/authStore'; +import type { LrcLine } from '../api/lrclib'; +import type { Track } from '../store/playerStore'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -15,12 +20,136 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } +// ─── Fullscreen lyrics overlay ──────────────────────────────────────────────── +// Slot height = 3.6vh = window.innerHeight * 0.036 — must match CSS height: 3.6vh. +// railY = (2 - activeIdx) * slotH centers slot `activeIdx` in a 5-slot window: +// activeIdx=0 → railY=+2×slotH (line 0 at slot 2) +// activeIdx=2 → railY=0 (line 2 at center) +// activeIdx=5 → railY=-3×slotH (line 5 at slot 2) + +const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track | null }) { + const { syncedLines, loading } = useLyrics(currentTrack); + const lines = syncedLines as LrcLine[] | null; + const hasSynced = lines !== null && lines.length > 0; + + // Keep a ref so the zustand selector can read lines without closing over + // a changing variable — avoids re-creating the selector on every render. + const linesRef = useRef([]); + linesRef.current = hasSynced ? lines! : []; + + // Selector returns the active line INDEX — zustand only re-renders when it + // actually changes, dropping us from ~10 Hz to ~0.2 Hz re-renders. + const activeIdx = usePlayerStore(s => { + const ls = linesRef.current; + if (ls.length === 0) return -1; + return ls.reduce((acc, line, i) => s.currentTime >= line.time ? i : acc, -1); + }); + + const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); + const seek = usePlayerStore(s => s.seek); + + // Cache slotH — avoids forcing a layout read (window.innerHeight) on every render. + const slotH = useRef(window.innerHeight * 0.036); + useEffect(() => { + const onResize = () => { slotH.current = window.innerHeight * 0.036; }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + // Event delegation — one handler for all lyric lines instead of N closures per tick. + const handleLineClick = useCallback((e: React.MouseEvent) => { + const target = (e.target as HTMLElement).closest('[data-time]'); + if (!target || duration <= 0) return; + seek(parseFloat(target.dataset.time!) / duration); + }, [duration, seek]); + + if (!currentTrack || loading || !hasSynced) return null; + + const railY = (2 - Math.max(0, activeIdx)) * slotH.current; + + return ( + + ); +}); + +// ─── Album art box — crossfades layers so old art stays visible while new loads ─ +// Uses 300px thumbnails (portrait fallback uses 500px separately). +// +// Why onLoad instead of new Image() preload: +// React batches setLayers(add invisible) + rAF setLayers(make visible) into one +// commit, so the browser never sees opacity:0 and the CSS transition never fires. +// Using the DOM img's own onLoad guarantees the element was painted at opacity:0 +// before we flip it to 1. +const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) { + // true = show raw fetchUrl immediately as fallback while blob resolves. + // PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit. + // Showing the URL directly avoids the multi-second blank wait. + const blobUrl = useCachedUrl(fetchUrl, cacheKey, true); + + const [layers, setLayers] = useState>([]); + const counter = useRef(0); + const cleanupTimer = useRef | null>(null); + + // Add a new invisible layer whenever the blob URL changes. + useEffect(() => { + if (!blobUrl) return; + const id = ++counter.current; + setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]); + }, [blobUrl]); + + // Called by the DOM once it has painted at opacity:0 — now safe to transition. + // Cancel any pending cleanup timer so a stale setTimeout from a previous layer + // cannot remove the layer we are making visible right now. + const handleLoad = useCallback((id: number) => { + if (cleanupTimer.current) clearTimeout(cleanupTimer.current); + setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id }))); + cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400); + }, []); + + if (layers.length === 0) { + return
; + } + + return ( + <> + {layers.map(l => ( + handleLoad(l.id)} + alt="" + decoding="async" + /> + ))} + + ); +}); + // ─── Artist portrait — right half, crossfades on track change ───────────────── const FsPortrait = memo(function FsPortrait({ url }: { url: string }) { const [layers, setLayers] = useState>(() => url ? [{ url, id: 0, visible: true }] : [] ); const counterRef = useRef(1); + const cleanupTimer = useRef | null>(null); useEffect(() => { if (!url) return; @@ -32,8 +161,9 @@ const FsPortrait = memo(function FsPortrait({ url }: { url: string }) { setLayers(prev => [...prev, { url, id, visible: false }]); requestAnimationFrame(() => { if (cancelled) return; + if (cleanupTimer.current) clearTimeout(cleanupTimer.current); setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))); - setTimeout(() => { + cleanupTimer.current = setTimeout(() => { if (!cancelled) setLayers(prev => prev.filter(l => l.id === id)); }, 1000); }); @@ -122,12 +252,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { 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 isStarred = currentTrack - ? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred) - : false; + // Derive isStarred inside the selector so we only re-render when the boolean + // actually flips — not when any unrelated track's star status changes. + const isStarred = usePlayerStore(s => { + const track = s.currentTrack; + if (!track) return false; + return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred; + }); const toggleStar = useCallback(async () => { if (!currentTrack) return; @@ -144,6 +276,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const duration = currentTrack?.duration ?? 0; // buildCoverArtUrl generates a new salt on every call — must be memoized. + // 300px for the small art box; 500px for the right-side portrait fallback. + const artUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 300) : '', [currentTrack?.coverArt]); + const artKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 300) : '', [currentTrack?.coverArt]); 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). @@ -163,22 +298,71 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { }, [currentTrack?.artistId]); const portraitUrl = artistBgUrl || resolvedCoverUrl; + const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics); + + // Pre-fetch next track's 300px cover into the IndexedDB cache. + // Selector returns only the coverArt id, so it only re-runs on actual changes. + const nextCoverArt = usePlayerStore(s => { + const q = s.queue; + const idx = s.queueIndex; + return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null; + }); + useEffect(() => { + if (!nextCoverArt) return; + const url = buildCoverArtUrl(nextCoverArt, 300); + const key = coverArtCacheKey(nextCoverArt, 300); + getCachedUrl(url, key).catch(() => {}); + }, [nextCoverArt]); + + // Idle-fade system — hides controls after 3 s of inactivity + const [isIdle, setIsIdle] = useState(false); + const idleTimer = useRef | null>(null); + + const resetIdle = useCallback(() => { + setIsIdle(false); + if (idleTimer.current) clearTimeout(idleTimer.current); + idleTimer.current = setTimeout(() => setIsIdle(true), 3000); + }, []); + + // Throttled wrapper for mousemove — avoids clearing/setting timeouts on every pixel. + const lastMoveTime = useRef(0); + const handleMouseMove = useCallback(() => { + const now = Date.now(); + if (now - lastMoveTime.current < 200) return; + lastMoveTime.current = now; + resetIdle(); + }, [resetIdle]); useEffect(() => { - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + resetIdle(); + return () => { if (idleTimer.current) clearTimeout(idleTimer.current); }; + }, [resetIdle]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + resetIdle(); + if (e.key === 'Escape') onClose(); + }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [onClose]); + }, [onClose, resetIdle]); - const metaParts = [ + const metaParts = useMemo(() => [ currentTrack?.album, currentTrack?.year?.toString(), currentTrack?.suffix?.toUpperCase(), currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '', - ].filter(Boolean); + ].filter(Boolean), [currentTrack]); return ( -
+
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */} diff --git a/src/components/LyricsPane.tsx b/src/components/LyricsPane.tsx index fffd5981..c0b84da0 100644 --- a/src/components/LyricsPane.tsx +++ b/src/components/LyricsPane.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { usePlayerStore } from '../store/playerStore'; -import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib'; +import type { LrcLine } from '../api/lrclib'; +import { useLyrics } from '../hooks/useLyrics'; import { useTranslation } from 'react-i18next'; import type { Track } from '../store/playerStore'; @@ -8,87 +9,27 @@ interface Props { currentTrack: Track | null; } -interface CachedLyrics { - syncedLines: LrcLine[] | null; - plainLyrics: string | null; - notFound: boolean; -} - -// Session-level cache — survives tab switches (component unmount/remount). -// Cleared implicitly when the app restarts. -const lyricsCache = new Map(); - export default function LyricsPane({ currentTrack }: Props) { const { t } = useTranslation(); - const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined; + const { syncedLines, plainLyrics, source, loading, notFound } = useLyrics(currentTrack); - const [loading, setLoading] = useState(!cached && !!currentTrack); - const [syncedLines, setSyncedLines] = useState(cached?.syncedLines ?? null); - const [plainLyrics, setPlainLyrics] = useState(cached?.plainLyrics ?? null); - const [notFound, setNotFound] = useState(cached?.notFound ?? false); - - const hasSynced = syncedLines !== null && syncedLines.length > 0; + const hasSynced = syncedLines !== null && syncedLines.length > 0; const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0); - const seek = usePlayerStore(s => s.seek); - const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); + const seek = usePlayerStore(s => s.seek); + const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const lineRefs = useRef<(HTMLDivElement | null)[]>([]); const prevActive = useRef(-1); + // Reset refs when track changes useEffect(() => { - if (!currentTrack) return; - - // Serve from cache if available - const hit = lyricsCache.get(currentTrack.id); - if (hit) { - setSyncedLines(hit.syncedLines); - setPlainLyrics(hit.plainLyrics); - setNotFound(hit.notFound); - setLoading(false); - lineRefs.current = []; - prevActive.current = -1; - return; - } - - let cancelled = false; - setSyncedLines(null); - setPlainLyrics(null); - setNotFound(false); - setLoading(true); lineRefs.current = []; prevActive.current = -1; - - fetchLyrics( - currentTrack.artist ?? '', - currentTrack.title, - currentTrack.album ?? '', - currentTrack.duration ?? 0, - ).then(result => { - if (cancelled) return; - setLoading(false); - if (!result || (!result.syncedLyrics && !result.plainLyrics)) { - lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true }); - setNotFound(true); - return; - } - const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null; - const synced = lines && lines.length > 0 ? lines : null; - lyricsCache.set(currentTrack.id, { syncedLines: synced, plainLyrics: result.plainLyrics, notFound: false }); - setSyncedLines(synced); - setPlainLyrics(result.plainLyrics); - }).catch(() => { - if (!cancelled) { - lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true }); - setLoading(false); - setNotFound(true); - } - }); - return () => { cancelled = true; }; - }, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [currentTrack?.id]); const activeIdx = hasSynced - ? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1) + ? (syncedLines as LrcLine[]).reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1) : -1; useEffect(() => { @@ -112,13 +53,19 @@ export default function LyricsPane({ currentTrack }: Props) { return `${base} active`; }; + const sourceLabel = source === 'server' + ? t('player.lyricsSourceServer') + : source === 'lrclib' + ? t('player.lyricsSourceLrclib') + : null; + return (
{loading &&

{t('player.lyricsLoading')}

} {notFound && !loading &&

{t('player.lyricsNotFound')}

} {hasSynced && (
- {syncedLines!.map((line, i) => ( + {(syncedLines as LrcLine[]).map((line, i) => (
{ lineRefs.current[i] = el; }} @@ -138,6 +85,9 @@ export default function LyricsPane({ currentTrack }: Props) { ))}
)} + {sourceLabel && !loading && !notFound && ( +

{sourceLabel}

+ )}
); } diff --git a/src/hooks/useLyrics.ts b/src/hooks/useLyrics.ts new file mode 100644 index 00000000..8faa27ee --- /dev/null +++ b/src/hooks/useLyrics.ts @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react'; +import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib'; +import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic'; +import { useAuthStore } from '../store/authStore'; +import type { Track } from '../store/playerStore'; + +export type LyricsSource = 'server' | 'lrclib'; + +export interface CachedLyrics { + syncedLines: LrcLine[] | null; + plainLyrics: string | null; + source: LyricsSource | null; + notFound: boolean; +} + +// Session-level cache — survives tab switches and component unmount/remount. +export const lyricsCache = new Map(); + +/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */ +export function parseStructuredLyrics( + lyrics: SubsonicStructuredLyrics, +): Pick { + if (lyrics.issynced && lyrics.line.length > 0) { + const lines: LrcLine[] = lyrics.line + .filter(l => l.start !== undefined) + .map(l => ({ time: l.start! / 1000, text: l.value.trim() })) + .sort((a, b) => a.time - b.time); + if (lines.length > 0) return { syncedLines: lines, plainLyrics: null }; + } + const plain = lyrics.line.map(l => l.value).join('\n').trim(); + return { syncedLines: null, plainLyrics: plain || null }; +} + +export interface UseLyricsResult { + syncedLines: LrcLine[] | null; + plainLyrics: string | null; + source: LyricsSource | null; + loading: boolean; + notFound: boolean; +} + +export function useLyrics(currentTrack: Track | null): UseLyricsResult { + const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined; + const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst); + + const [loading, setLoading] = useState(!cached && !!currentTrack); + const [syncedLines, setSyncedLines] = useState(cached?.syncedLines ?? null); + const [plainLyrics, setPlainLyrics] = useState(cached?.plainLyrics ?? null); + const [source, setSource] = useState(cached?.source ?? null); + const [notFound, setNotFound] = useState(cached?.notFound ?? false); + + useEffect(() => { + if (!currentTrack) return; + + const hit = lyricsCache.get(currentTrack.id); + if (hit) { + setSyncedLines(hit.syncedLines); + setPlainLyrics(hit.plainLyrics); + setSource(hit.source); + setNotFound(hit.notFound); + setLoading(false); + return; + } + + let cancelled = false; + setSyncedLines(null); + setPlainLyrics(null); + setSource(null); + setNotFound(false); + setLoading(true); + + const store = (entry: CachedLyrics) => { + if (cancelled) return; + lyricsCache.set(currentTrack.id, entry); + setSyncedLines(entry.syncedLines); + setPlainLyrics(entry.plainLyrics); + setSource(entry.source); + setNotFound(entry.notFound); + setLoading(false); + }; + + const fetchServer = async (): Promise => { + const structured = await getLyricsBySongId(currentTrack.id); + if (!structured) return false; + const parsed = parseStructuredLyrics(structured); + if (!parsed.syncedLines && !parsed.plainLyrics) return false; + store({ ...parsed, source: 'server', notFound: false }); + return true; + }; + + const fetchLrclibFn = async (): Promise => { + try { + const result = await fetchLyrics( + currentTrack.artist ?? '', + currentTrack.title, + currentTrack.album ?? '', + currentTrack.duration ?? 0, + ); + if (!result || (!result.syncedLyrics && !result.plainLyrics)) return false; + const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null; + const synced = lines && lines.length > 0 ? lines : null; + store({ syncedLines: synced, plainLyrics: result.plainLyrics, source: 'lrclib', notFound: false }); + return true; + } catch { + return false; + } + }; + + (async () => { + const [first, second] = lyricsServerFirst + ? [fetchServer, fetchLrclibFn] + : [fetchLrclibFn, fetchServer]; + + if (cancelled) return; + if (await first()) return; + if (cancelled) return; + if (await second()) return; + if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true }); + })(); + + return () => { cancelled = true; }; + }, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + return { syncedLines, plainLyrics, source, loading, notFound }; +} diff --git a/src/i18n.ts b/src/i18n.ts index 6f2cf020..89d28740 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -6,7 +6,6 @@ import { frTranslation } from './locales/fr'; import { nbTranslation } from './locales/nb'; import { nlTranslation } from './locales/nl'; import { ruTranslation } from './locales/ru'; -import { ru2Translation } from './locales/ru2'; import { zhTranslation } from './locales/zh'; const savedLanguage = localStorage.getItem('psysonic_language') || 'en'; @@ -22,7 +21,6 @@ i18n zh: { translation: zhTranslation }, nb: { translation: nbTranslation }, ru: { translation: ruTranslation }, - ru2: { translation: ru2Translation }, }, lng: savedLanguage, fallbackLng: 'en', diff --git a/src/locales/de.ts b/src/locales/de.ts index 72862c05..7a3347ef 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -345,7 +345,6 @@ export const deTranslation = { languageZh: 'Chinesisch', languageNb: 'Norwegisch', languageRu: 'Russisch', - languageRu2: 'Russisch 2', font: 'Schriftart', theme: 'Design', appearance: 'Darstellung', @@ -426,6 +425,8 @@ export const deTranslation = { discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.', nowPlayingEnabled: 'Im Livefenster anzeigen', nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.', + lyricsServerFirst: 'Server-Lyrics bevorzugen', + lyricsServerFirstDesc: 'Server-seitige Lyrics (eingebettete Tags, Sidecar-Dateien) vor LRCLIB abfragen. Deaktivieren, um LRCLIB zuerst zu verwenden.', downloadsTitle: 'ZIP-Export & Archivierung', downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.', downloadsDefault: 'Standard-Downloads-Ordner', @@ -689,8 +690,11 @@ export const deTranslation = { volume: 'Lautstärke', toggleQueue: 'Warteschlange umschalten', lyrics: 'Lyrics', + fsLyricsToggle: 'Lyrics im Vollbild', lyricsLoading: 'Lyrics werden geladen…', lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden', + lyricsSourceServer: 'Quelle: Server', + lyricsSourceLrclib: 'Quelle: LRCLIB', }, songInfo: { title: 'Song-Infos', diff --git a/src/locales/en.ts b/src/locales/en.ts index 4c121b47..51adb663 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -345,7 +345,6 @@ export const enTranslation = { languageZh: 'Chinese', languageNb: 'Norwegian', languageRu: 'Russian', - languageRu2: 'Russian 2', font: 'Font', theme: 'Theme', appearance: 'Appearance', @@ -426,6 +425,8 @@ export const enTranslation = { discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.', nowPlayingEnabled: 'Show in Now Playing', nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.', + lyricsServerFirst: 'Prefer server lyrics', + lyricsServerFirstDesc: 'Check server-provided lyrics (embedded tags, sidecar files) before querying LRCLIB. Disable to use LRCLIB first.', downloadsTitle: 'ZIP Export & Archiving', downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.', downloadsDefault: 'Default Downloads Folder', @@ -689,8 +690,11 @@ export const enTranslation = { volume: 'Volume', toggleQueue: 'Toggle Queue', lyrics: 'Lyrics', + fsLyricsToggle: 'Lyrics in fullscreen', lyricsLoading: 'Loading lyrics…', lyricsNotFound: 'No lyrics found for this track', + lyricsSourceServer: 'Source: Server', + lyricsSourceLrclib: 'Source: LRCLIB', }, songInfo: { title: 'Song Info', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index d85dd0c1..c76b5292 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -345,7 +345,6 @@ export const frTranslation = { languageZh: 'Chinois', languageNb: 'Norvégien', languageRu: 'Russe', - languageRu2: 'Russe 2', font: 'Police', theme: 'Thème', appearance: 'Apparence', @@ -426,6 +425,8 @@ export const frTranslation = { discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.', nowPlayingEnabled: 'Afficher dans la fenêtre live', nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.', + lyricsServerFirst: 'Préférer les paroles du serveur', + lyricsServerFirstDesc: 'Consulter d\'abord les paroles fournies par le serveur (tags intégrés, fichiers sidecar) avant LRCLIB. Désactiver pour utiliser LRCLIB en priorité.', downloadsTitle: 'Export ZIP & Archivage', downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.', downloadsDefault: 'Dossier de téléchargement par défaut', @@ -689,8 +690,11 @@ export const frTranslation = { volume: 'Volume', toggleQueue: 'Afficher/masquer la file', lyrics: 'Paroles', + fsLyricsToggle: 'Paroles en plein écran', lyricsLoading: 'Chargement des paroles…', lyricsNotFound: 'Aucune parole trouvée pour ce titre', + lyricsSourceServer: 'Source : Serveur', + lyricsSourceLrclib: 'Source : LRCLIB', }, songInfo: { title: 'Infos du morceau', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 7a3319f7..1d812270 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -345,7 +345,6 @@ export const nbTranslation = { languageZh: 'Kinesisk', languageNb: 'Norsk', languageRu: 'Russisk', - languageRu2: 'Russisk 2', font: 'Skrifttype', theme: 'Tema', appearance: 'Utseende', @@ -425,6 +424,8 @@ export const nbTranslation = { 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.', + lyricsServerFirst: 'Foretrekk server-sangtekst', + lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.', downloadsTitle: 'ZIP Eksport & Arkivering', downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.', downloadsDefault: 'Standard nedlastingsmappe', @@ -688,8 +689,11 @@ export const nbTranslation = { volume: 'Volum', toggleQueue: 'Veksle kø', lyrics: 'Sangtekst', + fsLyricsToggle: 'Sangtekst i fullskjerm', lyricsLoading: 'Laster sangtekst…', lyricsNotFound: 'Ingen sangtekst funnet for dette sporet', + lyricsSourceServer: 'Kilde: Server', + lyricsSourceLrclib: 'Kilde: LRCLIB', }, songInfo: { title: 'Sanginfo', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 831cacc5..6c2db16f 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -345,7 +345,6 @@ export const nlTranslation = { languageZh: 'Chinees', languageNb: 'Noors', languageRu: 'Russisch', - languageRu2: 'Russisch 2', font: 'Lettertype', theme: 'Thema', appearance: 'Weergave', @@ -426,6 +425,8 @@ export const nlTranslation = { discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.', nowPlayingEnabled: 'Weergeven in live-venster', nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.', + lyricsServerFirst: 'Server-songtekst voorrang geven', + lyricsServerFirstDesc: 'Controleer eerst door de server geleverde songteksten (ingebedde tags, sidecar-bestanden) vóór LRCLIB. Uitschakelen om LRCLIB eerst te gebruiken.', downloadsTitle: 'ZIP-export & Archivering', downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.', downloadsDefault: 'Standaard downloadmap', @@ -689,8 +690,11 @@ export const nlTranslation = { volume: 'Volume', toggleQueue: 'Wachtrij in-/uitschakelen', lyrics: 'Songtekst', + fsLyricsToggle: 'Songtekst in volledig scherm', lyricsLoading: 'Songtekst laden…', lyricsNotFound: 'Geen songtekst gevonden voor dit nummer', + lyricsSourceServer: 'Bron: Server', + lyricsSourceLrclib: 'Bron: LRCLIB', }, songInfo: { title: 'Nummerinfo', diff --git a/src/locales/ru2.ts b/src/locales/ru2.ts deleted file mode 100644 index a6bca42b..00000000 --- a/src/locales/ru2.ts +++ /dev/null @@ -1,804 +0,0 @@ -/** Russian UI strings (alternative — kilyabin, PR #107) */ -export const ru2Translation = { - sidebar: { - library: 'Библиотека', - mainstage: 'Главная', - newReleases: 'Новинки', - allAlbums: 'Все альбомы', - randomAlbums: 'Случайные альбомы', - artists: 'Исполнители', - randomMix: 'Случайный микс', - favorites: 'Избранное', - nowPlaying: 'Сейчас играет', - system: 'Система', - statistics: 'Статистика', - settings: 'Настройки', - help: 'Помощь', - expand: 'Развернуть боковую панель', - collapse: 'Свернуть боковую панель', - updateAvailable: 'Доступно обновление', - updateReady: '{{version}} готово', - updateLink: 'Перейти к релизу →', - downloadingTracks: 'Кэширование {{n}} треков…', - offlineLibrary: 'Офлайн библиотека', - genres: 'Жанры', - playlists: 'Плейлисты', - radio: 'Интернет радио', - }, - home: { - hero: 'Рекомендации', - starred: 'Личные избранные', - recent: 'Недавно добавленные', - mostPlayed: 'Самые прослушиваемые', - recentlyPlayed: 'Недавно прослушанные', - discover: 'Открыть', - loadMore: 'Загрузить еще', - discoverMore: 'Открыть больше', - discoverArtists: 'Открыть исполнителей', - discoverArtistsMore: 'Все исполнители' - }, - hero: { - eyebrow: 'Рекомендуемый альбом', - playAlbum: 'Воспроизвести альбом', - enqueue: 'В очередь', - enqueueTooltip: 'Добавить весь альбом в очередь', - }, - search: { - placeholder: 'Поиск исполнителя, альбома или песни…', - noResults: 'Нет результатов для "{{query}}"', - artists: 'Исполнители', - albums: 'Альбомы', - songs: 'Песни', - clearLabel: 'Очистить поиск', - title: 'Поиск', - resultsFor: 'Результаты для "{{query}}"', - album: 'Альбом', - advanced: 'Расширенный поиск', - advancedSearchTerm: 'Поисковый запрос', - advancedSearchPlaceholder: 'Название, альбом, исполнитель…', - advancedGenre: 'Жанр', - advancedAllGenres: 'Все жанры', - advancedYear: 'Год', - advancedYearFrom: 'от', - advancedYearTo: 'до', - advancedAll: 'Все', - advancedSearch: 'Поиск', - advancedEmpty: 'Введите поисковый запрос или выберите фильтр.', - advancedNoResults: 'Результаты не найдены.', - advancedGenreNote: 'Песни случайно выбираются из этого жанра.', - recentSearches: 'Недавние запросы', - browse: 'Обзор', - emptyHint: 'Что хочешь послушать?', - genres: 'Жанры', - }, - nowPlaying: { - tooltip: 'Кто слушает?', - title: 'Кто слушает?', - loading: 'Загрузка…', - nobody: 'Никто сейчас не слушает.', - minutesAgo: '{{n}} мин. назад', - nothingPlaying: 'Пока ничего не играет. Включите трек!', - aboutArtist: 'Об исполнителе', - fromAlbum: 'Из этого альбома', - viewAlbum: 'Просмотр альбома', - goToArtist: 'Перейти к исполнителю', - readMore: 'Читать далее', - showLess: 'Свернуть', - genreInfo: 'Жанр', - trackInfo: 'Информация о треке', - }, - contextMenu: { - playNow: 'Воспроизвести сейчас', - playNext: 'Воспроизвести следующим', - addToQueue: 'Добавить в очередь', - enqueueAlbum: 'Добавить альбом в очередь', - startRadio: 'Запустить радио', - lfmLove: 'Нравится на Last.fm', - lfmUnlove: 'Убрать из любимых на Last.fm', - favorite: 'Избранное', - favoriteArtist: 'Добавить исполнителя в избранное', - favoriteAlbum: 'Добавить альбом в избранное', - unfavorite: 'Удалить из избранного', - unfavoriteArtist: 'Удалить исполнителя из избранного', - unfavoriteAlbum: 'Удалить альбом из избранного', - removeFromQueue: 'Удалить из очереди', - openAlbum: 'Открыть альбом', - goToArtist: 'Перейти к исполнителю', - download: 'Скачать (ZIP)', - addToPlaylist: 'Добавить в плейлист', - songInfo: 'Информация о песне', - }, - albumDetail: { - back: 'Назад', - playAll: 'Воспроизвести все', - enqueue: 'В очередь', - enqueueTooltip: 'Добавить весь альбом в очередь', - artistBio: 'Биография исполнителя', - download: 'Скачать (ZIP)', - downloading: 'Загрузка…', - cacheOffline: 'Сделать доступным офлайн', - offlineCached: 'Доступно офлайн', - offlineDownloading: 'Кэширование… ({{n}}/{{total}})', - removeOffline: 'Удалить офлайн кэш', - offlineStorageFull: 'Офлайн хранилище заполнено (лимит: {{mb}} МБ). Освободите место, удалив альбом из офлайн библиотеки, или увеличьте лимит в настройках.', - offlineStorageGoToLibrary: 'Офлайн библиотека', - offlineStorageGoToSettings: 'Настройки', - favoriteAdd: 'Добавить в избранное', - favoriteRemove: 'Удалить из избранного', - favorite: 'Избранное', - noBio: 'Биография недоступна.', - moreByArtist: 'Больше от {{artist}}', - tracksCount: '{{n}} треков', - goToArtist: 'Перейти к {{artist}}', - moreLabelAlbums: 'Больше альбомов на {{label}}', - trackTitle: 'Название', - trackArtist: 'Исполнитель', - trackGenre: 'Жанр', - trackFormat: 'Формат', - trackFavorite: 'Избранное', - trackRating: 'Рейтинг', - trackDuration: 'Длительность', - trackTotal: 'Всего', - columns: 'Столбцы', - notFound: 'Альбом не найден.', - bioModal: 'Биография исполнителя', - bioClose: 'Закрыть', - ratingLabel: 'Рейтинг', - enlargeCover: 'Увеличить', - }, - artistDetail: { - back: 'Назад', - albums: 'Альбомы', - album: 'Альбом', - playAll: 'Воспроизвести все', - shuffle: 'Перемешать', - radio: 'Радио', - loading: 'Загрузка…', - noRadio: 'Похожие треки для этого исполнителя не найдены.', - notFound: 'Исполнитель не найден.', - albumsBy: 'Альбомы {{name}}', - topTracks: 'Лучшие треки', - noAlbums: 'Альбомы не найдены.', - trackTitle: 'Название', - trackAlbum: 'Альбом', - trackDuration: 'Длительность', - favoriteAdd: 'Добавить в избранное', - favoriteRemove: 'Удалить из избранного', - favorite: 'Избранное', - albumCount_one: '{{count}} альбом', - albumCount_few: '{{count}} альбома', - albumCount_many: '{{count}} альбомов', - albumCount_other: '{{count}} альбомов', - openedInBrowser: 'Открыто в браузере', - featuredOn: 'Также представлен на', - similarArtists: 'Похожие исполнители', - cacheOffline: 'Сохранить дискографию офлайн', - offlineCached: 'Дискография кэширована', - offlineDownloading: 'Кэширование… ({{done}}/{{total}} альбомов)', - uploadImage: 'Загрузить изображение исполнителя', - uploadImageError: 'Не удалось загрузить изображение', - }, - favorites: { - title: 'Избранное', - empty: 'Вы еще не добавили ничего в избранное.', - artists: 'Исполнители', - albums: 'Альбомы', - songs: 'Песни', - enqueueAll: 'Добавить все в очередь', - playAll: 'Воспроизвести все', - removeSong: 'Удалить из избранного', - stations: 'Радиостанции', - }, - randomAlbums: { - title: 'Случайные альбомы', - refresh: 'Обновить', - }, - genres: { - title: 'Жанры', - genreCount: 'Жанры', - albumCount_one: '{{count}} альбом', - albumCount_few: '{{count}} альбома', - albumCount_many: '{{count}} альбомов', - albumCount_other: '{{count}} альбомов', - loading: 'Загрузка жанров…', - empty: 'Жанры не найдены.', - albumsLoading: 'Загрузка альбомов…', - albumsEmpty: 'Альбомы в этом жанре не найдены.', - loadMore: 'Загрузить еще', - back: 'Назад', - }, - randomMix: { - title: 'Случайный микс', - remix: 'Ремикс', - remixTooltip: 'Загрузить новые случайные песни', - playAll: 'Воспроизвести все', - trackTitle: 'Название', - trackArtist: 'Исполнитель', - trackAlbum: 'Альбом', - trackFavorite: 'Избранное', - trackDuration: 'Длительность', - favoriteAdd: 'Добавить в избранное', - favoriteRemove: 'Удалить из избранного', - play: 'Воспроизвести', - trackGenre: 'Жанр', - excludeAudiobooks: 'Исключить аудиокниги и радиопостановки', - excludeAudiobooksDesc: 'Совпадения ключевых слов в жанре, названии, альбоме и исполнителе — например, Аудиокнига, Разговорный жанр, …', - genreBlocked: 'Ключевое слово заблокировано', - genreAddedToBlacklist: 'Добавлено в список фильтрации', - genreAlreadyBlocked: 'Уже заблокировано', - artistBlocked: 'Исполнитель заблокирован', - artistAddedToBlacklist: 'Исполнитель добавлен в список фильтрации', - artistClickHint: 'Нажмите, чтобы заблокировать исполнителя', - blacklistToggle: 'Фильтр ключевых слов', - genreMixTitle: 'Жанровый микс', - genreMixDesc: 'Топ-20 жанров по количеству песен — нажмите для загрузки случайного микса', - genreMixLoadMore: 'Загрузить еще 10', - genreMixNoGenres: 'Жанры на сервере не найдены.', - shuffleGenres: 'Показать другие жанры', - filterPanelTitle: 'Фильтры', - filterPanelDesc: 'Нажмите на жанровый тег или имя исполнителя в списке треков ниже, чтобы заблокировать их в будущих миксах.', - genreClickHint: 'Нажмите на жанровый тег, чтобы добавить его\nкак ключевое слово фильтра.\nСовпадает с жанром, названием, альбомом и исполнителем.', - }, - albums: { - title: 'Все альбомы', - sortByName: 'А–Я (Альбом)', - sortByArtist: 'А–Я (Исполнитель)', - sortNewest: 'Сначала новые', - sortRandom: 'Случайные', - yearFrom: 'От', - yearTo: 'До', - yearFilterClear: 'Очистить фильтр по году', - yearFilterLabel: 'Год', - }, - artists: { - title: 'Исполнители', - search: 'Поиск…', - all: 'Все', - gridView: 'Сетка', - listView: 'Список', - imagesOn: 'Изображения исполнителей включены — могут увеличить нагрузку на сеть и систему', - imagesOff: 'Изображения исполнителей отключены — показываются только инициалы', - loadMore: 'Загрузить еще', - notFound: 'Исполнители не найдены.', - albumCount_one: '{{count}} альбом', - albumCount_few: '{{count}} альбома', - albumCount_many: '{{count}} альбомов', - albumCount_other: '{{count}} альбомов', - }, - login: { - subtitle: 'Ваш настольный проигрыватель Navidrome', - serverName: 'Имя сервера (необязательно)', - serverNamePlaceholder: 'Мой Navidrome', - serverUrl: 'URL сервера', - serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com', - username: 'Имя пользователя', - usernamePlaceholder: 'admin', - password: 'Пароль', - showPassword: 'Показать пароль', - hidePassword: 'Скрыть пароль', - connect: 'Подключиться', - connecting: 'Подключение…', - connected: 'Подключено!', - error: 'Ошибка подключения — проверьте ваши данные.', - urlRequired: 'Введите URL сервера.', - savedServers: 'Сохраненные серверы', - addNew: 'Или добавьте новый сервер', - }, - connection: { - connected: 'Подключено', - connectedTo: 'Подключено к {{server}}', - disconnected: 'Отключено', - disconnectedFrom: 'Не удается подключиться к {{server}} — нажмите для проверки настроек', - checking: 'Подключение…', - extern: 'Внешний', - offlineTitle: 'Нет подключения к серверу', - offlineSubtitle: 'Не удается подключиться к {{server}}. Проверьте вашу сеть или сервер.', - offlineModeBanner: 'Офлайн-режим — воспроизведение из локального кэша', - offlineLibraryTitle: 'Офлайн-библиотека', - offlineLibraryEmpty: 'Пока нет кэшированных альбомов. Подключитесь к сети, откройте альбом и нажмите "Сделать доступным офлайн".', - offlineAlbumCount: '{{n}} альбом', - offlineAlbumCount_few: '{{n}} альбома', - offlineAlbumCount_many: '{{n}} альбомов', - offlineAlbumCount_plural: '{{n}} альбомов', - offlineFilterAll: 'Все', - offlineFilterAlbums: 'Альбомы', - offlineFilterPlaylists: 'Плейлисты', - offlineFilterArtists: 'Дискографии', - retry: 'Повторить', - lastfmConnected: 'Last.fm подключен как @{{user}}', - lastfmSessionInvalid: 'Сессия недействительна — нажмите для переподключения', - }, - common: { - albums: 'Альбомы', - album: 'Альбом', - loading: 'Загрузка…', - loadingMore: 'Загрузка…', - loadingPlaylists: 'Загрузка плейлистов…', - noAlbums: 'Альбомы не найдены.', - downloading: 'Загрузка…', - downloadZip: 'Скачать (ZIP)', - back: 'Назад', - cancel: 'Отмена', - save: 'Сохранить', - delete: 'Удалить', - use: 'Использовать', - add: 'Добавить', - active: 'Активно', - download: 'Скачать', - chooseDownloadFolder: 'Выберите папку загрузки', - noFolderSelected: 'Папка не выбрана', - rememberDownloadFolder: 'Запомнить эту папку', - filterGenre: 'Фильтр по жанру', - filterSearchGenres: 'Поиск жанров…', - filterNoGenres: 'Нет совпадений по жанрам', - filterClear: 'Очистить', - bulkSelected: 'Выбрано: {{count}}', - bulkAddToPlaylist: 'Добавить в плейлист', - bulkRemoveFromPlaylist: 'Удалить из плейлиста', - bulkClear: 'Очистить выбор', - updaterAvailable: 'Доступно обновление', - updaterVersion: 'v{{version}} готово', - updaterInstall: 'Установить и перезапустить', - updaterDownloading: 'Загрузка…', - updaterInstalling: 'Установка…', - updaterDownload: 'Скачать с GitHub', - updaterExperimentalHint: 'Автообновление все еще в разработке', - }, - settings: { - title: 'Настройки', - language: 'Язык', - languageEn: 'Английский', - languageDe: 'Немецкий', - languageFr: 'Французский', - languageNl: 'Нидерландский', - languageZh: 'Китайский', - languageNb: 'Норвежский', - languageRu: 'Русский', - languageRu2: 'Русский 2', - font: 'Шрифт', - theme: 'Тема', - appearance: 'Внешний вид', - servers: 'Серверы', - serverName: 'Имя сервера', - serverUrl: 'URL сервера', - serverUsername: 'Имя пользователя', - serverPassword: 'Пароль', - addServer: 'Добавить сервер', - addServerTitle: 'Добавить новый сервер', - useServer: 'Использовать', - deleteServer: 'Удалить', - noServers: 'Серверы не сохранены.', - serverActive: 'Активный', - confirmDeleteServer: 'Удалить сервер "{{name}}"?', - serverConnecting: 'Подключение…', - serverConnected: 'Подключено!', - serverFailed: 'Ошибка подключения.', - testBtn: 'Проверить подключение', - testingBtn: 'Проверка…', - serverCompatible: 'Совместимо с: Navidrome · Gonic · Airsonic · Subsonic', - connected: 'Подключено', - failed: 'Ошибка', - eqTitle: 'Эквалайзер', - eqEnabled: 'Включить эквалайзер', - eqPreset: 'Пресет', - eqPresetCustom: 'Пользовательский', - eqPresetBuiltin: 'Встроенные пресеты', - eqPresetCustomGroup: 'Мои пресеты', - eqSavePreset: 'Сохранить как пресет', - eqPresetName: 'Название пресета…', - eqDeletePreset: 'Удалить пресет', - eqResetBands: 'Сбросить', - eqPreGain: 'Предусиление', - eqResetPreGain: 'Сбросить предусиление', - eqAutoEqTitle: 'Поиск наушников AutoEQ', - eqAutoEqPlaceholder: 'Поиск модели наушников / IEM…', - eqAutoEqSearching: 'Поиск…', - eqAutoEqNoResults: 'Результаты не найдены', - eqAutoEqError: 'Ошибка поиска', - eqAutoEqRateLimit: 'Достигнут лимит GitHub — попробуйте через минуту', - eqAutoEqFetchError: 'Не удалось загрузить профиль EQ', - lfmTitle: 'Last.fm', - lfmConnect: 'Подключить Last.fm', - lfmConnecting: 'Ожидание авторизации…', - lfmConfirm: 'Я авторизовал приложение', - lfmConnected: 'Подключено как', - lfmDisconnect: 'Отключить', - lfmConnectDesc: 'Подключите аккаунт Last.fm для включения скробблинга и обновления "Сейчас играет" напрямую из Psysonic — без необходимости настройки Navidrome.', - lfmOpenBrowser: 'Откроется окно браузера. Авторизуйте Psysonic на Last.fm, затем нажмите кнопку ниже.', - lfmScrobbles: '{{n}} скробблов', - lfmMemberSince: 'Участник с {{year}}', - scrobbleEnabled: 'Скробблинг включен', - scrobbleDesc: 'Отправлять песни на Last.fm после 50% воспроизведения', - behavior: 'Поведение приложения', - cacheTitle: 'Макс. размер хранилища', - cacheDesc: 'Обложки и изображения исполнителей. При заполнении старые записи удаляются автоматически. Офлайн альбомы не удаляются автоматически, но будут удалены при ручной очистке кэша.', - cacheUsedImages: 'Изображения:', - cacheUsedOffline: 'Офлайн треки:', - cacheMaxLabel: 'Макс. размер', - cacheClearBtn: 'Очистить кэш', - cacheClearWarning: 'Это также удалит все офлайн альбомы из библиотеки.', - cacheClearConfirm: 'Очистить все', - cacheClearCancel: 'Отмена', - offlineDirTitle: 'Офлайн библиотека (в приложении)', - offlineDirDesc: 'Место хранения треков, которые вы делаете доступными офлайн в Psysonic.', - offlineDirDefault: 'По умолчанию (данные приложения)', - offlineDirChange: 'Изменить директорию', - offlineDirClear: 'Сбросить по умолчанию', - offlineDirHint: 'Новые загрузки будут использовать это место. Существующие загрузки останутся по своему исходному пути.', - showArtistImages: 'Показать изображения исполнителей', - showArtistImagesDesc: 'Загружать и отображать изображения исполнителей в обзоре исполнителей. Отключено по умолчанию для снижения дискового ввода-вывода сервера и сетевой нагрузки на больших библиотеках.', - showTrayIcon: 'Показать значок в трее', - showTrayIconDesc: 'Отображать значок Psysonic в области уведомлений системы / строке меню.', - minimizeToTray: 'Свернуть в трей', - minimizeToTrayDesc: 'При закрытии окна оставлять Psysonic работающим в системном трее вместо выхода.', - discordRichPresence: 'Discord Rich Presence', - discordRichPresenceDesc: 'Показывать текущий воспроизводимый трек в вашем профиле Discord. Требуется запущенный Discord.', - nowPlayingEnabled: 'Показывать в "Сейчас играет"', - nowPlayingEnabledDesc: 'Транслировать текущий воспроизводимый трек на сервер в режим просмотра слушателей. Отключите, чтобы прекратить отправку данных воспроизведения.', - downloadsTitle: 'ZIP экспорт и архивирование', - downloadsFolderDesc: 'Папка назначения для альбомов, которые вы скачиваете в виде ZIP файла на компьютер.', - downloadsDefault: 'Папка загрузок по умолчанию', - pickFolder: 'Выбрать', - pickFolderTitle: 'Выберите папку загрузки', - clearFolder: 'Очистить папку загрузки', - logout: 'Выйти', - aboutTitle: 'О Psysonic', - aboutDesc: 'Современный настольный музыкальный проигрыватель для серверов, совместимых с Subsonic (Navidrome, Gonic и другие). Построен на Tauri v2 с нативным аудио движком на Rust — легкий и быстрый, но с богатым функционалом: волновая форма для перемотки, синхронизированные тексты песен, интеграция с Last.fm, 10-полосный эквалайзер, кроссфейд, воспроизведение без пауз, Replay Gain, просмотр жанров и большая библиотека тем.', - aboutFeatures: 'Многосерверность · Скробблинг и лайки Last.fm · Полноэкранный Ambient Stage · Перемотка по волновой форме · Синхронизированные тексты · 10-полосный эквалайзер · Кроссфейд и воспроизведение без пауз · Replay Gain · Жанры · 63 темы · 5 языков', - aboutLicense: 'Лицензия', - aboutLicenseText: 'GNU GPL v3 — бесплатное использование, модификация и распространение на тех же условиях.', - aboutRepo: 'Исходный код на GitHub', - aboutVersion: 'Версия', - aboutBuiltWith: 'Создано с Tauri · React · TypeScript · Rust/rodio', - aboutAiCredit: 'Разработано при поддержке Claude Code от Anthropic', - aboutContributorsLabel: 'Участники', - aboutSpecialThanksLabel: 'Особая благодарность', - changelog: 'Список изменений', - showChangelogOnUpdate: 'Показывать "Что нового" при обновлении', - showChangelogOnUpdateDesc: 'Автоматически показывать, что нового, при первом запуске новой версии.', - randomMixTitle: 'Случайный микс', - randomMixBlacklistTitle: 'Пользовательские ключевые слова фильтра', - randomMixBlacklistDesc: 'Песни исключаются, когда любое ключевое слово совпадает с их жанром, названием, альбомом или исполнителем (активно при включенном флажке выше).', - randomMixBlacklistPlaceholder: 'Добавить ключевое слово…', - randomMixBlacklistAdd: 'Добавить', - randomMixBlacklistEmpty: 'Пользовательские ключевые слова еще не добавлены.', - randomMixHardcodedTitle: 'Встроенные ключевые слова (активны при включенном флажке)', - tabAudio: 'Аудио', - tabStorage: 'Хранилище и загрузки', - tabAppearance: 'Внешний вид', - homeCustomizerTitle: 'Главная страница', - sidebarTitle: 'Боковая панель', - sidebarReset: 'Сбросить по умолчанию', - sidebarDrag: 'Перетащите для изменения порядка', - sidebarFixed: 'Всегда видна', - tabInput: 'Ввод', - tabServer: 'Сервер', - tabSystem: 'Система', - tabGeneral: 'Общие', - backupTitle: 'Резервное копирование и восстановление', - backupExport: 'Экспорт настроек', - backupExportDesc: 'Сохраняет все настройки, профили серверов, конфигурацию Last.fm, тему, эквалайзер и привязки клавиш в файл .psybkp. Пароли хранятся в открытом виде — храните файл в безопасности.', - backupImport: 'Импорт настроек', - backupImportDesc: 'Восстанавливает настройки из файла .psybkp. Приложение перезагрузится после импорта.', - backupImportConfirm: 'Это перезапишет все текущие настройки. Продолжить?', - backupSuccess: 'Резервная копия сохранена', - backupImportSuccess: 'Настройки восстановлены — перезагрузка…', - backupImportError: 'Недействительный или поврежденный файл резервной копии.', - shortcutsReset: 'Сбросить по умолчанию', - shortcutListening: 'Нажмите клавишу…', - shortcutUnbound: '—', - globalShortcutsTitle: 'Глобальные горячие клавиши', - globalShortcutsNote: 'Работают по всей системе, даже когда Psysonic работает в фоне. Требуют Ctrl, Alt или Super в качестве модификатора.', - shortcutClear: 'Очистить', - shortcutPlayPause: 'Воспроизведение / Пауза', - shortcutNext: 'Следующий трек', - shortcutPrev: 'Предыдущий трек', - shortcutVolumeUp: 'Увеличить громкость', - shortcutVolumeDown: 'Уменьшить громкость', - shortcutSeekForward: 'Перемотка вперед на 10 с', - shortcutSeekBackward: 'Перемотка назад на 10 с', - shortcutToggleQueue: 'Переключить очередь', - shortcutFullscreenPlayer: 'Полноэкранный плеер', - shortcutNativeFullscreen: 'Нативный полный экран', - playbackTitle: 'Воспроизведение', - replayGain: 'Replay Gain', - replayGainDesc: 'Нормализация громкости трека с использованием метаданных ReplayGain', - replayGainMode: 'Режим', - replayGainTrack: 'Трек', - replayGainAlbum: 'Альбом', - crossfade: 'Кроссфейд', - crossfadeDesc: 'Плавный переход между треками', - crossfadeSecs: '{{n}} с', - notWithGapless: 'Недоступно при активном воспроизведении без пауз', - notWithCrossfade: 'Недоступно при активном кроссфейде', - gapless: 'Воспроизведение без пауз', - gaplessDesc: 'Предварительная буферизация следующего трека для устранения пауз между песнями', - preloadMode: 'Предзагрузка следующего трека', - preloadModeDesc: 'Когда начинать буферизацию следующего трека в очереди', - preloadBalanced: 'Сбалансированный (за 30 с до конца)', - preloadEarly: 'Ранний (после 5 с воспроизведения)', - preloadCustom: 'Пользовательский', - preloadCustomSeconds: 'Секунд до конца: {{n}}', - infiniteQueue: 'Бесконечная очередь', - infiniteQueueDesc: 'Автоматически добавлять случайные треки при окончании очереди', - experimental: 'Экспериментальный', - }, - changelog: { - modalTitle: 'Что нового', - dontShowAgain: 'Больше не показывать', - close: 'Понятно', - }, - help: { - title: 'Помощь', - s1: 'Начало работы', - q1: 'Какие серверы совместимы?', - a1: 'Psysonic работает с любым сервером, совместимым с Subsonic: Navidrome, Gonic, Subsonic, Airsonic и другими. Navidrome — рекомендуемый выбор.', - q2: 'Как подключиться к серверу?', - a2: 'Откройте Настройки и нажмите "Добавить сервер". Введите URL сервера (например, 192.168.1.100:4533), имя пользователя и пароль. Psysonic проверяет подключение перед сохранением — ничего не сохраняется, если подключение не удалось.', - q3: 'Могу ли я использовать несколько серверов?', - a3: 'Да. Вы можете добавить сколько угодно серверов в Настройках и переключаться между ними в любое время. Только один сервер активен одновременно.', - s2: 'Воспроизведение', - q4: 'Как воспроизводить музыку?', - a4: 'Дважды щелкните любой трек для воспроизведения. На страницах альбомов и исполнителей используйте "Воспроизвести все" для запуска всего альбома. Вы также можете перетащить треки в панель очереди.', - q5: 'Какие горячие клавиши доступны?', - a5: 'Пробел = Воспроизведение / Пауза · Escape = Закрыть полноэкранный плеер. Медиа-клавиши (Воспроизведение/Пауза, Следующий, Предыдущий) работают на всех платформах. На Linux используйте кнопки панели проигрывателя для медиа-клавиш. Внутриприложенные горячие клавиши и системные глобальные горячие клавиши можно настроить в Настройках → Горячие клавиши / Глобальные горячие клавиши.', - q6: 'Что такое очередь?', - a6: 'Очередь показывает все предстоящие треки. Откройте ее с помощью значка панели в правом верхнем углу заголовка (рядом с индикатором "Сейчас играет"). Вы можете переупорядочивать треки перетаскиванием, перемешивать кнопкой перемешивания и сохранять очередь как плейлист.', - q7: 'Как открыть полноэкранный плеер?', - a7: 'Нажмите на миниатюру обложки в панели проигрывателя внизу или на значок расширения рядом с ней. Нажмите Escape для закрытия.', - q8: 'Как работает повтор?', - a8: 'Нажмите кнопку повтора в панели проигрывателя для цикла: Выкл → Повтор всех → Повтор одного.', - s3: 'Библиотека', - q9: 'Как скачать альбом?', - a9: 'Откройте страницу альбома и нажмите "Скачать (ZIP)". Сервер сначала сжимает альбом в ZIP файл — для больших альбомов или файлов без потерь (FLAC / WAV) это может занять некоторое время перед фактическим началом загрузки. Это нормально: прогресс-бар появляется после завершения упаковки сервером и начала передачи.', - q10: 'Как добавить треки и альбомы в избранное?', - a10: 'Нажмите на значок звезды на любой строке трека или в заголовке альбома. Отмеченные элементы появляются в разделе "Избранное" на боковой панели.', - q11: 'Что такое карусель на главной странице?', - a11: 'Баннер в верхней части главной страницы случайно выбирает альбомы из вашей библиотеки и вращает их каждые 10 секунд. Нажмите на точки для перехода к конкретному альбому или нажмите на баннер для открытия альбома.', - s4: 'Настройки', - q12: 'Как изменить тему?', - a12: 'Настройки → Тема. Выберите из большого количества тем в 8 группах: Темы Psysonic, Медиаплееры, Операционные системы, Игры, Фильмы, Сериалы, Социальные сети и Классика открытого исходного кода (Catppuccin, Nord, Gruvbox).', - q13: 'Как изменить язык?', - a13: 'Настройки → Язык. Поддерживаются английский, немецкий, французский, нидерландский, китайский, норвежский и русский.', - q15: 'Как установить папку загрузки?', - a15: 'Настройки → Поведение приложения → Папка загрузки. Выберите любую папку — загруженные альбомы сохраняются туда как ZIP файлы. Без пользовательской папки используется местоположение загрузок браузера по умолчанию.', - s5: 'Скробблинг', - q16: 'Как работает скробблинг?', - a16: 'Psysonic отправляет скробблы напрямую на Last.fm — настройка Navidrome не требуется. Подключите аккаунт Last.fm в Настройках → Last.fm и включите скробблинг там.', - q17: 'Когда отправляется скроббл?', - a17: 'Скроббл отправляется после прослушивания 50% трека.', - q22: 'Что такое волновая форма в панели проигрывателя?', - a22: 'Панель перемотки с волновой формой заменяет классический ползунок прогресса. Нажмите в любом месте для перехода к этой позиции или перетащите для перемотки. Воспроизведенная часть светится градиентом от синего к лиловому, буферизованный диапазон немного ярче, а невоспроизведенная часть затемнена.', - q24: 'Могу ли я перемешать очередь?', - a24: 'Да. Откройте панель очереди и нажмите значок перемешивания в заголовке очереди. Текущий воспроизводимый трек остается на позиции 1 — все остальные треки случайно переупорядочиваются.', - q25: 'Открываются ли ссылки Last.fm и Wikipedia на страницах исполнителей в браузере?', - a25: 'Да — они открываются в вашем системном браузере по умолчанию. Кнопка кратко показывает метку подтверждения при нажатии.', - s7: 'Случайный микс', - q26: 'Что такое Случайный микс?', - a26: 'Случайный микс создает плейлист из случайных треков из всей вашей библиотеки...', - q27: 'Что такое Фильтр ключевых слов?', - a27: 'Фильтр ключевых слов исключает треки, жанр, название или альбом которых содержат определенные слова. Аудиокниги фильтруются автоматически. Добавьте пользовательские ключевые слова в Настройках → Случайный микс или нажмите любой жанровый тег в списке треков для мгновенного добавления.', - q28: 'Что такое Супер жанровый микс?', - a28: 'Супер жанровый микс группирует вашу библиотеку в широкие категории (Рок, Метал, Электронная, Джаз, Классика и т.д.) и создает сфокусированный микс из этого стиля. Выберите категорию ниже списка треков. Результаты появляются прогрессивно по мере получения каждого поджанра.', - s6: 'Устранение неполадок', - q18: 'Обложки и изображения исполнителей загружаются медленно.', - a18: 'Изображения загружаются с диска вашего сервера при первом посещении и затем кэшируются локально на 30 дней. Если хранилище сервера медленное, первое посещение страницы может занять некоторое время. Последующие посещения будут мгновенными.', - q19: 'Тест подключения не удается.', - a19: 'Проверьте URL включая порт (например, http://192.168.1.100:4533). Убедитесь, что брандмауэр не блокирует подключение. Попробуйте http:// вместо https:// в локальной сети. Также проверьте правильность имени пользователя и пароля.', - q20: 'Нет звука на Linux.', - a20: 'Psysonic доступен как .deb (Ubuntu/Debian), .rpm (Fedora/RHEL) и через AUR на Arch/CachyOS (yay -S psysonic или yay -S psysonic-bin). Нет AppImage. Если звук отсутствует, убедитесь, что PipeWire или PulseAudio запущены.', - q21: 'Приложение показывает черный экран на Linux.', - a21: 'Обычно это проблема GPU/EGL драйвера в WebKitGTK. Запустите с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Пакет AUR и официальные установщики .deb/.rpm устанавливают это автоматически.', - q29: 'Что такое Кроссфейд и Воспроизведение без пауз?', - a29: 'Оба являются экспериментальными аудио функциями в Настройках → Аудио. Воспроизведение без пауз предварительно буферизует следующий трек для устранения тишины между песнями. Кроссфейд затухает текущий трек при одновременном нарастании следующего — настройте длительность (1–10 с) по вкусу.', - q30: 'Показывает ли Psysonic тексты песен?', - a30: 'Да. Нажмите значок микрофона в панели проигрывателя для открытия вкладки "Текст" в панели очереди. Psysonic автоматически загружает тексты из LRCLIB. При наличии синхронизированных текстов активная строка выделяется и прокручивается в реальном времени по мере воспроизведения песни. При наличии только обычного текста он отображается статически.', - q31: 'Могу ли я настроить горячие клавиши?', - a31: 'Да. Настройки → Горячие клавиши позволяют переназначить действия приложения (Воспроизведение/Пауза, Следующий, Предыдущий, Громкость вверх/вниз, Полный экран и другие) на любую клавишу. Настройки → Глобальные горячие клавиши позволяют назначить системные горячие клавиши, которые срабатывают даже когда Psysonic работает в фоне.', - q32: 'Могу ли я изменить шрифт?', - a32: 'Да. Настройки → Шрифт позволяют выбрать из 10 шрифтов, включая IBM Plex Mono, Fira Code, JetBrains Mono, Courier Prime и другие. Выбранный шрифт применяется ко всему интерфейсу.', - s8: 'Офлайн режим', - q34: 'Что такое Офлайн режим?', - a34: 'Офлайн режим (бета) позволяет кэшировать альбомы на ваше устройство для прослушивания без активного подключения к серверу. Кэшированные треки хранятся локально и воспроизводятся напрямую с диска — во время воспроизведения сетевые запросы не выполняются.', - q35: 'Как кэшировать альбом для офлайн использования?', - a35: 'Откройте любой альбом и нажмите значок загрузки в заголовке альбома. Psysonic загружает все треки в фоне. Прогресс показывается на кнопке. После кэширования значок становится зеленым. Вы можете просмотреть и удалить кэшированные альбомы на странице Офлайн библиотеки (боковая панель).', - q36: 'Сколько хранилища может использовать офлайн кэширование?', - a36: 'Вы можете установить максимальный размер кэша в Настройках → Библиотека. При достижении лимита на странице альбома появляется предупреждающий баннер. Вы можете удалять отдельные альбомы из Офлайн библиотеки для освобождения места.', - }, - queue: { - title: 'Очередь', - savePlaylist: 'Сохранить плейлист', - updatePlaylist: 'Обновить плейлист', - filterPlaylists: 'Фильтр плейлистов…', - playlistName: 'Название плейлиста', - cancel: 'Отмена', - save: 'Сохранить', - loadPlaylist: 'Загрузить плейлист', - loading: 'Загрузка…', - noPlaylists: 'Плейлисты не найдены.', - load: 'Заменить очередь и воспроизвести', - appendToQueue: 'Добавить в конец очереди', - delete: 'Удалить', - deleteConfirm: 'Удалить плейлист "{{name}}"?', - clear: 'Очистить', - shuffle: 'Перемешать очередь', - gapless: 'Без пауз', - crossfade: 'Кроссфейд', - infiniteQueue: 'Бесконечная очередь', - autoAdded: '— Добавлено автоматически —', - radioAdded: '— Радио —', - hide: 'Скрыть', - close: 'Закрыть', - nextTracks: 'Следующие треки', - emptyQueue: 'Очередь пуста.', - trackSingular: 'трек', - trackPlural: 'треков', - showRemaining: 'Показать оставшееся время', - showTotal: 'Показать общее время', - }, - statistics: { - title: 'Статистика', - recentlyPlayed: 'Недавно прослушанные', - mostPlayed: 'Самые прослушиваемые альбомы', - highestRated: 'Альбомы с highest рейтингом', - genreDistribution: 'Распределение по жанрам (Топ-20)', - loadMore: 'Загрузить еще', - statArtists: 'Исполнители', - statAlbums: 'Альбомы', - statSongs: 'Песни', - statGenres: 'Жанры', - statPlaytime: 'Общее время воспроизведения', - genreInsights: 'Обзор по жанрам', - formatDistribution: 'Распределение по форматам', - formatSample: 'Выборка из {{n}} треков', - computing: 'Вычисление…', - genreSongs: '{{count}} песен', - genreAlbums: '{{count}} альбомов', - recentlyAdded: 'Недавно добавленные', - decadeDistribution: 'Альбомы по десятилетиям', - decadeAlbums_one: '{{count}} альбом', - decadeAlbums_few: '{{count}} альбома', - decadeAlbums_many: '{{count}} альбомов', - decadeAlbums_other: '{{count}} альбомов', - decadeUnknown: 'Неизвестно', - lfmTitle: 'Статистика Last.fm', - lfmTopArtists: 'Топ исполнителей', - lfmTopAlbums: 'Топ альбомов', - lfmTopTracks: 'Топ треков', - lfmPlays: '{{count}} воспроизведений', - lfmPeriodOverall: 'Все время', - lfmPeriod7day: '7 дней', - lfmPeriod1month: '1 месяц', - lfmPeriod3month: '3 месяца', - lfmPeriod6month: '6 месяцев', - lfmPeriod12month: '12 месяцев', - lfmNotConnected: 'Подключите Last.fm в Настройках для просмотра статистики.', - lfmRecentTracks: 'Недавние скробблы', - lfmNowPlaying: 'Сейчас играет', - lfmJustNow: 'только что', - lfmMinutesAgo: '{{n}} мин. назад', - lfmHoursAgo: '{{n}} ч. назад', - lfmDaysAgo: '{{n}} дн. назад', - }, - player: { - regionLabel: 'Музыкальный проигрыватель', - openFullscreen: 'Открыть полноэкранный плеер', - fullscreen: 'Полноэкранный плеер', - closeFullscreen: 'Закрыть полный экран', - closeTooltip: 'Закрыть (Esc)', - noTitle: 'Без названия', - stop: 'Стоп', - prev: 'Предыдущий трек', - play: 'Воспроизвести', - pause: 'Пауза', - next: 'Следующий трек', - repeat: 'Повтор', - repeatOff: 'Выкл', - repeatAll: 'Все', - repeatOne: 'Один', - progress: 'Прогресс песни', - volume: 'Громкость', - toggleQueue: 'Переключить очередь', - lyrics: 'Текст', - lyricsLoading: 'Загрузка текста…', - lyricsNotFound: 'Текст для этого трека не найден', - }, - songInfo: { - title: 'Информация о песне', - songTitle: 'Название', - artist: 'Исполнитель', - album: 'Альбом', - albumArtist: 'Исполнитель альбома', - year: 'Год', - genre: 'Жанр', - duration: 'Длительность', - track: 'Трек', - format: 'Формат', - bitrate: 'Битрейт', - sampleRate: 'Частота дискретизации', - bitDepth: 'Глубина бита', - channels: 'Каналы', - fileSize: 'Размер файла', - path: 'Путь', - replayGainTrack: 'RG усиление трека', - replayGainAlbum: 'RG усиление альбома', - replayGainPeak: 'RG пик трека', - mono: 'Моно', - stereo: 'Стерео', - }, - playlists: { - title: 'Плейлисты', - newPlaylist: 'Новый плейлист', - unnamed: 'Безымянный плейлист', - createName: 'Название плейлиста…', - create: 'Создать', - cancel: 'Отмена', - empty: 'Плейлистов пока нет.', - emptyPlaylist: 'Этот плейлист пуст.', - addFirstSong: 'Добавьте первую песню', - notFound: 'Плейлист не найден.', - songs: '{{n}} песен', - playAll: 'Воспроизвести все', - shuffle: 'Перемешать', - addToQueue: 'Добавить в очередь', - back: 'Назад к плейлистам', - deletePlaylist: 'Удалить', - confirmDelete: 'Нажмите еще раз для подтверждения', - removeSong: 'Удалить из плейлиста', - addSongs: 'Добавить песни', - searchPlaceholder: 'Поиск в библиотеке…', - noResults: 'Результаты не найдены.', - suggestions: 'Предложенные песни', - noSuggestions: 'Предложения недоступны.', - titleBadge: 'Плейлист', - refreshSuggestions: 'Новые предложения', - addSong: 'Добавить в плейлист', - cacheOffline: 'Кэшировать плейлист офлайн', - offlineCached: 'Плейлист кэширован', - offlineDownloading: 'Кэширование… ({{done}}/{{total}} альбомов)', - publicLabel: 'Публичный', - privateLabel: 'Приватный', - editMeta: 'Редактировать плейлист', - editNamePlaceholder: 'Название плейлиста…', - editCommentPlaceholder: 'Добавьте описание…', - editPublic: 'Публичный плейлист', - editSave: 'Сохранить', - editCancel: 'Отмена', - changeCover: 'Изменить обложку', - changeCoverLabel: 'Изменить фото', - removeCover: 'Удалить фото', - coverUpdated: 'Обложка обновлена', - metaSaved: 'Плейлист обновлен', - }, - radio: { - title: 'Интернет-радио', - empty: 'Радиостанции не настроены.', - addStation: 'Добавить станцию', - editStation: 'Редактировать', - deleteStation: 'Удалить станцию', - confirmDelete: 'Нажмите еще раз для подтверждения', - stationName: 'Название станции…', - streamUrl: 'URL потока…', - homepageUrl: 'URL главной страницы (необязательно)', - save: 'Сохранить', - cancel: 'Отмена', - live: 'ПРЯМОЙ ЭФИР', - liveStream: 'Интернет радио', - openHomepage: 'Открыть главную страницу', - changeCoverLabel: 'Изменить обложку', - removeCover: 'Удалить обложку', - browseDirectory: 'Поиск в каталоге', - directoryPlaceholder: 'Поиск станций…', - noResults: 'Станции не найдены.', - stationAdded: 'Станция добавлена', - filterAll: 'Все', - filterFavorites: 'Избранное', - sortManual: 'Вручную', - sortAZ: 'А → Я', - sortZA: 'Я → А', - sortNewest: 'Новые', - favorite: 'Добавить в избранное', - unfavorite: 'Удалить из избранного', - noFavorites: 'Избранных станций нет.', - } -} diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 375844ce..2ec5751f 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -341,7 +341,6 @@ export const zhTranslation = { languageZh: '中文', languageNb: '挪威', languageRu: '俄语', - languageRu2: '俄语 2', font: '字体', theme: '主题', appearance: '外观', @@ -422,6 +421,8 @@ export const zhTranslation = { discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。', nowPlayingEnabled: '在实时窗口中显示', nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。', + lyricsServerFirst: '优先使用服务器歌词', + lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。', downloadsTitle: 'ZIP 导出与归档', downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。', downloadsDefault: '默认下载文件夹', @@ -685,8 +686,11 @@ export const zhTranslation = { volume: '音量', toggleQueue: '切换队列', lyrics: '歌词', + fsLyricsToggle: '全屏歌词', lyricsLoading: '正在加载歌词…', lyricsNotFound: '未找到此曲目的歌词', + lyricsSourceServer: '来源:服务器', + lyricsSourceLrclib: '来源:LRCLIB', }, songInfo: { title: '歌曲信息', diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index b09499c0..2f012c0c 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; +import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; import CoverLightbox from '../components/CoverLightbox'; -import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check } from 'lucide-react'; +import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2 } from 'lucide-react'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; @@ -12,6 +12,8 @@ import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; +import { invalidateCoverArt } from '../utils/imageCache'; +import { showToast } from '../utils/toast'; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); @@ -55,6 +57,10 @@ export default function ArtistDetail() { const [similarLoading, setSimilarLoading] = useState(false); const [featuredLoading, setFeaturedLoading] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false); + const [bioExpanded, setBioExpanded] = useState(false); + const [uploading, setUploading] = useState(false); + const [coverRevision, setCoverRevision] = useState(0); + const imageInputRef = useRef(null); const playTrack = usePlayerStore(state => state.playTrack); const enqueue = usePlayerStore(state => state.enqueue); @@ -233,6 +239,32 @@ export default function ArtistDetail() { } }; + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file || !artist) return; + setUploading(true); + try { + await uploadArtistImage(artist.id, file); + const coverId = artist.coverArt || artist.id; + await invalidateCoverArt(coverId); + // Also invalidate with bare artist.id in case coverArt differs + if (artist.coverArt && artist.coverArt !== artist.id) { + await invalidateCoverArt(artist.id); + } + setCoverRevision(r => r + 1); + showToast(t('artistDetail.uploadImage')); + } catch (err) { + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('artistDetail.uploadImageError'), + 4000, + 'error', + ); + } finally { + setUploading(false); + } + }; + if (loading) { return (
@@ -273,7 +305,7 @@ export default function ArtistDetail() { )}
-
+
{coverId ? (
@@ -373,11 +422,29 @@ export default function ArtistDetail() { {/* Biography — sanitized HTML from server */} {info?.biography && ( -
-
+
+
+

{t('nowPlaying.aboutArtist')}

+
+
+ {(info.largeImageUrl || coverId) && ( + {artist.name} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + )} +
+
+ +
+
)} diff --git a/src/pages/InternetRadio.tsx b/src/pages/InternetRadio.tsx index abb660cc..014e31f2 100644 --- a/src/pages/InternetRadio.tsx +++ b/src/pages/InternetRadio.tsx @@ -11,6 +11,7 @@ import { } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import CachedImage from '../components/CachedImage'; +import { invalidateCoverArt } from '../utils/imageCache'; import CustomSelect from '../components/CustomSelect'; import { useTranslation } from 'react-i18next'; import { open } from '@tauri-apps/plugin-shell'; @@ -141,6 +142,7 @@ export default function InternetRadio() { if (created) { try { await uploadRadioCoverArt(created.id, opts.coverFile); + await invalidateCoverArt(`ra-${created.id}`); } catch (err) { showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error'); } @@ -163,11 +165,13 @@ export default function InternetRadio() { if (opts.coverFile) { try { await uploadRadioCoverArt(id, opts.coverFile); + await invalidateCoverArt(`ra-${id}`); } catch (err) { showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error'); } } else if (opts.coverRemoved) { await deleteRadioCoverArt(id).catch(() => {}); + await invalidateCoverArt(`ra-${id}`); } await reload(); } diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 4f1ffae6..d8635776 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -42,6 +42,8 @@ interface AuthState { minimizeToTray: boolean; discordRichPresence: boolean; nowPlayingEnabled: boolean; + lyricsServerFirst: boolean; + showFullscreenLyrics: boolean; showChangelogOnUpdate: boolean; lastSeenChangelogVersion: string; @@ -82,6 +84,8 @@ interface AuthState { setMinimizeToTray: (v: boolean) => void; setDiscordRichPresence: (v: boolean) => void; setNowPlayingEnabled: (v: boolean) => void; + setLyricsServerFirst: (v: boolean) => void; + setShowFullscreenLyrics: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void; setLastSeenChangelogVersion: (v: string) => void; logout: () => void; @@ -123,6 +127,8 @@ export const useAuthStore = create()( minimizeToTray: false, discordRichPresence: false, nowPlayingEnabled: false, + lyricsServerFirst: true, + showFullscreenLyrics: true, showChangelogOnUpdate: true, lastSeenChangelogVersion: '', isLoggedIn: false, @@ -196,6 +202,8 @@ export const useAuthStore = create()( setMinimizeToTray: (v) => set({ minimizeToTray: v }), setDiscordRichPresence: (v) => set({ discordRichPresence: v }), setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }), + setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }), + setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), diff --git a/src/styles/components.css b/src/styles/components.css index a9d9decc..eb725cb3 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -2022,6 +2022,32 @@ filter: brightness(1.08); } +/* Camera overlay for uploading a new artist image */ +.artist-avatar-upload-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 44px; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + cursor: pointer; + opacity: 0; + transition: opacity 150ms ease; + border-bottom-left-radius: 50%; + border-bottom-right-radius: 50%; + z-index: 2; +} +.artist-detail-avatar:hover .artist-avatar-upload-overlay { + opacity: 1; +} +.artist-avatar-upload-overlay:hover { + background: rgba(0, 0, 0, 0.7); +} + .artist-detail-meta { flex: 1; min-width: 0; @@ -2056,21 +2082,9 @@ } /* Bio section */ -.artist-bio-section { - margin-bottom: 0.5rem; -} - -.artist-bio-text { - font-size: 14px; - line-height: 1.75; - color: var(--text-secondary); - max-width: 100%; - user-select: text; -} - -.artist-bio-text a { - color: var(--accent); - text-decoration: underline; +/* Artist Detail bio — reuses np-info-card + np-artist-bio-row */ +.artist-bio-card { + margin-bottom: 2rem; } @@ -2483,6 +2497,14 @@ margin: 0; } +.lyrics-source { + font-size: 11px; + color: var(--text-muted); + text-align: center; + margin: 8px 0 4px; + opacity: 0.6; +} + /* ── Help ─────────────────────────────────────────────────────────────────── */ @@ -2924,23 +2946,21 @@ align-items: center; justify-content: center; pointer-events: none; - border-radius: 12px; - overflow: hidden; + /* No overflow:hidden / border-radius — object-fit:contain never overflows, and + rounding the wrap visually clips wide images. */ transform: translateZ(0); - /* Fade left edge to blend into the dark mesh */ + /* Thin left-edge fade to blend into the scrim — keep image fully visible */ -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% + rgba(0, 0, 0, 0.6) 6%, + #000 16% ); mask-image: linear-gradient( to right, transparent 0%, - rgba(0, 0, 0, 0.45) 18%, - rgba(0, 0, 0, 0.85) 40%, - #000 60% + rgba(0, 0, 0, 0.6) 6%, + #000 16% ); } @@ -3005,7 +3025,7 @@ position: absolute; bottom: 72px; left: clamp(28px, 4vw, 64px); - z-index: 3; + z-index: 4; display: flex; flex-direction: column; gap: 12px; @@ -3014,6 +3034,7 @@ /* Album art — small, rounded, glowing */ .fs-art-wrap { + position: relative; /* stacking context for crossfade layers */ width: clamp(120px, 10vw, 180px); height: clamp(120px, 10vw, 180px); border-radius: 12px; @@ -3025,16 +3046,19 @@ 0 0 28px var(--accent-glow, var(--accent)); } +/* Each layer is absolutely stacked — layers crossfade via opacity */ .fs-art { + position: absolute; + inset: 0; width: 100%; height: 100%; object-fit: cover; - display: block; + transition: opacity 300ms ease; } .fs-art-placeholder { - width: 100%; - height: 100%; + position: absolute; + inset: 0; background: rgba(255, 255, 255, 0.06); display: flex; align-items: center; @@ -3212,6 +3236,90 @@ appearance: none; } +/* ── Idle-fade — only the close button hides; cluster + seekbar always visible ── */ +.fs-player[data-idle="true"] .fs-close { + opacity: 0.05; + pointer-events: none; + transition: opacity 900ms ease; +} + +.fs-player[data-idle="false"] .fs-close { + opacity: 1; + pointer-events: auto; + transition: opacity 200ms ease; +} + +/* ── Lyrics overlay — upper-left quadrant, strictly 5 lines tall ── */ +/* Slot = 3.6vh → 5 × 3.6vh = 18vh. JS reads window.innerHeight * 0.036. */ +.fs-lyrics-overlay { + position: absolute; + top: 15vh; /* pushed down into the quadrant, clear of close button */ + left: clamp(28px, 4vw, 64px); + right: 52%; /* stay out of portrait area */ + height: 18vh; /* exactly 5 × 3.6vh — never grows, never overlaps cluster */ + z-index: 3; + overflow: hidden; + pointer-events: none; + + /* Fade first and last slot (each 3.6/18 = 20% of container) */ + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 20%, + #000 80%, + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 20%, + #000 80%, + transparent 100% + ); +} + +/* Synced rail — smooth slide, no abrupt jumps */ +.fs-lyrics-rail { + position: absolute; + top: 0; + left: 0; + right: 0; + will-change: transform; + transition: transform 500ms ease-out; +} + +/* Each lyric slot is exactly 3.6vh — matches JS window.innerHeight * 0.036 */ +.fs-lyric-line { + height: 3.6vh; + display: flex; + align-items: center; + overflow: hidden; + white-space: nowrap; + font-size: 2.2vh; + color: rgba(255, 255, 255, 0.22); + font-weight: 400; + cursor: pointer; + pointer-events: auto; + transition: color 280ms ease, font-weight 280ms ease, text-shadow 280ms ease; + user-select: none; +} + +.fs-lyric-line:hover { + color: rgba(255, 255, 255, 0.5); +} + +.fs-lyric-line.fsl-past { + color: rgba(255, 255, 255, 0.1); +} + +/* Active: emphasis via weight + glow only, height stays 44px */ +.fs-lyric-line.fsl-active { + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6); +} + + /* Chat */ .chat-popup { position: absolute; @@ -3969,6 +4077,7 @@ display: flex; flex-direction: column; gap: 8px; + padding-right: 1.5rem; } .np-bio-text { @@ -3984,8 +4093,25 @@ .np-bio-text.expanded { display: block; - overflow: visible; -webkit-line-clamp: unset; + overflow-y: auto; + max-height: 300px; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--ctp-teal) transparent; +} + +.np-bio-text.expanded::-webkit-scrollbar { + width: 4px; +} + +.np-bio-text.expanded::-webkit-scrollbar-track { + background: transparent; +} + +.np-bio-text.expanded::-webkit-scrollbar-thumb { + background: var(--ctp-teal); + border-radius: 2px; } .np-bio-text a { color: var(--accent); } @@ -4002,6 +4128,19 @@ } .np-bio-toggle:hover { opacity: 1; } +.app-shell[data-mobile] .np-artist-bio-row { + flex-direction: column; +} + +.app-shell[data-mobile] .np-artist-thumb { + width: 56px; + height: 56px; +} + +.app-shell[data-mobile] .np-bio-wrap { + padding-right: 0; +} + /* ── Album tracklist ── */ .np-album-tracklist { display: flex; diff --git a/src/utils/imageCache.ts b/src/utils/imageCache.ts index 85862774..c1fd6628 100644 --- a/src/utils/imageCache.ts +++ b/src/utils/imageCache.ts @@ -191,6 +191,16 @@ export async function invalidateCacheKey(cacheKey: string): Promise { } } +/** + * Invalidates all cached sizes for a given cover art entity (artist, radio, playlist, album). + * Call this after uploading or deleting a cover image so the UI re-fetches from the server. + */ +export async function invalidateCoverArt(entityId: string): Promise { + const serverId = useAuthStore.getState().getActiveServer()?.id ?? '_'; + const sizes = [40, 64, 128, 200, 256, 300, 500, 2000]; + await Promise.all(sizes.map(size => invalidateCacheKey(`${serverId}:cover:${entityId}:${size}`))); +} + /** Clears all entries from IndexedDB and revokes all in-memory object URLs. */ export async function clearImageCache(): Promise { for (const url of objectUrlCache.values()) {