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
+15 -1
View File
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Disc3, Search, Music4, AudioLines } from 'lucide-react';
import { Disc3, Search, Music4, AudioLines, MoreHorizontal } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import MobileSearchOverlay from './MobileSearchOverlay';
import MobileMoreOverlay from './MobileMoreOverlay';
const NAV_ITEMS = [
{ to: '/', end: true, icon: Disc3, labelKey: 'sidebar.mainstage' },
@@ -16,6 +17,7 @@ export default function BottomNav() {
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const [searchOpen, setSearchOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
return (
<>
@@ -47,9 +49,21 @@ export default function BottomNav() {
</span>
<span className="bottom-nav-label">{t('search.title')}</span>
</button>
<button
className={`bottom-nav-item${moreOpen ? ' active' : ''}`}
onClick={() => setMoreOpen(v => !v)}
aria-label={t('sidebar.more')}
>
<span className="bottom-nav-icon-wrap">
<MoreHorizontal size={22} />
</span>
<span className="bottom-nav-label">{t('sidebar.more')}</span>
</button>
</nav>
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
{moreOpen && <MobileMoreOverlay onClose={() => setMoreOpen(false)} />}
</>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { createPortal } from 'react-dom';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Settings, HardDriveDownload } from 'lucide-react';
import { useSidebarStore } from '../store/sidebarStore';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
export default function MobileMoreOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const sidebarItems = useSidebarStore(s => s.items);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const items = sidebarItems
.filter(cfg => {
if (!cfg?.visible) return false;
const item = ALL_NAV_ITEMS[cfg.id];
if (!item) return false;
if (BOTTOM_NAV_ROUTES.has(item.to)) return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
return createPortal(
<>
<div className="mobile-more-backdrop" onClick={onClose} />
<div className="mobile-more-sheet">
<div className="mobile-more-handle" />
<div className="mobile-more-grid">
{items.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
onClick={onClose}
>
<span className="mobile-more-icon"><item.icon size={24} /></span>
<span className="mobile-more-label">{t(item.labelKey)}</span>
</NavLink>
))}
{hasOfflineContent && (
<NavLink
to="/offline"
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
onClick={onClose}
>
<span className="mobile-more-icon"><HardDriveDownload size={24} /></span>
<span className="mobile-more-label">{t('sidebar.offlineLibrary')}</span>
</NavLink>
)}
<NavLink
to="/settings"
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
onClick={onClose}
>
<span className="mobile-more-icon"><Settings size={24} /></span>
<span className="mobile-more-label">{t('sidebar.settings')}</span>
</NavLink>
</div>
</div>
</>,
document.body
);
}