feat(mobile): comprehensive mobile UI overhaul (#238)

RandomMix: filters and genre mix panels collapse on mobile
- RandomAlbums / album grids: auto-fill favouring 3 columns at narrow widths
- BottomNav: More button opens a bottom sheet with remaining nav items
- MobileMoreOverlay: new sheet component with backdrop and slide-up animation
- Tracklist: mobile Title / Artist two-line stacked layout (Apple Music style)
- ArtistDetail: centred header (photo → name → buttons), icon-only buttons
  except Play All, collapsible Similar Artists (5 default), 2-column album
  grid, avatar glow derived from dominant cover colour
- Settings: fixed overflow in ReplayGain, Crossfade, mix rating filters,
  theme scheduler, and Next Track Buffering sections

Co-authored-by: kilyabin <65072190+kilyabin@users.noreply.github.com>
This commit is contained in:
Frank Stellmacher
2026-04-21 12:10:02 +02:00
committed by GitHub
parent f73cca669b
commit c61bcacd0d
15 changed files with 518 additions and 161 deletions
+59 -22
View File
@@ -4,7 +4,8 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, sear
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, Camera, Loader2 } from 'lucide-react';
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { useIsMobile } from '../hooks/useIsMobile';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
@@ -15,6 +16,7 @@ import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
import { invalidateCoverArt } from '../utils/imageCache';
import { showToast } from '../utils/toast';
import { extractCoverColors } from '../utils/dynamicColors';
import StarRating from '../components/StarRating';
function formatDuration(seconds: number): string {
@@ -62,7 +64,10 @@ export default function ArtistDetail() {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [bioExpanded, setBioExpanded] = useState(false);
const [uploading, setUploading] = useState(false);
const [similarCollapsed, setSimilarCollapsed] = useState(true);
const isMobile = useIsMobile();
const [coverRevision, setCoverRevision] = useState(0);
const [avatarGlow, setAvatarGlow] = useState('');
const imageInputRef = useRef<HTMLInputElement>(null);
const playTrack = usePlayerStore(state => state.playTrack);
@@ -91,6 +96,7 @@ export default function ArtistDetail() {
setInfo(null);
setTopSongs([]);
setFeaturedAlbums([]);
setAvatarGlow('');
getArtist(id).then(artistData => {
if (cancelled) return;
setArtist(artistData.artist);
@@ -449,7 +455,14 @@ export default function ArtistDetail() {
)}
<div className="artist-detail-header">
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
<div
className="artist-detail-avatar"
style={{
position: 'relative',
boxShadow: avatarGlow ? `0 0 36px 8px ${avatarGlow.replace('rgb(', 'rgba(').replace(')', ', 0.55)')}` : undefined,
transition: 'box-shadow 0.6s ease',
}}
>
{coverId ? (
<button
className="artist-detail-avatar-btn"
@@ -462,6 +475,7 @@ export default function ArtistDetail() {
cacheKey={coverArtCacheKey(coverId, 300)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onLoad={e => extractCoverColors(e.currentTarget.src).then(({ accent }) => { if (accent) setAvatarGlow(accent); })}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
</button>
@@ -538,15 +552,25 @@ export default function ArtistDetail() {
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
{t('artistDetail.playAll')}
</button>
<button className="btn btn-surface" onClick={handleShuffle} disabled={playAllLoading}>
<button
className="btn btn-surface"
onClick={handleShuffle}
disabled={playAllLoading}
data-tooltip={isMobile ? t('artistDetail.shuffle') : undefined}
>
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
{t('artistDetail.shuffle')}
{!isMobile && t('artistDetail.shuffle')}
</button>
</>
)}
<button className="btn btn-surface" onClick={handleStartRadio} disabled={radioLoading}>
<button
className="btn btn-surface"
onClick={handleStartRadio}
disabled={radioLoading}
data-tooltip={isMobile ? t('artistDetail.radio') : undefined}
>
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
{!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))}
</button>
{albums.length > 0 && (() => {
const progress = id ? bulkProgress[id] : undefined;
@@ -564,9 +588,9 @@ export default function ArtistDetail() {
{isDownloading
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
{isDownloading
{!isMobile && (isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline'))}
</button>
);
})()}
@@ -665,9 +689,20 @@ export default function ArtistDetail() {
{showSimilarSection && (
<>
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
{t('artistDetail.similarArtists')}
</h2>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '2rem', marginBottom: '1rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>
{t('artistDetail.similarArtists')}
</h2>
{isMobile && (() => {
const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists;
return list.length > 5 ? (
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSimilarCollapsed(v => !v)}>
{similarCollapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
{similarCollapsed ? t('nowPlaying.readMore') : t('nowPlaying.showLess')}
</button>
) : null;
})()}
</div>
{showLastfmSimilar && similarLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
@@ -675,15 +710,17 @@ export default function ArtistDetail() {
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists).map(a => (
<button
key={a.id}
className="artist-ext-link"
onClick={() => navigate(`/artist/${a.id}`)}
>
{a.name}
</button>
))}
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
.map(a => (
<button
key={a.id}
className="artist-ext-link"
onClick={() => navigate(`/artist/${a.id}`)}
>
{a.name}
</button>
))}
</div>
)}
</>
@@ -695,7 +732,7 @@ export default function ArtistDetail() {
</h2>
{albums.length > 0 ? (
<div className="album-grid-wrap">
<div className="album-grid-wrap album-grid-wrap--artist">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
) : (
@@ -715,7 +752,7 @@ export default function ArtistDetail() {
))}
</div>
) : (
<div className="album-grid-wrap" style={{ animation: 'fadeIn 0.3s ease' }}>
<div className="album-grid-wrap album-grid-wrap--artist" style={{ animation: 'fadeIn 0.3s ease' }}>
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
)}