diff --git a/src/App.tsx b/src/App.tsx index e53b163c..17bbcdaa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( + } /> diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index ac4d2e84..20c45a87 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -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 && } @@ -242,6 +254,16 @@ export default function AlbumHeader({ + + + + diff --git a/src/locales/de.ts b/src/locales/de.ts index c88f6fb3..ff1a44e8 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 78b2927e..b74eb856 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/es.ts b/src/locales/es.ts index 42743d98..6e0dd202 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 2b2d69d1..347119fc 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index b7d4ad20..c0ea48ca 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 905e3b35..da9117e5 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 08e83631..a3dee578 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -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: 'треков', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index c3700c26..30ec61b0 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -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: '首曲目', diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index de0cc086..0b7c94bc 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -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 ?
: } {!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))} + {id && artist && ( + + )} {albums.length > 0 && (() => { const progress = id ? bulkProgress[id] : undefined; const isDone = progress && progress.done === progress.total; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 569560ae..842f6b1c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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) => setForm(f => ({ ...f, [k]: e.target.value })); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e31b36fa..d62dc054 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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) => void; onCancel: () => void }) { +function AddServerForm({ + onSave, + onCancel, + initialInvite = null, +}: { + onSave: (data: Omit) => 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) => setForm(f => ({ ...f, [k]: e.target.value })); @@ -1369,7 +1391,8 @@ export default function Settings() { const [listeningFor, setListeningFor] = useState(null); const [listeningForGlobal, setListeningForGlobal] = useState(null); const navigate = useNavigate(); - const { state: routeState } = useLocation(); + const location = useLocation(); + const routeState = location.state; const { t, i18n } = useTranslation(); const [activeTab, setActiveTab] = useState(resolveTab((routeState as { tab?: string } | null)?.tab)); @@ -1387,6 +1410,7 @@ export default function Settings() { serversRef.current = auth.servers; const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(false); + const [pastedServerInvite, setPastedServerInvite] = useState(null); const [newGenre, setNewGenre] = useState(''); const [lfmState, setLfmState] = useState<'idle' | 'waiting' | 'error'>('idle'); const [lfmPendingToken, setLfmPendingToken] = useState(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(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) => { setShowAddForm(false); + setPastedServerInvite(null); const tempId = '_new'; setConnStatus(s => ({ ...s, [tempId]: 'testing' })); try { @@ -3595,13 +3647,31 @@ export default function Settings() {
)} - {showAddForm ? ( - setShowAddForm(false)} /> - ) : ( - - )} +
+ {showAddForm ? ( + + ) : ( + + )} +
diff --git a/src/utils/applySharePaste.ts b/src/utils/applySharePaste.ts new file mode 100644 index 00000000..d2aa1ef1 --- /dev/null +++ b/src/utils/applySharePaste.ts @@ -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 { + 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'); + } +} diff --git a/src/utils/copyEntityShareLink.ts b/src/utils/copyEntityShareLink.ts new file mode 100644 index 00000000..303cf6c7 --- /dev/null +++ b/src/utils/copyEntityShareLink.ts @@ -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 { + const srv = useAuthStore.getState().getBaseUrl(); + if (!srv || !id.trim()) return false; + return copyTextToClipboard(encodeSharePayload({ srv, k: kind, id: id.trim() })); +} diff --git a/src/utils/serverMagicString.test.ts b/src/utils/serverMagicString.test.ts index c5ba806d..3e83be09 100644 --- a/src/utils/serverMagicString.test.ts +++ b/src/utils/serverMagicString.test.ts @@ -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(); + }); }); diff --git a/src/utils/serverMagicString.ts b/src/utils/serverMagicString.ts index 6247d892..15ff40df 100644 --- a/src/utils/serverMagicString.ts +++ b/src/utils/serverMagicString.ts @@ -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; diff --git a/src/utils/shareLink.test.ts b/src/utils/shareLink.test.ts new file mode 100644 index 00000000..07934677 --- /dev/null +++ b/src/utils/shareLink.test.ts @@ -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(); + }); +}); diff --git a/src/utils/shareLink.ts b/src/utils/shareLink.ts new file mode 100644 index 00000000..ea7feded --- /dev/null +++ b/src/utils/shareLink.ts @@ -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; + 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; +}