chore: prepare release v1.0.1 and ignore CLAUDE.md

This commit is contained in:
Psychotoxical
2026-03-11 21:56:26 +01:00
parent 730eb877ea
commit 6456b3e561
35 changed files with 1355 additions and 740 deletions
+4 -2
View File
@@ -3,6 +3,7 @@ import { SubsonicAlbum } from '../api/subsonic';
import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
@@ -13,6 +14,7 @@ interface Props {
}
export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
@@ -86,7 +88,7 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<div className="spinner" style={{ width: 24, height: 24 }} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>Lädt...</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
@@ -94,7 +96,7 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText || 'Alle ansehen'}</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
</div>
)}
</div>
+4 -2
View File
@@ -3,6 +3,7 @@ import { SubsonicArtist } from '../api/subsonic';
import ArtistCardLocal from './ArtistCardLocal';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
@@ -13,6 +14,7 @@ interface Props {
}
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
@@ -86,7 +88,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<div className="spinner" style={{ width: 24, height: 24 }} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>Lädt...</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
@@ -94,7 +96,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText || 'Alle ansehen'}</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
</div>
)}
</div>
+125 -137
View File
@@ -7,13 +7,23 @@ import { useAuthStore } from '../store/authStore';
import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { useTranslation } from 'react-i18next';
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
const auth = useAuthStore();
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
// Adjusted coordinates to keep menu on screen
const [coords, setCoords] = useState({ x: 0, y: 0 });
@@ -28,37 +38,14 @@ export default function ContextMenu() {
const rect = menuRef.current.getBoundingClientRect();
const winW = window.innerWidth;
const winH = window.innerHeight;
let finalX = contextMenu.x;
let finalY = contextMenu.y;
if (finalX + rect.width > winW) finalX = winW - rect.width - 10;
if (finalY + rect.height > winH) finalY = winH - rect.height - 10;
setCoords({ x: finalX, y: finalY });
}
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeContextMenu();
}
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu();
};
if (contextMenu.isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEsc);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEsc);
};
}, [contextMenu.isOpen, closeContextMenu]);
if (!contextMenu.isOpen || !contextMenu.item) return null;
const { type, item, queueIndex } = contextMenu;
@@ -91,139 +78,140 @@ export default function ContextMenu() {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
const path = await join(auth.downloadFolder, `${albumName}.zip`);
const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} else {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${albumName}.zip`;
a.download = `${sanitizeFilename(albumName)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
} catch (e) {
console.error('Download fehlgeschlagen:', e);
console.error('Download failed:', e);
}
};
return (
<div
ref={menuRef}
className="context-menu animate-fade-in"
style={{ left: coords.x, top: coords.y }}
>
{(type === 'song' || type === 'album-song') && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
<Play size={14} /> Direkt abspielen
</div>
<div className="context-menu-item" onClick={() => handleAction(() => {
if (!currentTrack) {
playTrack(song, [song]);
return;
}
const currentIdx = usePlayerStore.getState().queueIndex;
const newQueue = [...queue];
newQueue.splice(currentIdx + 1, 0, song);
usePlayerStore.setState({ queue: newQueue });
})}>
<ChevronRight size={14} /> Als Nächstes abspielen
</div>
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> Zur Warteschlange hinzufügen
</div>
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(song.albumId);
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
})}>
<ListPlus size={14} /> Ganzes Album einreihen
<>
{/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 998 }}
onMouseDown={() => closeContextMenu()}
/>
<div
ref={menuRef}
className="context-menu animate-fade-in"
style={{ left: coords.x, top: coords.y, zIndex: 999 }}
>
{(type === 'song' || type === 'album-song') && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> Song-Radio starten
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> Favorisieren
</div>
</>
);
})()}
<div className="context-menu-item" onClick={() => handleAction(() => {
if (!currentTrack) {
playTrack(song, [song]);
return;
}
const currentIdx = usePlayerStore.getState().queueIndex;
const newQueue = [...queue];
newQueue.splice(currentIdx + 1, 0, song);
usePlayerStore.setState({ queue: newQueue });
})}>
<ChevronRight size={14} /> {t('contextMenu.playNext')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(song.albumId);
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
</div>
</>
);
})()}
{type === 'album' && (() => {
const album = item as SubsonicAlbum;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => {
// we don't have tracks here immediately, so we'd navigate or fetch. For now, navigate.
navigate(`/album/${album.id}`);
})}>
<Play size={14} /> Album öffnen
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> Zum Künstler
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
<Star size={14} /> Album favorisieren
</div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> Herunterladen (ZIP)
</div>
</>
);
})()}
{type === 'album' && (() => {
const album = item as SubsonicAlbum;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
<Play size={14} /> {t('contextMenu.openAlbum')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
</div>
</>
);
})()}
{type === 'artist' && (() => {
const artist = item as SubsonicArtist;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
<Radio size={14} /> Künstler-Radio starten
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
<Star size={14} /> Künstler favorisieren
</div>
</>
);
})()}
{type === 'artist' && (() => {
const artist = item as SubsonicArtist;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
<Star size={14} /> {t('contextMenu.favoriteArtist')}
</div>
</>
);
})()}
{type === 'queue-item' && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue))}>
<Play size={14} /> Direkt abspielen
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
if (queueIndex !== undefined) removeTrack(queueIndex);
})}>
Diesen Song entfernen
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> Song-Radio starten
</div>
</>
);
})()}
</div>
{type === 'queue-item' && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
if (queueIndex !== undefined) removeTrack(queueIndex);
})}>
{t('contextMenu.removeFromQueue')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
</>
);
})()}
</div>
</>
);
}
+11 -8
View File
@@ -5,6 +5,7 @@ import {
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -71,7 +72,7 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
value={progress}
onChange={handleSeek}
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
aria-label="Songfortschritt"
aria-label="progress"
/>
</div>
<span className="fs-time">{formatTime(duration)}</span>
@@ -84,10 +85,11 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
// ─── Isolated play/pause button (subscribes to isPlaying only) ───
const FsPlayBtn = memo(function FsPlayBtn() {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const togglePlay = usePlayerStore(s => s.togglePlay);
return (
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}>
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={36} /> : <Play size={36} fill="currentColor" />}
</button>
);
@@ -98,6 +100,7 @@ interface FullscreenPlayerProps {
}
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
// Static/slow-changing state only — does NOT subscribe to progress or currentTime
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
@@ -121,13 +124,13 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
}, [onClose]);
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label="Vollbild-Player">
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Crossfading blurred background */}
<FsBg url={coverUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Close button */}
<button className="fs-close" onClick={onClose} aria-label="Vollbild schließen" data-tooltip="Schließen (Esc)">
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')} data-tooltip={t('player.closeTooltip')}>
<ChevronDown size={28} />
</button>
@@ -148,7 +151,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
{/* Right column: upcoming tracks */}
{upcoming.length > 0 && (
<div className="fs-right">
<h2 className="fs-upcoming-title">Nächste Titel</h2>
<h2 className="fs-upcoming-title">{t('queue.nextTracks')}</h2>
<div className="fs-upcoming-list">
{upcoming.map((track, i) => (
<button
@@ -196,17 +199,17 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
<Square size={20} fill="currentColor" />
</button>
<button className="fs-btn" onClick={previous} aria-label="Vorheriger Titel">
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
<SkipBack size={28} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={next} aria-label="Nächster Titel">
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
<SkipForward size={28} />
</button>
<button
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
onClick={toggleRepeat}
data-tooltip={`Wiederholen: ${repeatMode === 'off' ? 'Aus' : repeatMode === 'all' ? 'Alle' : 'Einen'}`}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
</button>
+8 -6
View File
@@ -3,8 +3,10 @@ import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react';
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, getAlbum } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
export default function Hero() {
const { t } = useTranslation();
const [album, setAlbum] = useState<SubsonicAlbum | null>(null);
const navigate = useNavigate();
@@ -24,7 +26,7 @@ export default function Hero() {
<div
className="hero"
role="banner"
aria-label="Album des Augenblicks"
aria-label={t('hero.eyebrow')}
onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }}
>
@@ -41,7 +43,7 @@ export default function Hero() {
<img className="hero-cover" src={coverUrl} alt={`${album.name} Cover`} />
)}
<div className="hero-text">
<span className="hero-eyebrow">Album des Augenblicks</span>
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
<h2 className="hero-title">{album.name}</h2>
<p className="hero-artist">{album.artist}</p>
<div className="hero-meta">
@@ -54,10 +56,10 @@ export default function Hero() {
className="hero-play-btn"
id="hero-play-btn"
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
aria-label={`Album ${album.name} abspielen`}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={18} fill="currentColor" />
Album abspielen
{t('hero.playAlbum')}
</button>
<button
className="btn btn-surface"
@@ -74,10 +76,10 @@ export default function Hero() {
} catch (err) { }
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip="Ganzes Album zur Warteschlange hinzufügen"
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={18} />
Einreihen
{t('hero.enqueue')}
</button>
</div>
</div>
+10 -8
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
@@ -13,6 +14,7 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
}
export default function LiveSearch() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults | null>(null);
const [open, setOpen] = useState(false);
@@ -63,7 +65,7 @@ export default function LiveSearch() {
id="live-search-input"
className="input live-search-field"
type="search"
placeholder="Suchen nach Künstler, Album oder Song…"
placeholder={t('search.placeholder')}
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => results && setOpen(true)}
@@ -73,7 +75,7 @@ export default function LiveSearch() {
autoComplete="off"
/>
{query && (
<button className="live-search-clear" onClick={() => { setQuery(''); setResults(null); setOpen(false); }} aria-label="Suche leeren">
<button className="live-search-clear" onClick={() => { setQuery(''); setResults(null); setOpen(false); }} aria-label={t('search.clearLabel')}>
×
</button>
)}
@@ -82,12 +84,12 @@ export default function LiveSearch() {
{open && (
<div className="live-search-dropdown" id="search-results" role="listbox">
{!hasResults && !loading && (
<div className="search-empty">Keine Ergebnisse für {query}"</div>
<div className="search-empty">{t('search.noResults', { query })}</div>
)}
{results?.artists.length ? (
<div className="search-section">
<div className="search-section-label"><Users size={12} /> Künstler</div>
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => (
<button
key={a.id}
@@ -104,7 +106,7 @@ export default function LiveSearch() {
{results?.albums.length ? (
<div className="search-section">
<div className="search-section-label"><Disc3 size={12} /> Alben</div>
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => (
<button
key={a.id}
@@ -128,14 +130,14 @@ export default function LiveSearch() {
{results?.songs.length ? (
<div className="search-section">
<div className="search-section-label"><Music size={12} /> Songs</div>
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => (
<button
key={s.id}
className="search-result-item"
onClick={() => {
playTrack({
id: s.id, title: s.title, artist: s.artist, album: s.album,
playTrack({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating
});
+19 -18
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlayCircle, User, Radio, RefreshCw } from 'lucide-react';
import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
export default function NowPlayingDropdown() {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
const [loading, setLoading] = useState(false);
@@ -20,7 +22,6 @@ export default function NowPlayingDropdown() {
}
};
// Fetch when the dropdown is opened
useEffect(() => {
if (isOpen) {
fetchNowPlaying();
@@ -40,23 +41,23 @@ export default function NowPlayingDropdown() {
return (
<div className="now-playing-dropdown" ref={dropdownRef} style={{ position: 'relative' }}>
<button
className="btn btn-surface"
<button
className="btn btn-surface"
onClick={() => setIsOpen(!isOpen)}
data-tooltip="Wer hört was?"
data-tooltip={t('nowPlaying.tooltip')}
data-tooltip-pos="bottom"
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
>
<Radio size={18} className={nowPlaying.length > 0 ? 'animate-pulse' : ''} style={{ color: nowPlaying.length > 0 ? 'var(--accent)' : 'inherit' }} />
<span>Live</span>
{nowPlaying.length > 0 && (
<span style={{
background: 'var(--accent)',
color: 'var(--ctp-crust)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
borderRadius: '10px'
<span style={{
background: 'var(--accent)',
color: 'var(--ctp-crust)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
borderRadius: '10px'
}}>
{nowPlaying.length}
</span>
@@ -64,7 +65,7 @@ export default function NowPlayingDropdown() {
</button>
{isOpen && (
<div
<div
className="glass animate-fade-in"
style={{
position: 'absolute',
@@ -83,9 +84,9 @@ export default function NowPlayingDropdown() {
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-subtle)', paddingBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>Wer hört was?</h3>
<button
onClick={fetchNowPlaying}
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>{t('nowPlaying.title')}</h3>
<button
onClick={fetchNowPlaying}
className={`btn btn-ghost ${loading ? 'animate-spin' : ''}`}
style={{ width: '28px', height: '28px', padding: 0 }}
>
@@ -95,11 +96,11 @@ export default function NowPlayingDropdown() {
{loading && nowPlaying.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
Lädt...
{t('nowPlaying.loading')}
</div>
) : nowPlaying.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
Gerade hört niemand Musik.
{t('nowPlaying.nobody')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
@@ -118,7 +119,7 @@ export default function NowPlayingDropdown() {
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', fontSize: '11px', color: 'var(--text-muted)' }}>
<User size={10} />
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
{stream.minutesAgo > 0 && <span> vor {stream.minutesAgo}m</span>}
{stream.minutesAgo > 0 && <span> {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>}
</div>
</div>
</div>