mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(search): queue pasted share links from Live Search and mobile search
Implement share-link detection in search (track, queue, album, artist, composer): enqueue tracks/queues without interrupting playback; preview album/artist/composer without switching the active server; queue preview modal with scrollable track list. Based on community PR #551. Co-authored-by: Daniel Wagner <daniel.iuser@icloud.com>
This commit is contained in:
@@ -30,6 +30,15 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
|
|||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
|
### Search — queue pasted share links from Live Search and mobile search
|
||||||
|
|
||||||
|
Inspired by [@DanielWTE](https://github.com/DanielWTE)'s [PR #551](https://github.com/Psychotoxical/psysonic/pull/551).
|
||||||
|
|
||||||
|
* Pasting a `psysonic2-` share link into **Live Search** or the **mobile search overlay** shows a dedicated share row: track and queue links **enqueue** (append) instead of replacing the queue like global paste does; album, artist, and composer links preview metadata **without switching the active server**, then navigate on confirm.
|
||||||
|
* Queue share links expose a **Preview** action that opens a scrollable track list (resolved lazily against the share server) before **Add to queue**.
|
||||||
|
* Shared tracks and queues resolve against the matching saved server via explicit credentials; `orbitBulkGuard` applies before bulk enqueue.
|
||||||
|
* i18n: `search.share*` keys across all 9 locales.
|
||||||
|
|
||||||
### HTTP — gzip + brotli decompression for the Rust-side clients
|
### HTTP — gzip + brotli decompression for the Rust-side clients
|
||||||
|
|
||||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#704](https://github.com/Psychotoxical/psysonic/pull/704)**
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#704](https://github.com/Psychotoxical/psysonic/pull/704)**
|
||||||
|
|||||||
@@ -10,7 +10,14 @@
|
|||||||
* Network-bound endpoints (`getAlbum`, `search`, etc.) require axios
|
* Network-bound endpoints (`getAlbum`, `search`, etc.) require axios
|
||||||
* mocking and are not in this PR.
|
* mocking and are not in this PR.
|
||||||
*/
|
*/
|
||||||
import { buildCoverArtUrl, buildDownloadUrl, buildStreamUrl, coverArtCacheKey } from './subsonicStreamUrl';
|
import {
|
||||||
|
buildCoverArtUrl,
|
||||||
|
buildCoverArtUrlForServer,
|
||||||
|
buildDownloadUrl,
|
||||||
|
buildStreamUrl,
|
||||||
|
coverArtCacheKey,
|
||||||
|
coverArtCacheKeyForServer,
|
||||||
|
} from './subsonicStreamUrl';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { parseSubsonicEntityStarRating } from './subsonicRatings';
|
import { parseSubsonicEntityStarRating } from './subsonicRatings';
|
||||||
import { getClient, libraryFilterParams } from './subsonicClient';
|
import { getClient, libraryFilterParams } from './subsonicClient';
|
||||||
@@ -173,6 +180,24 @@ describe('buildCoverArtUrl', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildCoverArtUrlForServer', () => {
|
||||||
|
it('builds getCoverArt URL with explicit server credentials', () => {
|
||||||
|
const url = new URL(buildCoverArtUrlForServer('https://remote.example', 'bob', 'secret', 'art-9', 40));
|
||||||
|
expect(url.origin).toBe('https://remote.example');
|
||||||
|
expect(url.pathname).toBe('/rest/getCoverArt.view');
|
||||||
|
expect(url.searchParams.get('id')).toBe('art-9');
|
||||||
|
expect(url.searchParams.get('size')).toBe('40');
|
||||||
|
expect(url.searchParams.get('u')).toBe('bob');
|
||||||
|
expect(url.searchParams.get('t')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('coverArtCacheKeyForServer', () => {
|
||||||
|
it('scopes cache keys by server id', () => {
|
||||||
|
expect(coverArtCacheKeyForServer('srv-b', 'cover-1', 80)).toBe('srv-b:cover:cover-1:80');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildDownloadUrl', () => {
|
describe('buildDownloadUrl', () => {
|
||||||
it('returns a /rest/download.view URL with id + auth params', () => {
|
it('returns a /rest/download.view URL with id + auth params', () => {
|
||||||
setUpServer({ url: 'https://music.example.com' });
|
setUpServer({ url: 'https://music.example.com' });
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { apiWithCredentials } from './subsonicClient';
|
||||||
|
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
||||||
|
|
||||||
|
export async function getSongWithCredentials(
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<SubsonicSong | null> {
|
||||||
|
try {
|
||||||
|
const data = await apiWithCredentials<{ song: SubsonicSong }>(
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
'getSong.view',
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
return data.song ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbumWithCredentials(
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||||
|
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
'getAlbum.view',
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
const { song, ...album } = data.album;
|
||||||
|
return { album, songs: song ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistWithCredentials(
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||||
|
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
'getArtist.view',
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
const { album, ...artist } = data.artist;
|
||||||
|
return { artist, albums: album ?? [] };
|
||||||
|
}
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
import md5 from 'md5';
|
import md5 from 'md5';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
|
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
|
||||||
|
|
||||||
|
function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams {
|
||||||
|
const salt = secureRandomSalt();
|
||||||
|
const token = md5(password + salt);
|
||||||
|
return new URLSearchParams({
|
||||||
|
id,
|
||||||
|
size: String(size),
|
||||||
|
u: username,
|
||||||
|
t: token,
|
||||||
|
s: salt,
|
||||||
|
v: '1.16.1',
|
||||||
|
c: SUBSONIC_CLIENT,
|
||||||
|
f: 'json',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStreamUrl(id: string): string {
|
export function buildStreamUrl(id: string): string {
|
||||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||||
@@ -19,23 +34,33 @@ export function buildStreamUrl(id: string): string {
|
|||||||
/** Stable cache key for cover art — does not include ephemeral auth params. */
|
/** Stable cache key for cover art — does not include ephemeral auth params. */
|
||||||
export function coverArtCacheKey(id: string, size = 256): string {
|
export function coverArtCacheKey(id: string, size = 256): string {
|
||||||
const server = useAuthStore.getState().getActiveServer();
|
const server = useAuthStore.getState().getActiveServer();
|
||||||
return `${server?.id ?? '_'}:cover:${id}:${size}`;
|
return coverArtCacheKeyForServer(server?.id ?? '_', id, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverArtCacheKeyForServer(serverId: string, id: string, size = 256): string {
|
||||||
|
return `${serverId}:cover:${id}:${size}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCoverArtUrl(id: string, size = 256): string {
|
export function buildCoverArtUrl(id: string, size = 256): string {
|
||||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||||
const server = getActiveServer();
|
const server = getActiveServer();
|
||||||
const baseUrl = getBaseUrl();
|
const baseUrl = getBaseUrl();
|
||||||
const salt = secureRandomSalt();
|
const p = coverArtQueryParams(server?.username ?? '', server?.password ?? '', id, size);
|
||||||
const token = md5((server?.password ?? '') + salt);
|
|
||||||
const p = new URLSearchParams({
|
|
||||||
id, size: String(size),
|
|
||||||
u: server?.username ?? '',
|
|
||||||
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
|
|
||||||
});
|
|
||||||
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
|
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cover art for a specific saved server (e.g. share-search preview on a non-active server). */
|
||||||
|
export function buildCoverArtUrlForServer(
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
id: string,
|
||||||
|
size = 256,
|
||||||
|
): string {
|
||||||
|
const p = coverArtQueryParams(username, password, id, size);
|
||||||
|
return `${restBaseFromUrl(serverUrl)}/getCoverArt.view?${p.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDownloadUrl(id: string): string {
|
export function buildDownloadUrl(id: string): string {
|
||||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||||
const server = getActiveServer();
|
const server = getActiveServer();
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
|
import { useShareSearch } from '../hooks/useShareSearch';
|
||||||
|
import ShareSearchResults from './search/ShareSearchResults';
|
||||||
|
|
||||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
@@ -67,6 +69,13 @@ export default function LiveSearch() {
|
|||||||
const compactHeaderControlsRef = useRef(false);
|
const compactHeaderControlsRef = useRef(false);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
|
||||||
|
const closeSearch = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const share = useShareSearch(query, closeSearch);
|
||||||
|
|
||||||
const doSearch = useCallback(
|
const doSearch = useCallback(
|
||||||
debounce(async (q: string) => {
|
debounce(async (q: string) => {
|
||||||
if (!q.trim()) { setResults(null); setOpen(false); return; }
|
if (!q.trim()) { setResults(null); setOpen(false); return; }
|
||||||
@@ -82,7 +91,17 @@ export default function LiveSearch() {
|
|||||||
[musicLibraryFilterVersion]
|
[musicLibraryFilterVersion]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
|
useEffect(() => {
|
||||||
|
if (share.shareMatch) {
|
||||||
|
setResults(null);
|
||||||
|
setLoading(false);
|
||||||
|
setOpen(true);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doSearch(query);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}, [query, doSearch, share.shareMatch]);
|
||||||
|
|
||||||
const isSearchActive = isFocused || open || query.trim().length > 0;
|
const isSearchActive = isFocused || open || query.trim().length > 0;
|
||||||
|
|
||||||
@@ -202,10 +221,22 @@ export default function LiveSearch() {
|
|||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [ctxIsOpen]);
|
}, [ctxIsOpen]);
|
||||||
|
|
||||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
const hasResults =
|
||||||
|
!!share.shareMatch ||
|
||||||
|
(results && (results.artists.length || results.albums.length || results.songs.length));
|
||||||
|
|
||||||
// Flat list of all navigable items for keyboard nav
|
// Flat list of all navigable items for keyboard nav
|
||||||
const flatItems = results ? [
|
const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [
|
||||||
|
{
|
||||||
|
id: 'share-link',
|
||||||
|
action: () => {
|
||||||
|
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
||||||
|
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
||||||
|
else if (share.canOpenShareArtist) share.openShareArtist();
|
||||||
|
else if (share.canOpenShareComposer) share.openShareComposer();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] : results ? [
|
||||||
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
|
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||||
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
|
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||||
...(results.songs.map(s => ({ id: s.id, action: () => {
|
...(results.songs.map(s => ({ id: s.id, action: () => {
|
||||||
@@ -217,6 +248,22 @@ export default function LiveSearch() {
|
|||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (share.shareMatch) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
||||||
|
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
||||||
|
else if (share.canOpenShareArtist) share.openShareArtist();
|
||||||
|
else if (share.canOpenShareComposer) share.openShareComposer();
|
||||||
|
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(share.hasShareKeyboardTarget ? 0 : -1);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setOpen(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!open || !flatItems.length) {
|
if (!open || !flatItems.length) {
|
||||||
if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||||
return;
|
return;
|
||||||
@@ -312,7 +359,36 @@ export default function LiveSearch() {
|
|||||||
<div className="search-empty">{t('search.noResults', { query })}</div>
|
<div className="search-empty">{t('search.noResults', { query })}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{share.shareMatch && (
|
||||||
|
<ShareSearchResults
|
||||||
|
variant="desktop"
|
||||||
|
shareMatch={share.shareMatch}
|
||||||
|
shareServerLabel={share.shareServerLabel}
|
||||||
|
shareCoverServer={share.shareCoverServer}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
shareQueueBusy={share.shareQueueBusy}
|
||||||
|
onEnqueue={() => void share.enqueueShareMatch()}
|
||||||
|
onOpenAlbum={share.openShareAlbum}
|
||||||
|
onOpenArtist={share.openShareArtist}
|
||||||
|
onOpenComposer={share.openShareComposer}
|
||||||
|
onContextMenu={(e, item, type) => openContextMenu(e.clientX, e.clientY, item, type)}
|
||||||
|
shareTrackSong={share.shareTrackSong}
|
||||||
|
shareTrackResolving={share.shareTrackResolving}
|
||||||
|
shareTrackUnavailable={share.shareTrackUnavailable}
|
||||||
|
shareAlbum={share.shareAlbum}
|
||||||
|
shareAlbumResolving={share.shareAlbumResolving}
|
||||||
|
shareAlbumUnavailable={share.shareAlbumUnavailable}
|
||||||
|
shareArtist={share.shareArtist}
|
||||||
|
shareArtistResolving={share.shareArtistResolving}
|
||||||
|
shareArtistUnavailable={share.shareArtistUnavailable}
|
||||||
|
shareComposer={share.shareComposer}
|
||||||
|
shareComposerResolving={share.shareComposerResolving}
|
||||||
|
shareComposerUnavailable={share.shareComposerUnavailable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
|
if (share.shareMatch) return null;
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
return <>
|
return <>
|
||||||
{results?.artists.length ? (
|
{results?.artists.length ? (
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
|
import { useShareSearch } from '../hooks/useShareSearch';
|
||||||
|
import ShareSearchResults from './search/ShareSearchResults';
|
||||||
|
|
||||||
const STORAGE_KEY = 'psysonic_recent_searches';
|
const STORAGE_KEY = 'psysonic_recent_searches';
|
||||||
const MAX_RECENT = 6;
|
const MAX_RECENT = 6;
|
||||||
@@ -67,6 +69,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
|
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
const share = useShareSearch(query, onClose);
|
||||||
|
|
||||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||||
|
|
||||||
@@ -86,7 +89,14 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
[musicLibraryFilterVersion]
|
[musicLibraryFilterVersion]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => { doSearch(query); }, [query, doSearch]);
|
useEffect(() => {
|
||||||
|
if (share.shareMatch) {
|
||||||
|
setResults(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doSearch(query);
|
||||||
|
}, [query, doSearch, share.shareMatch]);
|
||||||
|
|
||||||
const commit = (q: string) => {
|
const commit = (q: string) => {
|
||||||
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
||||||
@@ -114,7 +124,9 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
const hasResults =
|
||||||
|
!!share.shareMatch ||
|
||||||
|
(results && (results.artists.length || results.albums.length || results.songs.length));
|
||||||
const showEmpty = !query;
|
const showEmpty = !query;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -209,8 +221,34 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{share.shareMatch && (
|
||||||
|
<ShareSearchResults
|
||||||
|
variant="mobile"
|
||||||
|
shareMatch={share.shareMatch}
|
||||||
|
shareServerLabel={share.shareServerLabel}
|
||||||
|
shareCoverServer={share.shareCoverServer}
|
||||||
|
shareQueueBusy={share.shareQueueBusy}
|
||||||
|
onEnqueue={() => void share.enqueueShareMatch()}
|
||||||
|
onOpenAlbum={share.openShareAlbum}
|
||||||
|
onOpenArtist={share.openShareArtist}
|
||||||
|
onOpenComposer={share.openShareComposer}
|
||||||
|
shareTrackSong={share.shareTrackSong}
|
||||||
|
shareTrackResolving={share.shareTrackResolving}
|
||||||
|
shareTrackUnavailable={share.shareTrackUnavailable}
|
||||||
|
shareAlbum={share.shareAlbum}
|
||||||
|
shareAlbumResolving={share.shareAlbumResolving}
|
||||||
|
shareAlbumUnavailable={share.shareAlbumUnavailable}
|
||||||
|
shareArtist={share.shareArtist}
|
||||||
|
shareArtistResolving={share.shareArtistResolving}
|
||||||
|
shareArtistUnavailable={share.shareArtistUnavailable}
|
||||||
|
shareComposer={share.shareComposer}
|
||||||
|
shareComposerResolving={share.shareComposerResolving}
|
||||||
|
shareComposerUnavailable={share.shareComposerUnavailable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Results ── */}
|
{/* ── Results ── */}
|
||||||
{hasResults && (
|
{hasResults && !share.shareMatch && (
|
||||||
<>
|
<>
|
||||||
{results!.artists.length > 0 && (
|
{results!.artists.length > 0 && (
|
||||||
<div className="mobile-search-section">
|
<div className="mobile-search-section">
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Music, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||||
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
|
import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview';
|
||||||
|
import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch';
|
||||||
|
import CachedImage from '../CachedImage';
|
||||||
|
import {
|
||||||
|
buildCoverArtUrl,
|
||||||
|
buildCoverArtUrlForServer,
|
||||||
|
coverArtCacheKey,
|
||||||
|
coverArtCacheKeyForServer,
|
||||||
|
} from '../../api/subsonicStreamUrl';
|
||||||
|
|
||||||
|
type ShareQueuePreviewModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
payload: Extract<QueueableShareSearchPayload, { k: 'queue' }>;
|
||||||
|
preview: ShareQueuePreviewState;
|
||||||
|
shareServerLabel?: string | null;
|
||||||
|
coverServer?: ServerProfile | null;
|
||||||
|
onEnqueue: () => void;
|
||||||
|
enqueueBusy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function QueuePreviewTrackRow({
|
||||||
|
song,
|
||||||
|
coverServer,
|
||||||
|
}: {
|
||||||
|
song: SubsonicSong;
|
||||||
|
coverServer?: ServerProfile | null;
|
||||||
|
}) {
|
||||||
|
const coverId = song.coverArt ?? '';
|
||||||
|
const src = coverServer
|
||||||
|
? buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId || song.id, 48)
|
||||||
|
: buildCoverArtUrl(coverId || song.id, 48);
|
||||||
|
const cacheKey = coverServer
|
||||||
|
? coverArtCacheKeyForServer(coverServer.id, coverId || song.id, 48)
|
||||||
|
: coverArtCacheKey(coverId || song.id, 48);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="share-queue-preview-track">
|
||||||
|
{coverId ? (
|
||||||
|
<CachedImage className="share-queue-preview-track__thumb" src={src} cacheKey={cacheKey} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="share-queue-preview-track__icon">
|
||||||
|
<Music size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="share-queue-preview-track__meta">
|
||||||
|
<div className="share-queue-preview-track__title">{song.title}</div>
|
||||||
|
<div className="share-queue-preview-track__sub">
|
||||||
|
{song.artist}{song.album ? ` · ${song.album}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="share-queue-preview-track__dur">{formatTrackTime(song.duration)}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewBody({
|
||||||
|
preview,
|
||||||
|
coverServer,
|
||||||
|
}: {
|
||||||
|
preview: ShareQueuePreviewState;
|
||||||
|
coverServer?: ServerProfile | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (preview.status === 'loading' || preview.status === 'idle') {
|
||||||
|
return <div className="share-queue-preview-modal__status">{t('search.shareQueuePreviewLoading')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.status === 'error') {
|
||||||
|
const msg =
|
||||||
|
preview.result.type === 'not-logged-in'
|
||||||
|
? t('sharePaste.notLoggedIn')
|
||||||
|
: preview.result.type === 'no-matching-server'
|
||||||
|
? t('sharePaste.noMatchingServer', { url: preview.result.url })
|
||||||
|
: preview.result.type === 'all-unavailable'
|
||||||
|
? t('search.shareQueuePreviewEmpty')
|
||||||
|
: t('sharePaste.genericError');
|
||||||
|
return <div className="share-queue-preview-modal__status share-queue-preview-modal__status--error">{msg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{preview.skipped > 0 && (
|
||||||
|
<p className="share-queue-preview-modal__skipped">
|
||||||
|
{t('search.shareQueuePreviewSkipped', { skipped: preview.skipped, total: preview.total })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="share-queue-preview-modal__list">
|
||||||
|
{preview.songs.map(song => (
|
||||||
|
<QueuePreviewTrackRow key={song.id} song={song} coverServer={coverServer} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShareQueuePreviewModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
payload,
|
||||||
|
preview,
|
||||||
|
shareServerLabel,
|
||||||
|
coverServer,
|
||||||
|
onEnqueue,
|
||||||
|
enqueueBusy,
|
||||||
|
}: ShareQueuePreviewModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const count = sharePayloadTotal(payload);
|
||||||
|
const canEnqueue = preview.status === 'ok' && preview.songs.length > 0;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal-overlay share-queue-preview-modal-overlay"
|
||||||
|
role="presentation"
|
||||||
|
onMouseDown={e => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content share-queue-preview-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="share-queue-preview-title"
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose} aria-label={t('common.close')}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<header className="share-queue-preview-modal__header">
|
||||||
|
<h2 id="share-queue-preview-title" className="share-queue-preview-modal__title">
|
||||||
|
{t('search.shareQueueTitle', { count })}
|
||||||
|
</h2>
|
||||||
|
{shareServerLabel && (
|
||||||
|
<p className="share-queue-preview-modal__server">
|
||||||
|
{t('search.shareFromServer', { server: shareServerLabel })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="share-queue-preview-modal__body">
|
||||||
|
<PreviewBody preview={preview} coverServer={coverServer} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="share-queue-preview-modal__footer">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!canEnqueue || enqueueBusy}
|
||||||
|
onClick={() => void onEnqueue()}
|
||||||
|
>
|
||||||
|
{enqueueBusy ? t('search.shareQueueing') : t('search.shareQueueAction')}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Disc3, Eye, Link2, ListPlus, Music, Users } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import {
|
||||||
|
buildCoverArtUrl,
|
||||||
|
buildCoverArtUrlForServer,
|
||||||
|
coverArtCacheKey,
|
||||||
|
coverArtCacheKeyForServer,
|
||||||
|
} from '../../api/subsonicStreamUrl';
|
||||||
|
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||||
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
|
import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchPayload';
|
||||||
|
import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch';
|
||||||
|
import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview';
|
||||||
|
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage';
|
||||||
|
import { useShareQueuePreview } from '../../hooks/useShareQueuePreview';
|
||||||
|
import ShareQueuePreviewModal from './ShareQueuePreviewModal';
|
||||||
|
|
||||||
|
type ShareSearchResultsProps = {
|
||||||
|
variant: 'desktop' | 'mobile';
|
||||||
|
shareMatch: ShareSearchMatch;
|
||||||
|
/** Saved server display name when the link is from a non-active server. */
|
||||||
|
shareServerLabel?: string | null;
|
||||||
|
/** Saved server profile for cover art when the link is from a non-active server. */
|
||||||
|
shareCoverServer?: ServerProfile | null;
|
||||||
|
activeIndex?: number;
|
||||||
|
shareQueueBusy: boolean;
|
||||||
|
onEnqueue: () => void | Promise<boolean>;
|
||||||
|
onOpenAlbum: () => void;
|
||||||
|
onOpenArtist: () => void;
|
||||||
|
onOpenComposer: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void;
|
||||||
|
} & ShareSearchPreviewState;
|
||||||
|
|
||||||
|
function useShareCoverArt(coverId: string, size: number, coverServer: ServerProfile | null | undefined) {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (coverServer) {
|
||||||
|
return {
|
||||||
|
src: buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId, size),
|
||||||
|
cacheKey: coverArtCacheKeyForServer(coverServer.id, coverId, size),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
src: buildCoverArtUrl(coverId, size),
|
||||||
|
cacheKey: coverArtCacheKey(coverId, size),
|
||||||
|
};
|
||||||
|
}, [coverServer, coverId, size]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareAlbumThumb({
|
||||||
|
coverArt,
|
||||||
|
size,
|
||||||
|
coverServer,
|
||||||
|
}: {
|
||||||
|
coverArt: string;
|
||||||
|
size: number;
|
||||||
|
coverServer?: ServerProfile | null;
|
||||||
|
}) {
|
||||||
|
const { src, cacheKey } = useShareCoverArt(coverArt, size, coverServer);
|
||||||
|
const cls = size >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
|
||||||
|
return <CachedImage className={cls} src={src} cacheKey={cacheKey} alt="" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareArtistThumb({
|
||||||
|
artist,
|
||||||
|
size,
|
||||||
|
coverServer,
|
||||||
|
}: {
|
||||||
|
artist: Pick<SubsonicArtist, 'id' | 'coverArt'>;
|
||||||
|
size: number;
|
||||||
|
coverServer?: ServerProfile | null;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const coverId = artist.coverArt || artist.id;
|
||||||
|
const { src, cacheKey } = useShareCoverArt(coverId, size, coverServer);
|
||||||
|
useEffect(() => { setFailed(false); }, [coverId]);
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
if (size >= 64) {
|
||||||
|
return (
|
||||||
|
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
||||||
|
<Users size={20} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="search-result-icon">
|
||||||
|
<Users size={14} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cls =
|
||||||
|
size >= 64
|
||||||
|
? 'mobile-search-thumb mobile-search-thumb--artist-round'
|
||||||
|
: 'search-result-thumb';
|
||||||
|
return (
|
||||||
|
<CachedImage
|
||||||
|
className={cls}
|
||||||
|
src={src}
|
||||||
|
cacheKey={cacheKey}
|
||||||
|
alt=""
|
||||||
|
loading="eager"
|
||||||
|
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StaticIcon({ className, children }: { className: string; children: React.ReactNode }) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withShareServer(
|
||||||
|
shareMatch: ShareSearchMatch,
|
||||||
|
t: TFunction,
|
||||||
|
fn: () => void,
|
||||||
|
): void {
|
||||||
|
if (shareMatch.type === 'unsupported') return;
|
||||||
|
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareSubLine(primary: string, serverLabel: string | null | undefined, t: TFunction): string {
|
||||||
|
if (!serverLabel) return primary;
|
||||||
|
const hint = t('search.shareFromServer', { server: serverLabel });
|
||||||
|
return primary ? `${primary} · ${hint}` : hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||||
|
const {
|
||||||
|
variant,
|
||||||
|
shareMatch,
|
||||||
|
shareServerLabel = null,
|
||||||
|
shareCoverServer = null,
|
||||||
|
activeIndex = 0,
|
||||||
|
shareQueueBusy,
|
||||||
|
onEnqueue,
|
||||||
|
onOpenAlbum,
|
||||||
|
onOpenArtist,
|
||||||
|
onOpenComposer,
|
||||||
|
onContextMenu,
|
||||||
|
shareTrackSong,
|
||||||
|
shareTrackResolving,
|
||||||
|
shareTrackUnavailable,
|
||||||
|
shareAlbum,
|
||||||
|
shareAlbumResolving,
|
||||||
|
shareAlbumUnavailable,
|
||||||
|
shareArtist,
|
||||||
|
shareArtistResolving,
|
||||||
|
shareArtistUnavailable,
|
||||||
|
shareComposer,
|
||||||
|
shareComposerResolving,
|
||||||
|
shareComposerUnavailable,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const desktop = variant === 'desktop';
|
||||||
|
const thumbSize = desktop ? 40 : 80;
|
||||||
|
const sectionCls = desktop ? 'search-section' : 'mobile-search-section';
|
||||||
|
const labelCls = desktop ? 'search-section-label' : 'mobile-search-section-label';
|
||||||
|
const mutedCls = desktop ? 'search-result-item search-result-item--muted' : 'mobile-search-item mobile-search-item--muted';
|
||||||
|
const iconCls = desktop ? 'search-result-icon' : 'mobile-search-avatar';
|
||||||
|
const nameCls = desktop ? 'search-result-name' : 'mobile-search-item-title';
|
||||||
|
const subCls = desktop ? 'search-result-sub' : 'mobile-search-item-sub';
|
||||||
|
const infoWrap = desktop ? undefined : 'mobile-search-item-info';
|
||||||
|
|
||||||
|
const wrap = (content: React.ReactNode) => (
|
||||||
|
<div className={sectionCls}>
|
||||||
|
<div className={labelCls}>
|
||||||
|
{desktop && <Link2 size={12} />} {t('search.shareLink')}
|
||||||
|
</div>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemCls = (active: boolean) =>
|
||||||
|
desktop ? `search-result-item${active ? ' active' : ''}` : 'mobile-search-item';
|
||||||
|
|
||||||
|
const sub = (primary: string) => shareSubLine(primary, shareServerLabel, t);
|
||||||
|
const showEntityKindSub = !desktop || !!shareServerLabel;
|
||||||
|
const [queuePreviewOpen, setQueuePreviewOpen] = useState(false);
|
||||||
|
const queuePayload =
|
||||||
|
shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue' ? shareMatch.payload : null;
|
||||||
|
const queuePreview = useShareQueuePreview(queuePayload, queuePreviewOpen);
|
||||||
|
|
||||||
|
const unsupportedRow = (
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{t('search.shareUnsupportedTitle')}</div>
|
||||||
|
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shareMatch.type === 'unsupported') {
|
||||||
|
return wrap(unsupportedRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMatch.type === 'artist') {
|
||||||
|
if (shareArtistResolving) {
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Users size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{t('common.loading')}</div>
|
||||||
|
<div className={subCls}>{sub(t('search.artists'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (shareArtist) {
|
||||||
|
return wrap(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={itemCls(activeIndex === 0)}
|
||||||
|
onClick={onOpenArtist}
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
withShareServer(shareMatch, t, () => onContextMenu?.(e, shareArtist, 'artist'));
|
||||||
|
}}
|
||||||
|
role={desktop ? 'option' : undefined}
|
||||||
|
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||||
|
>
|
||||||
|
<ShareArtistThumb artist={shareArtist} size={thumbSize} coverServer={shareCoverServer} />
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{shareArtist.name}</div>
|
||||||
|
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('search.artists') : '')}</div>}
|
||||||
|
</div>
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>
|
||||||
|
{shareArtistUnavailable ? t('sharePaste.artistUnavailable') : t('sharePaste.genericError')}
|
||||||
|
</div>
|
||||||
|
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMatch.type === 'composer') {
|
||||||
|
if (shareComposerResolving) {
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Users size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{t('common.loading')}</div>
|
||||||
|
<div className={subCls}>{sub(t('sidebar.composers'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (shareComposer) {
|
||||||
|
return wrap(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={itemCls(activeIndex === 0)}
|
||||||
|
onClick={onOpenComposer}
|
||||||
|
role={desktop ? 'option' : undefined}
|
||||||
|
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||||
|
>
|
||||||
|
<ShareArtistThumb artist={shareComposer} size={thumbSize} coverServer={shareCoverServer} />
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{shareComposer.name}</div>
|
||||||
|
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('sidebar.composers') : '')}</div>}
|
||||||
|
</div>
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>
|
||||||
|
{shareComposerUnavailable ? t('sharePaste.composerUnavailable') : t('sharePaste.genericError')}
|
||||||
|
</div>
|
||||||
|
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMatch.type === 'album') {
|
||||||
|
if (shareAlbumResolving) {
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{t('common.loading')}</div>
|
||||||
|
<div className={subCls}>{sub(t('search.album'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (shareAlbum) {
|
||||||
|
return wrap(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={itemCls(activeIndex === 0)}
|
||||||
|
onClick={onOpenAlbum}
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
withShareServer(shareMatch, t, () => onContextMenu?.(e, shareAlbum, 'album'));
|
||||||
|
}}
|
||||||
|
role={desktop ? 'option' : undefined}
|
||||||
|
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||||
|
>
|
||||||
|
{shareAlbum.coverArt ? (
|
||||||
|
<ShareAlbumThumb coverArt={shareAlbum.coverArt} size={thumbSize} coverServer={shareCoverServer} />
|
||||||
|
) : (
|
||||||
|
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
)}
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{shareAlbum.name}</div>
|
||||||
|
<div className={subCls}>{sub(shareAlbum.artist)}</div>
|
||||||
|
</div>
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>
|
||||||
|
{shareAlbumUnavailable ? t('sharePaste.albumUnavailable') : t('sharePaste.genericError')}
|
||||||
|
</div>
|
||||||
|
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMatch.type === 'queueable' && shareMatch.payload.k === 'track') {
|
||||||
|
if (shareTrackResolving) {
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{t('common.loading')}</div>
|
||||||
|
<div className={subCls}>{sub(t('search.shareTrackTitle'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (shareTrackSong) {
|
||||||
|
return wrap(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={itemCls(activeIndex === 0)}
|
||||||
|
onClick={onEnqueue}
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
withShareServer(shareMatch, t, () => onContextMenu?.(e, songToTrack(shareTrackSong), 'song'));
|
||||||
|
}}
|
||||||
|
disabled={shareQueueBusy}
|
||||||
|
role={desktop ? 'option' : undefined}
|
||||||
|
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||||
|
>
|
||||||
|
{shareTrackSong.coverArt ? (
|
||||||
|
<ShareAlbumThumb coverArt={shareTrackSong.coverArt} size={thumbSize} coverServer={shareCoverServer} />
|
||||||
|
) : (
|
||||||
|
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
)}
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{shareTrackSong.title}</div>
|
||||||
|
<div className={subCls}>
|
||||||
|
{shareQueueBusy
|
||||||
|
? sub(t('search.shareQueueing'))
|
||||||
|
: sub(`${shareTrackSong.artist}${shareTrackSong.album ? ` · ${shareTrackSong.album}` : ''}`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>
|
||||||
|
{shareTrackUnavailable ? t('sharePaste.trackUnavailable') : t('sharePaste.genericError')}
|
||||||
|
</div>
|
||||||
|
<div className={subCls}>{t('search.shareUnsupportedSub')}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue') {
|
||||||
|
const count = sharePayloadTotal(shareMatch.payload);
|
||||||
|
const rowCls = desktop ? 'search-share-queue-row' : 'mobile-search-share-queue-row';
|
||||||
|
const handleModalEnqueue = () => {
|
||||||
|
void Promise.resolve(onEnqueue()).then(ok => {
|
||||||
|
if (ok) setQueuePreviewOpen(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{wrap(
|
||||||
|
<div
|
||||||
|
className={`${rowCls}${activeIndex === 0 ? ' active' : ''}`}
|
||||||
|
role={desktop ? 'option' : undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={desktop ? 'search-share-queue-main' : 'mobile-search-item search-share-queue-main'}
|
||||||
|
onClick={() => void onEnqueue()}
|
||||||
|
disabled={shareQueueBusy}
|
||||||
|
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||||
|
>
|
||||||
|
<StaticIcon className={iconCls}><ListPlus size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{t('search.shareQueueTitle', { count })}</div>
|
||||||
|
<div className={subCls}>
|
||||||
|
{shareQueueBusy ? sub(t('search.shareQueueing')) : sub(t('search.shareQueueAction'))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-share-queue-preview-btn"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQueuePreviewOpen(true);
|
||||||
|
}}
|
||||||
|
aria-label={t('search.shareQueuePreview')}
|
||||||
|
>
|
||||||
|
<Eye size={desktop ? 16 : 18} />
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
)}
|
||||||
|
<ShareQueuePreviewModal
|
||||||
|
open={queuePreviewOpen}
|
||||||
|
onClose={() => setQueuePreviewOpen(false)}
|
||||||
|
payload={shareMatch.payload}
|
||||||
|
preview={queuePreview}
|
||||||
|
shareServerLabel={shareServerLabel}
|
||||||
|
coverServer={shareCoverServer}
|
||||||
|
onEnqueue={handleModalEnqueue}
|
||||||
|
enqueueBusy={shareQueueBusy}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
|
import {
|
||||||
|
resolveShareSearchPayload,
|
||||||
|
type ShareSearchResolveResult,
|
||||||
|
} from '../utils/share/enqueueShareSearchPayload';
|
||||||
|
import type { QueueableShareSearchPayload } from '../utils/share/shareSearch';
|
||||||
|
|
||||||
|
export type ShareQueuePreviewState =
|
||||||
|
| { status: 'idle' }
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'ok'; songs: SubsonicSong[]; total: number; skipped: number }
|
||||||
|
| { status: 'error'; result: Exclude<ShareSearchResolveResult, { type: 'ok' }> };
|
||||||
|
|
||||||
|
const IDLE: ShareQueuePreviewState = { status: 'idle' };
|
||||||
|
|
||||||
|
export function useShareQueuePreview(
|
||||||
|
payload: Extract<QueueableShareSearchPayload, { k: 'queue' }> | null,
|
||||||
|
open: boolean,
|
||||||
|
): ShareQueuePreviewState {
|
||||||
|
const [state, setState] = useState<ShareQueuePreviewState>(IDLE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !payload) {
|
||||||
|
setState(IDLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ status: 'loading' });
|
||||||
|
|
||||||
|
void resolveShareSearchPayload(payload)
|
||||||
|
.then(result => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (result.type === 'ok') {
|
||||||
|
setState({
|
||||||
|
status: 'ok',
|
||||||
|
songs: result.songs,
|
||||||
|
total: result.total,
|
||||||
|
skipped: result.skipped,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({ status: 'error', result });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setState({ status: 'error', result: { type: 'error' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, payload]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import {
|
||||||
|
activateShareSearchServer,
|
||||||
|
enqueueShareSearchPayload,
|
||||||
|
} from '../utils/share/enqueueShareSearchPayload';
|
||||||
|
import type { ServerProfile } from '../store/authStoreTypes';
|
||||||
|
import { findServerIdForShareUrl } from '../utils/share/shareLink';
|
||||||
|
import { shareServerOriginLabel } from '../utils/share/shareServerOriginLabel';
|
||||||
|
import { parseShareSearchText } from '../utils/share/shareSearch';
|
||||||
|
import { useShareSearchPreview } from './useShareSearchPreview';
|
||||||
|
|
||||||
|
export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const servers = useAuthStore(s => s.servers);
|
||||||
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
|
const shareMatch = useMemo(() => parseShareSearchText(query), [query]);
|
||||||
|
const shareServerLabel = useMemo(
|
||||||
|
() => shareServerOriginLabel(shareMatch, servers, activeServerId),
|
||||||
|
[shareMatch, servers, activeServerId],
|
||||||
|
);
|
||||||
|
const shareCoverServer = useMemo((): ServerProfile | null => {
|
||||||
|
if (!shareMatch || shareMatch.type === 'unsupported') return null;
|
||||||
|
const serverId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
|
||||||
|
if (!serverId || serverId === activeServerId) return null;
|
||||||
|
return servers.find(s => s.id === serverId) ?? null;
|
||||||
|
}, [shareMatch, servers, activeServerId]);
|
||||||
|
const preview = useShareSearchPreview(shareMatch);
|
||||||
|
const [shareQueueBusy, setShareQueueBusy] = useState(false);
|
||||||
|
|
||||||
|
const canQueueShareMatch =
|
||||||
|
shareMatch?.type === 'queueable' &&
|
||||||
|
(shareMatch.payload.k === 'queue' ||
|
||||||
|
(!preview.shareTrackResolving && !!preview.shareTrackSong));
|
||||||
|
|
||||||
|
const canOpenShareAlbum =
|
||||||
|
shareMatch?.type === 'album' && !!preview.shareAlbum && !preview.shareAlbumResolving;
|
||||||
|
const canOpenShareArtist =
|
||||||
|
shareMatch?.type === 'artist' && !!preview.shareArtist && !preview.shareArtistResolving;
|
||||||
|
const canOpenShareComposer =
|
||||||
|
shareMatch?.type === 'composer' && !!preview.shareComposer && !preview.shareComposerResolving;
|
||||||
|
|
||||||
|
const hasShareKeyboardTarget =
|
||||||
|
canQueueShareMatch || canOpenShareAlbum || canOpenShareArtist || canOpenShareComposer;
|
||||||
|
|
||||||
|
const openShareAlbum = useCallback(() => {
|
||||||
|
if (shareMatch?.type !== 'album' || !preview.shareAlbum) return;
|
||||||
|
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
|
||||||
|
navigate(`/album/${preview.shareAlbum.id}`);
|
||||||
|
onSuccess?.();
|
||||||
|
}, [shareMatch, preview.shareAlbum, navigate, t, onSuccess]);
|
||||||
|
|
||||||
|
const openShareArtist = useCallback(() => {
|
||||||
|
if (shareMatch?.type !== 'artist' || !preview.shareArtist) return;
|
||||||
|
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
|
||||||
|
navigate(`/artist/${preview.shareArtist.id}`);
|
||||||
|
onSuccess?.();
|
||||||
|
}, [shareMatch, preview.shareArtist, navigate, t, onSuccess]);
|
||||||
|
|
||||||
|
const openShareComposer = useCallback(() => {
|
||||||
|
if (shareMatch?.type !== 'composer' || !preview.shareComposer) return;
|
||||||
|
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
|
||||||
|
navigate(`/composer/${preview.shareComposer.id}`);
|
||||||
|
onSuccess?.();
|
||||||
|
}, [shareMatch, preview.shareComposer, navigate, t, onSuccess]);
|
||||||
|
|
||||||
|
const enqueueShareMatch = useCallback(async () => {
|
||||||
|
if (shareMatch?.type !== 'queueable' || shareQueueBusy) return false;
|
||||||
|
if (shareMatch.payload.k === 'track' && (!preview.shareTrackSong || preview.shareTrackResolving)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setShareQueueBusy(true);
|
||||||
|
const ok = await enqueueShareSearchPayload(shareMatch.payload, t);
|
||||||
|
setShareQueueBusy(false);
|
||||||
|
if (ok) onSuccess?.();
|
||||||
|
return ok;
|
||||||
|
}, [shareMatch, shareQueueBusy, preview.shareTrackSong, preview.shareTrackResolving, t, onSuccess]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shareMatch,
|
||||||
|
shareServerLabel,
|
||||||
|
shareCoverServer,
|
||||||
|
shareQueueBusy,
|
||||||
|
canQueueShareMatch,
|
||||||
|
canOpenShareAlbum,
|
||||||
|
canOpenShareArtist,
|
||||||
|
canOpenShareComposer,
|
||||||
|
hasShareKeyboardTarget,
|
||||||
|
openShareAlbum,
|
||||||
|
openShareArtist,
|
||||||
|
openShareComposer,
|
||||||
|
enqueueShareMatch,
|
||||||
|
...preview,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonicTypes';
|
||||||
|
import {
|
||||||
|
resolveShareSearchAlbum,
|
||||||
|
resolveShareSearchArtist,
|
||||||
|
resolveShareSearchPayload,
|
||||||
|
} from '../utils/share/enqueueShareSearchPayload';
|
||||||
|
import type { ShareSearchMatch } from '../utils/share/shareSearch';
|
||||||
|
|
||||||
|
export interface ShareSearchPreviewState {
|
||||||
|
shareTrackSong: SubsonicSong | null;
|
||||||
|
shareTrackResolving: boolean;
|
||||||
|
shareTrackUnavailable: boolean;
|
||||||
|
shareAlbum: SubsonicAlbum | null;
|
||||||
|
shareAlbumResolving: boolean;
|
||||||
|
shareAlbumUnavailable: boolean;
|
||||||
|
shareArtist: SubsonicArtist | null;
|
||||||
|
shareArtistResolving: boolean;
|
||||||
|
shareArtistUnavailable: boolean;
|
||||||
|
shareComposer: SubsonicArtist | null;
|
||||||
|
shareComposerResolving: boolean;
|
||||||
|
shareComposerUnavailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_PREVIEW: ShareSearchPreviewState = {
|
||||||
|
shareTrackSong: null,
|
||||||
|
shareTrackResolving: false,
|
||||||
|
shareTrackUnavailable: false,
|
||||||
|
shareAlbum: null,
|
||||||
|
shareAlbumResolving: false,
|
||||||
|
shareAlbumUnavailable: false,
|
||||||
|
shareArtist: null,
|
||||||
|
shareArtistResolving: false,
|
||||||
|
shareArtistUnavailable: false,
|
||||||
|
shareComposer: null,
|
||||||
|
shareComposerResolving: false,
|
||||||
|
shareComposerUnavailable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useShareSearchPreview(shareMatch: ShareSearchMatch | null): ShareSearchPreviewState {
|
||||||
|
const [preview, setPreview] = useState<ShareSearchPreviewState>(EMPTY_PREVIEW);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setPreview(EMPTY_PREVIEW);
|
||||||
|
|
||||||
|
if (shareMatch?.type === 'queueable' && shareMatch.payload.k === 'track') {
|
||||||
|
setPreview({ ...EMPTY_PREVIEW, shareTrackResolving: true });
|
||||||
|
void resolveShareSearchPayload(shareMatch.payload)
|
||||||
|
.then(result => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPreview({
|
||||||
|
...EMPTY_PREVIEW,
|
||||||
|
shareTrackSong: result.type === 'ok' ? (result.songs[0] ?? null) : null,
|
||||||
|
shareTrackUnavailable: result.type !== 'ok' || result.songs.length === 0,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreview(current => ({ ...current, shareTrackResolving: false }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMatch?.type === 'artist') {
|
||||||
|
setPreview({ ...EMPTY_PREVIEW, shareArtistResolving: true });
|
||||||
|
void resolveShareSearchArtist(shareMatch.payload)
|
||||||
|
.then(result => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPreview({
|
||||||
|
...EMPTY_PREVIEW,
|
||||||
|
shareArtist: result.type === 'ok' ? result.artist : null,
|
||||||
|
shareArtistUnavailable: result.type !== 'ok',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreview(current => ({ ...current, shareArtistResolving: false }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMatch?.type === 'composer') {
|
||||||
|
setPreview({ ...EMPTY_PREVIEW, shareComposerResolving: true });
|
||||||
|
void resolveShareSearchArtist(shareMatch.payload)
|
||||||
|
.then(result => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPreview({
|
||||||
|
...EMPTY_PREVIEW,
|
||||||
|
shareComposer: result.type === 'ok' ? result.artist : null,
|
||||||
|
shareComposerUnavailable: result.type !== 'ok',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreview(current => ({ ...current, shareComposerResolving: false }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMatch?.type === 'album') {
|
||||||
|
setPreview({ ...EMPTY_PREVIEW, shareAlbumResolving: true });
|
||||||
|
void resolveShareSearchAlbum(shareMatch.payload)
|
||||||
|
.then(result => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPreview({
|
||||||
|
...EMPTY_PREVIEW,
|
||||||
|
shareAlbum: result.type === 'ok' ? result.album : null,
|
||||||
|
shareAlbumUnavailable: result.type !== 'ok',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreview(current => ({ ...current, shareAlbumResolving: false }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [shareMatch]);
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
@@ -26,4 +26,20 @@ export const search = {
|
|||||||
browse: 'Stöbern',
|
browse: 'Stöbern',
|
||||||
emptyHint: 'Was möchtest du hören?',
|
emptyHint: 'Was möchtest du hören?',
|
||||||
genres: 'Genres',
|
genres: 'Genres',
|
||||||
|
shareLink: 'Share link',
|
||||||
|
shareTrackTitle: 'Shared track',
|
||||||
|
shareQueueTitle_one: 'Shared queue ({{count}} track)',
|
||||||
|
shareQueueTitle_other: 'Shared queue ({{count}} tracks)',
|
||||||
|
shareQueueAction: 'Add to queue',
|
||||||
|
shareQueueing: 'Adding to queue…',
|
||||||
|
shareQueued_one: 'Added {{count}} shared track to queue',
|
||||||
|
shareQueued_other: 'Added {{count}} shared tracks to queue',
|
||||||
|
shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).',
|
||||||
|
shareUnsupportedTitle: 'This share link cannot be used here',
|
||||||
|
shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.',
|
||||||
|
shareFromServer: 'From {{server}}',
|
||||||
|
shareQueuePreview: 'Preview tracks',
|
||||||
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,4 +26,20 @@ export const search = {
|
|||||||
browse: 'Browse',
|
browse: 'Browse',
|
||||||
emptyHint: 'What do you want to hear?',
|
emptyHint: 'What do you want to hear?',
|
||||||
genres: 'Genres',
|
genres: 'Genres',
|
||||||
|
shareLink: 'Share link',
|
||||||
|
shareTrackTitle: 'Shared track',
|
||||||
|
shareQueueTitle_one: 'Shared queue ({{count}} track)',
|
||||||
|
shareQueueTitle_other: 'Shared queue ({{count}} tracks)',
|
||||||
|
shareQueueAction: 'Add to queue',
|
||||||
|
shareQueueing: 'Adding to queue…',
|
||||||
|
shareQueued_one: 'Added {{count}} shared track to queue',
|
||||||
|
shareQueued_other: 'Added {{count}} shared tracks to queue',
|
||||||
|
shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).',
|
||||||
|
shareUnsupportedTitle: 'This share link cannot be used here',
|
||||||
|
shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.',
|
||||||
|
shareFromServer: 'From {{server}}',
|
||||||
|
shareQueuePreview: 'Preview tracks',
|
||||||
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,4 +26,20 @@ export const search = {
|
|||||||
browse: 'Explorar',
|
browse: 'Explorar',
|
||||||
emptyHint: '¿Qué quieres escuchar?',
|
emptyHint: '¿Qué quieres escuchar?',
|
||||||
genres: 'Géneros',
|
genres: 'Géneros',
|
||||||
|
shareLink: 'Share link',
|
||||||
|
shareTrackTitle: 'Shared track',
|
||||||
|
shareQueueTitle_one: 'Shared queue ({{count}} track)',
|
||||||
|
shareQueueTitle_other: 'Shared queue ({{count}} tracks)',
|
||||||
|
shareQueueAction: 'Add to queue',
|
||||||
|
shareQueueing: 'Adding to queue…',
|
||||||
|
shareQueued_one: 'Added {{count}} shared track to queue',
|
||||||
|
shareQueued_other: 'Added {{count}} shared tracks to queue',
|
||||||
|
shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).',
|
||||||
|
shareUnsupportedTitle: 'This share link cannot be used here',
|
||||||
|
shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.',
|
||||||
|
shareFromServer: 'From {{server}}',
|
||||||
|
shareQueuePreview: 'Preview tracks',
|
||||||
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,4 +26,20 @@ export const search = {
|
|||||||
browse: 'Parcourir',
|
browse: 'Parcourir',
|
||||||
emptyHint: 'Que veux-tu écouter ?',
|
emptyHint: 'Que veux-tu écouter ?',
|
||||||
genres: 'Genres',
|
genres: 'Genres',
|
||||||
|
shareLink: 'Share link',
|
||||||
|
shareTrackTitle: 'Shared track',
|
||||||
|
shareQueueTitle_one: 'Shared queue ({{count}} track)',
|
||||||
|
shareQueueTitle_other: 'Shared queue ({{count}} tracks)',
|
||||||
|
shareQueueAction: 'Add to queue',
|
||||||
|
shareQueueing: 'Adding to queue…',
|
||||||
|
shareQueued_one: 'Added {{count}} shared track to queue',
|
||||||
|
shareQueued_other: 'Added {{count}} shared tracks to queue',
|
||||||
|
shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).',
|
||||||
|
shareUnsupportedTitle: 'This share link cannot be used here',
|
||||||
|
shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.',
|
||||||
|
shareFromServer: 'From {{server}}',
|
||||||
|
shareQueuePreview: 'Preview tracks',
|
||||||
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,4 +26,20 @@ export const search = {
|
|||||||
browse: 'Utforsk',
|
browse: 'Utforsk',
|
||||||
emptyHint: 'Hva vil du høre?',
|
emptyHint: 'Hva vil du høre?',
|
||||||
genres: 'Sjangre',
|
genres: 'Sjangre',
|
||||||
|
shareLink: 'Share link',
|
||||||
|
shareTrackTitle: 'Shared track',
|
||||||
|
shareQueueTitle_one: 'Shared queue ({{count}} track)',
|
||||||
|
shareQueueTitle_other: 'Shared queue ({{count}} tracks)',
|
||||||
|
shareQueueAction: 'Add to queue',
|
||||||
|
shareQueueing: 'Adding to queue…',
|
||||||
|
shareQueued_one: 'Added {{count}} shared track to queue',
|
||||||
|
shareQueued_other: 'Added {{count}} shared tracks to queue',
|
||||||
|
shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).',
|
||||||
|
shareUnsupportedTitle: 'This share link cannot be used here',
|
||||||
|
shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.',
|
||||||
|
shareFromServer: 'From {{server}}',
|
||||||
|
shareQueuePreview: 'Preview tracks',
|
||||||
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,4 +26,20 @@ export const search = {
|
|||||||
browse: 'Bladeren',
|
browse: 'Bladeren',
|
||||||
emptyHint: 'Wat wil je horen?',
|
emptyHint: 'Wat wil je horen?',
|
||||||
genres: 'Genres',
|
genres: 'Genres',
|
||||||
|
shareLink: 'Share link',
|
||||||
|
shareTrackTitle: 'Shared track',
|
||||||
|
shareQueueTitle_one: 'Shared queue ({{count}} track)',
|
||||||
|
shareQueueTitle_other: 'Shared queue ({{count}} tracks)',
|
||||||
|
shareQueueAction: 'Add to queue',
|
||||||
|
shareQueueing: 'Adding to queue…',
|
||||||
|
shareQueued_one: 'Added {{count}} shared track to queue',
|
||||||
|
shareQueued_other: 'Added {{count}} shared tracks to queue',
|
||||||
|
shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).',
|
||||||
|
shareUnsupportedTitle: 'This share link cannot be used here',
|
||||||
|
shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.',
|
||||||
|
shareFromServer: 'From {{server}}',
|
||||||
|
shareQueuePreview: 'Preview tracks',
|
||||||
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,4 +26,20 @@ export const search = {
|
|||||||
browse: 'Răsfoiește',
|
browse: 'Răsfoiește',
|
||||||
emptyHint: 'Ce vrei să auzi?',
|
emptyHint: 'Ce vrei să auzi?',
|
||||||
genres: 'Genuri',
|
genres: 'Genuri',
|
||||||
|
shareLink: 'Share link',
|
||||||
|
shareTrackTitle: 'Shared track',
|
||||||
|
shareQueueTitle_one: 'Shared queue ({{count}} track)',
|
||||||
|
shareQueueTitle_other: 'Shared queue ({{count}} tracks)',
|
||||||
|
shareQueueAction: 'Add to queue',
|
||||||
|
shareQueueing: 'Adding to queue…',
|
||||||
|
shareQueued_one: 'Added {{count}} shared track to queue',
|
||||||
|
shareQueued_other: 'Added {{count}} shared tracks to queue',
|
||||||
|
shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).',
|
||||||
|
shareUnsupportedTitle: 'This share link cannot be used here',
|
||||||
|
shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.',
|
||||||
|
shareFromServer: 'From {{server}}',
|
||||||
|
shareQueuePreview: 'Preview tracks',
|
||||||
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,4 +26,24 @@ export const search = {
|
|||||||
browse: 'Обзор',
|
browse: 'Обзор',
|
||||||
emptyHint: 'Что хочешь послушать?',
|
emptyHint: 'Что хочешь послушать?',
|
||||||
genres: 'Жанры',
|
genres: 'Жанры',
|
||||||
|
shareLink: 'Ссылка для обмена',
|
||||||
|
shareTrackTitle: 'Общий трек',
|
||||||
|
shareQueueTitle_one: 'Общая очередь ({{count}} трек)',
|
||||||
|
shareQueueTitle_other: 'Общая очередь ({{count}} трека)',
|
||||||
|
shareQueueTitle_few: 'Общая очередь ({{count}} трека)',
|
||||||
|
shareQueueTitle_many: 'Общая очередь ({{count}} треков)',
|
||||||
|
shareQueueAction: 'Добавить в очередь',
|
||||||
|
shareQueueing: 'Добавление в очередь…',
|
||||||
|
shareQueued_one: 'В очередь добавлен {{count}} трек',
|
||||||
|
shareQueued_few: 'В очередь добавлено {{count}} трека',
|
||||||
|
shareQueued_many: 'В очередь добавлено {{count}} треков',
|
||||||
|
shareQueued_other: 'В очередь добавлено {{count}} треков',
|
||||||
|
shareQueuedPartial: 'Добавлено {{queued}} из {{total}} треков ({{skipped}} не найдено).',
|
||||||
|
shareUnsupportedTitle: 'Эту ссылку нельзя использовать здесь',
|
||||||
|
shareUnsupportedSub: 'Нужна ссылка на трек, альбом, исполнителя, композитора или очередь.',
|
||||||
|
shareFromServer: 'С сервера «{{server}}»',
|
||||||
|
shareQueuePreview: 'Просмотр треков',
|
||||||
|
shareQueuePreviewLoading: 'Загрузка треков…',
|
||||||
|
shareQueuePreviewEmpty: 'Треки из этой ссылки не найдены на сервере.',
|
||||||
|
shareQueuePreviewSkipped: '{{skipped}} из {{total}} треков не найдено на этом сервере.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,4 +26,20 @@ export const search = {
|
|||||||
browse: '浏览',
|
browse: '浏览',
|
||||||
emptyHint: '你想听什么?',
|
emptyHint: '你想听什么?',
|
||||||
genres: '流派',
|
genres: '流派',
|
||||||
|
shareLink: 'Share link',
|
||||||
|
shareTrackTitle: 'Shared track',
|
||||||
|
shareQueueTitle_one: 'Shared queue ({{count}} track)',
|
||||||
|
shareQueueTitle_other: 'Shared queue ({{count}} tracks)',
|
||||||
|
shareQueueAction: 'Add to queue',
|
||||||
|
shareQueueing: 'Adding to queue…',
|
||||||
|
shareQueued_one: 'Added {{count}} shared track to queue',
|
||||||
|
shareQueued_other: 'Added {{count}} shared tracks to queue',
|
||||||
|
shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).',
|
||||||
|
shareUnsupportedTitle: 'This share link cannot be used here',
|
||||||
|
shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.',
|
||||||
|
shareFromServer: 'From {{server}}',
|
||||||
|
shareQueuePreview: 'Preview tracks',
|
||||||
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
@import './offline-cache-button.css';
|
@import './offline-cache-button.css';
|
||||||
@import './download-folder-modal.css';
|
@import './download-folder-modal.css';
|
||||||
@import './song-info-modal.css';
|
@import './song-info-modal.css';
|
||||||
|
@import './share-queue-preview-modal.css';
|
||||||
@import './tracklist.css';
|
@import './tracklist.css';
|
||||||
@import './modal.css';
|
@import './modal.css';
|
||||||
@import './playback-delay-sleep-delayed-start-modal.css';
|
@import './playback-delay-sleep-delayed-start-modal.css';
|
||||||
|
|||||||
@@ -178,6 +178,20 @@
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-result-item:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item--muted {
|
||||||
|
cursor: default;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item--muted:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.search-result-icon {
|
.search-result-icon {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
@@ -12,3 +12,12 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-search-item:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-item--muted {
|
||||||
|
cursor: default;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/* ─ Share queue preview modal (search paste) ─ */
|
||||||
|
.share-queue-preview-modal-overlay {
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(520px, 92vw);
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: min(85vh, 720px);
|
||||||
|
padding: var(--space-6);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal__header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-right: var(--space-8);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal__server {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal__status {
|
||||||
|
padding: var(--space-6) var(--space-2);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal__status--error {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal__skipped {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal__list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: min(52vh, 480px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-track:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-track__thumb {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-track__icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-track__meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-track__title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-track__sub {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-track__dur {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-queue-preview-modal__footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live / mobile search — queue row with preview + enqueue */
|
||||||
|
.search-share-queue-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-share-queue-row .search-share-queue-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4);
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-share-queue-row:hover .search-share-queue-main,
|
||||||
|
.search-share-queue-row.active .search-share-queue-main {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-share-queue-row.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-share-queue-row .search-share-queue-main:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-share-queue-preview-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 44px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-share-queue-preview-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-share-queue-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-share-queue-row .mobile-search-item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-share-queue-row .search-share-queue-preview-btn {
|
||||||
|
width: 48px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
authState: {
|
||||||
|
current: {
|
||||||
|
servers: [] as Array<{ id: string; name: string; url: string; username: string; password: string }>,
|
||||||
|
isLoggedIn: true,
|
||||||
|
activeServerId: 'active',
|
||||||
|
setActiveServer: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enqueue: vi.fn(),
|
||||||
|
getAlbum: vi.fn(),
|
||||||
|
getAlbumWithCredentials: vi.fn(),
|
||||||
|
getArtist: vi.fn(),
|
||||||
|
getArtistWithCredentials: vi.fn(),
|
||||||
|
getSong: vi.fn(),
|
||||||
|
getSongWithCredentials: vi.fn(),
|
||||||
|
orbitBulkGuard: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
songToTrack: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../api/subsonicLibrary', () => ({
|
||||||
|
getAlbum: mocks.getAlbum,
|
||||||
|
getSong: mocks.getSong,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../api/subsonicArtists', () => ({
|
||||||
|
getArtist: mocks.getArtist,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../api/subsonicEntityWithCredentials', () => ({
|
||||||
|
getAlbumWithCredentials: mocks.getAlbumWithCredentials,
|
||||||
|
getArtistWithCredentials: mocks.getArtistWithCredentials,
|
||||||
|
getSongWithCredentials: mocks.getSongWithCredentials,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../store/authStore', () => ({
|
||||||
|
useAuthStore: {
|
||||||
|
getState: () => mocks.authState.current,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../store/playerStore', () => ({
|
||||||
|
usePlayerStore: {
|
||||||
|
getState: () => ({ enqueue: mocks.enqueue }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../playback/songToTrack', () => ({
|
||||||
|
songToTrack: mocks.songToTrack,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../orbitBulkGuard', () => ({
|
||||||
|
orbitBulkGuard: mocks.orbitBulkGuard,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../ui/toast', () => ({
|
||||||
|
showToast: mocks.showToast,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
activateShareSearchServer,
|
||||||
|
enqueueShareSearchPayload,
|
||||||
|
resolveShareSearchAlbum,
|
||||||
|
resolveShareSearchArtist,
|
||||||
|
resolveShareSearchPayload,
|
||||||
|
} from './enqueueShareSearchPayload';
|
||||||
|
|
||||||
|
const sharedServer = {
|
||||||
|
id: 'shared',
|
||||||
|
name: 'Shared',
|
||||||
|
url: 'https://shared.example.com',
|
||||||
|
username: 'shared-user',
|
||||||
|
password: 'shared-pass',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeServer = {
|
||||||
|
id: 'active',
|
||||||
|
name: 'Active',
|
||||||
|
url: 'https://active.example.com',
|
||||||
|
username: 'active-user',
|
||||||
|
password: 'active-pass',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharedSong = {
|
||||||
|
id: 'song-1',
|
||||||
|
title: 'Shared Song',
|
||||||
|
artist: 'Shared Artist',
|
||||||
|
album: 'Shared Album',
|
||||||
|
albumId: 'album-1',
|
||||||
|
duration: 180,
|
||||||
|
minutesAgo: 0,
|
||||||
|
playerId: 0,
|
||||||
|
playerName: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('share search payload resolution', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.authState.current = {
|
||||||
|
servers: [activeServer, sharedServer],
|
||||||
|
isLoggedIn: true,
|
||||||
|
activeServerId: 'active',
|
||||||
|
setActiveServer: vi.fn(),
|
||||||
|
};
|
||||||
|
mocks.getSongWithCredentials.mockResolvedValue(sharedSong);
|
||||||
|
mocks.getAlbumWithCredentials.mockResolvedValue({
|
||||||
|
album: { id: 'album-1', name: 'Shared Album', artist: 'Shared Artist' },
|
||||||
|
songs: [],
|
||||||
|
});
|
||||||
|
mocks.getArtistWithCredentials.mockResolvedValue({
|
||||||
|
artist: { id: 'artist-1', name: 'Shared Artist' },
|
||||||
|
albums: [],
|
||||||
|
});
|
||||||
|
mocks.getSong.mockResolvedValue(sharedSong);
|
||||||
|
mocks.songToTrack.mockImplementation(song => ({ id: song.id, title: song.title }));
|
||||||
|
mocks.orbitBulkGuard.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a shared track preview with explicit credentials without switching active server', async () => {
|
||||||
|
const result = await resolveShareSearchPayload({
|
||||||
|
srv: 'https://shared.example.com',
|
||||||
|
k: 'track',
|
||||||
|
id: 'song-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: 'ok', songs: [sharedSong], total: 1, skipped: 0 });
|
||||||
|
expect(mocks.getSongWithCredentials).toHaveBeenCalledWith(
|
||||||
|
sharedServer.url,
|
||||||
|
sharedServer.username,
|
||||||
|
sharedServer.password,
|
||||||
|
'song-1',
|
||||||
|
);
|
||||||
|
expect(mocks.getSong).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves album and artist previews without switching active server', async () => {
|
||||||
|
await resolveShareSearchAlbum({ srv: 'https://shared.example.com', k: 'album', id: 'album-1' });
|
||||||
|
await resolveShareSearchArtist({ srv: 'https://shared.example.com', k: 'artist', id: 'artist-1' });
|
||||||
|
|
||||||
|
expect(mocks.getAlbumWithCredentials).toHaveBeenCalledWith(
|
||||||
|
sharedServer.url,
|
||||||
|
sharedServer.username,
|
||||||
|
sharedServer.password,
|
||||||
|
'album-1',
|
||||||
|
);
|
||||||
|
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
|
||||||
|
sharedServer.url,
|
||||||
|
sharedServer.username,
|
||||||
|
sharedServer.password,
|
||||||
|
'artist-1',
|
||||||
|
);
|
||||||
|
expect(mocks.getAlbum).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.getArtist).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves composer previews via artist credentials without switching active server', async () => {
|
||||||
|
const result = await resolveShareSearchArtist({
|
||||||
|
srv: 'https://shared.example.com',
|
||||||
|
k: 'composer',
|
||||||
|
id: 'composer-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.type).toBe('ok');
|
||||||
|
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
|
||||||
|
sharedServer.url,
|
||||||
|
sharedServer.username,
|
||||||
|
sharedServer.password,
|
||||||
|
'composer-1',
|
||||||
|
);
|
||||||
|
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns not-logged-in without calling the API', async () => {
|
||||||
|
mocks.authState.current.isLoggedIn = false;
|
||||||
|
|
||||||
|
const result = await resolveShareSearchPayload({
|
||||||
|
srv: 'https://shared.example.com',
|
||||||
|
k: 'track',
|
||||||
|
id: 'song-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: 'not-logged-in' });
|
||||||
|
expect(mocks.getSongWithCredentials).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates the share server for confirmed enqueue actions', async () => {
|
||||||
|
const t = ((key: string) => key) as TFunction;
|
||||||
|
const ok = await enqueueShareSearchPayload({
|
||||||
|
srv: 'https://shared.example.com',
|
||||||
|
k: 'track',
|
||||||
|
id: 'song-1',
|
||||||
|
}, t);
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(mocks.authState.current.setActiveServer).toHaveBeenCalledWith('shared');
|
||||||
|
expect(mocks.getSong).toHaveBeenCalledWith('song-1');
|
||||||
|
expect(mocks.getSongWithCredentials).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.enqueue).toHaveBeenCalledWith([{ id: 'song-1', title: 'Shared Song' }], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts enqueue when orbitBulkGuard rejects the bulk add', async () => {
|
||||||
|
mocks.orbitBulkGuard.mockResolvedValue(false);
|
||||||
|
const t = ((key: string) => key) as TFunction;
|
||||||
|
|
||||||
|
const ok = await enqueueShareSearchPayload({
|
||||||
|
srv: 'https://shared.example.com',
|
||||||
|
k: 'track',
|
||||||
|
id: 'song-1',
|
||||||
|
}, t);
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(mocks.enqueue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports partial queue enqueue with a partial toast', async () => {
|
||||||
|
mocks.getSong.mockImplementation((id: string) =>
|
||||||
|
id === 'song-1' ? Promise.resolve(sharedSong) : Promise.resolve(null),
|
||||||
|
);
|
||||||
|
const t = ((key: string, opts?: Record<string, unknown>) =>
|
||||||
|
opts ? `${key}:${JSON.stringify(opts)}` : key) as TFunction;
|
||||||
|
|
||||||
|
const ok = await enqueueShareSearchPayload({
|
||||||
|
srv: 'https://shared.example.com',
|
||||||
|
k: 'queue',
|
||||||
|
ids: ['song-1', 'missing'],
|
||||||
|
}, t);
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(mocks.enqueue).toHaveBeenCalledWith([{ id: 'song-1', title: 'Shared Song' }], true);
|
||||||
|
expect(mocks.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('search.shareQueuedPartial'),
|
||||||
|
5000,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activateShareSearchServer switches server when lookup succeeds', () => {
|
||||||
|
const t = ((key: string) => key) as TFunction;
|
||||||
|
expect(activateShareSearchServer('https://shared.example.com', t)).toBe(true);
|
||||||
|
expect(mocks.authState.current.setActiveServer).toHaveBeenCalledWith('shared');
|
||||||
|
expect(mocks.showToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activateShareSearchServer toasts when no matching server exists', () => {
|
||||||
|
const t = ((key: string) => key) as TFunction;
|
||||||
|
expect(activateShareSearchServer('https://unknown.example.com', t)).toBe(false);
|
||||||
|
expect(mocks.showToast).toHaveBeenCalledWith(
|
||||||
|
'sharePaste.noMatchingServer',
|
||||||
|
6000,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import {
|
||||||
|
getAlbumWithCredentials,
|
||||||
|
getArtistWithCredentials,
|
||||||
|
getSongWithCredentials,
|
||||||
|
} from '../../api/subsonicEntityWithCredentials';
|
||||||
|
import { getAlbum, getSong } from '../../api/subsonicLibrary';
|
||||||
|
import { getArtist } from '../../api/subsonicArtists';
|
||||||
|
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||||
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
|
import { songToTrack } from '../playback/songToTrack';
|
||||||
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
|
import { orbitBulkGuard } from '../orbitBulkGuard';
|
||||||
|
import { findServerIdForShareUrl } from './shareLink';
|
||||||
|
import type {
|
||||||
|
AlbumShareSearchPayload,
|
||||||
|
ArtistShareSearchPayload,
|
||||||
|
ComposerShareSearchPayload,
|
||||||
|
QueueableShareSearchPayload,
|
||||||
|
} from './shareSearch';
|
||||||
|
import { showToast } from '../ui/toast';
|
||||||
|
|
||||||
|
const RESOLVE_QUEUE_CHUNK = 12;
|
||||||
|
|
||||||
|
type ShareServerLookupResult =
|
||||||
|
| { type: 'ok'; serverId: string; server: ServerProfile }
|
||||||
|
| { type: 'not-logged-in' }
|
||||||
|
| { type: 'no-matching-server'; url: string };
|
||||||
|
|
||||||
|
type ShareResolveOptions = {
|
||||||
|
activateServer?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShareSearchResolveResult =
|
||||||
|
| { type: 'ok'; songs: SubsonicSong[]; total: number; skipped: number }
|
||||||
|
| { type: 'not-logged-in' }
|
||||||
|
| { type: 'no-matching-server'; url: string }
|
||||||
|
| { type: 'all-unavailable' }
|
||||||
|
| { type: 'error' };
|
||||||
|
|
||||||
|
export type ShareSearchAlbumResolveResult =
|
||||||
|
| { type: 'ok'; album: SubsonicAlbum }
|
||||||
|
| { type: 'not-logged-in' }
|
||||||
|
| { type: 'no-matching-server'; url: string }
|
||||||
|
| { type: 'unavailable' }
|
||||||
|
| { type: 'error' };
|
||||||
|
|
||||||
|
export type ShareSearchArtistResolveResult =
|
||||||
|
| { type: 'ok'; artist: SubsonicArtist }
|
||||||
|
| { type: 'not-logged-in' }
|
||||||
|
| { type: 'no-matching-server'; url: string }
|
||||||
|
| { type: 'unavailable' }
|
||||||
|
| { type: 'error' };
|
||||||
|
|
||||||
|
function lookupShareServer(shareSrv: string): ShareServerLookupResult {
|
||||||
|
const { servers, isLoggedIn } = useAuthStore.getState();
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return { type: 'not-logged-in' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = findServerIdForShareUrl(servers, shareSrv);
|
||||||
|
const server = serverId ? servers.find(s => s.id === serverId) : undefined;
|
||||||
|
if (!serverId || !server) {
|
||||||
|
return { type: 'no-matching-server', url: shareSrv };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'ok', serverId, server };
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateShareServer(serverId: string): void {
|
||||||
|
const { activeServerId, setActiveServer } = useAuthStore.getState();
|
||||||
|
if (activeServerId !== serverId) {
|
||||||
|
setActiveServer(serverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateShareSearchServer(shareSrv: string, t: TFunction): boolean {
|
||||||
|
const lookup = lookupShareServer(shareSrv);
|
||||||
|
if (lookup.type === 'not-logged-in') {
|
||||||
|
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (lookup.type === 'no-matching-server') {
|
||||||
|
showToast(t('sharePaste.noMatchingServer', { url: lookup.url }), 6000, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activateShareServer(lookup.serverId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSharedSong(
|
||||||
|
id: string,
|
||||||
|
lookup: Extract<ShareServerLookupResult, { type: 'ok' }>,
|
||||||
|
options: ShareResolveOptions,
|
||||||
|
): Promise<SubsonicSong | null> {
|
||||||
|
if (options.activateServer) {
|
||||||
|
activateShareServer(lookup.serverId);
|
||||||
|
return getSong(id);
|
||||||
|
}
|
||||||
|
return getSongWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAlbumAfterActivation(
|
||||||
|
id: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||||
|
activateShareServer(serverId);
|
||||||
|
return getAlbum(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getArtistAfterActivation(
|
||||||
|
id: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||||
|
activateShareServer(serverId);
|
||||||
|
return getArtist(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveShareSearchPayload(
|
||||||
|
payload: QueueableShareSearchPayload,
|
||||||
|
options: ShareResolveOptions = {},
|
||||||
|
): Promise<ShareSearchResolveResult> {
|
||||||
|
const lookup = lookupShareServer(payload.srv);
|
||||||
|
if (lookup.type === 'not-logged-in') {
|
||||||
|
return { type: 'not-logged-in' };
|
||||||
|
}
|
||||||
|
if (lookup.type === 'no-matching-server') {
|
||||||
|
return { type: 'no-matching-server', url: lookup.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = payload.k === 'track' ? [payload.id] : payload.ids;
|
||||||
|
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 => resolveSharedSong(id, lookup, options)));
|
||||||
|
for (const song of songs) {
|
||||||
|
if (song) resolved.push(song);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipped = ids.length - resolved.length;
|
||||||
|
if (resolved.length === 0) {
|
||||||
|
return { type: 'all-unavailable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'ok', songs: resolved, total: ids.length, skipped };
|
||||||
|
} catch {
|
||||||
|
return { type: 'error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveShareSearchAlbum(
|
||||||
|
payload: AlbumShareSearchPayload,
|
||||||
|
options: ShareResolveOptions = {},
|
||||||
|
): Promise<ShareSearchAlbumResolveResult> {
|
||||||
|
const lookup = lookupShareServer(payload.srv);
|
||||||
|
if (lookup.type === 'not-logged-in') {
|
||||||
|
return { type: 'not-logged-in' };
|
||||||
|
}
|
||||||
|
if (lookup.type === 'no-matching-server') {
|
||||||
|
return { type: 'no-matching-server', url: lookup.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { album } = options.activateServer
|
||||||
|
? await getAlbumAfterActivation(payload.id, lookup.serverId)
|
||||||
|
: await getAlbumWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, payload.id);
|
||||||
|
return { type: 'ok', album };
|
||||||
|
} catch {
|
||||||
|
return { type: 'unavailable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveShareSearchArtist(
|
||||||
|
payload: ArtistShareSearchPayload | ComposerShareSearchPayload,
|
||||||
|
options: ShareResolveOptions = {},
|
||||||
|
): Promise<ShareSearchArtistResolveResult> {
|
||||||
|
const lookup = lookupShareServer(payload.srv);
|
||||||
|
if (lookup.type === 'not-logged-in') {
|
||||||
|
return { type: 'not-logged-in' };
|
||||||
|
}
|
||||||
|
if (lookup.type === 'no-matching-server') {
|
||||||
|
return { type: 'no-matching-server', url: lookup.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { artist } = options.activateServer
|
||||||
|
? await getArtistAfterActivation(payload.id, lookup.serverId)
|
||||||
|
: await getArtistWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, payload.id);
|
||||||
|
return { type: 'ok', artist };
|
||||||
|
} catch {
|
||||||
|
return { type: 'unavailable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueShareSearchPayload(
|
||||||
|
payload: QueueableShareSearchPayload,
|
||||||
|
t: TFunction,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const resolved = await resolveShareSearchPayload(payload, { activateServer: true });
|
||||||
|
if (resolved.type === 'not-logged-in') {
|
||||||
|
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (resolved.type === 'no-matching-server') {
|
||||||
|
showToast(t('sharePaste.noMatchingServer', { url: resolved.url }), 6000, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (resolved.type === 'all-unavailable') {
|
||||||
|
showToast(
|
||||||
|
payload.k === 'track' ? t('sharePaste.trackUnavailable') : t('sharePaste.queueAllUnavailable'),
|
||||||
|
payload.k === 'track' ? 5000 : 6000,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (resolved.type === 'error') {
|
||||||
|
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tracks: Track[] = resolved.songs.map(songToTrack);
|
||||||
|
const okToEnqueue = await orbitBulkGuard(tracks.length);
|
||||||
|
if (!okToEnqueue) return false;
|
||||||
|
usePlayerStore.getState().enqueue(tracks, true);
|
||||||
|
if (resolved.skipped > 0) {
|
||||||
|
showToast(
|
||||||
|
t('search.shareQueuedPartial', { queued: tracks.length, total: resolved.total, skipped: resolved.skipped }),
|
||||||
|
5000,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast(t('search.shareQueued', { count: tracks.length }), 3000, 'info');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[psysonic] share search enqueue failed', e);
|
||||||
|
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { encodeServerMagicString } from '../server/serverMagicString';
|
||||||
|
import { encodeSharePayload } from './shareLink';
|
||||||
|
import { parseShareSearchText, sharePayloadTotal } from './shareSearch';
|
||||||
|
|
||||||
|
describe('share search parsing', () => {
|
||||||
|
it('detects track share links as queueable', () => {
|
||||||
|
const link = encodeSharePayload({
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'track',
|
||||||
|
id: 'song-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseShareSearchText(link)).toEqual({
|
||||||
|
type: 'queueable',
|
||||||
|
payload: {
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'track',
|
||||||
|
id: 'song-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects queue share links as queueable and counts their tracks', () => {
|
||||||
|
const link = encodeSharePayload({
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'queue',
|
||||||
|
ids: ['a', 'b', 'c'],
|
||||||
|
});
|
||||||
|
const match = parseShareSearchText(`try this ${link}`);
|
||||||
|
|
||||||
|
expect(match).toEqual({
|
||||||
|
type: 'queueable',
|
||||||
|
payload: {
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'queue',
|
||||||
|
ids: ['a', 'b', 'c'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (match?.type === 'queueable') {
|
||||||
|
expect(sharePayloadTotal(match.payload)).toBe(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat server magic strings as share-search links', () => {
|
||||||
|
const invite = encodeServerMagicString({
|
||||||
|
url: 'https://music.example.com',
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseShareSearchText(invite)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects album share links as album search results', () => {
|
||||||
|
const album = encodeSharePayload({
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'album',
|
||||||
|
id: 'album-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseShareSearchText(album)).toEqual({
|
||||||
|
type: 'album',
|
||||||
|
payload: {
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'album',
|
||||||
|
id: 'album-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects artist share links as artist search results', () => {
|
||||||
|
const artist = encodeSharePayload({
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'artist',
|
||||||
|
id: 'artist-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseShareSearchText(artist)).toEqual({
|
||||||
|
type: 'artist',
|
||||||
|
payload: {
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'artist',
|
||||||
|
id: 'artist-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects composer share links as composer search results', () => {
|
||||||
|
const composer = encodeSharePayload({
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'composer',
|
||||||
|
id: 'composer-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseShareSearchText(composer)).toEqual({
|
||||||
|
type: 'composer',
|
||||||
|
payload: {
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'composer',
|
||||||
|
id: 'composer-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unsupported for invalid psysonic2 payloads', () => {
|
||||||
|
expect(parseShareSearchText('psysonic2-not-valid-base64!!!')).toEqual({ type: 'unsupported' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a single track in sharePayloadTotal', () => {
|
||||||
|
expect(
|
||||||
|
sharePayloadTotal({ srv: 'https://music.example.com', k: 'track', id: 'song-1' }),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks orbit invites as unsupported in search', () => {
|
||||||
|
const orbit = encodeSharePayload({
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'orbit',
|
||||||
|
sid: '1234abcd',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseShareSearchText(orbit)).toEqual({ type: 'unsupported' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
decodeSharePayloadFromText,
|
||||||
|
PSYSONIC_SHARE_PREFIX,
|
||||||
|
} from './shareLink';
|
||||||
|
|
||||||
|
export type QueueableShareSearchPayload =
|
||||||
|
| { srv: string; k: 'track'; id: string }
|
||||||
|
| { srv: string; k: 'queue'; ids: string[] };
|
||||||
|
|
||||||
|
export type AlbumShareSearchPayload = { srv: string; k: 'album'; id: string };
|
||||||
|
export type ArtistShareSearchPayload = { srv: string; k: 'artist'; id: string };
|
||||||
|
export type ComposerShareSearchPayload = { srv: string; k: 'composer'; id: string };
|
||||||
|
|
||||||
|
export type ShareSearchMatch =
|
||||||
|
| { type: 'queueable'; payload: QueueableShareSearchPayload }
|
||||||
|
| { type: 'album'; payload: AlbumShareSearchPayload }
|
||||||
|
| { type: 'artist'; payload: ArtistShareSearchPayload }
|
||||||
|
| { type: 'composer'; payload: ComposerShareSearchPayload }
|
||||||
|
| { type: 'unsupported' };
|
||||||
|
|
||||||
|
export function parseShareSearchText(text: string): ShareSearchMatch | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed.includes(PSYSONIC_SHARE_PREFIX)) return null;
|
||||||
|
|
||||||
|
const payload = decodeSharePayloadFromText(trimmed);
|
||||||
|
if (!payload) return { type: 'unsupported' };
|
||||||
|
if (payload.k === 'track') {
|
||||||
|
return {
|
||||||
|
type: 'queueable',
|
||||||
|
payload: { srv: payload.srv, k: 'track', id: payload.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (payload.k === 'queue') {
|
||||||
|
return {
|
||||||
|
type: 'queueable',
|
||||||
|
payload: { srv: payload.srv, k: 'queue', ids: payload.ids },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (payload.k === 'album') {
|
||||||
|
return {
|
||||||
|
type: 'album',
|
||||||
|
payload: { srv: payload.srv, k: 'album', id: payload.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (payload.k === 'artist') {
|
||||||
|
return {
|
||||||
|
type: 'artist',
|
||||||
|
payload: { srv: payload.srv, k: 'artist', id: payload.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (payload.k === 'composer') {
|
||||||
|
return {
|
||||||
|
type: 'composer',
|
||||||
|
payload: { srv: payload.srv, k: 'composer', id: payload.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'unsupported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sharePayloadTotal(payload: QueueableShareSearchPayload): number {
|
||||||
|
return payload.k === 'track' ? 1 : payload.ids.length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { encodeSharePayload } from './shareLink';
|
||||||
|
import { parseShareSearchText } from './shareSearch';
|
||||||
|
import { shareServerOriginLabel } from './shareServerOriginLabel';
|
||||||
|
|
||||||
|
const home = {
|
||||||
|
id: 'home',
|
||||||
|
name: 'Home NAS',
|
||||||
|
url: 'https://music.home.example',
|
||||||
|
username: 'u1',
|
||||||
|
password: 'p1',
|
||||||
|
};
|
||||||
|
const office = {
|
||||||
|
id: 'office',
|
||||||
|
name: 'Office',
|
||||||
|
url: 'https://music.office.example',
|
||||||
|
username: 'u2',
|
||||||
|
password: 'p2',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('shareServerOriginLabel', () => {
|
||||||
|
it('returns null when share targets the active server', () => {
|
||||||
|
const match = parseShareSearchText(
|
||||||
|
encodeSharePayload({ srv: home.url, k: 'track', id: 't-1' }),
|
||||||
|
);
|
||||||
|
expect(shareServerOriginLabel(match, [home, office], 'home')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the saved server display name when share is from another saved server', () => {
|
||||||
|
const match = parseShareSearchText(
|
||||||
|
encodeSharePayload({ srv: office.url, k: 'track', id: 't-1' }),
|
||||||
|
);
|
||||||
|
expect(shareServerOriginLabel(match, [home, office], 'home')).toBe('Office');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the share server is not in saved profiles', () => {
|
||||||
|
const match = parseShareSearchText(
|
||||||
|
encodeSharePayload({ srv: 'https://unknown.example', k: 'track', id: 't-1' }),
|
||||||
|
);
|
||||||
|
expect(shareServerOriginLabel(match, [home], 'home')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unsupported share payloads', () => {
|
||||||
|
expect(shareServerOriginLabel({ type: 'unsupported' }, [home], 'home')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||||
|
import { serverListDisplayLabel } from '../server/serverDisplayName';
|
||||||
|
import { findServerIdForShareUrl } from './shareLink';
|
||||||
|
import type { ShareSearchMatch } from './shareSearch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display name for the share link's origin server when it differs from the
|
||||||
|
* active server. Returns null when the link targets the active server, is
|
||||||
|
* unsupported, or does not match any saved server profile.
|
||||||
|
*/
|
||||||
|
export function shareServerOriginLabel(
|
||||||
|
shareMatch: ShareSearchMatch | null,
|
||||||
|
servers: ServerProfile[],
|
||||||
|
activeServerId: string | null,
|
||||||
|
): string | null {
|
||||||
|
if (!shareMatch || shareMatch.type === 'unsupported') return null;
|
||||||
|
|
||||||
|
const shareServerId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
|
||||||
|
if (!shareServerId || shareServerId === activeServerId) return null;
|
||||||
|
|
||||||
|
const server = servers.find(s => s.id === shareServerId);
|
||||||
|
if (!server) return null;
|
||||||
|
|
||||||
|
return serverListDisplayLabel(server, servers);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user