diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 321df249..9142ba32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,6 +128,17 @@ jobs: releaseDraft: true args: ${{ matrix.settings.args }} + # tauri-action auto-generates a latest.json using the pubkey from tauri.conf.json + # as the signature value (wrong). Delete it so only our generate-update-manifest.js + # output is ever used as the canonical update manifest. + - name: remove tauri-action generated update manifest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release delete-asset \ + "app-v${{ needs.create-release.outputs.package_version }}" \ + latest.json --yes 2>/dev/null || true + - name: sign and upload Windows NSIS updater bundle if: matrix.settings.platform == 'windows-latest' shell: pwsh diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f6d97c..33192520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.34.0] - 2026-04-06 + +### Added + +- **Mobile UI — Early Preview** ⚠️ — After multiple requests from the community, an initial mobile layout is shipping in this release. **This is a very early work-in-progress** — expect rough edges, missing features, and layouts that still need a lot of polish. Feedback is very welcome! Join the [Discord](https://discord.gg/ckVPGPMS) to share your thoughts. + - Sidebar and queue panel are hidden on mobile; a sticky **Bottom Navigation Bar** replaces them with quick access to Mainstage, Albums, Now Playing, and Search. + - **Mobile Player View** (`/now-playing`) — Full-screen ambient player with dynamic album-art-based background color, large cover art, track metadata line, and playback controls. + - **Mobile Search Overlay** — Full-screen search with recent search history, category chips (Albums, Artists, Genres), and grouped results. + - **Mobile Album Header** — Compact two-row icon button layout (Play + Queue primary, Favorite + Bio + Download + Offline secondary). + - **Mobile Tracklist** — Simplified track rows; disc headers preserved for multi-disc albums. + - **Mobile Hero / Carousel** — Blurred-background-only layout with circular Play + Queue buttons. +- **Russian 2 translation** *(PR [#107](https://github.com/Psychotoxical/psysonic/pull/107) by [@kilyabin](https://github.com/kilyabin))*: A second Russian translation alongside the existing one from [@cucadmuh](https://github.com/cucadmuh) *(PR [#106](https://github.com/Psychotoxical/psysonic/pull/106))*. Both are selectable in Settings → Appearance as **Russian** and **Russian 2**. Since the maintainer neither speaks nor reads Russian, **community feedback is essential here** — please vote on the [Discord](https://discord.gg/ckVPGPMS) or via GitHub which translation feels more natural so we can retire the weaker one in a future release. +- **Clickable Mainstage section headers** — "Zuletzt hinzugefügt", "Entdecken", "Künstler entdecken", and "Persönliche Favoriten" now navigate to their respective pages on click, with a `ChevronRight` indicator and accent-color hover effect. + +### Fixed + +- **macOS network playback** *(Issue [#108](https://github.com/Psychotoxical/psysonic/issues/108))*: Added `com.apple.security.network.client` to `Entitlements.plist` and disabled the app sandbox for unsigned/ad-hoc builds. Without this, macOS silently blocked outbound TCP connections from the Rust audio engine, causing the player to skip through every track without playing anything. +- **Auto-updater** *(under observation)*: Fixed an incorrect signature in the auto-generated `latest.json` — the CI was writing the public key as the signature value. The updater now receives a correctly signed manifest. **Note:** Due to OS-level restrictions on macOS (Gatekeeper) and Windows (SmartScreen) for unsigned apps, it is not yet certain whether the in-app updater will reliably work on these platforms. Manual installation from the Releases page remains the safe fallback. + +### Changed + +- All new i18n keys added to all 8 languages (EN, DE, FR, NL, ZH, NB, RU, RU2). + ## [1.33.0] - 2026-04-06 ### Added diff --git a/package.json b/package.json index 90a2da82..4cbd286a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.33.0", + "version": "1.34.0", "private": true, "scripts": { "dev": "vite", diff --git a/scripts/generate-update-manifest.js b/scripts/generate-update-manifest.js index de8806c7..d3708c63 100644 --- a/scripts/generate-update-manifest.js +++ b/scripts/generate-update-manifest.js @@ -26,6 +26,25 @@ const PLATFORM_FILES = { const platforms = {}; +// A real minisign .sig file is multi-line and ~200+ chars. +// A public key (RWTxxx... single line, ~56 chars) must never appear here. +function validateSignature(sig, platform, sigFile) { + // Public keys start with RWT and are a single short base64 token + if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) { + throw new Error( + `${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` + + ` Got: ${sig}\n` + + ` The TAURI_SIGNING_PUBLIC_KEY env var must never be used as the signature value.\n` + + ` Ensure the signing step correctly writes the .sig file from the private key.` + ); + } + if (sig.length < 80) { + throw new Error( + `${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.` + ); + } +} + for (const [platform, filename] of Object.entries(PLATFORM_FILES)) { const sigFile = `${filename}.sig`; try { @@ -34,11 +53,12 @@ for (const [platform, filename] of Object.entries(PLATFORM_FILES)) { { stdio: 'pipe' } ); const signature = fs.readFileSync(sigFile, 'utf8').trim(); + validateSignature(signature, platform, sigFile); const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`; platforms[platform] = { signature, url }; console.log(`✓ ${platform}`); } catch (e) { - console.warn(`⚠ Skipping ${platform}: asset not found (${sigFile})`); + console.warn(`⚠ Skipping ${platform}: ${e.message}`); } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cfe1fb74..6eecfd68 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3493,7 +3493,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.33.0" +version = "1.34.0" dependencies = [ "biquad", "discord-rich-presence", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 96eae087..96e934a7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.33.0" +version = "1.34.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/Entitlements.plist b/src-tauri/Entitlements.plist index c01bd2bc..6f9fde60 100644 --- a/src-tauri/Entitlements.plist +++ b/src-tauri/Entitlements.plist @@ -2,6 +2,17 @@ + + com.apple.security.app-sandbox + + + + com.apple.security.network.client + + diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d249dffc..36439f2f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.33.0", + "version": "1.34.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", @@ -16,8 +16,8 @@ "title": "Psysonic", "width": 1280, "height": 800, - "minWidth": 1280, - "minHeight": 720, + "minWidth": 360, + "minHeight": 480, "resizable": true, "fullscreen": false, "decorations": true, diff --git a/src/App.tsx b/src/App.tsx index ab306041..9bd8a20e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 (
e.preventDefault()} > - setIsSidebarCollapsed(!isSidebarCollapsed)} - /> + {!isMobile && ( + setIsSidebarCollapsed(!isSidebarCollapsed)} + /> + )}
@@ -273,7 +283,7 @@ function AppShell() { } /> } /> } /> - } /> + : } /> } /> } /> } /> @@ -285,16 +295,19 @@ function AppShell() {
-
{ - e.preventDefault(); - setIsDraggingQueue(true); - }} - style={{ display: isQueueVisible ? 'block' : 'none' }} - /> - - + {!isMobile && ( +
{ + e.preventDefault(); + setIsDraggingQueue(true); + }} + style={{ display: isQueueVisible ? 'block' : 'none' }} + /> + )} + {!isMobile && } + {isMobile && !isMobilePlayer && } + {!isMobilePlayer && } {isFullscreenOpen && ( )} diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index b85a43e1..f9159913 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -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({ )}
-
-
- - + {isMobile ? ( +
+ {/* Row 1 — Primary actions */} +
+ + +
+ + {/* Row 2 — Secondary actions */} +
+ + + + + {downloadProgress !== null ? ( +
+ + {downloadProgress}% +
+ ) : ( + + )} + + {offlineStatus === 'downloading' ? ( +
+ +
+ ) : offlineStatus === 'cached' ? ( + + ) : ( + + )} +
+ ) : ( +
+
+ + +
- + - + - {downloadProgress !== null ? ( -
- -
-
+ {downloadProgress !== null ? ( +
+ +
+
+
+ {downloadProgress}%
- {downloadProgress}% -
- ) : ( - - )} - {offlineStatus === 'downloading' && offlineProgress ? ( -
- - {t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })} -
- ) : offlineStatus === 'cached' ? ( - - ) : ( - - )} -
+ ) : ( + + )} + {offlineStatus === 'downloading' && offlineProgress ? ( +
+ + {t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })} +
+ ) : offlineStatus === 'cached' ? ( + + ) : ( + + )} +
+ )}
diff --git a/src/components/AlbumRow.tsx b/src/components/AlbumRow.tsx index f13243d6..36bbea25 100644 --- a/src/components/AlbumRow.tsx +++ b/src/components/AlbumRow.tsx @@ -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; } -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(null); const navigate = useNavigate(); @@ -61,7 +62,13 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore return (
-

{title}

+ {titleLink ? ( + + {title} + + ) : ( +

{title}

+ )}
+ + + {searchOpen && setSearchOpen(false)} />} + + ); +} diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index c8b09b44..e28abcfd 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -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([]); const [activeIdx, setActiveIdx] = useState(0); const timerRef = useRef | 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 */}
- {coverRawUrl && ( + {coverRawUrl && !isMobile && ( {album.year && {album.year}} {album.genre && {album.genre}} - {album.songCount && {album.songCount} Tracks} - {albumFormats[album.id] && {albumFormats[album.id]}} -
-
- - + {!isMobile && album.songCount && {album.songCount} Tracks} + {!isMobile && albumFormats[album.id] && {albumFormats[album.id]}}
+ {isMobile ? ( +
e.stopPropagation()}> + + +
+ ) : ( +
+ + +
+ )}
diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx new file mode 100644 index 00000000..70da4eab --- /dev/null +++ b/src/components/MobilePlayerView.tsx @@ -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 { + 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(null); + + // Scroll active track into view on open + useEffect(() => { + const el = listRef.current?.querySelector('.mq-item.active'); + el?.scrollIntoView({ block: 'center', behavior: 'instant' }); + }, []); + + return ( +
+
e.stopPropagation()}> +
+

{t('queue.title')}

+ + {queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} + + +
+
+ {queue.length === 0 ? ( +
{t('queue.emptyQueue')}
+ ) : ( + queue.map((track, idx) => { + const isActive = idx === queueIndex; + return ( +
{ playTrack(track, queue); onClose(); }} + > +
+
+ {isActive && } + {track.title} +
+
{track.artist}
+
+ {formatTime(track.duration)} +
+ ); + }) + )} +
+
+
+ ); +} + +// ── Lyrics Drawer ───────────────────────────────────────────────────────────── + +function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentTrack: Track | null }) { + const { t } = useTranslation(); + + return ( +
+
e.stopPropagation()}> +
+

