refactor(share): split share cluster into lib/share + features/share

a11y-branch hold lifted. Pure share machinery (no @/features runtime imports) →
lib/share: shareLink, shareSearch, shareServerOriginLabel, copyEntityShareLink
(+tests). The two orchestrators that runtime-import offline/playback/orbit →
new features/share: applySharePaste, enqueueShareSearchPayload (+test); doc-only
barrel, consumers use deep paths. No low-layer consumes the orchestrators, so no
seam is needed — pure move, tests pass unmodified. Drains utils/share.

Only import lines changed in the PasteClipboardHandler consumer (logic untouched).
This commit is contained in:
Psychotoxical
2026-06-30 22:21:57 +02:00
parent 6590bd15ef
commit 92635381ea
26 changed files with 56 additions and 47 deletions
+4 -4
View File
@@ -2,11 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import type { EntitySharePayloadV1 } from '../utils/share/shareLink';
import { decodeSharePayloadFromText } from '../utils/share/shareLink';
import type { EntitySharePayloadV1 } from '@/lib/share/shareLink';
import { decodeSharePayloadFromText } from '@/lib/share/shareLink';
import { decodeServerMagicStringFromText } from '@/lib/server/serverMagicString';
import { applySharePastePayload, applySharePasteQueue } from '../utils/share/applySharePaste';
import { shareQueueServerContext } from '../utils/share/shareServerOriginLabel';
import { applySharePastePayload, applySharePasteQueue } from '@/features/share/applySharePaste';
import { shareQueueServerContext } from '@/lib/share/shareServerOriginLabel';
import { showToast } from '@/lib/dom/toast';
import { useShareQueuePreview } from '@/features/search';
import { ShareQueuePreviewModal } from '@/features/search';
@@ -11,7 +11,7 @@ import { useIsMobile } from '@/lib/hooks/useIsMobile';
import { useAlbumDetailBack } from '@/features/album/hooks/useAlbumDetailBack';
import { useThemeStore } from '@/store/themeStore';
import StarRating from '@/ui/StarRating';
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
import { showToast } from '@/lib/dom/toast';
import { isAlbumRecentlyAdded } from '@/features/album/utils/albumRecency';
import { formatLongDuration } from '@/lib/format/formatDuration';
@@ -4,7 +4,7 @@ import { uploadArtistImage } from '@/lib/api/subsonicArtists';
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
import { invalidateCoverArt } from '@/utils/imageCache';
import { showToast } from '@/lib/dom/toast';
@@ -14,7 +14,7 @@ import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useTranslation } from 'react-i18next';
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
import { showToast } from '@/lib/dom/toast';
import { sanitizeHtml } from '@/lib/util/sanitizeHtml';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
@@ -5,7 +5,7 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '@/store/authStore';
import { useTranslation } from 'react-i18next';
import type { EntityShareKind } from '@/utils/share/shareLink';
import type { EntityShareKind } from '@/lib/share/shareLink';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/AddToPlaylistSubmenu';
import {
copyShareLink as copyShareLinkAction,
@@ -1,7 +1,7 @@
import type React from 'react';
import type { SubsonicAlbum, SubsonicArtist } from '@/lib/api/subsonicTypes';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import type { EntityShareKind } from '@/utils/share/shareLink';
import type { EntityShareKind } from '@/lib/share/shareLink';
import type { OfflineActionPolicy } from '@/features/offline';
export type RatingKind = 'song' | 'album' | 'artist';
@@ -9,8 +9,8 @@ import type { Track } from '@/lib/media/trackTypes';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import { useZipDownloadStore } from '@/features/offline';
import { useDownloadModalStore } from '@/features/offline';
import type { EntityShareKind } from '@/utils/share/shareLink';
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
import type { EntityShareKind } from '@/lib/share/shareLink';
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
import { sanitizeFilename, shuffleArray } from '@/features/contextMenu/utils/contextMenuHelpers';
import { songToTrack } from '@/lib/media/songToTrack';
import { showToast } from '@/lib/dom/toast';
+1 -1
View File
@@ -1,4 +1,4 @@
import { decodeOrbitSharePayloadFromText, encodeSharePayload } from '@/utils/share/shareLink';
import { decodeOrbitSharePayloadFromText, encodeSharePayload } from '@/lib/share/shareLink';
export interface OrbitShareLink {
/** Base URL of the Navidrome server (decoded). */
+1 -1
View File
@@ -12,7 +12,7 @@ import { usePlaylistStore } from '@/features/playlist';
import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
import { useAuthStore } from '@/store/authStore';
import { encodeSharePayload } from '@/utils/share/shareLink';
import { encodeSharePayload } from '@/lib/share/shareLink';
import { serverShareBaseUrl } from '@/lib/server/serverEndpoint';
import { copyTextToClipboard } from '@/lib/server/serverMagicString';
import { showToast } from '@/lib/dom/toast';
@@ -6,7 +6,7 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { ServerProfile } from '@/store/authStoreTypes';
import { formatTrackTime } from '@/lib/format/formatDuration';
import type { ShareQueuePreviewState } from '@/features/search/hooks/useShareQueuePreview';
import { sharePayloadTotal, type QueueableShareSearchPayload } from '@/utils/share/shareSearch';
import { sharePayloadTotal, type QueueableShareSearchPayload } from '@/lib/share/shareSearch';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
@@ -5,8 +5,8 @@ import type { TFunction } from 'i18next';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import type { ServerProfile } from '@/store/authStoreTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { activateShareSearchServer } from '@/utils/share/enqueueShareSearchPayload';
import { sharePayloadTotal, type ShareSearchMatch } from '@/utils/share/shareSearch';
import { activateShareSearchServer } from '@/features/share/enqueueShareSearchPayload';
import { sharePayloadTotal, type ShareSearchMatch } from '@/lib/share/shareSearch';
import type { ShareSearchPreviewState } from '@/features/search/hooks/useShareSearchPreview';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
@@ -3,8 +3,8 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import {
resolveShareSearchPayload,
type ShareSearchResolveResult,
} from '@/utils/share/enqueueShareSearchPayload';
import type { QueueableShareSearchPayload } from '@/utils/share/shareSearch';
} from '@/features/share/enqueueShareSearchPayload';
import type { QueueableShareSearchPayload } from '@/lib/share/shareSearch';
export type ShareQueuePreviewState =
| { status: 'idle' }
+4 -4
View File
@@ -6,11 +6,11 @@ import { useAuthStore } from '@/store/authStore';
import {
activateShareSearchServer,
enqueueShareSearchPayload,
} from '@/utils/share/enqueueShareSearchPayload';
} from '@/features/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 { findServerIdForShareUrl } from '@/lib/share/shareLink';
import { shareServerOriginLabel } from '@/lib/share/shareServerOriginLabel';
import { parseShareSearchText } from '@/lib/share/shareSearch';
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
import { useShareSearchPreview } from '@/features/search/hooks/useShareSearchPreview';
@@ -4,8 +4,8 @@ import {
resolveShareSearchAlbum,
resolveShareSearchArtist,
resolveShareSearchPayload,
} from '@/utils/share/enqueueShareSearchPayload';
import type { ShareSearchMatch } from '@/utils/share/shareSearch';
} from '@/features/share/enqueueShareSearchPayload';
import type { ShareSearchMatch } from '@/lib/share/shareSearch';
export interface ShareSearchPreviewState {
shareTrackSong: SubsonicSong | null;
@@ -4,10 +4,10 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import type { Location, NavigateFunction } from 'react-router-dom';
import type { TFunction } from 'i18next';
import { useAuthStore } from '../../store/authStore';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { navigateToAlbumDetail } from '@/lib/navigation/albumDetailNavigation';
import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink';
import { findServerIdForShareUrl, type EntitySharePayloadV1 } from '@/lib/share/shareLink';
import { showToast } from '@/lib/dom/toast';
const RESOLVE_QUEUE_CHUNK = 12;
@@ -37,7 +37,7 @@ vi.mock('@/lib/api/subsonicEntityWithCredentials', () => ({
getSongWithCredentials: mocks.getSongWithCredentials,
}));
vi.mock('../../store/authStore', () => ({
vi.mock('@/store/authStore', () => ({
useAuthStore: {
getState: () => mocks.authState.current,
},
@@ -67,7 +67,7 @@ import {
resolveShareSearchAlbum,
resolveShareSearchArtist,
resolveShareSearchPayload,
} from './enqueueShareSearchPayload';
} from '@/features/share/enqueueShareSearchPayload';
const sharedServer = {
id: 'shared',
@@ -7,13 +7,13 @@ import {
import { getSong } from '@/lib/api/subsonicLibrary';
import { resolveAlbum, resolveArtist } from '@/features/offline';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import type { ServerProfile } from '../../store/authStoreTypes';
import { useAuthStore } from '@/store/authStore';
import type { ServerProfile } from '@/store/authStoreTypes';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { songToTrack } from '@/lib/media/songToTrack';
import type { Track } from '@/lib/media/trackTypes';
import { orbitBulkGuard } from '@/features/orbit';
import { findServerIdForShareUrl } from './shareLink';
import { findServerIdForShareUrl } from '@/lib/share/shareLink';
import { connectBaseUrlForServer } from '@/lib/server/serverEndpoint';
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
import type {
@@ -21,7 +21,7 @@ import type {
ArtistShareSearchPayload,
ComposerShareSearchPayload,
QueueableShareSearchPayload,
} from './shareSearch';
} from '@/lib/share/shareSearch';
import { showToast } from '@/lib/dom/toast';
const RESOLVE_QUEUE_CHUNK = 12;
+9
View File
@@ -0,0 +1,9 @@
/**
* Share feature applying pasted entity/queue shares (server switch + resolve +
* play/navigate) and enqueueing share-search results. The pure encode/decode/parse
* and origin-label helpers live in `@/lib/share` (feature-free); these orchestrators
* runtime-import offline/playback/orbit, so they are feature-scoped. Consumers import
* deep paths (`@/features/share/...`); this barrel is documentation.
*/
export * from './applySharePaste';
export * from './enqueueShareSearchPayload';
@@ -7,11 +7,11 @@
* garbage to the clipboard.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { copyEntityShareLink } from './copyEntityShareLink';
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
import {
decodeSharePayloadFromText,
PSYSONIC_SHARE_PREFIX,
} from './shareLink';
} from '@/lib/share/shareLink';
import { useAuthStore } from '@/store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
@@ -1,6 +1,6 @@
import { useAuthStore } from '../../store/authStore';
import { useAuthStore } from '@/store/authStore';
import { serverShareBaseUrl } from '@/lib/server/serverEndpoint';
import { encodeSharePayload, type EntityShareKind } from './shareLink';
import { encodeSharePayload, type EntityShareKind } from '@/lib/share/shareLink';
import { copyTextToClipboard } from '@/lib/server/serverMagicString';
/** Copies a track / album / artist / composer share link (`psysonic2-`) to the clipboard. */
@@ -6,7 +6,7 @@ import {
encodeSharePayload,
findServerIdForShareUrl,
normalizeShareServerUrl,
} from './shareLink';
} from '@/lib/share/shareLink';
import { decodeServerMagicString, encodeServerMagicString, SERVER_MAGIC_STRING_PREFIX } from '@/lib/server/serverMagicString';
import { makeServer } from '@/test/helpers/factories';
@@ -1,4 +1,4 @@
import type { ServerProfile } from '../../store/authStoreTypes';
import type { ServerProfile } from '@/store/authStoreTypes';
/** Library share (track / album / artist / queue). Same naming family as `psysonic1-` server invites. */
export const PSYSONIC_SHARE_PREFIX = 'psysonic2-';
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { encodeServerMagicString } from '@/lib/server/serverMagicString';
import { encodeSharePayload } from './shareLink';
import { parseShareSearchText, sharePayloadTotal } from './shareSearch';
import { encodeSharePayload } from '@/lib/share/shareLink';
import { parseShareSearchText, sharePayloadTotal } from '@/lib/share/shareSearch';
describe('share search parsing', () => {
it('detects track share links as queueable', () => {
@@ -1,7 +1,7 @@
import {
decodeSharePayloadFromText,
PSYSONIC_SHARE_PREFIX,
} from './shareLink';
} from '@/lib/share/shareLink';
export type QueueableShareSearchPayload =
| { srv: string; k: 'track'; id: string }
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { encodeSharePayload } from './shareLink';
import { parseShareSearchText } from './shareSearch';
import { shareServerOriginLabel } from './shareServerOriginLabel';
import { encodeSharePayload } from '@/lib/share/shareLink';
import { parseShareSearchText } from '@/lib/share/shareSearch';
import { shareServerOriginLabel } from '@/lib/share/shareServerOriginLabel';
const home = {
id: 'home',
@@ -1,8 +1,8 @@
import type { ServerProfile } from '../../store/authStoreTypes';
import type { ServerProfile } from '@/store/authStoreTypes';
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
import { findServerIdForShareUrl } from './shareLink';
import type { ShareSearchMatch } from './shareSearch';
import { findServerIdForShareUrl } from '@/lib/share/shareLink';
import type { ShareSearchMatch } from '@/lib/share/shareSearch';
/**
* Display name for the share link's origin server when it differs from the