mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat: v1.34.0 — Mobile UI Early Preview, Russian 2, macOS network fix
Co-authored-by: kilyabin <kilyabin@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+148
-61
@@ -5,6 +5,7 @@ import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -109,6 +110,7 @@ export default function AlbumHeader({
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
@@ -184,72 +186,157 @@ export default function AlbumHeader({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<div className="album-detail-actions-mobile">
|
||||
{/* Row 1 — Primary actions */}
|
||||
<div className="album-actions-row album-actions-row--primary">
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--play"
|
||||
onClick={onPlayAll}
|
||||
aria-label={t('albumDetail.playAll')}
|
||||
data-tooltip={t('albumDetail.playAll')}
|
||||
>
|
||||
<Play size={24} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--queue"
|
||||
onClick={onEnqueueAll}
|
||||
aria-label={t('albumDetail.enqueue')}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — Secondary actions */}
|
||||
<div className="album-actions-row album-actions-row--secondary">
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onBio}
|
||||
aria-label={t('albumDetail.artistBio')}
|
||||
data-tooltip={t('albumDetail.artistBio')}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,18 +2,19 @@ import React, { useRef, useState, useEffect } from 'react';
|
||||
import { SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from './AlbumCard';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
titleLink?: string;
|
||||
albums: SubsonicAlbum[];
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) {
|
||||
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -61,7 +62,13 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
|
||||
return (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
{titleLink ? (
|
||||
<NavLink to={titleLink} className="section-title-link" style={{ marginBottom: 0 }}>
|
||||
{title}<ChevronRight size={18} className="section-title-chevron" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
)}
|
||||
<div className="album-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -96,6 +97,7 @@ export default function AlbumTrackList({
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
@@ -293,6 +295,50 @@ export default function AlbumTrackList({
|
||||
}
|
||||
};
|
||||
|
||||
// ── Mobile tracklist ─────────────────────────────────────────────────────
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="tracklist-mobile">
|
||||
{discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
{isMultiDisc && (
|
||||
<div className="disc-header">
|
||||
<span className="disc-icon">💿</span> CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map(song => {
|
||||
const isActive = currentTrack?.id === song.id;
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
onClick={() => onPlaySong(song)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
<div className="tracklist-mobile-main">
|
||||
{isActive && isPlaying ? (
|
||||
<span className="tracklist-mobile-eq">
|
||||
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
</span>
|
||||
) : (
|
||||
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
|
||||
)}
|
||||
<span className="tracklist-mobile-title">{song.title}</span>
|
||||
</div>
|
||||
<span className="tracklist-mobile-duration">{formatDuration(song.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tracklist" ref={tracklistRef}>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Disc3, Search, Music4, AudioLines } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import MobileSearchOverlay from './MobileSearchOverlay';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', end: true, icon: Disc3, labelKey: 'sidebar.mainstage' },
|
||||
{ to: '/albums', end: false, icon: Music4, labelKey: 'sidebar.allAlbums' },
|
||||
{ to: '/now-playing', end: false, icon: AudioLines, labelKey: 'sidebar.nowPlaying' },
|
||||
] as const;
|
||||
|
||||
export default function BottomNav() {
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="bottom-nav" aria-label="Mobile navigation">
|
||||
{NAV_ITEMS.map(({ to, end, icon: Icon, labelKey }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) => `bottom-nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<span className="bottom-nav-icon-wrap">
|
||||
<Icon size={22} />
|
||||
{to === '/now-playing' && isPlaying && currentTrack && (
|
||||
<span className="bottom-nav-np-dot" />
|
||||
)}
|
||||
</span>
|
||||
<span className="bottom-nav-label">{t(labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="bottom-nav-item"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
aria-label={t('search.title')}
|
||||
>
|
||||
<span className="bottom-nav-icon-wrap">
|
||||
<Search size={22} />
|
||||
</span>
|
||||
<span className="bottom-nav-label">{t('search.title')}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+58
-30
@@ -6,6 +6,7 @@ import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
|
||||
@@ -50,6 +51,7 @@ interface HeroProps {
|
||||
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -120,7 +122,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
<div className="hero-content animate-fade-in" key={album.id}>
|
||||
{coverRawUrl && (
|
||||
{coverRawUrl && !isMobile && (
|
||||
<CachedImage
|
||||
className="hero-cover"
|
||||
src={coverRawUrl}
|
||||
@@ -135,36 +137,62 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
<div className="hero-meta">
|
||||
{album.year && <span className="badge">{album.year}</span>}
|
||||
{album.genre && <span className="badge">{album.genre}</span>}
|
||||
{album.songCount && <span className="badge">{album.songCount} Tracks</span>}
|
||||
{albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="hero-play-btn"
|
||||
id="hero-play-btn"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${t('hero.playAlbum')} ${album.name}`}
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
{t('hero.playAlbum')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) { }
|
||||
}}
|
||||
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
|
||||
data-tooltip={t('hero.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={18} />
|
||||
{t('hero.enqueue')}
|
||||
</button>
|
||||
{!isMobile && album.songCount && <span className="badge">{album.songCount} Tracks</span>}
|
||||
{!isMobile && albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="hero-actions-mobile" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--play"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${t('hero.playAlbum')} ${album.name}`}
|
||||
>
|
||||
<Play size={22} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--queue"
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
|
||||
} catch (_) {}
|
||||
}}
|
||||
aria-label={t('hero.enqueue')}
|
||||
data-tooltip={t('hero.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="hero-play-btn"
|
||||
id="hero-play-btn"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${t('hero.playAlbum')} ${album.name}`}
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
{t('hero.playAlbum')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) {}
|
||||
}}
|
||||
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
|
||||
data-tooltip={t('hero.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={18} />
|
||||
{t('hero.enqueue')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect, CSSProperties } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
||||
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import LyricsPane from './LyricsPane';
|
||||
|
||||
// ── Color extraction ──────────────────────────────────────────────────────────
|
||||
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
|
||||
// medium-dark) pixel. Returns an "R, G, B" string for use in rgba().
|
||||
|
||||
function extractVibrantColor(imageUrl: string): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 16;
|
||||
canvas.height = 16;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { resolve('0,0,0'); return; }
|
||||
ctx.drawImage(img, 0, 0, 16, 16);
|
||||
const { data } = ctx.getImageData(0, 0, 16, 16);
|
||||
let bestR = 0, bestG = 0, bestB = 0, bestScore = -1;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||
const max = Math.max(r, g, b) / 255;
|
||||
const min = Math.min(r, g, b) / 255;
|
||||
const l = (max + min) / 2;
|
||||
const s = max === min ? 0 : (max - min) / (l > 0.5 ? 2 - max - min : max + min);
|
||||
// Prefer saturated pixels in the medium-dark range (l 0.2–0.6)
|
||||
const score = s * (1 - Math.abs(l - 0.4));
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestR = r; bestG = g; bestB = b;
|
||||
}
|
||||
}
|
||||
resolve(`${bestR},${bestG},${bestB}`);
|
||||
};
|
||||
img.onerror = () => resolve('0,0,0');
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function useAlbumAccentColor(imageUrl: string): string {
|
||||
const [color, setColor] = useState('0,0,0');
|
||||
useEffect(() => {
|
||||
if (!imageUrl) { setColor('0,0,0'); return; }
|
||||
let cancelled = false;
|
||||
extractVibrantColor(imageUrl).then(c => { if (!cancelled) setColor(c); });
|
||||
return () => { cancelled = true; };
|
||||
}, [imageUrl]);
|
||||
return color;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Queue Drawer ──────────────────────────────────────────────────────────────
|
||||
|
||||
function QueueDrawer({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll active track into view on open
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector('.mq-item.active');
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mq-drawer-backdrop" onClick={onClose}>
|
||||
<div className="mq-drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="mq-drawer-header">
|
||||
<h3>{t('queue.title')}</h3>
|
||||
<span className="mq-drawer-count">
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')}
|
||||
</span>
|
||||
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mq-drawer-list" ref={listRef}>
|
||||
{queue.length === 0 ? (
|
||||
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isActive = idx === queueIndex;
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
className={`mq-item${isActive ? ' active' : ''}`}
|
||||
onClick={() => { playTrack(track, queue); onClose(); }}
|
||||
>
|
||||
<div className="mq-item-info">
|
||||
<div className="mq-item-title">
|
||||
{isActive && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
|
||||
<span className="truncate">{track.title}</span>
|
||||
</div>
|
||||
<div className="mq-item-artist truncate">{track.artist}</div>
|
||||
</div>
|
||||
<span className="mq-item-dur">{formatTime(track.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lyrics Drawer ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentTrack: Track | null }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mq-drawer-backdrop" onClick={onClose}>
|
||||
<div className="mq-drawer mq-drawer-lyrics" onClick={e => e.stopPropagation()}>
|
||||
<div className="mq-drawer-header">
|
||||
<h3>{t('player.lyrics')}</h3>
|
||||
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mq-drawer-list">
|
||||
<LyricsPane currentTrack={currentTrack} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mobile Player View ────────────────────────────────────────────────────────
|
||||
|
||||
export default function MobilePlayerView() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Lock body scroll while full-screen player is mounted
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Cover art
|
||||
const coverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '',
|
||||
[currentTrack?.coverArt]
|
||||
);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
// Dynamic background color extracted from cover art
|
||||
const accentColor = useAlbumAccentColor(resolvedCover);
|
||||
|
||||
// Star / favorite
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
const nextVal = !isStarred;
|
||||
setStarredOverride(currentTrack.id, nextVal);
|
||||
try {
|
||||
if (nextVal) await star(currentTrack.id, 'song');
|
||||
else await unstar(currentTrack.id, 'song');
|
||||
} catch {
|
||||
setStarredOverride(currentTrack.id, !nextVal);
|
||||
}
|
||||
}, [currentTrack, isStarred, setStarredOverride]);
|
||||
|
||||
// Scrubber touch/mouse drag
|
||||
const scrubberRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const seekFromX = useCallback((clientX: number) => {
|
||||
const el = scrubberRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
seek(pct);
|
||||
}, [seek]);
|
||||
|
||||
const onScrubStart = useCallback((clientX: number) => {
|
||||
isDragging.current = true;
|
||||
seekFromX(clientX);
|
||||
}, [seekFromX]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: TouchEvent | MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
seekFromX(clientX);
|
||||
};
|
||||
const onEnd = () => { isDragging.current = false; };
|
||||
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onEnd);
|
||||
window.addEventListener('touchmove', onMove, { passive: true });
|
||||
window.addEventListener('touchend', onEnd);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onEnd);
|
||||
window.removeEventListener('touchmove', onMove);
|
||||
window.removeEventListener('touchend', onEnd);
|
||||
};
|
||||
}, [seekFromX]);
|
||||
|
||||
// Drawers
|
||||
const [showQueue, setShowQueue] = useState(false);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
// ── Empty state ──
|
||||
if (!currentTrack) {
|
||||
return (
|
||||
<div className="mp-view">
|
||||
<div className="mp-header">
|
||||
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
|
||||
<div style={{ width: 44 }} />
|
||||
</div>
|
||||
<div className="mp-empty">
|
||||
<Music size={56} style={{ opacity: 0.25 }} />
|
||||
<p>{t('nowPlaying.nothingPlaying')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bgStyle: CSSProperties = {
|
||||
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mp-view" style={bgStyle}>
|
||||
{/* Header */}
|
||||
<div className="mp-header">
|
||||
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
|
||||
<div style={{ width: 44 }} />
|
||||
</div>
|
||||
|
||||
{/* Cover Art */}
|
||||
<div className="mp-cover-wrap">
|
||||
{resolvedCover ? (
|
||||
<img src={resolvedCover} alt="" className="mp-cover" />
|
||||
) : (
|
||||
<div className="mp-cover mp-cover-fallback">
|
||||
<Music size={64} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track Metadata */}
|
||||
<div className="mp-meta">
|
||||
<div className="mp-meta-text">
|
||||
<div className="mp-title truncate">{currentTrack.title}</div>
|
||||
<div
|
||||
className="mp-artist truncate"
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
>
|
||||
{currentTrack.artist}
|
||||
</div>
|
||||
{(() => {
|
||||
const parts = [
|
||||
currentTrack.year,
|
||||
currentTrack.genre,
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0
|
||||
? <div className="mp-track-info truncate">{parts.join(' • ')}</div>
|
||||
: null;
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
className={`mp-heart${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={22} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrubber */}
|
||||
<div className="mp-scrubber-wrap">
|
||||
<div
|
||||
className="mp-scrubber"
|
||||
ref={scrubberRef}
|
||||
onMouseDown={e => onScrubStart(e.clientX)}
|
||||
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
|
||||
>
|
||||
<div className="mp-scrubber-bg" />
|
||||
<div className="mp-scrubber-fill" style={{ width: `${progress * 100}%` }} />
|
||||
<div className="mp-scrubber-thumb" style={{ left: `${progress * 100}%` }} />
|
||||
</div>
|
||||
<div className="mp-scrubber-times">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="mp-controls">
|
||||
<button
|
||||
className="mp-ctrl-btn mp-ctrl-sm"
|
||||
onClick={() => shuffleQueue()}
|
||||
aria-label={t('queue.shuffle')}
|
||||
>
|
||||
<Shuffle size={20} />
|
||||
</button>
|
||||
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button className="mp-ctrl-btn mp-ctrl-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
|
||||
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
<button
|
||||
className={`mp-ctrl-btn mp-ctrl-sm`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Utility Footer */}
|
||||
<div className="mp-footer">
|
||||
<button className="mp-footer-btn" onClick={() => setShowLyrics(true)}>
|
||||
<MicVocal size={20} />
|
||||
<span>{t('player.lyrics')}</span>
|
||||
</button>
|
||||
<button className="mp-footer-btn" onClick={() => setShowQueue(true)}>
|
||||
<ListMusic size={20} />
|
||||
<span>{t('queue.title')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue Drawer */}
|
||||
{showQueue && <QueueDrawer onClose={() => setShowQueue(false)} />}
|
||||
|
||||
{/* Lyrics Drawer */}
|
||||
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const STORAGE_KEY = 'psysonic_recent_searches';
|
||||
const MAX_RECENT = 6;
|
||||
|
||||
function loadRecent(): string[] {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
|
||||
function saveRecent(q: string, prev: string[]): string[] {
|
||||
const updated = [q.trim(), ...prev.filter(s => s !== q.trim())].slice(0, MAX_RECENT);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
|
||||
}
|
||||
|
||||
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
const doSearch = useCallback(
|
||||
debounce(async (q: string) => {
|
||||
if (!q.trim()) { setResults(null); setLoading(false); return; }
|
||||
setLoading(true);
|
||||
try { setResults(await search(q)); }
|
||||
finally { setLoading(false); }
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => { doSearch(query); }, [query, doSearch]);
|
||||
|
||||
const commit = (q: string) => {
|
||||
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
||||
};
|
||||
|
||||
const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
|
||||
const goCategory = (path: string) => { navigate(path); onClose(); };
|
||||
const playSong = (song: SearchResults['songs'][number]) => {
|
||||
commit(query);
|
||||
playTrack(songToTrack(song));
|
||||
onClose();
|
||||
};
|
||||
const useRecent = (term: string) => {
|
||||
setQuery(term);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
const removeRecent = (term: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setRecentSearches(prev => {
|
||||
const updated = prev.filter(s => s !== term);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
const showEmpty = !query;
|
||||
|
||||
return createPortal(
|
||||
<div className="mobile-search-overlay">
|
||||
{/* ── Search bar ── */}
|
||||
<div className="mobile-search-bar">
|
||||
<div className="mobile-search-field">
|
||||
{loading ? (
|
||||
<div className="mobile-search-spinner" />
|
||||
) : (
|
||||
<Search size={16} className="mobile-search-icon" />
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="mobile-search-input"
|
||||
type="search"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="mobile-search-clear"
|
||||
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
|
||||
aria-label={t('search.clearLabel')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="mobile-search-cancel" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mobile-search-results">
|
||||
{/* ── Empty state ── */}
|
||||
{showEmpty && (
|
||||
<div className="mobile-search-empty-state">
|
||||
{recentSearches.length > 0 && (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
|
||||
{recentSearches.map(term => (
|
||||
<button key={term} className="mobile-search-item" onClick={() => useRecent(term)}>
|
||||
<div className="mobile-search-avatar">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<div className="mobile-search-item-info" style={{ flex: 1 }}>
|
||||
<span className="mobile-search-item-title">{term}</span>
|
||||
</div>
|
||||
<button
|
||||
className="mobile-search-recent-remove"
|
||||
onClick={e => removeRecent(term, e)}
|
||||
aria-label={t('search.clearLabel')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.browse')}</div>
|
||||
<div className="mobile-search-chips">
|
||||
<button className="mobile-search-chip" onClick={() => goCategory('/albums')}>
|
||||
<Music2 size={15} /> {t('search.albums')}
|
||||
</button>
|
||||
<button className="mobile-search-chip" onClick={() => goCategory('/artists')}>
|
||||
<Users size={15} /> {t('search.artists')}
|
||||
</button>
|
||||
<button className="mobile-search-chip" onClick={() => goCategory('/genres')}>
|
||||
<Music size={15} /> {t('search.genres')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mobile-search-hint">
|
||||
<Search size={52} className="mobile-search-hint-icon" />
|
||||
<span className="mobile-search-hint-text">{t('search.emptyHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── No results ── */}
|
||||
{!loading && query && !hasResults && (
|
||||
<div className="mobile-search-noresults">
|
||||
{t('search.noResults', { query })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Results ── */}
|
||||
{hasResults && (
|
||||
<>
|
||||
{results!.artists.length > 0 && (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.artists')}</div>
|
||||
{results!.artists.map(a => (
|
||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/artist/${a.id}`)}>
|
||||
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div className="mobile-search-item-info">
|
||||
<span className="mobile-search-item-title">{a.name}</span>
|
||||
<span className="mobile-search-item-sub">{t('search.artists')}</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="mobile-search-item-chevron" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results!.albums.length > 0 && (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.albums')}</div>
|
||||
{results!.albums.map(a => (
|
||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
||||
{a.coverArt ? (
|
||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(a.coverArt, 80)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="mobile-search-avatar">
|
||||
<Disc3 size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile-search-item-info">
|
||||
<span className="mobile-search-item-title">{a.name}</span>
|
||||
<span className="mobile-search-item-sub">{a.artist}</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="mobile-search-item-chevron" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results!.songs.length > 0 && (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.songs')}</div>
|
||||
{results!.songs.map(s => (
|
||||
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}>
|
||||
{s.coverArt ? (
|
||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(s.coverArt, 80)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="mobile-search-avatar">
|
||||
<Music size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile-search-item-info">
|
||||
<span className="mobile-search-item-title">{s.title}</span>
|
||||
<span className="mobile-search-item-sub">{s.artist}{s.album ? ` · ${s.album}` : ''}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user