Files
Psychotoxical-psysonic/src/pages/RandomMix.tsx
T
Psychotoxical 623a6a4a54 feat: UI polish, DnD fix, navigation, and installer improvements (v1.0.10)
- Fix queue DnD on macOS/Windows: delay ref cleanup in onDragEnd so onDropQueue
  reads correct source/destination before dragend clears them
- Active track highlighting in album tracklist (pulsing accent bg + play icon)
- Marquee scrolling for long titles in Fullscreen Player
- Clickable artist/album in Player Bar and Queue now-playing strip
- Tracklist: format column moved after duration, auto width, kHz/filesize removed
- Settings dropdowns: visible border (base bg + overlay0 border)
- Sidebar: fixed responsive width via clamp(), removed drag-to-resize
- Favorites icon: HandMetal → Star in Random Mix page
- Linux app menu category set to Multimedia (AudioVideo)
- Windows MSI: stable upgradeCode for in-place upgrades
- Bundle: category/description fields at correct config level
- About: Claude Code credit, fixed German MIT licence wording

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:36:03 +01:00

150 lines
5.9 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { getRandomSongs, SubsonicSong, star, unstar } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play, Star, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function RandomMix() {
const { t } = useTranslation();
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const fetchSongs = () => {
setLoading(true);
getRandomSongs(50)
.then(fetched => {
setSongs(fetched);
const st = new Set<string>();
fetched.forEach(s => { if (s.starred) st.add(s.id); });
setStarredSongs(st);
setLoading(false);
})
.catch(() => setLoading(false));
};
useEffect(() => {
fetchSongs();
}, []);
const handlePlayAll = () => {
if (songs.length > 0) playTrack(songs[0], songs);
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const currentlyStarred = starredSongs.has(song.id);
const nextStarred = new Set(starredSongs);
if (currentlyStarred) nextStarred.delete(song.id);
else nextStarred.add(song.id);
setStarredSongs(nextStarred);
try {
if (currentlyStarred) await unstar(song.id, 'song');
else await star(song.id, 'song');
} catch (err) {
console.error('Failed to toggle song star', err);
setStarredSongs(new Set(starredSongs));
}
};
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1 className="page-title">{t('randomMix.title')}</h1>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
</button>
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || songs.length === 0}>
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
</button>
</div>
</div>
{loading && songs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}>
<span></span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{songs.map((song) => (
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}
onDoubleClick={() => playTrack(song, songs)}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
data-tooltip={t('randomMix.play')}
>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist" data-tooltip={song.artist}>{song.artist}</span>
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={(e) => toggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
</button>
</div>
<span className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</span>
</div>
))}
</div>
)}
</div>
);
}