feat: v1.34.0 — Mobile UI Early Preview, Russian 2, macOS network fix

Co-authored-by: kilyabin <kilyabin@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-06 12:46:13 +02:00
parent 29203803de
commit 50ac1b8284
30 changed files with 3141 additions and 123 deletions
+11
View File
@@ -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
+23
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.33.0",
"version": "1.34.0",
"private": true,
"scripts": {
"dev": "vite",
+21 -1
View File
@@ -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}`);
}
}
+1 -1
View File
@@ -3493,7 +3493,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.33.0"
version = "1.34.0"
dependencies = [
"biquad",
"discord-rich-presence",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.33.0"
version = "1.34.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+11
View File
@@ -2,6 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Sandbox disabled: app is unsigned/ad-hoc signed, so an active sandbox
would restrict network access more than without it. macOS users bypass
Gatekeeper via: xattr -cr /Applications/Psysonic.app -->
<key>com.apple.security.app-sandbox</key>
<false/>
<!-- Allow outbound TCP connections to the Navidrome server (reqwest in audio.rs).
Respected by ad-hoc signatures on some macOS versions. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Psysonic is a music PLAYER only — no microphone access needed.
This suppresses the macOS microphone permission prompt triggered
by cpal/CoreAudio enumerating input devices during audio init. -->
+3 -3
View File
@@ -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,
+18 -5
View File
@@ -8,6 +8,9 @@ import { PanelRight, PanelRightClose } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Sidebar from './components/Sidebar';
import PlayerBar from './components/PlayerBar';
import BottomNav from './components/BottomNav';
import MobilePlayerView from './components/MobilePlayerView';
import { useIsMobile } from './hooks/useIsMobile';
import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel';
@@ -66,6 +69,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
function AppShell() {
const { t } = useTranslation();
const isMobile = useIsMobile();
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
@@ -219,19 +223,25 @@ function AppShell() {
};
}, []);
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
return (
<div
className="app-shell"
data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined}
style={{
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
>
{!isMobile && (
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
)}
<main className="main-content">
<header className="content-header">
<LiveSearch />
@@ -273,7 +283,7 @@ function AppShell() {
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/now-playing" element={<NowPlayingPage />} />
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
<Route path="/offline" element={<OfflineLibrary />} />
@@ -285,6 +295,7 @@ function AppShell() {
</Routes>
</div>
</main>
{!isMobile && (
<div
className="resizer resizer-queue"
onMouseDown={(e) => {
@@ -293,8 +304,10 @@ function AppShell() {
}}
style={{ display: isQueueVisible ? 'block' : 'none' }}
/>
<QueuePanel />
<PlayerBar />
)}
{!isMobile && <QueuePanel />}
{isMobile && !isMobilePlayer && <BottomNav />}
{!isMobilePlayer && <PlayerBar />}
{isFullscreenOpen && (
<FullscreenPlayer onClose={toggleFullscreen} />
)}
+87
View File
@@ -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,6 +186,90 @@ export default function AlbumHeader({
</>
)}
</div>
{isMobile ? (
<div className="album-detail-actions-mobile">
{/* Row 1 — Primary actions */}
<div className="album-actions-row album-actions-row--primary">
<button
className="album-icon-btn album-icon-btn--play"
onClick={onPlayAll}
aria-label={t('albumDetail.playAll')}
data-tooltip={t('albumDetail.playAll')}
>
<Play size={24} fill="currentColor" />
</button>
<button
className="album-icon-btn album-icon-btn--queue"
onClick={onEnqueueAll}
aria-label={t('albumDetail.enqueue')}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={20} />
</button>
</div>
{/* Row 2 — Secondary actions */}
<div className="album-actions-row album-actions-row--secondary">
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onBio}
aria-label={t('albumDetail.artistBio')}
data-tooltip={t('albumDetail.artistBio')}
>
<ExternalLink size={16} />
</button>
{downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
)}
{offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
)}
</div>
</div>
) : (
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
@@ -250,6 +336,7 @@ export default function AlbumHeader({
</button>
)}
</div>
)}
</div>
</div>
</div>
+9 -2
View File
@@ -2,18 +2,19 @@ import React, { useRef, useState, useEffect } from 'react';
import { SubsonicAlbum } from '../api/subsonic';
import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
titleLink?: string;
albums: SubsonicAlbum[];
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
}
export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) {
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
@@ -61,7 +62,13 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
return (
<section className="album-row-section">
<div className="album-row-header">
{titleLink ? (
<NavLink to={titleLink} className="section-title-link" style={{ marginBottom: 0 }}>
{title}<ChevronRight size={18} className="section-title-chevron" />
</NavLink>
) : (
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
)}
<div className="album-row-nav">
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
+46
View File
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu';
import { useIsMobile } from '../hooks/useIsMobile';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
@@ -96,6 +97,7 @@ export default function AlbumTrackList({
}: AlbumTrackListProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
@@ -293,6 +295,50 @@ export default function AlbumTrackList({
}
};
// ── Mobile tracklist ─────────────────────────────────────────────────────
if (isMobile) {
return (
<div className="tracklist-mobile">
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
<div className="disc-header">
<span className="disc-icon">💿</span> CD {discNum}
</div>
)}
{discs.get(discNum)!.map(song => {
const isActive = currentTrack?.id === song.id;
return (
<div
key={song.id}
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
<div className="tracklist-mobile-main">
{isActive && isPlaying ? (
<span className="tracklist-mobile-eq">
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
</span>
) : (
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
)}
<span className="tracklist-mobile-title">{song.title}</span>
</div>
<span className="tracklist-mobile-duration">{formatDuration(song.duration)}</span>
</div>
);
})}
</div>
))}
</div>
);
}
return (
<div className="tracklist" ref={tracklistRef}>
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Disc3, Search, Music4, AudioLines } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import MobileSearchOverlay from './MobileSearchOverlay';
const NAV_ITEMS = [
{ to: '/', end: true, icon: Disc3, labelKey: 'sidebar.mainstage' },
{ to: '/albums', end: false, icon: Music4, labelKey: 'sidebar.allAlbums' },
{ to: '/now-playing', end: false, icon: AudioLines, labelKey: 'sidebar.nowPlaying' },
] as const;
export default function BottomNav() {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const [searchOpen, setSearchOpen] = useState(false);
return (
<>
<nav className="bottom-nav" aria-label="Mobile navigation">
{NAV_ITEMS.map(({ to, end, icon: Icon, labelKey }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) => `bottom-nav-item${isActive ? ' active' : ''}`}
>
<span className="bottom-nav-icon-wrap">
<Icon size={22} />
{to === '/now-playing' && isPlaying && currentTrack && (
<span className="bottom-nav-np-dot" />
)}
</span>
<span className="bottom-nav-label">{t(labelKey)}</span>
</NavLink>
))}
<button
className="bottom-nav-item"
onClick={() => setSearchOpen(true)}
aria-label={t('search.title')}
>
<span className="bottom-nav-icon-wrap">
<Search size={22} />
</span>
<span className="bottom-nav-label">{t('search.title')}</span>
</button>
</nav>
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
</>
);
}
+31 -3
View File
@@ -6,6 +6,7 @@ import CachedImage, { useCachedUrl } from './CachedImage';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile';
const INTERVAL_MS = 10000;
@@ -50,6 +51,7 @@ interface HeroProps {
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -120,7 +122,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content animate-fade-in" key={album.id}>
{coverRawUrl && (
{coverRawUrl && !isMobile && (
<CachedImage
className="hero-cover"
src={coverRawUrl}
@@ -135,9 +137,34 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
<div className="hero-meta">
{album.year && <span className="badge">{album.year}</span>}
{album.genre && <span className="badge">{album.genre}</span>}
{album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
{!isMobile && album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{!isMobile && albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
</div>
{isMobile ? (
<div className="hero-actions-mobile" onClick={e => e.stopPropagation()}>
<button
className="album-icon-btn album-icon-btn--play"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={22} fill="currentColor" />
</button>
<button
className="album-icon-btn album-icon-btn--queue"
onClick={async e => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
} catch (_) {}
}}
aria-label={t('hero.enqueue')}
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={20} />
</button>
</div>
) : (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
className="hero-play-btn"
@@ -165,6 +192,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{t('hero.enqueue')}
</button>
</div>
)}
</div>
</div>
+387
View File
@@ -0,0 +1,387 @@
import React, { useState, useCallback, useMemo, useRef, useEffect, CSSProperties } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ChevronDown, Play, Pause, SkipBack, SkipForward,
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
} from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import LyricsPane from './LyricsPane';
// ── Color extraction ──────────────────────────────────────────────────────────
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
// medium-dark) pixel. Returns an "R, G, B" string for use in rgba().
function extractVibrantColor(imageUrl: string): Promise<string> {
return new Promise(resolve => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve('0,0,0'); return; }
ctx.drawImage(img, 0, 0, 16, 16);
const { data } = ctx.getImageData(0, 0, 16, 16);
let bestR = 0, bestG = 0, bestB = 0, bestScore = -1;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const max = Math.max(r, g, b) / 255;
const min = Math.min(r, g, b) / 255;
const l = (max + min) / 2;
const s = max === min ? 0 : (max - min) / (l > 0.5 ? 2 - max - min : max + min);
// Prefer saturated pixels in the medium-dark range (l 0.20.6)
const score = s * (1 - Math.abs(l - 0.4));
if (score > bestScore) {
bestScore = score;
bestR = r; bestG = g; bestB = b;
}
}
resolve(`${bestR},${bestG},${bestB}`);
};
img.onerror = () => resolve('0,0,0');
img.src = imageUrl;
});
}
function useAlbumAccentColor(imageUrl: string): string {
const [color, setColor] = useState('0,0,0');
useEffect(() => {
if (!imageUrl) { setColor('0,0,0'); return; }
let cancelled = false;
extractVibrantColor(imageUrl).then(c => { if (!cancelled) setColor(c); });
return () => { cancelled = true; };
}, [imageUrl]);
return color;
}
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
// ── Queue Drawer ──────────────────────────────────────────────────────────────
function QueueDrawer({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const playTrack = usePlayerStore(s => s.playTrack);
const listRef = useRef<HTMLDivElement>(null);
// Scroll active track into view on open
useEffect(() => {
const el = listRef.current?.querySelector('.mq-item.active');
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
}, []);
return (
<div className="mq-drawer-backdrop" onClick={onClose}>
<div className="mq-drawer" onClick={e => e.stopPropagation()}>
<div className="mq-drawer-header">
<h3>{t('queue.title')}</h3>
<span className="mq-drawer-count">
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')}
</span>
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className="mq-drawer-list" ref={listRef}>
{queue.length === 0 ? (
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
) : (
queue.map((track, idx) => {
const isActive = idx === queueIndex;
return (
<div
key={`${track.id}-${idx}`}
className={`mq-item${isActive ? ' active' : ''}`}
onClick={() => { playTrack(track, queue); onClose(); }}
>
<div className="mq-item-info">
<div className="mq-item-title">
{isActive && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
<span className="truncate">{track.title}</span>
</div>
<div className="mq-item-artist truncate">{track.artist}</div>
</div>
<span className="mq-item-dur">{formatTime(track.duration)}</span>
</div>
);
})
)}
</div>
</div>
</div>
);
}
// ── Lyrics Drawer ─────────────────────────────────────────────────────────────
function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentTrack: Track | null }) {
const { t } = useTranslation();
return (
<div className="mq-drawer-backdrop" onClick={onClose}>
<div className="mq-drawer mq-drawer-lyrics" onClick={e => e.stopPropagation()}>
<div className="mq-drawer-header">
<h3>{t('player.lyrics')}</h3>
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className="mq-drawer-list">
<LyricsPane currentTrack={currentTrack} />
</div>
</div>
</div>
);
}
// ── Mobile Player View ────────────────────────────────────────────────────────
export default function MobilePlayerView() {
const { t } = useTranslation();
const navigate = useNavigate();
// Lock body scroll while full-screen player is mounted
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const progress = usePlayerStore(s => s.progress);
const currentTime = usePlayerStore(s => s.currentTime);
const togglePlay = usePlayerStore(s => s.togglePlay);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const seek = usePlayerStore(s => s.seek);
const repeatMode = usePlayerStore(s => s.repeatMode);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const duration = currentTrack?.duration ?? 0;
// Cover art
const coverFetchUrl = useMemo(
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '',
[currentTrack?.coverArt]
);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Dynamic background color extracted from cover art
const accentColor = useAlbumAccentColor(resolvedCover);
// Star / favorite
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
const nextVal = !isStarred;
setStarredOverride(currentTrack.id, nextVal);
try {
if (nextVal) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !nextVal);
}
}, [currentTrack, isStarred, setStarredOverride]);
// Scrubber touch/mouse drag
const scrubberRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const seekFromX = useCallback((clientX: number) => {
const el = scrubberRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
seek(pct);
}, [seek]);
const onScrubStart = useCallback((clientX: number) => {
isDragging.current = true;
seekFromX(clientX);
}, [seekFromX]);
useEffect(() => {
const onMove = (e: TouchEvent | MouseEvent) => {
if (!isDragging.current) return;
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
seekFromX(clientX);
};
const onEnd = () => { isDragging.current = false; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onEnd);
window.addEventListener('touchmove', onMove, { passive: true });
window.addEventListener('touchend', onEnd);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onEnd);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
};
}, [seekFromX]);
// Drawers
const [showQueue, setShowQueue] = useState(false);
const [showLyrics, setShowLyrics] = useState(false);
// ── Empty state ──
if (!currentTrack) {
return (
<div className="mp-view">
<div className="mp-header">
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
<ChevronDown size={28} />
</button>
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
<div style={{ width: 44 }} />
</div>
<div className="mp-empty">
<Music size={56} style={{ opacity: 0.25 }} />
<p>{t('nowPlaying.nothingPlaying')}</p>
</div>
</div>
);
}
const bgStyle: CSSProperties = {
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
};
return (
<div className="mp-view" style={bgStyle}>
{/* Header */}
<div className="mp-header">
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
<ChevronDown size={28} />
</button>
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
<div style={{ width: 44 }} />
</div>
{/* Cover Art */}
<div className="mp-cover-wrap">
{resolvedCover ? (
<img src={resolvedCover} alt="" className="mp-cover" />
) : (
<div className="mp-cover mp-cover-fallback">
<Music size={64} />
</div>
)}
</div>
{/* Track Metadata */}
<div className="mp-meta">
<div className="mp-meta-text">
<div className="mp-title truncate">{currentTrack.title}</div>
<div
className="mp-artist truncate"
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
>
{currentTrack.artist}
</div>
{(() => {
const parts = [
currentTrack.year,
currentTrack.genre,
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : null,
].filter(Boolean);
return parts.length > 0
? <div className="mp-track-info truncate">{parts.join(' • ')}</div>
: null;
})()}
</div>
<button
className={`mp-heart${isStarred ? ' active' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Heart size={22} fill={isStarred ? 'currentColor' : 'none'} />
</button>
</div>
{/* Scrubber */}
<div className="mp-scrubber-wrap">
<div
className="mp-scrubber"
ref={scrubberRef}
onMouseDown={e => onScrubStart(e.clientX)}
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
>
<div className="mp-scrubber-bg" />
<div className="mp-scrubber-fill" style={{ width: `${progress * 100}%` }} />
<div className="mp-scrubber-thumb" style={{ left: `${progress * 100}%` }} />
</div>
<div className="mp-scrubber-times">
<span>{formatTime(currentTime)}</span>
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
</div>
{/* Transport Controls */}
<div className="mp-controls">
<button
className="mp-ctrl-btn mp-ctrl-sm"
onClick={() => shuffleQueue()}
aria-label={t('queue.shuffle')}
>
<Shuffle size={20} />
</button>
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
<SkipBack size={28} />
</button>
<button className="mp-ctrl-btn mp-ctrl-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
</button>
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
<SkipForward size={28} />
</button>
<button
className={`mp-ctrl-btn mp-ctrl-sm`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
>
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
</button>
</div>
{/* Utility Footer */}
<div className="mp-footer">
<button className="mp-footer-btn" onClick={() => setShowLyrics(true)}>
<MicVocal size={20} />
<span>{t('player.lyrics')}</span>
</button>
<button className="mp-footer-btn" onClick={() => setShowQueue(true)}>
<ListMusic size={20} />
<span>{t('queue.title')}</span>
</button>
</div>
{/* Queue Drawer */}
{showQueue && <QueueDrawer onClose={() => setShowQueue(false)} />}
{/* Lyrics Drawer */}
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
const STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6;
function loadRecent(): string[] {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
}
function saveRecent(q: string, prev: string[]): string[] {
const updated = [q.trim(), ...prev.filter(s => s !== q.trim())].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
}
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
}
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const navigate = useNavigate();
const playTrack = usePlayerStore(s => s.playTrack);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { inputRef.current?.focus(); }, []);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const doSearch = useCallback(
debounce(async (q: string) => {
if (!q.trim()) { setResults(null); setLoading(false); return; }
setLoading(true);
try { setResults(await search(q)); }
finally { setLoading(false); }
}, 300),
[]
);
useEffect(() => { doSearch(query); }, [query, doSearch]);
const commit = (q: string) => {
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
};
const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
const goCategory = (path: string) => { navigate(path); onClose(); };
const playSong = (song: SearchResults['songs'][number]) => {
commit(query);
playTrack(songToTrack(song));
onClose();
};
const useRecent = (term: string) => {
setQuery(term);
inputRef.current?.focus();
};
const removeRecent = (term: string, e: React.MouseEvent) => {
e.stopPropagation();
setRecentSearches(prev => {
const updated = prev.filter(s => s !== term);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
};
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
const showEmpty = !query;
return createPortal(
<div className="mobile-search-overlay">
{/* ── Search bar ── */}
<div className="mobile-search-bar">
<div className="mobile-search-field">
{loading ? (
<div className="mobile-search-spinner" />
) : (
<Search size={16} className="mobile-search-icon" />
)}
<input
ref={inputRef}
className="mobile-search-input"
type="search"
placeholder={t('search.placeholder')}
value={query}
onChange={e => setQuery(e.target.value)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{query && (
<button
className="mobile-search-clear"
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
aria-label={t('search.clearLabel')}
>
<X size={15} />
</button>
)}
</div>
<button className="mobile-search-cancel" onClick={onClose}>
{t('common.cancel')}
</button>
</div>
<div className="mobile-search-results">
{/* ── Empty state ── */}
{showEmpty && (
<div className="mobile-search-empty-state">
{recentSearches.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
{recentSearches.map(term => (
<button key={term} className="mobile-search-item" onClick={() => useRecent(term)}>
<div className="mobile-search-avatar">
<Clock size={18} />
</div>
<div className="mobile-search-item-info" style={{ flex: 1 }}>
<span className="mobile-search-item-title">{term}</span>
</div>
<button
className="mobile-search-recent-remove"
onClick={e => removeRecent(term, e)}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
</button>
))}
</div>
)}
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.browse')}</div>
<div className="mobile-search-chips">
<button className="mobile-search-chip" onClick={() => goCategory('/albums')}>
<Music2 size={15} /> {t('search.albums')}
</button>
<button className="mobile-search-chip" onClick={() => goCategory('/artists')}>
<Users size={15} /> {t('search.artists')}
</button>
<button className="mobile-search-chip" onClick={() => goCategory('/genres')}>
<Music size={15} /> {t('search.genres')}
</button>
</div>
</div>
<div className="mobile-search-hint">
<Search size={52} className="mobile-search-hint-icon" />
<span className="mobile-search-hint-text">{t('search.emptyHint')}</span>
</div>
</div>
)}
{/* ── No results ── */}
{!loading && query && !hasResults && (
<div className="mobile-search-noresults">
{t('search.noResults', { query })}
</div>
)}
{/* ── Results ── */}
{hasResults && (
<>
{results!.artists.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.artists')}</div>
{results!.artists.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/artist/${a.id}`)}>
<div className="mobile-search-avatar mobile-search-avatar--circle">
<Users size={20} />
</div>
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{a.name}</span>
<span className="mobile-search-item-sub">{t('search.artists')}</span>
</div>
<ChevronRight size={16} className="mobile-search-item-chevron" />
</button>
))}
</div>
)}
{results!.albums.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.albums')}</div>
{results!.albums.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
{a.coverArt ? (
<img className="mobile-search-thumb" src={buildCoverArtUrl(a.coverArt, 80)} alt="" loading="lazy" />
) : (
<div className="mobile-search-avatar">
<Disc3 size={20} />
</div>
)}
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{a.name}</span>
<span className="mobile-search-item-sub">{a.artist}</span>
</div>
<ChevronRight size={16} className="mobile-search-item-chevron" />
</button>
))}
</div>
)}
{results!.songs.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.songs')}</div>
{results!.songs.map(s => (
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}>
{s.coverArt ? (
<img className="mobile-search-thumb" src={buildCoverArtUrl(s.coverArt, 80)} alt="" loading="lazy" />
) : (
<div className="mobile-search-avatar">
<Music size={20} />
</div>
)}
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{s.title}</span>
<span className="mobile-search-item-sub">{s.artist}{s.album ? ` · ${s.album}` : ''}</span>
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>,
document.body
);
}
+33
View File
@@ -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);
}
+2
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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: 'Оформление',
+804
View File
@@ -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: 'Избранных станций нет.',
}
}
+5
View File
@@ -65,6 +65,10 @@ export const zhTranslation = {
advancedEmpty: '请输入搜索词或选择过滤器。',
advancedNoResults: '未找到结果。',
advancedGenreNote: '歌曲从该流派中随机选取。',
recentSearches: '最近搜索',
browse: '浏览',
emptyHint: '你想听什么?',
genres: '流派',
},
nowPlaying: {
tooltip: '谁正在收听?',
@@ -337,6 +341,7 @@ export const zhTranslation = {
languageZh: '中文',
languageNb: '挪威',
languageRu: '俄语',
languageRu2: '俄语 2',
font: '字体',
theme: '主题',
appearance: '外观',
+8 -2
View File
@@ -3,7 +3,8 @@ import Hero from '../components/Hero';
import AlbumRow from '../components/AlbumRow';
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '../store/homeStore';
export default function Home() {
@@ -76,6 +77,7 @@ export default function Home() {
{isVisible('recent') && (
<AlbumRow
title={t('home.recent')}
titleLink="/albums"
albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText={t('home.loadMore')}
@@ -84,6 +86,7 @@ export default function Home() {
{isVisible('discover') && (
<AlbumRow
title={t('home.discover')}
titleLink="/random-albums"
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')}
@@ -92,7 +95,9 @@ export default function Home() {
{isVisible('discoverArtists') && randomArtists.length > 0 && (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2>
<NavLink to="/artists" className="section-title-link" style={{ marginBottom: 0 }}>
{t('home.discoverArtists')}<ChevronRight size={18} className="section-title-chevron" />
</NavLink>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{randomArtists.map(a => (
@@ -118,6 +123,7 @@ export default function Home() {
{isVisible('starred') && starred.length > 0 && (
<AlbumRow
title={t('home.starred')}
titleLink="/favorites"
albums={starred}
onLoadMore={() => loadMore('starred', starred, setStarred)}
moreText={t('home.loadMore')}
+18 -3
View File
@@ -82,6 +82,20 @@ const CONTRIBUTORS = [
'Norwegian (Bokmål) translation (PR #101)',
],
},
{
github: 'cucadmuh',
since: '1.33.0',
contributions: [
'Russian translation & i18n locale split (PR #106)',
],
},
{
github: 'kilyabin',
since: '1.34.0',
contributions: [
'Alternative Russian translation (PR #107)',
],
},
] as const;
const SPECIAL_THANKS = [
@@ -805,13 +819,14 @@ export default function Settings() {
value={i18n.language}
onChange={v => i18n.changeLanguage(v)}
options={[
{ value: 'nl', label: t('settings.languageNl') },
{ value: 'en', label: t('settings.languageEn') },
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'de', label: t('settings.languageDe') },
{ value: 'zh', label: t('settings.languageZh') },
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'nl', label: t('settings.languageNl') },
{ value: 'nb', label: t('settings.languageNb') },
{ value: 'ru', label: t('settings.languageRu') },
{ value: 'ru2', label: t('settings.languageRu2') },
{ value: 'zh', label: t('settings.languageZh') },
]}
/>
</div>
+558
View File
@@ -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;
}
+631
View File
@@ -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;
}