{t('player.lyrics')}

+ +
+
+ +
+
+
+ ); +} + +// ── 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(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 ( +
+
+ + {t('sidebar.nowPlaying')} +
+
+
+ +

{t('nowPlaying.nothingPlaying')}

+
+
+ ); + } + + const bgStyle: CSSProperties = { + background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`, + }; + + return ( +
+ {/* Header */} +
+ + {t('sidebar.nowPlaying')} +
+
+ + {/* Cover Art */} +
+ {resolvedCover ? ( + + ) : ( +
+ +
+ )} +
+ + {/* Track Metadata */} +
+
+
{currentTrack.title}
+
currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} + style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }} + > + {currentTrack.artist} +
+ {(() => { + const parts = [ + currentTrack.year, + currentTrack.genre, + currentTrack.suffix?.toUpperCase(), + currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : null, + ].filter(Boolean); + return parts.length > 0 + ?
{parts.join(' • ')}
+ : null; + })()} +
+ +
+ + {/* Scrubber */} +
+
onScrubStart(e.clientX)} + onTouchStart={e => onScrubStart(e.touches[0].clientX)} + > +
+
+
+
+
+ {formatTime(currentTime)} + -{formatTime(Math.max(0, duration - currentTime))} +
+
+ + {/* Transport Controls */} +
+ + + + + +
+ + {/* Utility Footer */} +
+ + +
+ + {/* Queue Drawer */} + {showQueue && setShowQueue(false)} />} + + {/* Lyrics Drawer */} + {showLyrics && setShowLyrics(false)} currentTrack={currentTrack} />} +
+ ); +} diff --git a/src/components/MobileSearchOverlay.tsx b/src/components/MobileSearchOverlay.tsx new file mode 100644 index 00000000..b27ddf05 --- /dev/null +++ b/src/components/MobileSearchOverlay.tsx @@ -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; + 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(null); + const [loading, setLoading] = useState(false); + const [recentSearches, setRecentSearches] = useState(loadRecent); + const inputRef = useRef(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( +
+ {/* ── Search bar ── */} +
+
+ {loading ? ( +
+ ) : ( + + )} + setQuery(e.target.value)} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + /> + {query && ( + + )} +
+ +
+ +
+ {/* ── Empty state ── */} + {showEmpty && ( +
+ {recentSearches.length > 0 && ( +
+
{t('search.recentSearches')}
+ {recentSearches.map(term => ( + + + ))} +
+ )} + +
+
{t('search.browse')}
+
+ + + +
+
+ +
+ + {t('search.emptyHint')} +
+
+ )} + + {/* ── No results ── */} + {!loading && query && !hasResults && ( +
+ {t('search.noResults', { query })} +
+ )} + + {/* ── Results ── */} + {hasResults && ( + <> + {results!.artists.length > 0 && ( +
+
{t('search.artists')}
+ {results!.artists.map(a => ( + + ))} +
+ )} + + {results!.albums.length > 0 && ( +
+
{t('search.albums')}
+ {results!.albums.map(a => ( + + ))} +
+ )} + + {results!.songs.length > 0 && ( +
+
{t('search.songs')}
+ {results!.songs.map(s => ( + + ))} +
+ )} + + )} +
+
, + document.body + ); +} diff --git a/src/hooks/useIsMobile.ts b/src/hooks/useIsMobile.ts new file mode 100644 index 00000000..c6b07045 --- /dev/null +++ b/src/hooks/useIsMobile.ts @@ -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); +} diff --git a/src/i18n.ts b/src/i18n.ts index 89d28740..6f2cf020 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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', diff --git a/src/locales/de.ts b/src/locales/de.ts index f67c7e81..3a09f947 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index ebc7624e..a7138321 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index f7eddf18..d70828fe 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 64ebb8e6..7a3319f7 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 8f76762d..c8e8e69e 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 4ce852aa..5b065fcf 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -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: 'Оформление', diff --git a/src/locales/ru2.ts b/src/locales/ru2.ts new file mode 100644 index 00000000..a6bca42b --- /dev/null +++ b/src/locales/ru2.ts @@ -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: 'Избранных станций нет.', + } +} diff --git a/src/locales/zh.ts b/src/locales/zh.ts index e6a86eed..acb3f11a 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -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: '外观', diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 79d4cacd..8cdd60e7 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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') && ( loadMore('newest', recent, setRecent)} moreText={t('home.loadMore')} @@ -84,6 +86,7 @@ export default function Home() { {isVisible('discover') && ( loadMore('random', random, setRandom)} moreText={t('home.discoverMore')} @@ -92,7 +95,9 @@ export default function Home() { {isVisible('discoverArtists') && randomArtists.length > 0 && (
-

{t('home.discoverArtists')}

+ + {t('home.discoverArtists')} +
{randomArtists.map(a => ( @@ -118,6 +123,7 @@ export default function Home() { {isVisible('starred') && starred.length > 0 && ( loadMore('starred', starred, setStarred)} moreText={t('home.loadMore')} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7f570dd8..7589a218 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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') }, ]} />
diff --git a/src/styles/components.css b/src/styles/components.css index d10209c7..a9d9decc 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 00913ca8..a9ed488a 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; +}