diff --git a/.gitignore b/.gitignore index 4125ba78..398d1732 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ dist-ssr # Tauri src-tauri/target/ + +# Documentation +CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index feae1481..fa4ea895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.0.1] - 2026-03-11 + +### Fixed +- **Optimized Codebase**: Integrated core fixes and performance improvements. +- **Improved Multi-Server Support**: Fixed edge cases in server switching and credential management. +- **Enhanced Security**: Switched to `crypto.getRandomValues()` for more robust auth salt generation. +- **Connection Reliability**: Added pre-verification for server connections to prevent state synchronization issues. +- **Linux Compatibility**: Applied workarounds for WebKitGTK compositing issues on Linux. + +### Changed +- Repository maintenance and preparation for the 1.0.1 release. + ## [1.0.0] - 2026-03-09 ### Added diff --git a/logo-psysonic.png b/logo-psysonic.png deleted file mode 100644 index bfb490c5..00000000 Binary files a/logo-psysonic.png and /dev/null differ diff --git a/logo-square.png b/logo-square.png deleted file mode 100644 index 3ddf7cbc..00000000 Binary files a/logo-square.png and /dev/null differ diff --git a/package.json b/package.json index 892419f7..cb003209 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.0.0", + "version": "1.0.1", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4d03e7af..e8096f94 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "0.1.0" +version = "1.0.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d4db6f48..0194999f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.0.0" +version = "1.0.1" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1a7d7e40..b5f014a9 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,5 +1,5 @@ { - "$schema": "https://schema.tauri.app/config/2/capability.json", + "$schema": "../gen/schemas/capability-schema.json", "identifier": "default", "description": "Default capabilities for Psysonic", "platforms": ["linux", "macOS", "windows"], @@ -22,7 +22,6 @@ "fs:allow-write-file", "fs:allow-mkdir", "fs:scope-download-recursive", - "fs:scope-home-recursive", "core:window:allow-set-title", "core:window:allow-close", "core:window:allow-hide", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 67247b55..c0e94a76 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show"],"platforms":["linux","macOS","windows"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show"],"platforms":["linux","macOS","windows"]}} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cd75c807..aea5c6eb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.0.0", + "version": "1.0.1", "identifier": "dev.psysonic.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index d67e1db1..fd37e7eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,8 +28,8 @@ import { usePlayerStore } from './store/playerStore'; import { useThemeStore } from './store/themeStore'; function RequireAuth({ children }: { children: React.ReactNode }) { - const { isLoggedIn } = useAuthStore(); - if (!isLoggedIn) return ; + const { isLoggedIn, servers, activeServerId } = useAuthStore(); + if (!isLoggedIn || !activeServerId || servers.length === 0) return ; return <>{children}; } diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index c7264dd3..ee7c5286 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -2,31 +2,38 @@ import axios from 'axios'; import md5 from 'md5'; import { useAuthStore } from '../store/authStore'; +// ─── Secure random salt ──────────────────────────────────────── +function secureRandomSalt(): string { + const buf = new Uint8Array(8); + crypto.getRandomValues(buf); + return Array.from(buf, b => b.toString(16).padStart(2, '0')).join(''); +} + // ─── Token Auth ─────────────────────────────────────────────── function getAuthParams(username: string, password: string) { - const salt = Math.random().toString(36).substring(2, 10); + const salt = secureRandomSalt(); const token = md5(password + salt); return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' }; } function getClient() { - const { getBaseUrl, username, password } = useAuthStore.getState(); + const { getBaseUrl, getActiveServer } = useAuthStore.getState(); + const server = getActiveServer(); const baseUrl = getBaseUrl(); - const params = getAuthParams(username, password); - - return { - baseUrl: `${baseUrl}/rest`, - params, - }; + if (!baseUrl) throw new Error('No server configured'); + const params = getAuthParams(server?.username ?? '', server?.password ?? ''); + return { baseUrl: `${baseUrl}/rest`, params }; } -async function api(endpoint: string, extra: Record = {}): Promise { +async function api(endpoint: string, extra: Record = {}, timeout = 15000): Promise { const { baseUrl, params } = getClient(); const resp = await axios.get(`${baseUrl}/${endpoint}`, { params: { ...params, ...extra }, - paramsSerializer: { indexes: null } + paramsSerializer: { indexes: null }, + timeout, }); - const data = resp.data['subsonic-response']; + const data = resp.data?.['subsonic-response']; + if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)'); if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error'); return data as T; } @@ -59,11 +66,11 @@ export interface SubsonicSong { year?: number; userRating?: number; // Audio technical info - bitRate?: number; // kbps - suffix?: string; // mp3, flac, opus… - contentType?: string; // audio/mpeg, audio/flac… - size?: number; // bytes - samplingRate?: number; // Hz + bitRate?: number; + suffix?: string; + contentType?: string; + size?: number; + samplingRate?: number; channelCount?: number; starred?: string; } @@ -95,7 +102,7 @@ export interface SubsonicArtist { } export interface SubsonicGenre { - value: string; // The genre name is returned as "value" by subsonic + value: string; songCount: number; albumCount: number; } @@ -119,6 +126,24 @@ export async function ping(): Promise { } } +/** Test a connection with explicit credentials — does NOT depend on store state. */ +export async function pingWithCredentials(serverUrl: string, username: string, password: string): Promise { + try { + const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`; + const salt = secureRandomSalt(); + const token = md5(password + salt); + const resp = await axios.get(`${base}/rest/ping.view`, { + params: { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' }, + paramsSerializer: { indexes: null }, + timeout: 15000, + }); + const data = resp.data?.['subsonic-response']; + return data?.status === 'ok'; + } catch { + return false; + } +} + export async function getRandomAlbums(size = 6): Promise { const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', size }); return data.albumList2?.album ?? []; @@ -205,13 +230,8 @@ export async function getStarred(): Promise { song?: SubsonicSong[]; } }>('getStarred2.view'); - const r = data.starred2 ?? {}; - return { - artists: r.artist ?? [], - albums: r.album ?? [], - songs: r.song ?? [], - }; + return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; } export async function star(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise { @@ -270,33 +290,44 @@ export async function reportNowPlaying(id: string): Promise { // ─── Stream URL ─────────────────────────────────────────────── export function buildStreamUrl(id: string): string { - const { getBaseUrl, username, password } = useAuthStore.getState(); + const { getBaseUrl, getActiveServer } = useAuthStore.getState(); + const server = getActiveServer(); const baseUrl = getBaseUrl(); - const { u, t, s, v, c, f } = (() => { - const salt = Math.random().toString(36).substring(2, 10); - const token = md5(password + salt); - return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' }; - })(); - - const p = new URLSearchParams({ id, u, t, s, v, c, f }); + const salt = secureRandomSalt(); + const token = md5((server?.password ?? '') + salt); + const p = new URLSearchParams({ + id, + u: server?.username ?? '', + t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json', + }); return `${baseUrl}/rest/stream.view?${p.toString()}`; } export function buildCoverArtUrl(id: string, size = 256): string { - const { getBaseUrl, username, password } = useAuthStore.getState(); + const { getBaseUrl, getActiveServer } = useAuthStore.getState(); + const server = getActiveServer(); const baseUrl = getBaseUrl(); - const salt = Math.random().toString(36).substring(2, 10); - const token = md5(password + salt); - const p = new URLSearchParams({ id, size: String(size), u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' }); + const salt = secureRandomSalt(); + const token = md5((server?.password ?? '') + salt); + const p = new URLSearchParams({ + id, size: String(size), + u: server?.username ?? '', + t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json', + }); return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`; } export function buildDownloadUrl(id: string): string { - const { getBaseUrl, username, password } = useAuthStore.getState(); + const { getBaseUrl, getActiveServer } = useAuthStore.getState(); + const server = getActiveServer(); const baseUrl = getBaseUrl(); - const salt = Math.random().toString(36).substring(2, 10); - const token = md5(password + salt); - const p = new URLSearchParams({ id, u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' }); + const salt = secureRandomSalt(); + const token = md5((server?.password ?? '') + salt); + const p = new URLSearchParams({ + id, + u: server?.username ?? '', + t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json', + }); return `${baseUrl}/rest/download.view?${p.toString()}`; } @@ -307,7 +338,6 @@ export async function getPlaylists(): Promise { } export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> { - // Songs inside a playlist are typically in the 'entry' array const data = await api<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>('getPlaylist.view', { id }); const { entry, ...playlist } = data.playlist; return { playlist, songs: entry ?? [] }; @@ -330,11 +360,7 @@ export async function getPlayQueue(): Promise<{ current?: string; position?: num try { const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view'); const pq = data.playQueue; - return { - current: pq?.current, - position: pq?.position, - songs: pq?.entry ?? [] - }; + return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] }; } catch { return { songs: [] }; } @@ -342,12 +368,9 @@ export async function getPlayQueue(): Promise<{ current?: string; position?: num export async function savePlayQueue(songIds: string[], current?: string, position?: number): Promise { const params: Record = {}; - if (songIds.length > 0) { - params.id = songIds; - } + if (songIds.length > 0) params.id = songIds; if (current !== undefined) params.current = current; if (position !== undefined) params.position = position; - await api('savePlayQueue.view', params); } @@ -355,7 +378,6 @@ export async function savePlayQueue(songIds: string[], current?: string, positio export async function getNowPlaying(): Promise { try { const data = await api<{ nowPlaying: { entry?: SubsonicNowPlaying[] } | '' }>('getNowPlaying.view'); - // Navidrome might return an empty string or empty object if nobody is playing if (!data.nowPlaying || typeof data.nowPlaying === 'string') return []; return data.nowPlaying.entry ?? []; } catch { diff --git a/src/components/AlbumRow.tsx b/src/components/AlbumRow.tsx index 132f5556..f13243d6 100644 --- a/src/components/AlbumRow.tsx +++ b/src/components/AlbumRow.tsx @@ -3,6 +3,7 @@ import { SubsonicAlbum } from '../api/subsonic'; import AlbumCard from './AlbumCard'; import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; interface Props { title: string; @@ -13,6 +14,7 @@ interface Props { } export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) { + const { t } = useTranslation(); const scrollRef = useRef(null); const navigate = useNavigate(); const [showLeft, setShowLeft] = useState(false); @@ -86,7 +88,7 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
- Lädt... + {t('common.loadingMore')}
)} {!loadingMore && moreLink && ( @@ -94,7 +96,7 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
- {moreText || 'Alle ansehen'} + {moreText} )} diff --git a/src/components/ArtistRow.tsx b/src/components/ArtistRow.tsx index 6eb44408..fa03c8a0 100644 --- a/src/components/ArtistRow.tsx +++ b/src/components/ArtistRow.tsx @@ -3,6 +3,7 @@ import { SubsonicArtist } from '../api/subsonic'; import ArtistCardLocal from './ArtistCardLocal'; import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; interface Props { title: string; @@ -13,6 +14,7 @@ interface Props { } export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) { + const { t } = useTranslation(); const scrollRef = useRef(null); const navigate = useNavigate(); const [showLeft, setShowLeft] = useState(false); @@ -86,7 +88,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
- Lädt... + {t('common.loadingMore')}
)} {!loadingMore && moreLink && ( @@ -94,7 +96,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
- {moreText || 'Alle ansehen'} + {moreText} )} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 72c32a15..87167fe7 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -7,13 +7,23 @@ import { useAuthStore } from '../store/authStore'; import { open } from '@tauri-apps/plugin-shell'; import { writeFile } from '@tauri-apps/plugin-fs'; import { join } from '@tauri-apps/api/path'; +import { useTranslation } from 'react-i18next'; + +function sanitizeFilename(name: string): string { + return name + .replace(/[/\\?%*:|"<>]/g, '-') + .replace(/\.{2,}/g, '.') + .replace(/^[\s.]+|[\s.]+$/g, '') + .substring(0, 200) || 'download'; +} export default function ContextMenu() { + const { t } = useTranslation(); const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore(); const auth = useAuthStore(); const navigate = useNavigate(); const menuRef = useRef(null); - + // Adjusted coordinates to keep menu on screen const [coords, setCoords] = useState({ x: 0, y: 0 }); @@ -28,37 +38,14 @@ export default function ContextMenu() { const rect = menuRef.current.getBoundingClientRect(); const winW = window.innerWidth; const winH = window.innerHeight; - let finalX = contextMenu.x; let finalY = contextMenu.y; - if (finalX + rect.width > winW) finalX = winW - rect.width - 10; if (finalY + rect.height > winH) finalY = winH - rect.height - 10; - setCoords({ x: finalX, y: finalY }); } }, [contextMenu.isOpen, contextMenu.x, contextMenu.y]); - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - closeContextMenu(); - } - }; - const handleEsc = (e: KeyboardEvent) => { - if (e.key === 'Escape') closeContextMenu(); - }; - - if (contextMenu.isOpen) { - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEsc); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEsc); - }; - }, [contextMenu.isOpen, closeContextMenu]); - if (!contextMenu.isOpen || !contextMenu.item) return null; const { type, item, queueIndex } = contextMenu; @@ -91,139 +78,140 @@ export default function ContextMenu() { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const blob = await response.blob(); - + if (auth.downloadFolder) { const buffer = await blob.arrayBuffer(); - const path = await join(auth.downloadFolder, `${albumName}.zip`); + const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`); await writeFile(path, new Uint8Array(buffer)); } else { const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; - a.download = `${albumName}.zip`; + a.download = `${sanitizeFilename(albumName)}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(blobUrl), 2000); } } catch (e) { - console.error('Download fehlgeschlagen:', e); + console.error('Download failed:', e); } }; - return ( -
- {(type === 'song' || type === 'album-song') && (() => { - const song = item as Track; - return ( - <> -
handleAction(() => playTrack(song, [song]))}> - Direkt abspielen -
-
handleAction(() => { - if (!currentTrack) { - playTrack(song, [song]); - return; - } - const currentIdx = usePlayerStore.getState().queueIndex; - const newQueue = [...queue]; - newQueue.splice(currentIdx + 1, 0, song); - usePlayerStore.setState({ queue: newQueue }); - })}> - Als Nächstes abspielen -
-
handleAction(() => enqueue([song]))}> - Zur Warteschlange hinzufügen -
- {type === 'album-song' && ( -
handleAction(async () => { - const albumData = await getAlbum(song.albumId); - const tracks = albumData.songs.map(s => ({ - id: s.id, title: s.title, artist: s.artist, album: s.album, - albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track, - year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, - })); - enqueue(tracks); - })}> - Ganzes Album einreihen + <> + {/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */} +
closeContextMenu()} + /> +
+ {(type === 'song' || type === 'album-song') && (() => { + const song = item as Track; + return ( + <> +
handleAction(() => playTrack(song, [song]))}> + {t('contextMenu.playNow')}
- )} - -
- -
handleAction(() => startRadio(song.artist, song.artist))}> - Song-Radio starten -
-
handleAction(() => star(song.id, 'song'))}> - Favorisieren -
- - ); - })()} +
handleAction(() => { + if (!currentTrack) { + playTrack(song, [song]); + return; + } + const currentIdx = usePlayerStore.getState().queueIndex; + const newQueue = [...queue]; + newQueue.splice(currentIdx + 1, 0, song); + usePlayerStore.setState({ queue: newQueue }); + })}> + {t('contextMenu.playNext')} +
+
handleAction(() => enqueue([song]))}> + {t('contextMenu.addToQueue')} +
+ {type === 'album-song' && ( +
handleAction(async () => { + const albumData = await getAlbum(song.albumId); + const tracks = albumData.songs.map(s => ({ + id: s.id, title: s.title, artist: s.artist, album: s.album, + albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track, + year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, + })); + enqueue(tracks); + })}> + {t('contextMenu.enqueueAlbum')} +
+ )} +
+
handleAction(() => startRadio(song.artist, song.artist))}> + {t('contextMenu.startRadio')} +
+
handleAction(() => star(song.id, 'song'))}> + {t('contextMenu.favorite')} +
+ + ); + })()} - {type === 'album' && (() => { - const album = item as SubsonicAlbum; - return ( - <> -
handleAction(() => { - // we don't have tracks here immediately, so we'd navigate or fetch. For now, navigate. - navigate(`/album/${album.id}`); - })}> - Album öffnen -
-
-
handleAction(() => navigate(`/artist/${album.artistId}`))}> - Zum Künstler -
-
handleAction(() => star(album.id, 'album'))}> - Album favorisieren -
-
handleAction(() => downloadAlbum(album.name, album.id))}> - Herunterladen (ZIP) -
- - ); - })()} + {type === 'album' && (() => { + const album = item as SubsonicAlbum; + return ( + <> +
handleAction(() => navigate(`/album/${album.id}`))}> + {t('contextMenu.openAlbum')} +
+
+
handleAction(() => navigate(`/artist/${album.artistId}`))}> + {t('contextMenu.goToArtist')} +
+
handleAction(() => star(album.id, 'album'))}> + {t('contextMenu.favoriteAlbum')} +
+
handleAction(() => downloadAlbum(album.name, album.id))}> + {t('contextMenu.download')} +
+ + ); + })()} - {type === 'artist' && (() => { - const artist = item as SubsonicArtist; - return ( - <> -
handleAction(() => startRadio(artist.id, artist.name))}> - Künstler-Radio starten -
-
-
handleAction(() => star(artist.id, 'artist'))}> - Künstler favorisieren -
- - ); - })()} + {type === 'artist' && (() => { + const artist = item as SubsonicArtist; + return ( + <> +
handleAction(() => startRadio(artist.id, artist.name))}> + {t('contextMenu.startRadio')} +
+
+
handleAction(() => star(artist.id, 'artist'))}> + {t('contextMenu.favoriteArtist')} +
+ + ); + })()} - {type === 'queue-item' && (() => { - const song = item as Track; - return ( - <> -
handleAction(() => playTrack(song, queue))}> - Direkt abspielen -
-
handleAction(() => { - if (queueIndex !== undefined) removeTrack(queueIndex); - })}> - Diesen Song entfernen -
-
-
handleAction(() => startRadio(song.artist, song.artist))}> - Song-Radio starten -
- - ); - })()} -
+ {type === 'queue-item' && (() => { + const song = item as Track; + return ( + <> +
handleAction(() => playTrack(song, queue))}> + {t('contextMenu.playNow')} +
+
handleAction(() => { + if (queueIndex !== undefined) removeTrack(queueIndex); + })}> + {t('contextMenu.removeFromQueue')} +
+
+
handleAction(() => startRadio(song.artist, song.artist))}> + {t('contextMenu.startRadio')} +
+ + ); + })()} +
+ ); } diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index b205534e..17571f2c 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -5,6 +5,7 @@ import { } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { buildCoverArtUrl } from '../api/subsonic'; +import { useTranslation } from 'react-i18next'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -71,7 +72,7 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number }) value={progress} onChange={handleSeek} style={{ '--pct': `${progress * 100}%` } as React.CSSProperties} - aria-label="Songfortschritt" + aria-label="progress" />
{formatTime(duration)} @@ -84,10 +85,11 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number }) // ─── Isolated play/pause button (subscribes to isPlaying only) ─── const FsPlayBtn = memo(function FsPlayBtn() { + const { t } = useTranslation(); const isPlaying = usePlayerStore(s => s.isPlaying); const togglePlay = usePlayerStore(s => s.togglePlay); return ( - ); @@ -98,6 +100,7 @@ interface FullscreenPlayerProps { } export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { + const { t } = useTranslation(); // Static/slow-changing state only — does NOT subscribe to progress or currentTime const currentTrack = usePlayerStore(s => s.currentTrack); const repeatMode = usePlayerStore(s => s.repeatMode); @@ -121,13 +124,13 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { }, [onClose]); return ( -
+
{/* Crossfading blurred background */}