mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(share): library deep links (psysonic2) with paste + toolbar actions (#261)
* feat(share): library deep links (psysonic2) with paste and toolbar actions Add base64url-encoded payloads for track, album, artist, and ordered queue. Handle paste outside inputs to switch server, validate entities, navigate or play. Reuse clipboard helper for context menu, queue panel, album and artist headers. Tests distinguish server invite strings from library shares. * fix(share): play partial queue when some shared tracks are missing Skip unavailable song IDs when pasting a queue share while preserving order. Toast when some tracks are missing; error only if none resolve. Add openedQueuePartial and queueAllUnavailable i18n; remove queueTracksMissing. * feat(settings): paste server invites globally and scroll to add form Handle psysonic1- magic strings outside inputs: navigate to settings or login with prefilled add-server fields. Decode invites embedded in surrounding text. Scroll the add-server block into view when opening from a paste so long server lists stay oriented. --------- Co-authored-by: Maxim Isaev <im@friclub.ru>
This commit is contained in:
committed by
GitHub
parent
21d00889aa
commit
7c9a300022
@@ -90,6 +90,7 @@ import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './sto
|
||||
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
||||
import { useZipDownloadStore } from './store/zipDownloadStore';
|
||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
||||
import PasteClipboardHandler from './components/PasteClipboardHandler';
|
||||
|
||||
/** Volume before last `psysonic --player mute` (CLI only; in-memory). */
|
||||
let cliPremuteVolume: number | null = null;
|
||||
@@ -1130,6 +1131,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<PasteClipboardHandler />
|
||||
<TauriEventBridge />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle, Share2 } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
@@ -9,6 +9,8 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import StarRating from './StarRating';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
import { copyEntityShareLink } from '../utils/copyEntityShareLink';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -130,6 +132,16 @@ export default function AlbumHeader({
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||
|
||||
const handleShareAlbum = async () => {
|
||||
try {
|
||||
const ok = await copyEntityShareLink('album', info.id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
} catch {
|
||||
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
@@ -242,6 +254,16 @@ export default function AlbumHeader({
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
type="button"
|
||||
onClick={handleShareAlbum}
|
||||
aria-label={t('albumDetail.shareAlbum')}
|
||||
data-tooltip={t('albumDetail.shareAlbum')}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onBio}
|
||||
@@ -321,6 +343,15 @@ export default function AlbumHeader({
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={handleShareAlbum}
|
||||
aria-label={t('albumDetail.shareAlbum')}
|
||||
data-tooltip={t('albumDetail.shareAlbum')}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2 } from 'lucide-react';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import StarRating from './StarRating';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
@@ -16,6 +16,8 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
import type { EntityShareKind } from '../utils/shareLink';
|
||||
import { copyEntityShareLink } from '../utils/copyEntityShareLink';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
@@ -1265,6 +1267,12 @@ export default function ContextMenu() {
|
||||
await action();
|
||||
};
|
||||
|
||||
const copyShareLink = useCallback(async (kind: EntityShareKind, id: string) => {
|
||||
const ok = await copyEntityShareLink(kind, id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}, [t]);
|
||||
|
||||
const startRadio = async (artistId: string, artistName: string, seedTrack?: Track) => {
|
||||
if (seedTrack) {
|
||||
// Start playback immediately based on current state
|
||||
@@ -1515,6 +1523,9 @@ export default function ContextMenu() {
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
@@ -1627,6 +1638,9 @@ export default function ContextMenu() {
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
@@ -1685,6 +1699,9 @@ export default function ContextMenu() {
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
@@ -1801,6 +1818,9 @@ export default function ContextMenu() {
|
||||
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('artist', artist.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
@@ -1989,6 +2009,9 @@ export default function ContextMenu() {
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { decodeSharePayloadFromText } from '../utils/shareLink';
|
||||
import { decodeServerMagicStringFromText } from '../utils/serverMagicString';
|
||||
import { applySharePastePayload } from '../utils/applySharePaste';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
/**
|
||||
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
|
||||
* outside text fields. Shares require login; invites open add-server (settings or login).
|
||||
*/
|
||||
export default function PasteClipboardHandler() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const busy = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const text = e.clipboardData?.getData('text/plain') ?? '';
|
||||
const share = decodeSharePayloadFromText(text);
|
||||
if (share) {
|
||||
if (!isLoggedIn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||
return;
|
||||
}
|
||||
if (busy.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
busy.current = true;
|
||||
void applySharePastePayload(share, navigate, t).finally(() => {
|
||||
busy.current = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const invite = decodeServerMagicStringFromText(text);
|
||||
if (!invite) return;
|
||||
if (busy.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
busy.current = true;
|
||||
if (isLoggedIn) {
|
||||
navigate('/settings', { state: { tab: 'server' as const, openAddServerInvite: invite } });
|
||||
} else {
|
||||
navigate('/login', { state: { openAddServerInvite: invite } });
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
busy.current = false;
|
||||
});
|
||||
};
|
||||
document.addEventListener('paste', onPaste, true);
|
||||
return () => document.removeEventListener('paste', onPaste, true);
|
||||
}, [navigate, t, isLoggedIn]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown, Info } from 'lucide-react';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { encodeSharePayload } from '../utils/shareLink';
|
||||
import { copyTextToClipboard } from '../utils/serverMagicString';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
@@ -404,6 +407,18 @@ export default function QueuePanel() {
|
||||
setActivePlaylist(null);
|
||||
};
|
||||
|
||||
const handleCopyQueueShare = async () => {
|
||||
if (queue.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
const srv = useAuthStore.getState().getBaseUrl();
|
||||
if (!srv) return;
|
||||
const ids = queue.map(t => t.id);
|
||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -553,6 +568,14 @@ export default function QueuePanel() {
|
||||
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="queue-round-btn"
|
||||
onClick={() => void handleCopyQueueShare()}
|
||||
data-tooltip={t('queue.shareQueue')}
|
||||
aria-label={t('queue.shareQueue')}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
</button>
|
||||
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
|
||||
@@ -123,10 +123,30 @@ export const deTranslation = {
|
||||
selectedAlbums: '{{count}} Alben ausgewählt',
|
||||
selectedArtists: '{{count}} Künstler ausgewählt',
|
||||
songInfo: 'Song-Infos',
|
||||
shareLink: 'Freigabe-Link kopieren',
|
||||
shareCopied: 'Freigabe-Link wurde in die Zwischenablage kopiert.',
|
||||
shareCopyFailed: 'Kopieren in die Zwischenablage fehlgeschlagen.',
|
||||
},
|
||||
sharePaste: {
|
||||
notLoggedIn: 'Melde dich an und füge den Server hinzu, bevor du einen Freigabe-Link einfügst.',
|
||||
noMatchingServer: 'Kein gespeicherter Server passt zu diesem Link. Füge einen Server mit dieser Adresse hinzu: {{url}}',
|
||||
trackUnavailable: 'Dieser Titel wurde auf dem Server nicht gefunden.',
|
||||
albumUnavailable: 'Dieses Album wurde auf dem Server nicht gefunden.',
|
||||
artistUnavailable: 'Dieser Künstler wurde auf dem Server nicht gefunden.',
|
||||
openedTrack: 'Geteilter Titel wird abgespielt.',
|
||||
openedAlbum: 'Geteiltes Album wird geöffnet.',
|
||||
openedArtist: 'Geteilter Künstler wird geöffnet.',
|
||||
openedQueue_one: '{{count}} Titel aus Freigabe-Link wird abgespielt.',
|
||||
openedQueue_other: '{{count}} Titel aus Freigabe-Link werden abgespielt.',
|
||||
openedQueuePartial:
|
||||
'{{played}} von {{total}} Titeln aus dem Link werden abgespielt ({{skipped}} auf diesem Server nicht gefunden).',
|
||||
queueAllUnavailable: 'Keiner der Titel aus diesem Link wurde auf dem Server gefunden.',
|
||||
genericError: 'Freigabe-Link konnte nicht geöffnet werden.',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Zurück',
|
||||
playAll: 'Alle abspielen',
|
||||
shareAlbum: 'Album teilen',
|
||||
enqueue: 'Einreihen',
|
||||
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
|
||||
artistBio: 'Künstler-Bio',
|
||||
@@ -181,6 +201,7 @@ export const deTranslation = {
|
||||
albums: 'Alben',
|
||||
album: 'Album',
|
||||
playAll: 'Alle abspielen',
|
||||
shareArtist: 'Künstler teilen',
|
||||
shuffle: 'Zufallswiedergabe',
|
||||
radio: 'Radio',
|
||||
loading: 'Lädt…',
|
||||
@@ -992,6 +1013,8 @@ export const deTranslation = {
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
shareQueue: 'Warteschlangen-Link kopieren',
|
||||
shareQueueEmpty: 'Die Warteschlange ist leer — nichts zu teilen.',
|
||||
emptyQueue: 'Die Warteschlange ist leer.',
|
||||
trackSingular: 'Titel',
|
||||
trackPlural: 'Titel',
|
||||
|
||||
@@ -124,10 +124,30 @@ export const enTranslation = {
|
||||
selectedAlbums: '{{count}} albums selected',
|
||||
selectedArtists: '{{count}} artists selected',
|
||||
songInfo: 'Song Info',
|
||||
shareLink: 'Copy share link',
|
||||
shareCopied: 'Share link copied to the clipboard.',
|
||||
shareCopyFailed: 'Could not copy to the clipboard.',
|
||||
},
|
||||
sharePaste: {
|
||||
notLoggedIn: 'Sign in and add the server before pasting a share link.',
|
||||
noMatchingServer: 'No saved server matches this link. Add a server with this address: {{url}}',
|
||||
trackUnavailable: 'This track was not found on the server.',
|
||||
albumUnavailable: 'This album was not found on the server.',
|
||||
artistUnavailable: 'This artist was not found on the server.',
|
||||
openedTrack: 'Playing shared track.',
|
||||
openedAlbum: 'Opening shared album.',
|
||||
openedArtist: 'Opening shared artist.',
|
||||
openedQueue_one: 'Playing {{count}} track from the share link.',
|
||||
openedQueue_other: 'Playing {{count}} tracks from the share link.',
|
||||
openedQueuePartial:
|
||||
'Playing {{played}} of {{total}} tracks from the link ({{skipped}} not found on this server).',
|
||||
queueAllUnavailable: 'None of the tracks from this link were found on the server.',
|
||||
genericError: 'Could not open the share link.',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Back',
|
||||
playAll: 'Play All',
|
||||
shareAlbum: 'Share album',
|
||||
enqueue: 'Enqueue',
|
||||
enqueueTooltip: 'Add entire album to queue',
|
||||
artistBio: 'Artist Bio',
|
||||
@@ -182,6 +202,7 @@ export const enTranslation = {
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
playAll: 'Play All',
|
||||
shareArtist: 'Share artist',
|
||||
shuffle: 'Shuffle',
|
||||
radio: 'Radio',
|
||||
loading: 'Loading…',
|
||||
@@ -994,6 +1015,8 @@ export const enTranslation = {
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
shareQueue: 'Copy queue share link',
|
||||
shareQueueEmpty: 'The queue is empty — nothing to share.',
|
||||
emptyQueue: 'The queue is empty.',
|
||||
trackSingular: 'track',
|
||||
trackPlural: 'tracks',
|
||||
|
||||
@@ -124,10 +124,30 @@ export const esTranslation = {
|
||||
selectedAlbums: '{{count}} álbumes seleccionados',
|
||||
selectedArtists: '{{count}} artistas seleccionados',
|
||||
songInfo: 'Información de la Canción',
|
||||
shareLink: 'Copiar enlace para compartir',
|
||||
shareCopied: 'Enlace copiado al portapapeles.',
|
||||
shareCopyFailed: 'No se pudo copiar al portapapeles.',
|
||||
},
|
||||
sharePaste: {
|
||||
notLoggedIn: 'Inicia sesión y añade el servidor antes de pegar un enlace para compartir.',
|
||||
noMatchingServer: 'Ningún servidor guardado coincide con este enlace. Añade un servidor con esta dirección: {{url}}',
|
||||
trackUnavailable: 'No se encontró esta canción en el servidor.',
|
||||
albumUnavailable: 'No se encontró este álbum en el servidor.',
|
||||
artistUnavailable: 'No se encontró este artista en el servidor.',
|
||||
openedTrack: 'Reproduciendo la canción compartida.',
|
||||
openedAlbum: 'Abriendo el álbum compartido.',
|
||||
openedArtist: 'Abriendo el artista compartido.',
|
||||
openedQueue_one: 'Reproduciendo {{count}} pista del enlace para compartir.',
|
||||
openedQueue_other: 'Reproduciendo {{count}} pistas del enlace para compartir.',
|
||||
openedQueuePartial:
|
||||
'Reproduciendo {{played}} de {{total}} pistas del enlace ({{skipped}} no encontradas en este servidor).',
|
||||
queueAllUnavailable: 'Ninguna pista de este enlace se encontró en el servidor.',
|
||||
genericError: 'No se pudo abrir el enlace para compartir.',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Volver',
|
||||
playAll: 'Reproducir Todo',
|
||||
shareAlbum: 'Compartir álbum',
|
||||
enqueue: 'Agregar a la Cola',
|
||||
enqueueTooltip: 'Agregar álbum completo a la cola',
|
||||
artistBio: 'Biografía del Artista',
|
||||
@@ -182,6 +202,7 @@ export const esTranslation = {
|
||||
albums: 'Álbumes',
|
||||
album: 'Álbum',
|
||||
playAll: 'Reproducir Todo',
|
||||
shareArtist: 'Compartir artista',
|
||||
shuffle: 'Aleatorio',
|
||||
radio: 'Radio',
|
||||
loading: 'Cargando…',
|
||||
@@ -985,6 +1006,8 @@ export const esTranslation = {
|
||||
hide: 'Ocultar',
|
||||
close: 'Cerrar',
|
||||
nextTracks: 'Siguientes',
|
||||
shareQueue: 'Copiar enlace de la cola',
|
||||
shareQueueEmpty: 'La cola está vacía — no hay nada que compartir.',
|
||||
emptyQueue: 'La cola está vacía.',
|
||||
trackSingular: 'pista',
|
||||
trackPlural: 'pistas',
|
||||
|
||||
@@ -123,10 +123,30 @@ export const frTranslation = {
|
||||
selectedAlbums: '{{count}} albums sélectionnés',
|
||||
selectedArtists: '{{count}} artistes sélectionnés',
|
||||
songInfo: 'Infos du morceau',
|
||||
shareLink: 'Copier le lien de partage',
|
||||
shareCopied: 'Lien de partage copié dans le presse-papiers.',
|
||||
shareCopyFailed: 'Impossible de copier dans le presse-papiers.',
|
||||
},
|
||||
sharePaste: {
|
||||
notLoggedIn: 'Connectez-vous et ajoutez le serveur avant de coller un lien de partage.',
|
||||
noMatchingServer: 'Aucun serveur enregistré ne correspond à ce lien. Ajoutez un serveur avec cette adresse : {{url}}',
|
||||
trackUnavailable: 'Ce morceau est introuvable sur le serveur.',
|
||||
albumUnavailable: 'Cet album est introuvable sur le serveur.',
|
||||
artistUnavailable: 'Cet artiste est introuvable sur le serveur.',
|
||||
openedTrack: 'Lecture du morceau partagé.',
|
||||
openedAlbum: 'Ouverture de l’album partagé.',
|
||||
openedArtist: 'Ouverture de l’artiste partagé.',
|
||||
openedQueue_one: 'Lecture de {{count}} morceau depuis le lien de partage.',
|
||||
openedQueue_other: 'Lecture de {{count}} morceaux depuis le lien de partage.',
|
||||
openedQueuePartial:
|
||||
'{{played}} sur {{total}} morceaux du lien : {{skipped}} introuvable(s) sur ce serveur.',
|
||||
queueAllUnavailable: 'Aucun morceau de ce lien n’a été trouvé sur le serveur.',
|
||||
genericError: 'Impossible d’ouvrir le lien de partage.',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Retour',
|
||||
playAll: 'Tout lire',
|
||||
shareAlbum: 'Partager l\'album',
|
||||
enqueue: 'Mettre en file',
|
||||
enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente',
|
||||
artistBio: 'Biographie',
|
||||
@@ -181,6 +201,7 @@ export const frTranslation = {
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
playAll: 'Tout lire',
|
||||
shareArtist: 'Partager l\'artiste',
|
||||
shuffle: 'Aléatoire',
|
||||
radio: 'Radio',
|
||||
loading: 'Chargement…',
|
||||
@@ -980,6 +1001,8 @@ export const frTranslation = {
|
||||
hide: 'Masquer',
|
||||
close: 'Fermer',
|
||||
nextTracks: 'Pistes suivantes',
|
||||
shareQueue: 'Copier le lien de la file d’attente',
|
||||
shareQueueEmpty: 'La file d’attente est vide — rien à partager.',
|
||||
emptyQueue: 'La file d\'attente est vide.',
|
||||
trackSingular: 'piste',
|
||||
trackPlural: 'pistes',
|
||||
|
||||
@@ -123,10 +123,30 @@ export const nbTranslation = {
|
||||
selectedAlbums: '{{count}} album valgt',
|
||||
selectedArtists: '{{count}} artister valgt',
|
||||
songInfo: 'Sanginfo',
|
||||
shareLink: 'Kopiér delingslenke',
|
||||
shareCopied: 'Delingslenke kopiert til utklippstavlen.',
|
||||
shareCopyFailed: 'Kunne ikke kopiere til utklippstavlen.',
|
||||
},
|
||||
sharePaste: {
|
||||
notLoggedIn: 'Logg inn og legg til serveren før du limer inn en delingslenke.',
|
||||
noMatchingServer: 'Ingen lagret server samsvarer med denne lenken. Legg til en server med denne adressen: {{url}}',
|
||||
trackUnavailable: 'Fant ikke dette sporet på serveren.',
|
||||
albumUnavailable: 'Fant ikke dette albumet på serveren.',
|
||||
artistUnavailable: 'Fant ikke denne artisten på serveren.',
|
||||
openedTrack: 'Spiller delt spor.',
|
||||
openedAlbum: 'Åpner delt album.',
|
||||
openedArtist: 'Åpner delt artist.',
|
||||
openedQueue_one: 'Spiller {{count}} spor fra delingslenken.',
|
||||
openedQueue_other: 'Spiller {{count}} spor fra delingslenken.',
|
||||
openedQueuePartial:
|
||||
'Spiller {{played}} av {{total}} spor fra lenken ({{skipped}} ble ikke funnet på denne serveren).',
|
||||
queueAllUnavailable: 'Ingen av sporene fra denne lenken ble funnet på serveren.',
|
||||
genericError: 'Klarte ikke å åpne delingslenken.',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Tilbake',
|
||||
playAll: 'Spill alt',
|
||||
shareAlbum: 'Del album',
|
||||
enqueue: 'Legg i kø',
|
||||
enqueueTooltip: 'Legg hele albumet i kø',
|
||||
artistBio: 'Artistbiografi',
|
||||
@@ -181,6 +201,7 @@ export const nbTranslation = {
|
||||
albums: 'Album',
|
||||
album: 'Album',
|
||||
playAll: 'Spill alt',
|
||||
shareArtist: 'Del artist',
|
||||
shuffle: 'Tilfeldig rekkefølge',
|
||||
radio: 'Radio',
|
||||
loading: 'Laster…',
|
||||
@@ -978,6 +999,8 @@ export const nbTranslation = {
|
||||
hide: 'Skjul',
|
||||
close: 'Lukk',
|
||||
nextTracks: 'Neste spor',
|
||||
shareQueue: 'Kopiér lenke til kø',
|
||||
shareQueueEmpty: 'Køen er tom — ingenting å dele.',
|
||||
emptyQueue: 'Køen er tom.',
|
||||
trackSingular: 'spor',
|
||||
trackPlural: 'spor',
|
||||
|
||||
@@ -122,10 +122,30 @@ export const nlTranslation = {
|
||||
selectedAlbums: '{{count}} albums geselecteerd',
|
||||
selectedArtists: '{{count}} artiesten geselecteerd',
|
||||
songInfo: 'Nummerinfo',
|
||||
shareLink: 'Deellink kopiëren',
|
||||
shareCopied: 'Deellink gekopieerd naar het klembord.',
|
||||
shareCopyFailed: 'Kopiëren naar het klembord is mislukt.',
|
||||
},
|
||||
sharePaste: {
|
||||
notLoggedIn: 'Log in en voeg de server toe voordat je een deellink plakt.',
|
||||
noMatchingServer: 'Geen opgeslagen server komt overeen met deze link. Voeg een server toe met dit adres: {{url}}',
|
||||
trackUnavailable: 'Dit nummer is niet op de server gevonden.',
|
||||
albumUnavailable: 'Dit album is niet op de server gevonden.',
|
||||
artistUnavailable: 'Deze artiest is niet op de server gevonden.',
|
||||
openedTrack: 'Gedeeld nummer wordt afgespeeld.',
|
||||
openedAlbum: 'Gedeeld album wordt geopend.',
|
||||
openedArtist: 'Gedeelde artiest wordt geopend.',
|
||||
openedQueue_one: '{{count}} nummer van deellink wordt afgespeeld.',
|
||||
openedQueue_other: '{{count}} nummers van deellink worden afgespeeld.',
|
||||
openedQueuePartial:
|
||||
'{{played}} van {{total}} nummers uit de link worden afgespeeld ({{skipped}} niet gevonden op deze server).',
|
||||
queueAllUnavailable: 'Geen enkel nummer uit deze link is op de server gevonden.',
|
||||
genericError: 'De deellink kon niet worden geopend.',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Terug',
|
||||
playAll: 'Alles afspelen',
|
||||
shareAlbum: 'Album delen',
|
||||
enqueue: 'In wachtrij',
|
||||
enqueueTooltip: 'Volledig album aan wachtrij toevoegen',
|
||||
artistBio: 'Artiest biografie',
|
||||
@@ -180,6 +200,7 @@ export const nlTranslation = {
|
||||
albums: 'Albums',
|
||||
album: 'Album',
|
||||
playAll: 'Alles afspelen',
|
||||
shareArtist: 'Artiest delen',
|
||||
shuffle: 'Willekeurig',
|
||||
radio: 'Radio',
|
||||
loading: 'Laden…',
|
||||
@@ -979,6 +1000,8 @@ export const nlTranslation = {
|
||||
hide: 'Verbergen',
|
||||
close: 'Sluiten',
|
||||
nextTracks: 'Volgende nummers',
|
||||
shareQueue: 'Wachtrij-deellink kopiëren',
|
||||
shareQueueEmpty: 'De wachtrij is leeg — niets om te delen.',
|
||||
emptyQueue: 'De wachtrij is leeg.',
|
||||
trackSingular: 'nummer',
|
||||
trackPlural: 'nummers',
|
||||
|
||||
@@ -126,10 +126,32 @@ export const ruTranslation = {
|
||||
selectedAlbums: '{{count}} альбомов выбрано',
|
||||
selectedArtists: '{{count}} исполнителей выбрано',
|
||||
songInfo: 'Сведения о треке',
|
||||
shareLink: 'Копировать ссылку для обмена',
|
||||
shareCopied: 'Ссылка для обмена скопирована в буфер.',
|
||||
shareCopyFailed: 'Не удалось скопировать в буфер.',
|
||||
},
|
||||
sharePaste: {
|
||||
notLoggedIn: 'Войдите и добавьте сервер, затем вставьте ссылку.',
|
||||
noMatchingServer: 'Ни один сохранённый сервер не совпадает с адресом из ссылки. Добавьте сервер: {{url}}',
|
||||
trackUnavailable: 'Трек на сервере не найден.',
|
||||
albumUnavailable: 'Альбом на сервере не найден.',
|
||||
artistUnavailable: 'Исполнитель на сервере не найден.',
|
||||
openedTrack: 'Воспроизводится присланный трек.',
|
||||
openedAlbum: 'Открывается присланный альбом.',
|
||||
openedArtist: 'Открывается присланный исполнитель.',
|
||||
openedQueue_one: 'Воспроизводится присланная очередь: {{count}} трек.',
|
||||
openedQueue_few: 'Воспроизводится присланная очередь: {{count}} трека.',
|
||||
openedQueue_many: 'Воспроизводится присланная очередь: {{count}} треков.',
|
||||
openedQueue_other: 'Воспроизводится присланная очередь: {{count}} треков.',
|
||||
openedQueuePartial:
|
||||
'Воспроизводится {{played}} из {{total}} треков по ссылке ({{skipped}} на этом сервере не найдены).',
|
||||
queueAllUnavailable: 'Ни один из треков по ссылке не найден на сервере.',
|
||||
genericError: 'Не удалось открыть ссылку для обмена.',
|
||||
},
|
||||
albumDetail: {
|
||||
back: 'Назад',
|
||||
playAll: 'Воспроизвести всё',
|
||||
shareAlbum: 'Поделиться альбомом',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
artistBio: 'Биография',
|
||||
@@ -185,6 +207,7 @@ export const ruTranslation = {
|
||||
albums: 'Альбомы',
|
||||
album: 'Альбом',
|
||||
playAll: 'Воспроизвести всё',
|
||||
shareArtist: 'Поделиться исполнителем',
|
||||
shuffle: 'Перемешать',
|
||||
radio: 'Радио',
|
||||
loading: 'Загрузка…',
|
||||
@@ -1040,6 +1063,8 @@ export const ruTranslation = {
|
||||
hide: 'Скрыть',
|
||||
close: 'Закрыть',
|
||||
nextTracks: 'Дальше',
|
||||
shareQueue: 'Копировать ссылку на очередь',
|
||||
shareQueueEmpty: 'Очередь пуста — нечем поделиться.',
|
||||
emptyQueue: 'Очередь пуста.',
|
||||
trackSingular: 'трек',
|
||||
trackPlural: 'треков',
|
||||
|
||||
@@ -122,10 +122,29 @@ export const zhTranslation = {
|
||||
selectedAlbums: '已选择 {{count}} 个专辑',
|
||||
selectedArtists: '已选择 {{count}} 个艺术家',
|
||||
songInfo: '歌曲信息',
|
||||
shareLink: '复制分享链接',
|
||||
shareCopied: '分享链接已复制到剪贴板。',
|
||||
shareCopyFailed: '无法复制到剪贴板。',
|
||||
},
|
||||
sharePaste: {
|
||||
notLoggedIn: '请先登录并添加服务器,再粘贴分享链接。',
|
||||
noMatchingServer: '没有已保存的服务器与此链接匹配。请添加使用该地址的服务器:{{url}}',
|
||||
trackUnavailable: '在服务器上找不到此歌曲。',
|
||||
albumUnavailable: '在服务器上找不到此专辑。',
|
||||
artistUnavailable: '在服务器上找不到此艺术家。',
|
||||
openedTrack: '正在播放分享的歌曲。',
|
||||
openedAlbum: '正在打开分享的专辑。',
|
||||
openedArtist: '正在打开分享的艺术家。',
|
||||
openedQueue_one: '正在播放分享队列中的 {{count}} 首曲目。',
|
||||
openedQueue_other: '正在播放分享队列中的 {{count}} 首曲目。',
|
||||
openedQueuePartial: '正在播放链接中的 {{played}} / {{total}} 首曲目({{skipped}} 首在此服务器上未找到)。',
|
||||
queueAllUnavailable: '此链接中的曲目在服务器上均未找到。',
|
||||
genericError: '无法打开分享链接。',
|
||||
},
|
||||
albumDetail: {
|
||||
back: '返回',
|
||||
playAll: '全部播放',
|
||||
shareAlbum: '分享专辑',
|
||||
enqueue: '加入队列',
|
||||
enqueueTooltip: '将整张专辑添加到播放队列',
|
||||
artistBio: '艺术家简介',
|
||||
@@ -180,6 +199,7 @@ export const zhTranslation = {
|
||||
albums: '专辑',
|
||||
album: '专辑',
|
||||
playAll: '全部播放',
|
||||
shareArtist: '分享艺人',
|
||||
shuffle: '随机播放',
|
||||
radio: '电台',
|
||||
loading: '加载中…',
|
||||
@@ -975,6 +995,8 @@ export const zhTranslation = {
|
||||
hide: '隐藏',
|
||||
close: '关闭',
|
||||
nextTracks: '即将播放',
|
||||
shareQueue: '复制队列分享链接',
|
||||
shareQueueEmpty: '队列为空,无法分享。',
|
||||
emptyQueue: '队列为空。',
|
||||
trackSingular: '首曲目',
|
||||
trackPlural: '首曲目',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, sear
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronUp, Share2 } from 'lucide-react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
@@ -16,6 +16,7 @@ import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import { invalidateCoverArt } from '../utils/imageCache';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { copyEntityShareLink } from '../utils/copyEntityShareLink';
|
||||
import { extractCoverColors } from '../utils/dynamicColors';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
|
||||
@@ -354,6 +355,17 @@ export default function ArtistDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShareArtist = async () => {
|
||||
if (!id || !artist) return;
|
||||
try {
|
||||
const ok = await copyEntityShareLink('artist', artist.id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
} catch {
|
||||
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const playTopSongWithContinuation = async (startIndex: number) => {
|
||||
if (!artist || albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
@@ -596,6 +608,17 @@ export default function ArtistDetail() {
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))}
|
||||
</button>
|
||||
{id && artist && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
onClick={handleShareArtist}
|
||||
aria-label={t('artistDetail.shareArtist')}
|
||||
data-tooltip={t('artistDetail.shareArtist')}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{albums.length > 0 && (() => {
|
||||
const progress = id ? bulkProgress[id] : undefined;
|
||||
const isDone = progress && progress.done === progress.total;
|
||||
|
||||
+24
-3
@@ -1,10 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { decodeServerMagicString, DECODED_PASSWORD_VISUAL_MASK } from '../utils/serverMagicString';
|
||||
import {
|
||||
decodeServerMagicString,
|
||||
DECODED_PASSWORD_VISUAL_MASK,
|
||||
encodeServerMagicString,
|
||||
type ServerMagicPayload,
|
||||
} from '../utils/serverMagicString';
|
||||
import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/serverDisplayName';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
@@ -13,6 +18,7 @@ const PsysonicLogo = () => (
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore();
|
||||
|
||||
@@ -24,6 +30,21 @@ export default function Login() {
|
||||
const [status, setStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const inv = (location.state as { openAddServerInvite?: ServerMagicPayload } | null)?.openAddServerInvite;
|
||||
if (!inv) return;
|
||||
setShowPass(false);
|
||||
setBlockPasswordReveal(true);
|
||||
setForm({
|
||||
serverName: (inv.name && inv.name.trim()) || shortHostFromServerUrl(inv.url),
|
||||
url: inv.url,
|
||||
username: inv.username,
|
||||
password: inv.password,
|
||||
});
|
||||
setMagicString(encodeServerMagicString(inv));
|
||||
navigate('/login', { replace: true, state: {} });
|
||||
}, [location.state, navigate]);
|
||||
|
||||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
|
||||
+80
-10
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
encodeServerMagicString,
|
||||
copyTextToClipboard,
|
||||
DECODED_PASSWORD_VISUAL_MASK,
|
||||
type ServerMagicPayload,
|
||||
} from '../utils/serverMagicString';
|
||||
import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/serverDisplayName';
|
||||
|
||||
@@ -227,13 +228,34 @@ function resolveTab(input: string | undefined | null): Tab {
|
||||
return (known as string[]).includes(input) ? (input as Tab) : 'servers';
|
||||
}
|
||||
|
||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||
function AddServerForm({
|
||||
onSave,
|
||||
onCancel,
|
||||
initialInvite = null,
|
||||
}: {
|
||||
onSave: (data: Omit<ServerProfile, 'id'>) => void;
|
||||
onCancel: () => void;
|
||||
initialInvite?: ServerMagicPayload | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
|
||||
const [magicString, setMagicString] = useState('');
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialInvite) return;
|
||||
setShowPass(false);
|
||||
setBlockPasswordReveal(true);
|
||||
setForm({
|
||||
name: (initialInvite.name && initialInvite.name.trim()) || shortHostFromServerUrl(initialInvite.url),
|
||||
url: initialInvite.url,
|
||||
username: initialInvite.username,
|
||||
password: initialInvite.password,
|
||||
});
|
||||
setMagicString(encodeServerMagicString(initialInvite));
|
||||
}, [initialInvite]);
|
||||
|
||||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
@@ -1369,7 +1391,8 @@ export default function Settings() {
|
||||
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
|
||||
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { state: routeState } = useLocation();
|
||||
const location = useLocation();
|
||||
const routeState = location.state;
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>(resolveTab((routeState as { tab?: string } | null)?.tab));
|
||||
@@ -1387,6 +1410,7 @@ export default function Settings() {
|
||||
serversRef.current = auth.servers;
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [pastedServerInvite, setPastedServerInvite] = useState<ServerMagicPayload | null>(null);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
const [lfmState, setLfmState] = useState<'idle' | 'waiting' | 'error'>('idle');
|
||||
const [lfmPendingToken, setLfmPendingToken] = useState<string | null>(null);
|
||||
@@ -1404,6 +1428,28 @@ export default function Settings() {
|
||||
const [fontPickerOpen, setFontPickerOpen] = useState(false);
|
||||
const [ndAdminAuth, setNdAdminAuth] = useState<{ token: string; serverUrl: string; username: string } | null>(null);
|
||||
const [ndAuthChecked, setNdAuthChecked] = useState(false);
|
||||
const addServerInviteAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddForm || !pastedServerInvite) return;
|
||||
addServerInviteAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, [showAddForm, pastedServerInvite]);
|
||||
|
||||
useEffect(() => {
|
||||
const st = routeState as { openAddServerInvite?: ServerMagicPayload; tab?: Tab } | null;
|
||||
const inv = st?.openAddServerInvite;
|
||||
if (inv) {
|
||||
setPastedServerInvite(inv);
|
||||
setShowAddForm(true);
|
||||
setActiveTab('server');
|
||||
navigate(
|
||||
{ pathname: location.pathname, search: location.search, hash: location.hash },
|
||||
{ replace: true, state: { tab: 'server' as Tab } },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (st?.tab) setActiveTab(st.tab);
|
||||
}, [routeState, location.pathname, location.search, location.hash, navigate]);
|
||||
|
||||
// In-Page-Suche: filtert Sub-Sections des aktiven Tabs per data-settings-search
|
||||
// Attribut. DOM-basiert, damit wir nicht den Query durch jede Komponente
|
||||
@@ -1690,8 +1736,14 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const closeAddServerForm = () => {
|
||||
setShowAddForm(false);
|
||||
setPastedServerInvite(null);
|
||||
};
|
||||
|
||||
const handleAddServer = async (data: Omit<ServerProfile, 'id'>) => {
|
||||
setShowAddForm(false);
|
||||
setPastedServerInvite(null);
|
||||
const tempId = '_new';
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
|
||||
try {
|
||||
@@ -3595,13 +3647,31 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm ? (
|
||||
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
|
||||
) : (
|
||||
<button className="btn btn-surface" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
|
||||
<Plus size={16} /> {t('settings.addServer')}
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={addServerInviteAnchorRef}
|
||||
id="settings-add-server-anchor"
|
||||
style={{ scrollMarginTop: '12px' }}
|
||||
>
|
||||
{showAddForm ? (
|
||||
<AddServerForm
|
||||
initialInvite={pastedServerInvite}
|
||||
onSave={handleAddServer}
|
||||
onCancel={closeAddServerForm}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
style={{ marginTop: '0.75rem' }}
|
||||
onClick={() => {
|
||||
setPastedServerInvite(null);
|
||||
setShowAddForm(true);
|
||||
}}
|
||||
id="settings-add-server-btn"
|
||||
>
|
||||
<Plus size={16} /> {t('settings.addServer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { getAlbum, getArtist, getSong, type SubsonicSong } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { songToTrack, usePlayerStore } from '../store/playerStore';
|
||||
import { findServerIdForShareUrl, type SharePayloadV1 } from './shareLink';
|
||||
import { showToast } from './toast';
|
||||
|
||||
const RESOLVE_QUEUE_CHUNK = 12;
|
||||
|
||||
/**
|
||||
* Switches to the matching server, validates the entity on the server, then
|
||||
* plays or navigates. Caller should `preventDefault` on the paste event when
|
||||
* the payload was already decoded successfully.
|
||||
*/
|
||||
export async function applySharePastePayload(
|
||||
payload: SharePayloadV1,
|
||||
navigate: NavigateFunction,
|
||||
t: TFunction,
|
||||
): Promise<void> {
|
||||
const { servers, isLoggedIn, setActiveServer } = useAuthStore.getState();
|
||||
if (!isLoggedIn) {
|
||||
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = findServerIdForShareUrl(servers, payload.srv);
|
||||
if (!serverId) {
|
||||
showToast(t('sharePaste.noMatchingServer', { url: payload.srv }), 6000, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useAuthStore.getState().activeServerId !== serverId) {
|
||||
setActiveServer(serverId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (payload.k === 'track') {
|
||||
const song = await getSong(payload.id);
|
||||
if (!song) {
|
||||
showToast(t('sharePaste.trackUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
const track = songToTrack(song);
|
||||
usePlayerStore.getState().clearQueue();
|
||||
usePlayerStore.getState().playTrack(track, [track]);
|
||||
showToast(t('sharePaste.openedTrack'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.k === 'album') {
|
||||
try {
|
||||
await getAlbum(payload.id);
|
||||
} catch {
|
||||
showToast(t('sharePaste.albumUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
navigate(`/album/${payload.id}`);
|
||||
showToast(t('sharePaste.openedAlbum'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.k === 'artist') {
|
||||
try {
|
||||
await getArtist(payload.id);
|
||||
} catch {
|
||||
showToast(t('sharePaste.artistUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
navigate(`/artist/${payload.id}`);
|
||||
showToast(t('sharePaste.openedArtist'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.k === 'queue') {
|
||||
const { ids } = payload;
|
||||
if (ids.length === 0) {
|
||||
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
const resolved: SubsonicSong[] = [];
|
||||
for (let i = 0; i < ids.length; i += RESOLVE_QUEUE_CHUNK) {
|
||||
const chunk = ids.slice(i, i + RESOLVE_QUEUE_CHUNK);
|
||||
const songs = await Promise.all(chunk.map(id => getSong(id)));
|
||||
for (let j = 0; j < songs.length; j++) {
|
||||
const s = songs[j];
|
||||
if (s) resolved.push(s);
|
||||
}
|
||||
}
|
||||
const skipped = ids.length - resolved.length;
|
||||
if (resolved.length === 0) {
|
||||
showToast(t('sharePaste.queueAllUnavailable'), 6000, 'error');
|
||||
return;
|
||||
}
|
||||
const tracks = resolved.map(songToTrack);
|
||||
usePlayerStore.getState().clearQueue();
|
||||
usePlayerStore.getState().playTrack(tracks[0]!, tracks);
|
||||
if (skipped > 0) {
|
||||
showToast(
|
||||
t('sharePaste.openedQueuePartial', { played: tracks.length, total: ids.length, skipped }),
|
||||
5000,
|
||||
'info',
|
||||
);
|
||||
} else {
|
||||
showToast(t('sharePaste.openedQueue', { count: tracks.length }), 3000, 'info');
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[psysonic] share paste failed', e);
|
||||
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { encodeSharePayload, type EntityShareKind } from './shareLink';
|
||||
import { copyTextToClipboard } from './serverMagicString';
|
||||
|
||||
/** Copies a track / album / artist share link (`psysonic2-`) to the clipboard. */
|
||||
export async function copyEntityShareLink(kind: EntityShareKind, id: string): Promise<boolean> {
|
||||
const srv = useAuthStore.getState().getBaseUrl();
|
||||
if (!srv || !id.trim()) return false;
|
||||
return copyTextToClipboard(encodeSharePayload({ srv, k: kind, id: id.trim() }));
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
SERVER_MAGIC_STRING_PREFIX,
|
||||
DECODED_PASSWORD_VISUAL_MASK,
|
||||
decodeServerMagicString,
|
||||
decodeServerMagicStringFromText,
|
||||
encodeServerMagicString,
|
||||
} from './serverMagicString';
|
||||
|
||||
@@ -40,4 +41,15 @@ describe('serverMagicString', () => {
|
||||
expect(decodeServerMagicString('nope')).toBeNull();
|
||||
expect(decodeServerMagicString(`${SERVER_MAGIC_STRING_PREFIX}%%%`)).toBeNull();
|
||||
});
|
||||
|
||||
it('decodes invite embedded in surrounding text', () => {
|
||||
const original = {
|
||||
url: 'https://music.example.com',
|
||||
username: 'alice',
|
||||
password: 'pw',
|
||||
};
|
||||
const line = encodeServerMagicString(original);
|
||||
expect(decodeServerMagicStringFromText(`Copy:\n${line}\nThanks`)).toEqual(original);
|
||||
expect(decodeServerMagicStringFromText('no token')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/** Prefix for server share strings generated by Psysonic (Subsonic credentials bundle). */
|
||||
/**
|
||||
* Prefix for server invite strings (Subsonic credentials). Same family as library
|
||||
* shares in `shareLink.ts` (`psysonic2-` + payload).
|
||||
*/
|
||||
export const SERVER_MAGIC_STRING_PREFIX = 'psysonic1-';
|
||||
|
||||
/** Fixed-length placeholder so a password field does not reveal the real password length after decode. */
|
||||
@@ -45,6 +48,19 @@ export function encodeServerMagicString(p: ServerMagicPayload): string {
|
||||
* Decode a magic string from {@link encodeServerMagicString}.
|
||||
* Accepts optional surrounding whitespace.
|
||||
*/
|
||||
/**
|
||||
* Finds a server invite (`psysonic1-` + base64url payload) inside arbitrary pasted
|
||||
* text (e.g. a sentence with the token embedded).
|
||||
*/
|
||||
export function decodeServerMagicStringFromText(text: string): ServerMagicPayload | null {
|
||||
const idx = text.indexOf(SERVER_MAGIC_STRING_PREFIX);
|
||||
if (idx < 0) return null;
|
||||
const afterPrefix = text.slice(idx + SERVER_MAGIC_STRING_PREFIX.length);
|
||||
const token = afterPrefix.match(/^([A-Za-z0-9_-]+)/)?.[1];
|
||||
if (!token) return null;
|
||||
return decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + token);
|
||||
}
|
||||
|
||||
export function decodeServerMagicString(raw: string): ServerMagicPayload | null {
|
||||
const s = raw.trim();
|
||||
if (!s.startsWith(SERVER_MAGIC_STRING_PREFIX)) return null;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeSharePayloadFromText,
|
||||
encodeSharePayload,
|
||||
PSYSONIC_SHARE_PREFIX,
|
||||
} from './shareLink';
|
||||
import { decodeServerMagicString, encodeServerMagicString, SERVER_MAGIC_STRING_PREFIX } from './serverMagicString';
|
||||
|
||||
describe('shareLink vs serverMagicString', () => {
|
||||
it('uses the same psysonic* prefix family as server invites (distinct digit)', () => {
|
||||
expect(SERVER_MAGIC_STRING_PREFIX).toBe('psysonic1-');
|
||||
expect(PSYSONIC_SHARE_PREFIX).toBe('psysonic2-');
|
||||
expect(SERVER_MAGIC_STRING_PREFIX.slice(0, 8)).toBe(PSYSONIC_SHARE_PREFIX.slice(0, 8));
|
||||
expect(SERVER_MAGIC_STRING_PREFIX).not.toBe(PSYSONIC_SHARE_PREFIX);
|
||||
});
|
||||
|
||||
it('does not decode a server magic string as an entity share', () => {
|
||||
const serverLine = encodeServerMagicString({
|
||||
url: 'https://music.example.com',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
});
|
||||
expect(decodeSharePayloadFromText(serverLine)).toBeNull();
|
||||
expect(decodeSharePayloadFromText(`intro ${serverLine}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not decode an entity share as server magic', () => {
|
||||
const share = encodeSharePayload({
|
||||
srv: 'https://music.example.com',
|
||||
k: 'track',
|
||||
id: 'tr-1',
|
||||
});
|
||||
expect(share.startsWith(PSYSONIC_SHARE_PREFIX)).toBe(true);
|
||||
expect(decodeServerMagicString(share)).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips entity payload embedded in surrounding text', () => {
|
||||
const encoded = encodeSharePayload({
|
||||
srv: 'https://nd.example/rest',
|
||||
k: 'album',
|
||||
id: 'al-99',
|
||||
});
|
||||
const pasted = `Check this:\n${encoded}\n`;
|
||||
expect(decodeSharePayloadFromText(pasted)).toEqual({
|
||||
srv: 'https://nd.example/rest',
|
||||
k: 'album',
|
||||
id: 'al-99',
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips queue payload in order', () => {
|
||||
const ids = ['a', 'b', 'c'];
|
||||
const encoded = encodeSharePayload({
|
||||
srv: 'https://x.example',
|
||||
k: 'queue',
|
||||
ids,
|
||||
});
|
||||
expect(decodeSharePayloadFromText(encoded)).toEqual({
|
||||
srv: 'https://x.example',
|
||||
k: 'queue',
|
||||
ids: ['a', 'b', 'c'],
|
||||
});
|
||||
expect(decodeServerMagicString(encoded)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ServerProfile } from '../store/authStore';
|
||||
|
||||
/** Library share (track / album / artist / queue). Same naming family as `psysonic1-` server invites. */
|
||||
export const PSYSONIC_SHARE_PREFIX = 'psysonic2-';
|
||||
|
||||
export type EntityShareKind = 'track' | 'album' | 'artist';
|
||||
|
||||
export type SharePayloadV1 =
|
||||
| { srv: string; k: EntityShareKind; id: string }
|
||||
| { srv: string; k: 'queue'; ids: string[] };
|
||||
|
||||
export function normalizeShareServerUrl(url: string): string {
|
||||
const t = url.trim();
|
||||
if (!t) return '';
|
||||
const withScheme = t.startsWith('http') ? t : `http://${t}`;
|
||||
return withScheme.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function utf8ToBase64Url(s: string): string {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlToUtf8(s: string): string {
|
||||
let b64 = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (b64.length % 4) b64 += '=';
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function isEntityKind(k: unknown): k is EntityShareKind {
|
||||
return k === 'track' || k === 'album' || k === 'artist';
|
||||
}
|
||||
|
||||
export function encodeSharePayload(payload: SharePayloadV1): string {
|
||||
const srvNorm = normalizeShareServerUrl(payload.srv);
|
||||
const body =
|
||||
payload.k === 'queue'
|
||||
? JSON.stringify({
|
||||
v: 1,
|
||||
srv: srvNorm,
|
||||
k: 'queue',
|
||||
ids: payload.ids.map(id => String(id).trim()).filter(Boolean),
|
||||
})
|
||||
: JSON.stringify({
|
||||
v: 1,
|
||||
srv: srvNorm,
|
||||
k: payload.k,
|
||||
id: String(payload.id).trim(),
|
||||
});
|
||||
return PSYSONIC_SHARE_PREFIX + utf8ToBase64Url(body);
|
||||
}
|
||||
|
||||
export function decodeSharePayloadFromText(text: string): SharePayloadV1 | null {
|
||||
const idx = text.indexOf(PSYSONIC_SHARE_PREFIX);
|
||||
if (idx < 0) return null;
|
||||
const after = text.slice(idx + PSYSONIC_SHARE_PREFIX.length);
|
||||
const token = after.match(/^([A-Za-z0-9_-]+)/)?.[1];
|
||||
if (!token) return null;
|
||||
try {
|
||||
const raw = JSON.parse(base64UrlToUtf8(token)) as Record<string, unknown>;
|
||||
if (raw.v !== 1) return null;
|
||||
const srv = typeof raw.srv === 'string' ? normalizeShareServerUrl(raw.srv) : '';
|
||||
if (!srv) return null;
|
||||
const k = raw.k;
|
||||
if (k === 'queue') {
|
||||
const idsRaw = raw.ids;
|
||||
if (!Array.isArray(idsRaw) || idsRaw.length === 0) return null;
|
||||
const ids = idsRaw.map(x => (typeof x === 'string' ? x.trim() : '')).filter(Boolean);
|
||||
if (ids.length === 0) return null;
|
||||
return { srv, k: 'queue', ids };
|
||||
}
|
||||
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
||||
if (!id || !isEntityKind(k)) return null;
|
||||
return { srv, k, id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function findServerIdForShareUrl(servers: ServerProfile[], shareSrv: string): string | null {
|
||||
const norm = normalizeShareServerUrl(shareSrv);
|
||||
const hit = servers.find(s => normalizeShareServerUrl(s.url) === norm);
|
||||
return hit?.id ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user