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:
Psychotoxical
2026-04-06 23:00:33 +02:00
parent 390e6e788d
commit d73f348339
17 changed files with 687 additions and 942 deletions
+76 -9
View File
@@ -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>
)}
+4
View File
@@ -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();
}