mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user