mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
+30
-17
@@ -8,6 +8,9 @@ import { PanelRight, PanelRightClose } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import PlayerBar from './components/PlayerBar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import MobilePlayerView from './components/MobilePlayerView';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import LiveSearch from './components/LiveSearch';
|
||||
import NowPlayingDropdown from './components/NowPlayingDropdown';
|
||||
import QueuePanel from './components/QueuePanel';
|
||||
@@ -66,6 +69,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
@@ -219,19 +223,25 @@ function AppShell() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
data-mobile={isMobile || undefined}
|
||||
data-mobile-player={isMobilePlayer || undefined}
|
||||
style={{
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
|
||||
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
|
||||
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
|
||||
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
)}
|
||||
<main className="main-content">
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
@@ -273,7 +283,7 @@ function AppShell() {
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/now-playing" element={<NowPlayingPage />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
@@ -285,16 +295,19 @@ function AppShell() {
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
className="resizer resizer-queue"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingQueue(true);
|
||||
}}
|
||||
style={{ display: isQueueVisible ? 'block' : 'none' }}
|
||||
/>
|
||||
<QueuePanel />
|
||||
<PlayerBar />
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="resizer resizer-queue"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingQueue(true);
|
||||
}}
|
||||
style={{ display: isQueueVisible ? 'block' : 'none' }}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && <QueuePanel />}
|
||||
{isMobile && !isMobilePlayer && <BottomNav />}
|
||||
{!isMobilePlayer && <PlayerBar />}
|
||||
{isFullscreenOpen && (
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
|
||||
+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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 800;
|
||||
const query = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||
|
||||
let mql: MediaQueryList | null = null;
|
||||
|
||||
function getMql(): MediaQueryList {
|
||||
if (!mql) mql = window.matchMedia(query);
|
||||
return mql;
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
const m = getMql();
|
||||
m.addEventListener('change', cb);
|
||||
return () => m.removeEventListener('change', cb);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return getMql().matches;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when the viewport width is below 800px.
|
||||
* Updates in real-time on resize via `matchMedia`.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { frTranslation } from './locales/fr';
|
||||
import { nbTranslation } from './locales/nb';
|
||||
import { nlTranslation } from './locales/nl';
|
||||
import { ruTranslation } from './locales/ru';
|
||||
import { ru2Translation } from './locales/ru2';
|
||||
import { zhTranslation } from './locales/zh';
|
||||
|
||||
const savedLanguage = localStorage.getItem('psysonic_language') || 'en';
|
||||
@@ -21,6 +22,7 @@ i18n
|
||||
zh: { translation: zhTranslation },
|
||||
nb: { translation: nbTranslation },
|
||||
ru: { translation: ruTranslation },
|
||||
ru2: { translation: ru2Translation },
|
||||
},
|
||||
lng: savedLanguage,
|
||||
fallbackLng: 'en',
|
||||
|
||||
@@ -65,6 +65,10 @@ export const deTranslation = {
|
||||
advancedEmpty: 'Suchbegriff eingeben oder Filter wählen, um zu beginnen.',
|
||||
advancedNoResults: 'Keine Ergebnisse gefunden.',
|
||||
advancedGenreNote: 'Songs werden zufällig aus dem Genre gewählt.',
|
||||
recentSearches: 'Zuletzt gesucht',
|
||||
browse: 'Stöbern',
|
||||
emptyHint: 'Was möchtest du hören?',
|
||||
genres: 'Genres',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Wer hört was?',
|
||||
@@ -341,6 +345,7 @@ export const deTranslation = {
|
||||
languageZh: 'Chinesisch',
|
||||
languageNb: 'Norwegisch',
|
||||
languageRu: 'Russisch',
|
||||
languageRu2: 'Russisch 2',
|
||||
font: 'Schriftart',
|
||||
theme: 'Design',
|
||||
appearance: 'Darstellung',
|
||||
|
||||
@@ -65,6 +65,10 @@ export const enTranslation = {
|
||||
advancedEmpty: 'Enter a search term or select a filter to begin.',
|
||||
advancedNoResults: 'No results found.',
|
||||
advancedGenreNote: 'Songs are randomly selected from this genre.',
|
||||
recentSearches: 'Recent Searches',
|
||||
browse: 'Browse',
|
||||
emptyHint: 'What do you want to hear?',
|
||||
genres: 'Genres',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Who is listening?',
|
||||
@@ -341,6 +345,7 @@ export const enTranslation = {
|
||||
languageZh: 'Chinese',
|
||||
languageNb: 'Norwegian',
|
||||
languageRu: 'Russian',
|
||||
languageRu2: 'Russian 2',
|
||||
font: 'Font',
|
||||
theme: 'Theme',
|
||||
appearance: 'Appearance',
|
||||
|
||||
@@ -65,6 +65,10 @@ export const frTranslation = {
|
||||
advancedEmpty: 'Entrez un terme de recherche ou sélectionnez un filtre.',
|
||||
advancedNoResults: 'Aucun résultat trouvé.',
|
||||
advancedGenreNote: 'Les morceaux sont sélectionnés aléatoirement dans ce genre.',
|
||||
recentSearches: 'Recherches récentes',
|
||||
browse: 'Parcourir',
|
||||
emptyHint: 'Que veux-tu écouter ?',
|
||||
genres: 'Genres',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Qui écoute ?',
|
||||
@@ -341,6 +345,7 @@ export const frTranslation = {
|
||||
languageZh: 'Chinois',
|
||||
languageNb: 'Norvégien',
|
||||
languageRu: 'Russe',
|
||||
languageRu2: 'Russe 2',
|
||||
font: 'Police',
|
||||
theme: 'Thème',
|
||||
appearance: 'Apparence',
|
||||
|
||||
@@ -65,6 +65,10 @@ export const nbTranslation = {
|
||||
advancedEmpty: 'Skriv inn et søkeord eller velg ett filter for å begynne.',
|
||||
advancedNoResults: 'Ingen resultater funnet.',
|
||||
advancedGenreNote: 'Sanger er tilfeldig valgt fra denne sjangeren.',
|
||||
recentSearches: 'Siste søk',
|
||||
browse: 'Utforsk',
|
||||
emptyHint: 'Hva vil du høre?',
|
||||
genres: 'Sjangre',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Hvem lytter?',
|
||||
@@ -341,6 +345,7 @@ export const nbTranslation = {
|
||||
languageZh: 'Kinesisk',
|
||||
languageNb: 'Norsk',
|
||||
languageRu: 'Russisk',
|
||||
languageRu2: 'Russisk 2',
|
||||
font: 'Skrifttype',
|
||||
theme: 'Tema',
|
||||
appearance: 'Utseende',
|
||||
|
||||
@@ -65,6 +65,10 @@ export const nlTranslation = {
|
||||
advancedEmpty: 'Voer een zoekterm in of selecteer een filter.',
|
||||
advancedNoResults: 'Geen resultaten gevonden.',
|
||||
advancedGenreNote: 'Nummers worden willekeurig uit dit genre geselecteerd.',
|
||||
recentSearches: 'Recente zoekopdrachten',
|
||||
browse: 'Bladeren',
|
||||
emptyHint: 'Wat wil je horen?',
|
||||
genres: 'Genres',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Wie luistert er?',
|
||||
@@ -341,6 +345,7 @@ export const nlTranslation = {
|
||||
languageZh: 'Chinees',
|
||||
languageNb: 'Noors',
|
||||
languageRu: 'Russisch',
|
||||
languageRu2: 'Russisch 2',
|
||||
font: 'Lettertype',
|
||||
theme: 'Thema',
|
||||
appearance: 'Weergave',
|
||||
|
||||
@@ -66,6 +66,10 @@ export const ruTranslation = {
|
||||
advancedEmpty: 'Введите запрос или выберите фильтр.',
|
||||
advancedNoResults: 'Ничего не найдено.',
|
||||
advancedGenreNote: 'Треки выбираются случайно в рамках жанра.',
|
||||
recentSearches: 'Недавние запросы',
|
||||
browse: 'Обзор',
|
||||
emptyHint: 'Что хочешь послушать?',
|
||||
genres: 'Жанры',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Кто сейчас слушает?',
|
||||
@@ -355,6 +359,7 @@ export const ruTranslation = {
|
||||
languageZh: 'Китайский',
|
||||
languageNb: 'Норвежский',
|
||||
languageRu: 'Русский',
|
||||
languageRu2: 'Русский 2',
|
||||
font: 'Шрифт',
|
||||
theme: 'Тема',
|
||||
appearance: 'Оформление',
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
/** Russian UI strings (alternative — kilyabin, PR #107) */
|
||||
export const ru2Translation = {
|
||||
sidebar: {
|
||||
library: 'Библиотека',
|
||||
mainstage: 'Главная',
|
||||
newReleases: 'Новинки',
|
||||
allAlbums: 'Все альбомы',
|
||||
randomAlbums: 'Случайные альбомы',
|
||||
artists: 'Исполнители',
|
||||
randomMix: 'Случайный микс',
|
||||
favorites: 'Избранное',
|
||||
nowPlaying: 'Сейчас играет',
|
||||
system: 'Система',
|
||||
statistics: 'Статистика',
|
||||
settings: 'Настройки',
|
||||
help: 'Помощь',
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
updateAvailable: 'Доступно обновление',
|
||||
updateReady: '{{version}} готово',
|
||||
updateLink: 'Перейти к релизу →',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
offlineLibrary: 'Офлайн библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
radio: 'Интернет радио',
|
||||
},
|
||||
home: {
|
||||
hero: 'Рекомендации',
|
||||
starred: 'Личные избранные',
|
||||
recent: 'Недавно добавленные',
|
||||
mostPlayed: 'Самые прослушиваемые',
|
||||
recentlyPlayed: 'Недавно прослушанные',
|
||||
discover: 'Открыть',
|
||||
loadMore: 'Загрузить еще',
|
||||
discoverMore: 'Открыть больше',
|
||||
discoverArtists: 'Открыть исполнителей',
|
||||
discoverArtistsMore: 'Все исполнители'
|
||||
},
|
||||
hero: {
|
||||
eyebrow: 'Рекомендуемый альбом',
|
||||
playAlbum: 'Воспроизвести альбом',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Поиск исполнителя, альбома или песни…',
|
||||
noResults: 'Нет результатов для "{{query}}"',
|
||||
artists: 'Исполнители',
|
||||
albums: 'Альбомы',
|
||||
songs: 'Песни',
|
||||
clearLabel: 'Очистить поиск',
|
||||
title: 'Поиск',
|
||||
resultsFor: 'Результаты для "{{query}}"',
|
||||
album: 'Альбом',
|
||||
advanced: 'Расширенный поиск',
|
||||
advancedSearchTerm: 'Поисковый запрос',
|
||||
advancedSearchPlaceholder: 'Название, альбом, исполнитель…',
|
||||
advancedGenre: 'Жанр',
|
||||
advancedAllGenres: 'Все жанры',
|
||||
advancedYear: 'Год',
|
||||
advancedYearFrom: 'от',
|
||||
advancedYearTo: 'до',
|
||||
advancedAll: 'Все',
|
||||
advancedSearch: 'Поиск',
|
||||
advancedEmpty: 'Введите поисковый запрос или выберите фильтр.',
|
||||
advancedNoResults: 'Результаты не найдены.',
|
||||
advancedGenreNote: 'Песни случайно выбираются из этого жанра.',
|
||||
recentSearches: 'Недавние запросы',
|
||||
browse: 'Обзор',
|
||||
emptyHint: 'Что хочешь послушать?',
|
||||
genres: 'Жанры',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: 'Кто слушает?',
|
||||
title: 'Кто слушает?',
|
||||
loading: 'Загрузка…',
|
||||
nobody: 'Никто сейчас не слушает.',
|
||||
minutesAgo: '{{n}} мин. назад',
|
||||
nothingPlaying: 'Пока ничего не играет. Включите трек!',
|
||||
aboutArtist: 'Об исполнителе',
|
||||
fromAlbum: 'Из этого альбома',
|
||||
viewAlbum: 'Просмотр альбома',
|
||||
goToArtist: 'Перейти к исполнителю',
|
||||
readMore: 'Читать далее',
|
||||
showLess: 'Свернуть',
|
||||
genreInfo: 'Жанр',
|
||||
trackInfo: 'Информация о треке',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Воспроизвести сейчас',
|
||||
playNext: 'Воспроизвести следующим',
|
||||
addToQueue: 'Добавить в очередь',
|
||||
enqueueAlbum: 'Добавить альбом в очередь',
|
||||
startRadio: 'Запустить радио',
|
||||
lfmLove: 'Нравится на Last.fm',
|
||||
lfmUnlove: 'Убрать из любимых на Last.fm',
|
||||
favorite: 'Избранное',
|
||||
favoriteArtist: 'Добавить исполнителя в избранное',
|
||||
favoriteAlbum: 'Добавить альбом в избранное',
|
||||
unfavorite: 'Удалить из избранного',
|
||||
unfavoriteArtist: 'Удалить исполнителя из избранного',
|
||||
unfavoriteAlbum: 'Удалить альбом из избранного',
|
||||
removeFromQueue: 'Удалить из очереди',
|
||||
openAlbum: 'Открыть альбом',
|
||||
goToArtist: 'Перейти к исполнителю',
|
||||
download: 'Скачать (ZIP)',
|
||||
addToPlaylist: 'Добавить в плейлист',
|
||||
songInfo: 'Информация о песне',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Назад',
|
||||
playAll: 'Воспроизвести все',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
artistBio: 'Биография исполнителя',
|
||||
download: 'Скачать (ZIP)',
|
||||
downloading: 'Загрузка…',
|
||||
cacheOffline: 'Сделать доступным офлайн',
|
||||
offlineCached: 'Доступно офлайн',
|
||||
offlineDownloading: 'Кэширование… ({{n}}/{{total}})',
|
||||
removeOffline: 'Удалить офлайн кэш',
|
||||
offlineStorageFull: 'Офлайн хранилище заполнено (лимит: {{mb}} МБ). Освободите место, удалив альбом из офлайн библиотеки, или увеличьте лимит в настройках.',
|
||||
offlineStorageGoToLibrary: 'Офлайн библиотека',
|
||||
offlineStorageGoToSettings: 'Настройки',
|
||||
favoriteAdd: 'Добавить в избранное',
|
||||
favoriteRemove: 'Удалить из избранного',
|
||||
favorite: 'Избранное',
|
||||
noBio: 'Биография недоступна.',
|
||||
moreByArtist: 'Больше от {{artist}}',
|
||||
tracksCount: '{{n}} треков',
|
||||
goToArtist: 'Перейти к {{artist}}',
|
||||
moreLabelAlbums: 'Больше альбомов на {{label}}',
|
||||
trackTitle: 'Название',
|
||||
trackArtist: 'Исполнитель',
|
||||
trackGenre: 'Жанр',
|
||||
trackFormat: 'Формат',
|
||||
trackFavorite: 'Избранное',
|
||||
trackRating: 'Рейтинг',
|
||||
trackDuration: 'Длительность',
|
||||
trackTotal: 'Всего',
|
||||
columns: 'Столбцы',
|
||||
notFound: 'Альбом не найден.',
|
||||
bioModal: 'Биография исполнителя',
|
||||
bioClose: 'Закрыть',
|
||||
ratingLabel: 'Рейтинг',
|
||||
enlargeCover: 'Увеличить',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Назад',
|
||||
albums: 'Альбомы',
|
||||
album: 'Альбом',
|
||||
playAll: 'Воспроизвести все',
|
||||
shuffle: 'Перемешать',
|
||||
radio: 'Радио',
|
||||
loading: 'Загрузка…',
|
||||
noRadio: 'Похожие треки для этого исполнителя не найдены.',
|
||||
notFound: 'Исполнитель не найден.',
|
||||
albumsBy: 'Альбомы {{name}}',
|
||||
topTracks: 'Лучшие треки',
|
||||
noAlbums: 'Альбомы не найдены.',
|
||||
trackTitle: 'Название',
|
||||
trackAlbum: 'Альбом',
|
||||
trackDuration: 'Длительность',
|
||||
favoriteAdd: 'Добавить в избранное',
|
||||
favoriteRemove: 'Удалить из избранного',
|
||||
favorite: 'Избранное',
|
||||
albumCount_one: '{{count}} альбом',
|
||||
albumCount_few: '{{count}} альбома',
|
||||
albumCount_many: '{{count}} альбомов',
|
||||
albumCount_other: '{{count}} альбомов',
|
||||
openedInBrowser: 'Открыто в браузере',
|
||||
featuredOn: 'Также представлен на',
|
||||
similarArtists: 'Похожие исполнители',
|
||||
cacheOffline: 'Сохранить дискографию офлайн',
|
||||
offlineCached: 'Дискография кэширована',
|
||||
offlineDownloading: 'Кэширование… ({{done}}/{{total}} альбомов)',
|
||||
uploadImage: 'Загрузить изображение исполнителя',
|
||||
uploadImageError: 'Не удалось загрузить изображение',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Избранное',
|
||||
empty: 'Вы еще не добавили ничего в избранное.',
|
||||
artists: 'Исполнители',
|
||||
albums: 'Альбомы',
|
||||
songs: 'Песни',
|
||||
enqueueAll: 'Добавить все в очередь',
|
||||
playAll: 'Воспроизвести все',
|
||||
removeSong: 'Удалить из избранного',
|
||||
stations: 'Радиостанции',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Случайные альбомы',
|
||||
refresh: 'Обновить',
|
||||
},
|
||||
genres: {
|
||||
title: 'Жанры',
|
||||
genreCount: 'Жанры',
|
||||
albumCount_one: '{{count}} альбом',
|
||||
albumCount_few: '{{count}} альбома',
|
||||
albumCount_many: '{{count}} альбомов',
|
||||
albumCount_other: '{{count}} альбомов',
|
||||
loading: 'Загрузка жанров…',
|
||||
empty: 'Жанры не найдены.',
|
||||
albumsLoading: 'Загрузка альбомов…',
|
||||
albumsEmpty: 'Альбомы в этом жанре не найдены.',
|
||||
loadMore: 'Загрузить еще',
|
||||
back: 'Назад',
|
||||
},
|
||||
randomMix: {
|
||||
title: 'Случайный микс',
|
||||
remix: 'Ремикс',
|
||||
remixTooltip: 'Загрузить новые случайные песни',
|
||||
playAll: 'Воспроизвести все',
|
||||
trackTitle: 'Название',
|
||||
trackArtist: 'Исполнитель',
|
||||
trackAlbum: 'Альбом',
|
||||
trackFavorite: 'Избранное',
|
||||
trackDuration: 'Длительность',
|
||||
favoriteAdd: 'Добавить в избранное',
|
||||
favoriteRemove: 'Удалить из избранного',
|
||||
play: 'Воспроизвести',
|
||||
trackGenre: 'Жанр',
|
||||
excludeAudiobooks: 'Исключить аудиокниги и радиопостановки',
|
||||
excludeAudiobooksDesc: 'Совпадения ключевых слов в жанре, названии, альбоме и исполнителе — например, Аудиокнига, Разговорный жанр, …',
|
||||
genreBlocked: 'Ключевое слово заблокировано',
|
||||
genreAddedToBlacklist: 'Добавлено в список фильтрации',
|
||||
genreAlreadyBlocked: 'Уже заблокировано',
|
||||
artistBlocked: 'Исполнитель заблокирован',
|
||||
artistAddedToBlacklist: 'Исполнитель добавлен в список фильтрации',
|
||||
artistClickHint: 'Нажмите, чтобы заблокировать исполнителя',
|
||||
blacklistToggle: 'Фильтр ключевых слов',
|
||||
genreMixTitle: 'Жанровый микс',
|
||||
genreMixDesc: 'Топ-20 жанров по количеству песен — нажмите для загрузки случайного микса',
|
||||
genreMixLoadMore: 'Загрузить еще 10',
|
||||
genreMixNoGenres: 'Жанры на сервере не найдены.',
|
||||
shuffleGenres: 'Показать другие жанры',
|
||||
filterPanelTitle: 'Фильтры',
|
||||
filterPanelDesc: 'Нажмите на жанровый тег или имя исполнителя в списке треков ниже, чтобы заблокировать их в будущих миксах.',
|
||||
genreClickHint: 'Нажмите на жанровый тег, чтобы добавить его\nкак ключевое слово фильтра.\nСовпадает с жанром, названием, альбомом и исполнителем.',
|
||||
},
|
||||
albums: {
|
||||
title: 'Все альбомы',
|
||||
sortByName: 'А–Я (Альбом)',
|
||||
sortByArtist: 'А–Я (Исполнитель)',
|
||||
sortNewest: 'Сначала новые',
|
||||
sortRandom: 'Случайные',
|
||||
yearFrom: 'От',
|
||||
yearTo: 'До',
|
||||
yearFilterClear: 'Очистить фильтр по году',
|
||||
yearFilterLabel: 'Год',
|
||||
},
|
||||
artists: {
|
||||
title: 'Исполнители',
|
||||
search: 'Поиск…',
|
||||
all: 'Все',
|
||||
gridView: 'Сетка',
|
||||
listView: 'Список',
|
||||
imagesOn: 'Изображения исполнителей включены — могут увеличить нагрузку на сеть и систему',
|
||||
imagesOff: 'Изображения исполнителей отключены — показываются только инициалы',
|
||||
loadMore: 'Загрузить еще',
|
||||
notFound: 'Исполнители не найдены.',
|
||||
albumCount_one: '{{count}} альбом',
|
||||
albumCount_few: '{{count}} альбома',
|
||||
albumCount_many: '{{count}} альбомов',
|
||||
albumCount_other: '{{count}} альбомов',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Ваш настольный проигрыватель Navidrome',
|
||||
serverName: 'Имя сервера (необязательно)',
|
||||
serverNamePlaceholder: 'Мой Navidrome',
|
||||
serverUrl: 'URL сервера',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com',
|
||||
username: 'Имя пользователя',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Пароль',
|
||||
showPassword: 'Показать пароль',
|
||||
hidePassword: 'Скрыть пароль',
|
||||
connect: 'Подключиться',
|
||||
connecting: 'Подключение…',
|
||||
connected: 'Подключено!',
|
||||
error: 'Ошибка подключения — проверьте ваши данные.',
|
||||
urlRequired: 'Введите URL сервера.',
|
||||
savedServers: 'Сохраненные серверы',
|
||||
addNew: 'Или добавьте новый сервер',
|
||||
},
|
||||
connection: {
|
||||
connected: 'Подключено',
|
||||
connectedTo: 'Подключено к {{server}}',
|
||||
disconnected: 'Отключено',
|
||||
disconnectedFrom: 'Не удается подключиться к {{server}} — нажмите для проверки настроек',
|
||||
checking: 'Подключение…',
|
||||
extern: 'Внешний',
|
||||
offlineTitle: 'Нет подключения к серверу',
|
||||
offlineSubtitle: 'Не удается подключиться к {{server}}. Проверьте вашу сеть или сервер.',
|
||||
offlineModeBanner: 'Офлайн-режим — воспроизведение из локального кэша',
|
||||
offlineLibraryTitle: 'Офлайн-библиотека',
|
||||
offlineLibraryEmpty: 'Пока нет кэшированных альбомов. Подключитесь к сети, откройте альбом и нажмите "Сделать доступным офлайн".',
|
||||
offlineAlbumCount: '{{n}} альбом',
|
||||
offlineAlbumCount_few: '{{n}} альбома',
|
||||
offlineAlbumCount_many: '{{n}} альбомов',
|
||||
offlineAlbumCount_plural: '{{n}} альбомов',
|
||||
offlineFilterAll: 'Все',
|
||||
offlineFilterAlbums: 'Альбомы',
|
||||
offlineFilterPlaylists: 'Плейлисты',
|
||||
offlineFilterArtists: 'Дискографии',
|
||||
retry: 'Повторить',
|
||||
lastfmConnected: 'Last.fm подключен как @{{user}}',
|
||||
lastfmSessionInvalid: 'Сессия недействительна — нажмите для переподключения',
|
||||
},
|
||||
common: {
|
||||
albums: 'Альбомы',
|
||||
album: 'Альбом',
|
||||
loading: 'Загрузка…',
|
||||
loadingMore: 'Загрузка…',
|
||||
loadingPlaylists: 'Загрузка плейлистов…',
|
||||
noAlbums: 'Альбомы не найдены.',
|
||||
downloading: 'Загрузка…',
|
||||
downloadZip: 'Скачать (ZIP)',
|
||||
back: 'Назад',
|
||||
cancel: 'Отмена',
|
||||
save: 'Сохранить',
|
||||
delete: 'Удалить',
|
||||
use: 'Использовать',
|
||||
add: 'Добавить',
|
||||
active: 'Активно',
|
||||
download: 'Скачать',
|
||||
chooseDownloadFolder: 'Выберите папку загрузки',
|
||||
noFolderSelected: 'Папка не выбрана',
|
||||
rememberDownloadFolder: 'Запомнить эту папку',
|
||||
filterGenre: 'Фильтр по жанру',
|
||||
filterSearchGenres: 'Поиск жанров…',
|
||||
filterNoGenres: 'Нет совпадений по жанрам',
|
||||
filterClear: 'Очистить',
|
||||
bulkSelected: 'Выбрано: {{count}}',
|
||||
bulkAddToPlaylist: 'Добавить в плейлист',
|
||||
bulkRemoveFromPlaylist: 'Удалить из плейлиста',
|
||||
bulkClear: 'Очистить выбор',
|
||||
updaterAvailable: 'Доступно обновление',
|
||||
updaterVersion: 'v{{version}} готово',
|
||||
updaterInstall: 'Установить и перезапустить',
|
||||
updaterDownloading: 'Загрузка…',
|
||||
updaterInstalling: 'Установка…',
|
||||
updaterDownload: 'Скачать с GitHub',
|
||||
updaterExperimentalHint: 'Автообновление все еще в разработке',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
language: 'Язык',
|
||||
languageEn: 'Английский',
|
||||
languageDe: 'Немецкий',
|
||||
languageFr: 'Французский',
|
||||
languageNl: 'Нидерландский',
|
||||
languageZh: 'Китайский',
|
||||
languageNb: 'Норвежский',
|
||||
languageRu: 'Русский',
|
||||
languageRu2: 'Русский 2',
|
||||
font: 'Шрифт',
|
||||
theme: 'Тема',
|
||||
appearance: 'Внешний вид',
|
||||
servers: 'Серверы',
|
||||
serverName: 'Имя сервера',
|
||||
serverUrl: 'URL сервера',
|
||||
serverUsername: 'Имя пользователя',
|
||||
serverPassword: 'Пароль',
|
||||
addServer: 'Добавить сервер',
|
||||
addServerTitle: 'Добавить новый сервер',
|
||||
useServer: 'Использовать',
|
||||
deleteServer: 'Удалить',
|
||||
noServers: 'Серверы не сохранены.',
|
||||
serverActive: 'Активный',
|
||||
confirmDeleteServer: 'Удалить сервер "{{name}}"?',
|
||||
serverConnecting: 'Подключение…',
|
||||
serverConnected: 'Подключено!',
|
||||
serverFailed: 'Ошибка подключения.',
|
||||
testBtn: 'Проверить подключение',
|
||||
testingBtn: 'Проверка…',
|
||||
serverCompatible: 'Совместимо с: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Подключено',
|
||||
failed: 'Ошибка',
|
||||
eqTitle: 'Эквалайзер',
|
||||
eqEnabled: 'Включить эквалайзер',
|
||||
eqPreset: 'Пресет',
|
||||
eqPresetCustom: 'Пользовательский',
|
||||
eqPresetBuiltin: 'Встроенные пресеты',
|
||||
eqPresetCustomGroup: 'Мои пресеты',
|
||||
eqSavePreset: 'Сохранить как пресет',
|
||||
eqPresetName: 'Название пресета…',
|
||||
eqDeletePreset: 'Удалить пресет',
|
||||
eqResetBands: 'Сбросить',
|
||||
eqPreGain: 'Предусиление',
|
||||
eqResetPreGain: 'Сбросить предусиление',
|
||||
eqAutoEqTitle: 'Поиск наушников AutoEQ',
|
||||
eqAutoEqPlaceholder: 'Поиск модели наушников / IEM…',
|
||||
eqAutoEqSearching: 'Поиск…',
|
||||
eqAutoEqNoResults: 'Результаты не найдены',
|
||||
eqAutoEqError: 'Ошибка поиска',
|
||||
eqAutoEqRateLimit: 'Достигнут лимит GitHub — попробуйте через минуту',
|
||||
eqAutoEqFetchError: 'Не удалось загрузить профиль EQ',
|
||||
lfmTitle: 'Last.fm',
|
||||
lfmConnect: 'Подключить Last.fm',
|
||||
lfmConnecting: 'Ожидание авторизации…',
|
||||
lfmConfirm: 'Я авторизовал приложение',
|
||||
lfmConnected: 'Подключено как',
|
||||
lfmDisconnect: 'Отключить',
|
||||
lfmConnectDesc: 'Подключите аккаунт Last.fm для включения скробблинга и обновления "Сейчас играет" напрямую из Psysonic — без необходимости настройки Navidrome.',
|
||||
lfmOpenBrowser: 'Откроется окно браузера. Авторизуйте Psysonic на Last.fm, затем нажмите кнопку ниже.',
|
||||
lfmScrobbles: '{{n}} скробблов',
|
||||
lfmMemberSince: 'Участник с {{year}}',
|
||||
scrobbleEnabled: 'Скробблинг включен',
|
||||
scrobbleDesc: 'Отправлять песни на Last.fm после 50% воспроизведения',
|
||||
behavior: 'Поведение приложения',
|
||||
cacheTitle: 'Макс. размер хранилища',
|
||||
cacheDesc: 'Обложки и изображения исполнителей. При заполнении старые записи удаляются автоматически. Офлайн альбомы не удаляются автоматически, но будут удалены при ручной очистке кэша.',
|
||||
cacheUsedImages: 'Изображения:',
|
||||
cacheUsedOffline: 'Офлайн треки:',
|
||||
cacheMaxLabel: 'Макс. размер',
|
||||
cacheClearBtn: 'Очистить кэш',
|
||||
cacheClearWarning: 'Это также удалит все офлайн альбомы из библиотеки.',
|
||||
cacheClearConfirm: 'Очистить все',
|
||||
cacheClearCancel: 'Отмена',
|
||||
offlineDirTitle: 'Офлайн библиотека (в приложении)',
|
||||
offlineDirDesc: 'Место хранения треков, которые вы делаете доступными офлайн в Psysonic.',
|
||||
offlineDirDefault: 'По умолчанию (данные приложения)',
|
||||
offlineDirChange: 'Изменить директорию',
|
||||
offlineDirClear: 'Сбросить по умолчанию',
|
||||
offlineDirHint: 'Новые загрузки будут использовать это место. Существующие загрузки останутся по своему исходному пути.',
|
||||
showArtistImages: 'Показать изображения исполнителей',
|
||||
showArtistImagesDesc: 'Загружать и отображать изображения исполнителей в обзоре исполнителей. Отключено по умолчанию для снижения дискового ввода-вывода сервера и сетевой нагрузки на больших библиотеках.',
|
||||
showTrayIcon: 'Показать значок в трее',
|
||||
showTrayIconDesc: 'Отображать значок Psysonic в области уведомлений системы / строке меню.',
|
||||
minimizeToTray: 'Свернуть в трей',
|
||||
minimizeToTrayDesc: 'При закрытии окна оставлять Psysonic работающим в системном трее вместо выхода.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Показывать текущий воспроизводимый трек в вашем профиле Discord. Требуется запущенный Discord.',
|
||||
nowPlayingEnabled: 'Показывать в "Сейчас играет"',
|
||||
nowPlayingEnabledDesc: 'Транслировать текущий воспроизводимый трек на сервер в режим просмотра слушателей. Отключите, чтобы прекратить отправку данных воспроизведения.',
|
||||
downloadsTitle: 'ZIP экспорт и архивирование',
|
||||
downloadsFolderDesc: 'Папка назначения для альбомов, которые вы скачиваете в виде ZIP файла на компьютер.',
|
||||
downloadsDefault: 'Папка загрузок по умолчанию',
|
||||
pickFolder: 'Выбрать',
|
||||
pickFolderTitle: 'Выберите папку загрузки',
|
||||
clearFolder: 'Очистить папку загрузки',
|
||||
logout: 'Выйти',
|
||||
aboutTitle: 'О Psysonic',
|
||||
aboutDesc: 'Современный настольный музыкальный проигрыватель для серверов, совместимых с Subsonic (Navidrome, Gonic и другие). Построен на Tauri v2 с нативным аудио движком на Rust — легкий и быстрый, но с богатым функционалом: волновая форма для перемотки, синхронизированные тексты песен, интеграция с Last.fm, 10-полосный эквалайзер, кроссфейд, воспроизведение без пауз, Replay Gain, просмотр жанров и большая библиотека тем.',
|
||||
aboutFeatures: 'Многосерверность · Скробблинг и лайки Last.fm · Полноэкранный Ambient Stage · Перемотка по волновой форме · Синхронизированные тексты · 10-полосный эквалайзер · Кроссфейд и воспроизведение без пауз · Replay Gain · Жанры · 63 темы · 5 языков',
|
||||
aboutLicense: 'Лицензия',
|
||||
aboutLicenseText: 'GNU GPL v3 — бесплатное использование, модификация и распространение на тех же условиях.',
|
||||
aboutRepo: 'Исходный код на GitHub',
|
||||
aboutVersion: 'Версия',
|
||||
aboutBuiltWith: 'Создано с Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Разработано при поддержке Claude Code от Anthropic',
|
||||
aboutContributorsLabel: 'Участники',
|
||||
aboutSpecialThanksLabel: 'Особая благодарность',
|
||||
changelog: 'Список изменений',
|
||||
showChangelogOnUpdate: 'Показывать "Что нового" при обновлении',
|
||||
showChangelogOnUpdateDesc: 'Автоматически показывать, что нового, при первом запуске новой версии.',
|
||||
randomMixTitle: 'Случайный микс',
|
||||
randomMixBlacklistTitle: 'Пользовательские ключевые слова фильтра',
|
||||
randomMixBlacklistDesc: 'Песни исключаются, когда любое ключевое слово совпадает с их жанром, названием, альбомом или исполнителем (активно при включенном флажке выше).',
|
||||
randomMixBlacklistPlaceholder: 'Добавить ключевое слово…',
|
||||
randomMixBlacklistAdd: 'Добавить',
|
||||
randomMixBlacklistEmpty: 'Пользовательские ключевые слова еще не добавлены.',
|
||||
randomMixHardcodedTitle: 'Встроенные ключевые слова (активны при включенном флажке)',
|
||||
tabAudio: 'Аудио',
|
||||
tabStorage: 'Хранилище и загрузки',
|
||||
tabAppearance: 'Внешний вид',
|
||||
homeCustomizerTitle: 'Главная страница',
|
||||
sidebarTitle: 'Боковая панель',
|
||||
sidebarReset: 'Сбросить по умолчанию',
|
||||
sidebarDrag: 'Перетащите для изменения порядка',
|
||||
sidebarFixed: 'Всегда видна',
|
||||
tabInput: 'Ввод',
|
||||
tabServer: 'Сервер',
|
||||
tabSystem: 'Система',
|
||||
tabGeneral: 'Общие',
|
||||
backupTitle: 'Резервное копирование и восстановление',
|
||||
backupExport: 'Экспорт настроек',
|
||||
backupExportDesc: 'Сохраняет все настройки, профили серверов, конфигурацию Last.fm, тему, эквалайзер и привязки клавиш в файл .psybkp. Пароли хранятся в открытом виде — храните файл в безопасности.',
|
||||
backupImport: 'Импорт настроек',
|
||||
backupImportDesc: 'Восстанавливает настройки из файла .psybkp. Приложение перезагрузится после импорта.',
|
||||
backupImportConfirm: 'Это перезапишет все текущие настройки. Продолжить?',
|
||||
backupSuccess: 'Резервная копия сохранена',
|
||||
backupImportSuccess: 'Настройки восстановлены — перезагрузка…',
|
||||
backupImportError: 'Недействительный или поврежденный файл резервной копии.',
|
||||
shortcutsReset: 'Сбросить по умолчанию',
|
||||
shortcutListening: 'Нажмите клавишу…',
|
||||
shortcutUnbound: '—',
|
||||
globalShortcutsTitle: 'Глобальные горячие клавиши',
|
||||
globalShortcutsNote: 'Работают по всей системе, даже когда Psysonic работает в фоне. Требуют Ctrl, Alt или Super в качестве модификатора.',
|
||||
shortcutClear: 'Очистить',
|
||||
shortcutPlayPause: 'Воспроизведение / Пауза',
|
||||
shortcutNext: 'Следующий трек',
|
||||
shortcutPrev: 'Предыдущий трек',
|
||||
shortcutVolumeUp: 'Увеличить громкость',
|
||||
shortcutVolumeDown: 'Уменьшить громкость',
|
||||
shortcutSeekForward: 'Перемотка вперед на 10 с',
|
||||
shortcutSeekBackward: 'Перемотка назад на 10 с',
|
||||
shortcutToggleQueue: 'Переключить очередь',
|
||||
shortcutFullscreenPlayer: 'Полноэкранный плеер',
|
||||
shortcutNativeFullscreen: 'Нативный полный экран',
|
||||
playbackTitle: 'Воспроизведение',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Нормализация громкости трека с использованием метаданных ReplayGain',
|
||||
replayGainMode: 'Режим',
|
||||
replayGainTrack: 'Трек',
|
||||
replayGainAlbum: 'Альбом',
|
||||
crossfade: 'Кроссфейд',
|
||||
crossfadeDesc: 'Плавный переход между треками',
|
||||
crossfadeSecs: '{{n}} с',
|
||||
notWithGapless: 'Недоступно при активном воспроизведении без пауз',
|
||||
notWithCrossfade: 'Недоступно при активном кроссфейде',
|
||||
gapless: 'Воспроизведение без пауз',
|
||||
gaplessDesc: 'Предварительная буферизация следующего трека для устранения пауз между песнями',
|
||||
preloadMode: 'Предзагрузка следующего трека',
|
||||
preloadModeDesc: 'Когда начинать буферизацию следующего трека в очереди',
|
||||
preloadBalanced: 'Сбалансированный (за 30 с до конца)',
|
||||
preloadEarly: 'Ранний (после 5 с воспроизведения)',
|
||||
preloadCustom: 'Пользовательский',
|
||||
preloadCustomSeconds: 'Секунд до конца: {{n}}',
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
infiniteQueueDesc: 'Автоматически добавлять случайные треки при окончании очереди',
|
||||
experimental: 'Экспериментальный',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Что нового',
|
||||
dontShowAgain: 'Больше не показывать',
|
||||
close: 'Понятно',
|
||||
},
|
||||
help: {
|
||||
title: 'Помощь',
|
||||
s1: 'Начало работы',
|
||||
q1: 'Какие серверы совместимы?',
|
||||
a1: 'Psysonic работает с любым сервером, совместимым с Subsonic: Navidrome, Gonic, Subsonic, Airsonic и другими. Navidrome — рекомендуемый выбор.',
|
||||
q2: 'Как подключиться к серверу?',
|
||||
a2: 'Откройте Настройки и нажмите "Добавить сервер". Введите URL сервера (например, 192.168.1.100:4533), имя пользователя и пароль. Psysonic проверяет подключение перед сохранением — ничего не сохраняется, если подключение не удалось.',
|
||||
q3: 'Могу ли я использовать несколько серверов?',
|
||||
a3: 'Да. Вы можете добавить сколько угодно серверов в Настройках и переключаться между ними в любое время. Только один сервер активен одновременно.',
|
||||
s2: 'Воспроизведение',
|
||||
q4: 'Как воспроизводить музыку?',
|
||||
a4: 'Дважды щелкните любой трек для воспроизведения. На страницах альбомов и исполнителей используйте "Воспроизвести все" для запуска всего альбома. Вы также можете перетащить треки в панель очереди.',
|
||||
q5: 'Какие горячие клавиши доступны?',
|
||||
a5: 'Пробел = Воспроизведение / Пауза · Escape = Закрыть полноэкранный плеер. Медиа-клавиши (Воспроизведение/Пауза, Следующий, Предыдущий) работают на всех платформах. На Linux используйте кнопки панели проигрывателя для медиа-клавиш. Внутриприложенные горячие клавиши и системные глобальные горячие клавиши можно настроить в Настройках → Горячие клавиши / Глобальные горячие клавиши.',
|
||||
q6: 'Что такое очередь?',
|
||||
a6: 'Очередь показывает все предстоящие треки. Откройте ее с помощью значка панели в правом верхнем углу заголовка (рядом с индикатором "Сейчас играет"). Вы можете переупорядочивать треки перетаскиванием, перемешивать кнопкой перемешивания и сохранять очередь как плейлист.',
|
||||
q7: 'Как открыть полноэкранный плеер?',
|
||||
a7: 'Нажмите на миниатюру обложки в панели проигрывателя внизу или на значок расширения рядом с ней. Нажмите Escape для закрытия.',
|
||||
q8: 'Как работает повтор?',
|
||||
a8: 'Нажмите кнопку повтора в панели проигрывателя для цикла: Выкл → Повтор всех → Повтор одного.',
|
||||
s3: 'Библиотека',
|
||||
q9: 'Как скачать альбом?',
|
||||
a9: 'Откройте страницу альбома и нажмите "Скачать (ZIP)". Сервер сначала сжимает альбом в ZIP файл — для больших альбомов или файлов без потерь (FLAC / WAV) это может занять некоторое время перед фактическим началом загрузки. Это нормально: прогресс-бар появляется после завершения упаковки сервером и начала передачи.',
|
||||
q10: 'Как добавить треки и альбомы в избранное?',
|
||||
a10: 'Нажмите на значок звезды на любой строке трека или в заголовке альбома. Отмеченные элементы появляются в разделе "Избранное" на боковой панели.',
|
||||
q11: 'Что такое карусель на главной странице?',
|
||||
a11: 'Баннер в верхней части главной страницы случайно выбирает альбомы из вашей библиотеки и вращает их каждые 10 секунд. Нажмите на точки для перехода к конкретному альбому или нажмите на баннер для открытия альбома.',
|
||||
s4: 'Настройки',
|
||||
q12: 'Как изменить тему?',
|
||||
a12: 'Настройки → Тема. Выберите из большого количества тем в 8 группах: Темы Psysonic, Медиаплееры, Операционные системы, Игры, Фильмы, Сериалы, Социальные сети и Классика открытого исходного кода (Catppuccin, Nord, Gruvbox).',
|
||||
q13: 'Как изменить язык?',
|
||||
a13: 'Настройки → Язык. Поддерживаются английский, немецкий, французский, нидерландский, китайский, норвежский и русский.',
|
||||
q15: 'Как установить папку загрузки?',
|
||||
a15: 'Настройки → Поведение приложения → Папка загрузки. Выберите любую папку — загруженные альбомы сохраняются туда как ZIP файлы. Без пользовательской папки используется местоположение загрузок браузера по умолчанию.',
|
||||
s5: 'Скробблинг',
|
||||
q16: 'Как работает скробблинг?',
|
||||
a16: 'Psysonic отправляет скробблы напрямую на Last.fm — настройка Navidrome не требуется. Подключите аккаунт Last.fm в Настройках → Last.fm и включите скробблинг там.',
|
||||
q17: 'Когда отправляется скроббл?',
|
||||
a17: 'Скроббл отправляется после прослушивания 50% трека.',
|
||||
q22: 'Что такое волновая форма в панели проигрывателя?',
|
||||
a22: 'Панель перемотки с волновой формой заменяет классический ползунок прогресса. Нажмите в любом месте для перехода к этой позиции или перетащите для перемотки. Воспроизведенная часть светится градиентом от синего к лиловому, буферизованный диапазон немного ярче, а невоспроизведенная часть затемнена.',
|
||||
q24: 'Могу ли я перемешать очередь?',
|
||||
a24: 'Да. Откройте панель очереди и нажмите значок перемешивания в заголовке очереди. Текущий воспроизводимый трек остается на позиции 1 — все остальные треки случайно переупорядочиваются.',
|
||||
q25: 'Открываются ли ссылки Last.fm и Wikipedia на страницах исполнителей в браузере?',
|
||||
a25: 'Да — они открываются в вашем системном браузере по умолчанию. Кнопка кратко показывает метку подтверждения при нажатии.',
|
||||
s7: 'Случайный микс',
|
||||
q26: 'Что такое Случайный микс?',
|
||||
a26: 'Случайный микс создает плейлист из случайных треков из всей вашей библиотеки...',
|
||||
q27: 'Что такое Фильтр ключевых слов?',
|
||||
a27: 'Фильтр ключевых слов исключает треки, жанр, название или альбом которых содержат определенные слова. Аудиокниги фильтруются автоматически. Добавьте пользовательские ключевые слова в Настройках → Случайный микс или нажмите любой жанровый тег в списке треков для мгновенного добавления.',
|
||||
q28: 'Что такое Супер жанровый микс?',
|
||||
a28: 'Супер жанровый микс группирует вашу библиотеку в широкие категории (Рок, Метал, Электронная, Джаз, Классика и т.д.) и создает сфокусированный микс из этого стиля. Выберите категорию ниже списка треков. Результаты появляются прогрессивно по мере получения каждого поджанра.',
|
||||
s6: 'Устранение неполадок',
|
||||
q18: 'Обложки и изображения исполнителей загружаются медленно.',
|
||||
a18: 'Изображения загружаются с диска вашего сервера при первом посещении и затем кэшируются локально на 30 дней. Если хранилище сервера медленное, первое посещение страницы может занять некоторое время. Последующие посещения будут мгновенными.',
|
||||
q19: 'Тест подключения не удается.',
|
||||
a19: 'Проверьте URL включая порт (например, http://192.168.1.100:4533). Убедитесь, что брандмауэр не блокирует подключение. Попробуйте http:// вместо https:// в локальной сети. Также проверьте правильность имени пользователя и пароля.',
|
||||
q20: 'Нет звука на Linux.',
|
||||
a20: 'Psysonic доступен как .deb (Ubuntu/Debian), .rpm (Fedora/RHEL) и через AUR на Arch/CachyOS (yay -S psysonic или yay -S psysonic-bin). Нет AppImage. Если звук отсутствует, убедитесь, что PipeWire или PulseAudio запущены.',
|
||||
q21: 'Приложение показывает черный экран на Linux.',
|
||||
a21: 'Обычно это проблема GPU/EGL драйвера в WebKitGTK. Запустите с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Пакет AUR и официальные установщики .deb/.rpm устанавливают это автоматически.',
|
||||
q29: 'Что такое Кроссфейд и Воспроизведение без пауз?',
|
||||
a29: 'Оба являются экспериментальными аудио функциями в Настройках → Аудио. Воспроизведение без пауз предварительно буферизует следующий трек для устранения тишины между песнями. Кроссфейд затухает текущий трек при одновременном нарастании следующего — настройте длительность (1–10 с) по вкусу.',
|
||||
q30: 'Показывает ли Psysonic тексты песен?',
|
||||
a30: 'Да. Нажмите значок микрофона в панели проигрывателя для открытия вкладки "Текст" в панели очереди. Psysonic автоматически загружает тексты из LRCLIB. При наличии синхронизированных текстов активная строка выделяется и прокручивается в реальном времени по мере воспроизведения песни. При наличии только обычного текста он отображается статически.',
|
||||
q31: 'Могу ли я настроить горячие клавиши?',
|
||||
a31: 'Да. Настройки → Горячие клавиши позволяют переназначить действия приложения (Воспроизведение/Пауза, Следующий, Предыдущий, Громкость вверх/вниз, Полный экран и другие) на любую клавишу. Настройки → Глобальные горячие клавиши позволяют назначить системные горячие клавиши, которые срабатывают даже когда Psysonic работает в фоне.',
|
||||
q32: 'Могу ли я изменить шрифт?',
|
||||
a32: 'Да. Настройки → Шрифт позволяют выбрать из 10 шрифтов, включая IBM Plex Mono, Fira Code, JetBrains Mono, Courier Prime и другие. Выбранный шрифт применяется ко всему интерфейсу.',
|
||||
s8: 'Офлайн режим',
|
||||
q34: 'Что такое Офлайн режим?',
|
||||
a34: 'Офлайн режим (бета) позволяет кэшировать альбомы на ваше устройство для прослушивания без активного подключения к серверу. Кэшированные треки хранятся локально и воспроизводятся напрямую с диска — во время воспроизведения сетевые запросы не выполняются.',
|
||||
q35: 'Как кэшировать альбом для офлайн использования?',
|
||||
a35: 'Откройте любой альбом и нажмите значок загрузки в заголовке альбома. Psysonic загружает все треки в фоне. Прогресс показывается на кнопке. После кэширования значок становится зеленым. Вы можете просмотреть и удалить кэшированные альбомы на странице Офлайн библиотеки (боковая панель).',
|
||||
q36: 'Сколько хранилища может использовать офлайн кэширование?',
|
||||
a36: 'Вы можете установить максимальный размер кэша в Настройках → Библиотека. При достижении лимита на странице альбома появляется предупреждающий баннер. Вы можете удалять отдельные альбомы из Офлайн библиотеки для освобождения места.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Очередь',
|
||||
savePlaylist: 'Сохранить плейлист',
|
||||
updatePlaylist: 'Обновить плейлист',
|
||||
filterPlaylists: 'Фильтр плейлистов…',
|
||||
playlistName: 'Название плейлиста',
|
||||
cancel: 'Отмена',
|
||||
save: 'Сохранить',
|
||||
loadPlaylist: 'Загрузить плейлист',
|
||||
loading: 'Загрузка…',
|
||||
noPlaylists: 'Плейлисты не найдены.',
|
||||
load: 'Заменить очередь и воспроизвести',
|
||||
appendToQueue: 'Добавить в конец очереди',
|
||||
delete: 'Удалить',
|
||||
deleteConfirm: 'Удалить плейлист "{{name}}"?',
|
||||
clear: 'Очистить',
|
||||
shuffle: 'Перемешать очередь',
|
||||
gapless: 'Без пауз',
|
||||
crossfade: 'Кроссфейд',
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
autoAdded: '— Добавлено автоматически —',
|
||||
radioAdded: '— Радио —',
|
||||
hide: 'Скрыть',
|
||||
close: 'Закрыть',
|
||||
nextTracks: 'Следующие треки',
|
||||
emptyQueue: 'Очередь пуста.',
|
||||
trackSingular: 'трек',
|
||||
trackPlural: 'треков',
|
||||
showRemaining: 'Показать оставшееся время',
|
||||
showTotal: 'Показать общее время',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Статистика',
|
||||
recentlyPlayed: 'Недавно прослушанные',
|
||||
mostPlayed: 'Самые прослушиваемые альбомы',
|
||||
highestRated: 'Альбомы с highest рейтингом',
|
||||
genreDistribution: 'Распределение по жанрам (Топ-20)',
|
||||
loadMore: 'Загрузить еще',
|
||||
statArtists: 'Исполнители',
|
||||
statAlbums: 'Альбомы',
|
||||
statSongs: 'Песни',
|
||||
statGenres: 'Жанры',
|
||||
statPlaytime: 'Общее время воспроизведения',
|
||||
genreInsights: 'Обзор по жанрам',
|
||||
formatDistribution: 'Распределение по форматам',
|
||||
formatSample: 'Выборка из {{n}} треков',
|
||||
computing: 'Вычисление…',
|
||||
genreSongs: '{{count}} песен',
|
||||
genreAlbums: '{{count}} альбомов',
|
||||
recentlyAdded: 'Недавно добавленные',
|
||||
decadeDistribution: 'Альбомы по десятилетиям',
|
||||
decadeAlbums_one: '{{count}} альбом',
|
||||
decadeAlbums_few: '{{count}} альбома',
|
||||
decadeAlbums_many: '{{count}} альбомов',
|
||||
decadeAlbums_other: '{{count}} альбомов',
|
||||
decadeUnknown: 'Неизвестно',
|
||||
lfmTitle: 'Статистика Last.fm',
|
||||
lfmTopArtists: 'Топ исполнителей',
|
||||
lfmTopAlbums: 'Топ альбомов',
|
||||
lfmTopTracks: 'Топ треков',
|
||||
lfmPlays: '{{count}} воспроизведений',
|
||||
lfmPeriodOverall: 'Все время',
|
||||
lfmPeriod7day: '7 дней',
|
||||
lfmPeriod1month: '1 месяц',
|
||||
lfmPeriod3month: '3 месяца',
|
||||
lfmPeriod6month: '6 месяцев',
|
||||
lfmPeriod12month: '12 месяцев',
|
||||
lfmNotConnected: 'Подключите Last.fm в Настройках для просмотра статистики.',
|
||||
lfmRecentTracks: 'Недавние скробблы',
|
||||
lfmNowPlaying: 'Сейчас играет',
|
||||
lfmJustNow: 'только что',
|
||||
lfmMinutesAgo: '{{n}} мин. назад',
|
||||
lfmHoursAgo: '{{n}} ч. назад',
|
||||
lfmDaysAgo: '{{n}} дн. назад',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Музыкальный проигрыватель',
|
||||
openFullscreen: 'Открыть полноэкранный плеер',
|
||||
fullscreen: 'Полноэкранный плеер',
|
||||
closeFullscreen: 'Закрыть полный экран',
|
||||
closeTooltip: 'Закрыть (Esc)',
|
||||
noTitle: 'Без названия',
|
||||
stop: 'Стоп',
|
||||
prev: 'Предыдущий трек',
|
||||
play: 'Воспроизвести',
|
||||
pause: 'Пауза',
|
||||
next: 'Следующий трек',
|
||||
repeat: 'Повтор',
|
||||
repeatOff: 'Выкл',
|
||||
repeatAll: 'Все',
|
||||
repeatOne: 'Один',
|
||||
progress: 'Прогресс песни',
|
||||
volume: 'Громкость',
|
||||
toggleQueue: 'Переключить очередь',
|
||||
lyrics: 'Текст',
|
||||
lyricsLoading: 'Загрузка текста…',
|
||||
lyricsNotFound: 'Текст для этого трека не найден',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Информация о песне',
|
||||
songTitle: 'Название',
|
||||
artist: 'Исполнитель',
|
||||
album: 'Альбом',
|
||||
albumArtist: 'Исполнитель альбома',
|
||||
year: 'Год',
|
||||
genre: 'Жанр',
|
||||
duration: 'Длительность',
|
||||
track: 'Трек',
|
||||
format: 'Формат',
|
||||
bitrate: 'Битрейт',
|
||||
sampleRate: 'Частота дискретизации',
|
||||
bitDepth: 'Глубина бита',
|
||||
channels: 'Каналы',
|
||||
fileSize: 'Размер файла',
|
||||
path: 'Путь',
|
||||
replayGainTrack: 'RG усиление трека',
|
||||
replayGainAlbum: 'RG усиление альбома',
|
||||
replayGainPeak: 'RG пик трека',
|
||||
mono: 'Моно',
|
||||
stereo: 'Стерео',
|
||||
},
|
||||
playlists: {
|
||||
title: 'Плейлисты',
|
||||
newPlaylist: 'Новый плейлист',
|
||||
unnamed: 'Безымянный плейлист',
|
||||
createName: 'Название плейлиста…',
|
||||
create: 'Создать',
|
||||
cancel: 'Отмена',
|
||||
empty: 'Плейлистов пока нет.',
|
||||
emptyPlaylist: 'Этот плейлист пуст.',
|
||||
addFirstSong: 'Добавьте первую песню',
|
||||
notFound: 'Плейлист не найден.',
|
||||
songs: '{{n}} песен',
|
||||
playAll: 'Воспроизвести все',
|
||||
shuffle: 'Перемешать',
|
||||
addToQueue: 'Добавить в очередь',
|
||||
back: 'Назад к плейлистам',
|
||||
deletePlaylist: 'Удалить',
|
||||
confirmDelete: 'Нажмите еще раз для подтверждения',
|
||||
removeSong: 'Удалить из плейлиста',
|
||||
addSongs: 'Добавить песни',
|
||||
searchPlaceholder: 'Поиск в библиотеке…',
|
||||
noResults: 'Результаты не найдены.',
|
||||
suggestions: 'Предложенные песни',
|
||||
noSuggestions: 'Предложения недоступны.',
|
||||
titleBadge: 'Плейлист',
|
||||
refreshSuggestions: 'Новые предложения',
|
||||
addSong: 'Добавить в плейлист',
|
||||
cacheOffline: 'Кэшировать плейлист офлайн',
|
||||
offlineCached: 'Плейлист кэширован',
|
||||
offlineDownloading: 'Кэширование… ({{done}}/{{total}} альбомов)',
|
||||
publicLabel: 'Публичный',
|
||||
privateLabel: 'Приватный',
|
||||
editMeta: 'Редактировать плейлист',
|
||||
editNamePlaceholder: 'Название плейлиста…',
|
||||
editCommentPlaceholder: 'Добавьте описание…',
|
||||
editPublic: 'Публичный плейлист',
|
||||
editSave: 'Сохранить',
|
||||
editCancel: 'Отмена',
|
||||
changeCover: 'Изменить обложку',
|
||||
changeCoverLabel: 'Изменить фото',
|
||||
removeCover: 'Удалить фото',
|
||||
coverUpdated: 'Обложка обновлена',
|
||||
metaSaved: 'Плейлист обновлен',
|
||||
},
|
||||
radio: {
|
||||
title: 'Интернет-радио',
|
||||
empty: 'Радиостанции не настроены.',
|
||||
addStation: 'Добавить станцию',
|
||||
editStation: 'Редактировать',
|
||||
deleteStation: 'Удалить станцию',
|
||||
confirmDelete: 'Нажмите еще раз для подтверждения',
|
||||
stationName: 'Название станции…',
|
||||
streamUrl: 'URL потока…',
|
||||
homepageUrl: 'URL главной страницы (необязательно)',
|
||||
save: 'Сохранить',
|
||||
cancel: 'Отмена',
|
||||
live: 'ПРЯМОЙ ЭФИР',
|
||||
liveStream: 'Интернет радио',
|
||||
openHomepage: 'Открыть главную страницу',
|
||||
changeCoverLabel: 'Изменить обложку',
|
||||
removeCover: 'Удалить обложку',
|
||||
browseDirectory: 'Поиск в каталоге',
|
||||
directoryPlaceholder: 'Поиск станций…',
|
||||
noResults: 'Станции не найдены.',
|
||||
stationAdded: 'Станция добавлена',
|
||||
filterAll: 'Все',
|
||||
filterFavorites: 'Избранное',
|
||||
sortManual: 'Вручную',
|
||||
sortAZ: 'А → Я',
|
||||
sortZA: 'Я → А',
|
||||
sortNewest: 'Новые',
|
||||
favorite: 'Добавить в избранное',
|
||||
unfavorite: 'Удалить из избранного',
|
||||
noFavorites: 'Избранных станций нет.',
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,10 @@ export const zhTranslation = {
|
||||
advancedEmpty: '请输入搜索词或选择过滤器。',
|
||||
advancedNoResults: '未找到结果。',
|
||||
advancedGenreNote: '歌曲从该流派中随机选取。',
|
||||
recentSearches: '最近搜索',
|
||||
browse: '浏览',
|
||||
emptyHint: '你想听什么?',
|
||||
genres: '流派',
|
||||
},
|
||||
nowPlaying: {
|
||||
tooltip: '谁正在收听?',
|
||||
@@ -337,6 +341,7 @@ export const zhTranslation = {
|
||||
languageZh: '中文',
|
||||
languageNb: '挪威',
|
||||
languageRu: '俄语',
|
||||
languageRu2: '俄语 2',
|
||||
font: '字体',
|
||||
theme: '主题',
|
||||
appearance: '外观',
|
||||
|
||||
+8
-2
@@ -3,7 +3,8 @@ import Hero from '../components/Hero';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useHomeStore } from '../store/homeStore';
|
||||
|
||||
export default function Home() {
|
||||
@@ -76,6 +77,7 @@ export default function Home() {
|
||||
{isVisible('recent') && (
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
titleLink="/albums"
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText={t('home.loadMore')}
|
||||
@@ -84,6 +86,7 @@ export default function Home() {
|
||||
{isVisible('discover') && (
|
||||
<AlbumRow
|
||||
title={t('home.discover')}
|
||||
titleLink="/random-albums"
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText={t('home.discoverMore')}
|
||||
@@ -92,7 +95,9 @@ export default function Home() {
|
||||
{isVisible('discoverArtists') && randomArtists.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2>
|
||||
<NavLink to="/artists" className="section-title-link" style={{ marginBottom: 0 }}>
|
||||
{t('home.discoverArtists')}<ChevronRight size={18} className="section-title-chevron" />
|
||||
</NavLink>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{randomArtists.map(a => (
|
||||
@@ -118,6 +123,7 @@ export default function Home() {
|
||||
{isVisible('starred') && starred.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.starred')}
|
||||
titleLink="/favorites"
|
||||
albums={starred}
|
||||
onLoadMore={() => loadMore('starred', starred, setStarred)}
|
||||
moreText={t('home.loadMore')}
|
||||
|
||||
+18
-3
@@ -82,6 +82,20 @@ const CONTRIBUTORS = [
|
||||
'Norwegian (Bokmål) translation (PR #101)',
|
||||
],
|
||||
},
|
||||
{
|
||||
github: 'cucadmuh',
|
||||
since: '1.33.0',
|
||||
contributions: [
|
||||
'Russian translation & i18n locale split (PR #106)',
|
||||
],
|
||||
},
|
||||
{
|
||||
github: 'kilyabin',
|
||||
since: '1.34.0',
|
||||
contributions: [
|
||||
'Alternative Russian translation (PR #107)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SPECIAL_THANKS = [
|
||||
@@ -805,13 +819,14 @@ export default function Settings() {
|
||||
value={i18n.language}
|
||||
onChange={v => i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: 'nl', label: t('settings.languageNl') },
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
{ value: 'zh', label: t('settings.languageZh') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'nl', label: t('settings.languageNl') },
|
||||
{ value: 'nb', label: t('settings.languageNb') },
|
||||
{ value: 'ru', label: t('settings.languageRu') },
|
||||
{ value: 'ru2', label: t('settings.languageRu2') },
|
||||
{ value: 'zh', label: t('settings.languageZh') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -178,6 +178,34 @@
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.section-title-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.section-title-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.section-title-chevron {
|
||||
opacity: 0.45;
|
||||
transition: opacity var(--transition-fast), transform var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-title-link:hover .section-title-chevron {
|
||||
opacity: 1;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* ─ Album Card ─ */
|
||||
.album-card {
|
||||
cursor: pointer;
|
||||
@@ -5650,3 +5678,533 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
MOBILE COMPONENT OVERRIDES (< 800px)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── Album / Artist Grid — fewer columns ─── */
|
||||
.app-shell[data-mobile] .album-grid-wrap {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Horizontal scroll rows — smaller cards */
|
||||
.app-shell[data-mobile] .album-grid .album-card,
|
||||
.app-shell[data-mobile] .album-grid .artist-card {
|
||||
flex: 0 0 130px;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile] .album-card-more {
|
||||
flex: 0 0 130px;
|
||||
}
|
||||
|
||||
/* ─── Hero — mobile ─── */
|
||||
.app-shell[data-mobile] .hero,
|
||||
.app-shell[data-mobile] .hero-placeholder {
|
||||
height: 290px;
|
||||
}
|
||||
|
||||
/* Stronger bottom gradient so white text stays readable without a cover */
|
||||
.app-shell[data-mobile] .hero-overlay {
|
||||
background:
|
||||
linear-gradient(to top,
|
||||
rgba(0, 0, 0, 0.90) 0%,
|
||||
rgba(0, 0, 0, 0.55) 45%,
|
||||
rgba(0, 0, 0, 0.22) 100%);
|
||||
}
|
||||
|
||||
.app-shell[data-mobile] .hero-content {
|
||||
padding: var(--space-4);
|
||||
padding-bottom: 44px; /* keep buttons clear of the pagination dots */
|
||||
gap: 0;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile] .hero-title {
|
||||
font-size: clamp(17px, 5vw, 22px);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Circular action buttons on mobile hero */
|
||||
.hero-actions-mobile {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ─── Live Search — full width ─── */
|
||||
.app-shell[data-mobile] .live-search {
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ─── Section / Page titles ─── */
|
||||
.app-shell[data-mobile] .section-title,
|
||||
.app-shell[data-mobile] .page-title,
|
||||
.app-shell[data-mobile] .section-title-link {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* ─── Album Detail Header — stack vertically on mobile ─── */
|
||||
.app-shell[data-mobile] .album-detail-header-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile] .album-detail-cover {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
/* ─── Page padding ─── */
|
||||
.app-shell[data-mobile] .content-body {
|
||||
padding: var(--space-4) !important;
|
||||
}
|
||||
|
||||
/* ─── Hide desktop search on mobile ─── */
|
||||
.app-shell[data-mobile] .live-search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
MOBILE SEARCH OVERLAY
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@keyframes mobile-search-in {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.mobile-search-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: var(--bg-app);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: mobile-search-in 0.2s ease both;
|
||||
}
|
||||
|
||||
/* ─── Search bar ─── */
|
||||
.mobile-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px 10px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-search-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
border-radius: 20px;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-search-icon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-search-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 17px;
|
||||
color: var(--text-primary);
|
||||
caret-color: var(--accent);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-search-input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-search-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--text-muted);
|
||||
border: none;
|
||||
color: var(--bg-app);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mobile-search-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Results area ─── */
|
||||
.mobile-search-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.mobile-search-noresults {
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ─── Empty state ─── */
|
||||
.mobile-search-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-search-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 48px 20px 32px;
|
||||
}
|
||||
|
||||
.mobile-search-hint-icon {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.mobile-search-hint-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Category chips ─── */
|
||||
.mobile-search-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 6px 16px 16px;
|
||||
}
|
||||
|
||||
.mobile-search-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 7px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-search-chip:active,
|
||||
.mobile-search-chip:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── Recent searches ─── */
|
||||
.mobile-search-recent-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mobile-search-recent-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Section headers ─── */
|
||||
.mobile-search-section {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.mobile-search-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
padding: 16px 16px 6px;
|
||||
}
|
||||
|
||||
/* ─── Result items ─── */
|
||||
.mobile-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-search-item:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mobile-search-thumb {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-search-avatar {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-search-avatar--circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mobile-search-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-search-item-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-search-item-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-search-item-chevron {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Album Header — Mobile Actions ─── */
|
||||
.album-detail-actions-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.album-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.album-actions-row--secondary {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Base icon button */
|
||||
.album-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), filter var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.album-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Play — largest, accent fill */
|
||||
.album-icon-btn--play {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.album-icon-btn--play:hover {
|
||||
background: var(--accent);
|
||||
filter: brightness(1.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Queue — medium, slightly more prominent than secondary */
|
||||
.album-icon-btn--queue {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Secondary (smaller) icon buttons */
|
||||
.album-icon-btn--sm {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.album-icon-btn.is-starred {
|
||||
color: var(--color-star-active, var(--accent));
|
||||
}
|
||||
|
||||
.album-icon-btn.album-icon-btn--active {
|
||||
color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Download progress inline */
|
||||
.album-icon-btn.album-icon-btn--progress {
|
||||
width: auto;
|
||||
border-radius: 21px;
|
||||
padding: 0 12px;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.album-icon-btn-pct {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ─── Album Tracklist — Mobile ─── */
|
||||
.tracklist-mobile {
|
||||
padding: 0 var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.tracklist-mobile-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tracklist-mobile-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tracklist-mobile-row.active .tracklist-mobile-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tracklist-mobile-row.context-active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tracklist-mobile-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tracklist-mobile-num {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 18px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tracklist-mobile-eq {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 18px;
|
||||
}
|
||||
|
||||
.tracklist-mobile-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tracklist-mobile-duration {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1203,3 +1203,634 @@
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
MOBILE LAYOUT (< 800px)
|
||||
Controller: data-mobile attribute set by useIsMobile hook
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── BottomNav Component ─── */
|
||||
.bottom-nav {
|
||||
grid-area: bottomnav;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
background: var(--bg-sidebar);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
height: 56px;
|
||||
z-index: 100;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
transition: color var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bottom-nav-item:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bottom-nav-item.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.bottom-nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 32px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 0 var(--radius-full) var(--radius-full);
|
||||
}
|
||||
|
||||
.bottom-nav-item svg {
|
||||
opacity: 0.65;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.bottom-nav-item.active svg,
|
||||
.bottom-nav-item:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bottom-nav-icon-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bottom-nav-np-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: np-dot-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bottom-nav-label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ─── Grid Overrides ─── */
|
||||
.app-shell[data-mobile] {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
grid-template-areas:
|
||||
"main"
|
||||
"bottomnav"
|
||||
"player";
|
||||
width: 100vw;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Hide the sidebar collapse button on mobile */
|
||||
.app-shell[data-mobile] .collapse-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Content Header — Mobile ─── */
|
||||
.app-shell[data-mobile] .content-header {
|
||||
padding: 0 var(--space-4);
|
||||
height: 52px;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Hide queue toggle on mobile */
|
||||
.app-shell[data-mobile] .queue-toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Player Bar — Mobile ─── */
|
||||
.app-shell[data-mobile] .player-bar {
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-3);
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile] .player-track-info {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile] .player-waveform-section,
|
||||
.app-shell[data-mobile] .player-volume-section,
|
||||
.app-shell[data-mobile] .player-eq-btn,
|
||||
.app-shell[data-mobile] .player-bar > .player-btn.player-btn-sm:not(.player-star-btn):not(.player-love-btn) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile] .player-buttons {
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile] .player-btn-primary {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile] .player-album-art,
|
||||
.app-shell[data-mobile] .player-album-art-placeholder {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* Hide star + last.fm love buttons in mobile player to save space */
|
||||
.app-shell[data-mobile] .player-star-btn,
|
||||
.app-shell[data-mobile] .player-love-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── App Updater Toast — Mobile ─── */
|
||||
.app-shell[data-mobile] .app-updater-toast {
|
||||
bottom: calc(64px + 56px + 12px);
|
||||
}
|
||||
|
||||
/* ─── Mobile Player Takeover ─── */
|
||||
/* When the mobile player is active, the app-shell becomes a single full-screen
|
||||
area with no BottomNav or PlayerBar — just the main content. */
|
||||
.app-shell[data-mobile-player] {
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-areas: "main";
|
||||
}
|
||||
|
||||
.app-shell[data-mobile-player] .content-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile-player] .content-body {
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
MOBILE PLAYER VIEW (MobilePlayerView.tsx)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.mp-view {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
background: var(--bg-app); /* fallback; overridden by dynamic gradient via inline style */
|
||||
overflow: hidden !important;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
padding: 0 20px env(safe-area-inset-bottom, 0);
|
||||
box-sizing: border-box;
|
||||
transition: background 0.8s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mp-view::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Tauri/WebKitGTK */
|
||||
}
|
||||
|
||||
/* ─── Header ─── */
|
||||
.mp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-header-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mp-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.mp-back:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ─── Cover Art ─── */
|
||||
.mp-cover-wrap {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mp-cover {
|
||||
width: min(calc(100vw - 28px), 55vh);
|
||||
max-width: 440px;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 52px rgba(0, 0, 0, 0.7), 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.mp-cover-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── Track Metadata ─── */
|
||||
.mp-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 8px 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-meta-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mp-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.mp-artist {
|
||||
font-size: 15px;
|
||||
color: var(--accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mp-track-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.mp-heart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.mp-heart:hover {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
.mp-heart.active {
|
||||
color: var(--color-star-active, var(--accent));
|
||||
}
|
||||
|
||||
/* ─── Scrubber ─── */
|
||||
.mp-scrubber-wrap {
|
||||
padding: 12px 0 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-scrubber {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mp-scrubber-bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--ctp-surface2, rgba(255,255,255,0.12));
|
||||
}
|
||||
|
||||
.mp-scrubber-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
transition: width 0.08s linear;
|
||||
}
|
||||
|
||||
.mp-scrubber-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.mp-scrubber:active .mp-scrubber-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.3);
|
||||
}
|
||||
|
||||
.mp-scrubber-times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ─── Transport Controls ─── */
|
||||
.mp-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
padding: 8px 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-ctrl-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.mp-ctrl-btn:hover {
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.mp-ctrl-sm {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mp-ctrl-play {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.mp-ctrl-play:hover {
|
||||
color: var(--ctp-crust);
|
||||
filter: brightness(1.1);
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.4), var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* ─── Empty state ─── */
|
||||
.mp-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ─── Utility Footer ─── */
|
||||
.mp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
padding: 8px 0 max(12px, env(safe-area-inset-bottom, 12px));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-full);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mp-footer-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
MOBILE QUEUE / LYRICS DRAWER
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@keyframes mq-slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes mq-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.mq-drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: mq-fade-in 0.2s ease both;
|
||||
}
|
||||
|
||||
.mq-drawer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 90vh;
|
||||
z-index: 501;
|
||||
background: var(--bg-sidebar);
|
||||
border-radius: 18px 18px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: mq-slide-up 0.3s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.mq-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mq-drawer-header h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mq-drawer-count {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mq-drawer-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover);
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.mq-drawer-close:hover {
|
||||
background: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mq-drawer-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.mq-drawer-empty {
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Lyrics drawer — fill entire drawer */
|
||||
.mq-drawer-lyrics .mq-drawer-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Queue Item ── */
|
||||
.mq-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: background var(--transition-fast);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mq-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mq-item.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mq-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mq-item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mq-item-artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mq-item.active .mq-item-artist {
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mq-item-dur {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
|
||||
.mq-item.active .mq-item-dur {
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user