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
+23
View File
@@ -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',
+23
View File
@@ -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',
+23
View File
@@ -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',
+23
View File
@@ -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 lalbum partagé.',
openedArtist: 'Ouverture de lartiste 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 na été trouvé sur le serveur.',
genericError: 'Impossible douvrir 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 dattente',
shareQueueEmpty: 'La file dattente est vide — rien à partager.',
emptyQueue: 'La file d\'attente est vide.',
trackSingular: 'piste',
trackPlural: 'pistes',
+23
View File
@@ -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',
+23
View File
@@ -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',
+25
View File
@@ -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: 'треков',
+22
View File
@@ -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: '首曲目',