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:
Frank Stellmacher
2026-04-22 11:21:00 +02:00
committed by GitHub
parent 21d00889aa
commit 7c9a300022
22 changed files with 779 additions and 18 deletions
+32 -1
View File
@@ -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}>
+24 -1
View File
@@ -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>
+78
View File
@@ -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;
}
+24 -1
View File
@@ -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>