mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: v1.34.1 — fullscreen lyrics overlay, FsArt crossfade, lyrics toggle, contributor updates
- FullscreenPlayer: synced lyrics overlay (5-line rail, mask fade, click-to-seek) - FullscreenPlayer: FsArt crossfade component with onLoad pattern, 300px art, next-track pre-fetch - FullscreenPlayer: MicVocal lyrics toggle button next to heart (persisted in authStore) - FullscreenPlayer: idle system — only close button auto-hides, cluster+seekbar always visible - LyricsPane: refactored to use shared useLyrics hook (no double-fetch with FS overlay) - authStore: showFullscreenLyrics field (default true) - i18n: fsLyricsToggle key added to all 7 locales; ru2.ts removed (merged into ru.ts) - Settings: Contributors updated (nisrael, cucadmuh, kilyabin); logout moved to Server tab as btn-danger - theme.css: btn-danger style added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -606,3 +606,40 @@ export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStati
|
||||
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
|
||||
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<SubsonicStructuredLyrics | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LrcLine[]>([]);
|
||||
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<HTMLDivElement>) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>('[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 (
|
||||
<div className="fs-lyrics-overlay" aria-hidden="true">
|
||||
<div
|
||||
className="fs-lyrics-rail"
|
||||
style={{ transform: `translateY(${railY}px)` }}
|
||||
onClick={handleLineClick}
|
||||
>
|
||||
{lines!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 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<Array<{ src: string; id: number; vis: boolean }>>([]);
|
||||
const counter = useRef(0);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | 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 <img> 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 <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{layers.map(l => (
|
||||
<img
|
||||
key={l.id}
|
||||
src={l.src}
|
||||
className="fs-art"
|
||||
style={{ opacity: l.vis ? 1 : 0 }}
|
||||
onLoad={() => 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<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counterRef = useRef(1);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
<div
|
||||
className="fs-player"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
<div className="fs-mesh-bg" aria-hidden="true">
|
||||
@@ -197,21 +381,15 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Lyrics overlay — upper-left quadrant, above cluster */}
|
||||
{showFullscreenLyrics && <FsLyrics currentTrack={currentTrack} />}
|
||||
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
|
||||
{/* Album art */}
|
||||
<div className="fs-art-wrap">
|
||||
{coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverKey}
|
||||
alt={`${currentTrack?.album} Cover`}
|
||||
className="fs-art"
|
||||
/>
|
||||
) : (
|
||||
<div className="fs-art fs-art-placeholder"><Music size={40} /></div>
|
||||
)}
|
||||
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
|
||||
</div>
|
||||
|
||||
{/* Artist — massive statement */}
|
||||
@@ -262,6 +440,15 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="fs-btn fs-btn-sm"
|
||||
onClick={() => useAuthStore.getState().setShowFullscreenLyrics(!showFullscreenLyrics)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
data-tooltip={t('player.fsLyricsToggle')}
|
||||
style={{ color: showFullscreenLyrics ? 'var(--accent)' : 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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<string, CachedLyrics>();
|
||||
|
||||
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<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
const [plainLyrics, setPlainLyrics] = useState<string | null>(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 (
|
||||
<div className="lyrics-pane">
|
||||
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
|
||||
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
|
||||
{hasSynced && (
|
||||
<div className="lyrics-synced">
|
||||
{syncedLines!.map((line, i) => (
|
||||
{(syncedLines as LrcLine[]).map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { lineRefs.current[i] = el; }}
|
||||
@@ -138,6 +85,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sourceLabel && !loading && !notFound && (
|
||||
<p className="lyrics-source">{sourceLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, CachedLyrics>();
|
||||
|
||||
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
|
||||
export function parseStructuredLyrics(
|
||||
lyrics: SubsonicStructuredLyrics,
|
||||
): Pick<CachedLyrics, 'syncedLines' | 'plainLyrics'> {
|
||||
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<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
const [plainLyrics, setPlainLyrics] = useState<string | null>(cached?.plainLyrics ?? null);
|
||||
const [source, setSource] = useState<LyricsSource | null>(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<boolean> => {
|
||||
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<boolean> => {
|
||||
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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+5
-1
@@ -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',
|
||||
|
||||
+5
-1
@@ -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',
|
||||
|
||||
+5
-1
@@ -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',
|
||||
|
||||
+5
-1
@@ -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',
|
||||
|
||||
+5
-1
@@ -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',
|
||||
|
||||
@@ -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: 'Избранных станций нет.',
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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: '歌曲信息',
|
||||
|
||||
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
@@ -273,7 +305,7 @@ export default function ArtistDetail() {
|
||||
)}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{coverId ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
@@ -281,6 +313,7 @@ export default function ArtistDetail() {
|
||||
aria-label={`${artist.name} Bild vergrößern`}
|
||||
>
|
||||
<CachedImage
|
||||
key={coverRevision}
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
alt={artist.name}
|
||||
@@ -291,6 +324,22 @@ export default function ArtistDetail() {
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" />
|
||||
)}
|
||||
{/* Upload overlay */}
|
||||
<div
|
||||
className="artist-avatar-upload-overlay"
|
||||
onClick={e => { e.stopPropagation(); imageInputRef.current?.click(); }}
|
||||
>
|
||||
{uploading
|
||||
? <Loader2 size={22} className="spin-slow" />
|
||||
: <Camera size={22} />}
|
||||
</div>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-meta">
|
||||
@@ -373,11 +422,29 @@ export default function ArtistDetail() {
|
||||
|
||||
{/* Biography — sanitized HTML from server */}
|
||||
{info?.biography && (
|
||||
<div className="artist-bio-section">
|
||||
<div
|
||||
className="artist-bio-text"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
|
||||
/>
|
||||
<div className="np-info-card artist-bio-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
||||
</div>
|
||||
<div className="np-artist-bio-row">
|
||||
{(info.largeImageUrl || coverId) && (
|
||||
<img
|
||||
src={info.largeImageUrl || buildCoverArtUrl(coverId, 80)}
|
||||
alt={artist.name}
|
||||
className="np-artist-thumb"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div className="np-bio-wrap">
|
||||
<div
|
||||
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
|
||||
/>
|
||||
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
||||
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<AuthState>()(
|
||||
minimizeToTray: false,
|
||||
discordRichPresence: false,
|
||||
nowPlayingEnabled: false,
|
||||
lyricsServerFirst: true,
|
||||
showFullscreenLyrics: true,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
isLoggedIn: false,
|
||||
@@ -196,6 +202,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
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 }),
|
||||
|
||||
|
||||
+168
-29
@@ -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;
|
||||
|
||||
@@ -191,6 +191,16 @@ export async function invalidateCacheKey(cacheKey: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
for (const url of objectUrlCache.values()) {
|
||||
|
||||
Reference in New Issue
Block a user