From d4ab56f5a6daf27fe3d49d57b28bf279fe13519e Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:17:01 +0200 Subject: [PATCH] refactor(lib,playback): relocate albumDetailNavigation + mixRatingFilter decouple-knots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lower-layer→feature inversions removed: - Detail-route predicates (isAlbumDetailPath/isArtistDetailPath/isComposerDetailPath) were defined in features/album's browse store but are pure URL checks. Extracted to lib/navigation/detailRoutePaths; the browse store re-exports them so the @/features/album barrel surface is unchanged. This frees albumDetailNavigation of its only @/features import, so it moves utils/navigation → lib/navigation (drains utils/navigation). Also fixes the M5 isArtistDetailPath misplacement. - mixRatingFilter's sole feature dep is playback's userRatingOverrides and its only binding consumer is playback's buildInfiniteQueueCandidates, so it belongs in the playback feature: utils/mix → features/playback/utils (drains utils/mix). The earlier handoff note that lib/api/subsonicStarRating consumes it was stale — that is only a code comment, verified no runtime import. Pure moves; tests pass unmodified. The share cluster + switchActiveServer stay in utils/ — they are imported by the a11y-HELD PasteClipboardHandler, so relocating them would rewrite a do-not-touch file. --- src/app/AppShell.tsx | 2 +- src/app/tauriBridge/useCliBridge.ts | 2 +- .../album/hooks/useAlbumBrowseFilters.ts | 2 +- .../hooks/useAlbumBrowseScrollRestore.ts | 2 +- .../album/hooks/useAlbumDetailBack.test.tsx | 6 ++-- .../album/hooks/useAlbumDetailBack.ts | 2 +- .../album/hooks/useAlbumGridBrowseFilters.ts | 2 +- .../album/hooks/useNavigateToAlbum.ts | 2 +- src/features/album/pages/Albums.tsx | 2 +- src/features/album/pages/NewReleases.tsx | 2 +- src/features/album/pages/RandomAlbums.tsx | 4 +-- .../album/store/albumBrowseSessionStore.ts | 29 +++++++++---------- .../artist/hooks/useArtistsBrowseFilters.ts | 2 +- .../hooks/useArtistsBrowseScrollRestore.ts | 2 +- .../artist/hooks/useNavigateToArtist.ts | 2 +- src/features/artist/pages/Artists.tsx | 2 +- .../hooks/useComposersBrowseFilters.ts | 2 +- .../hooks/useComposersBrowseScrollRestore.ts | 2 +- .../composers/hooks/useNavigateToComposer.ts | 2 +- src/features/composers/pages/Composers.tsx | 2 +- .../contextMenu/utils/contextMenuActions.ts | 2 +- .../genre/hooks/useGenreDetailBrowse.ts | 2 +- src/features/genre/pages/GenreDetail.tsx | 2 +- src/features/home/components/Hero.tsx | 2 +- src/features/home/pages/Home.tsx | 2 +- .../hooks/usePlaybackLibraryNavigate.ts | 2 +- .../playback/utils}/mixRatingFilter.test.ts | 4 +-- .../playback/utils}/mixRatingFilter.ts | 2 +- .../buildInfiniteQueueCandidates.test.ts | 4 +-- .../playback/buildInfiniteQueueCandidates.ts | 2 +- src/features/randomMix/pages/RandomMix.tsx | 2 +- src/features/randomMix/utils/luckyMix.ts | 2 +- .../randomMix/utils/luckyMixHelpers.ts | 2 +- .../randomMix/utils/randomMixHelpers.ts | 2 +- .../search/components/MobileSearchOverlay.tsx | 2 +- .../search/pages/SearchBrowsePage.tsx | 2 +- .../navigation/albumDetailNavigation.test.ts | 4 +-- .../navigation/albumDetailNavigation.ts | 4 +-- src/lib/navigation/detailRoutePaths.ts | 21 ++++++++++++++ src/utils/share/applySharePaste.ts | 2 +- 40 files changed, 80 insertions(+), 60 deletions(-) rename src/{utils/mix => features/playback/utils}/mixRatingFilter.test.ts (96%) rename src/{utils/mix => features/playback/utils}/mixRatingFilter.ts (99%) rename src/{utils => lib}/navigation/albumDetailNavigation.test.ts (98%) rename src/{utils => lib}/navigation/albumDetailNavigation.ts (98%) create mode 100644 src/lib/navigation/detailRoutePaths.ts diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 6d181a0e..f0fadc73 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -1,7 +1,7 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { ensurePlaybackServerActive } from '@/features/playback/utils/playback/playbackServer'; -import { navigatePathWithAlbumReturnTo, shouldSkipMainScrollResetOnRouteChange } from '../utils/navigation/albumDetailNavigation'; +import { navigatePathWithAlbumReturnTo, shouldSkipMainScrollResetOnRouteChange } from '@/lib/navigation/albumDetailNavigation'; import { getCurrentWebview } from '@tauri-apps/api/webview'; import { PanelRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/tauriBridge/useCliBridge.ts b/src/app/tauriBridge/useCliBridge.ts index 06e769bd..3a404035 100644 --- a/src/app/tauriBridge/useCliBridge.ts +++ b/src/app/tauriBridge/useCliBridge.ts @@ -5,7 +5,7 @@ import type { NavigateFunction } from 'react-router-dom'; import { getSimilarSongs } from '@/lib/api/subsonicArtists'; import { getMusicFolders } from '@/lib/api/subsonicLibrary'; import { search as subsonicSearch } from '@/lib/api/subsonicSearch'; -import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter'; +import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter'; import { shuffleArray } from '@/lib/util/shuffleArray'; import { songToTrack } from '@/lib/media/songToTrack'; import { showToast } from '@/lib/dom/toast'; diff --git a/src/features/album/hooks/useAlbumBrowseFilters.ts b/src/features/album/hooks/useAlbumBrowseFilters.ts index b799bd39..fb63ed27 100644 --- a/src/features/album/hooks/useAlbumBrowseFilters.ts +++ b/src/features/album/hooks/useAlbumBrowseFilters.ts @@ -15,7 +15,7 @@ import { useAlbumBrowseSessionStore, } from '@/features/album/store/albumBrowseSessionStore'; import type { AlbumBrowseSort } from '@/lib/library/browseTextSearch'; -import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation'; +import { shouldRestoreAlbumBrowseSession } from '@/lib/navigation/albumDetailNavigation'; import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; const ALBUMS_SURFACE: AlbumBrowseSurface = 'albums'; diff --git a/src/features/album/hooks/useAlbumBrowseScrollRestore.ts b/src/features/album/hooks/useAlbumBrowseScrollRestore.ts index b42a3b94..2a297409 100644 --- a/src/features/album/hooks/useAlbumBrowseScrollRestore.ts +++ b/src/features/album/hooks/useAlbumBrowseScrollRestore.ts @@ -7,7 +7,7 @@ import { type AlbumBrowseSurface, useAlbumBrowseSessionStore, } from '@/features/album/store/albumBrowseSessionStore'; -import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation'; +import { shouldRestoreAlbumBrowseSession } from '@/lib/navigation/albumDetailNavigation'; type PendingScroll = { scrollTop: number; diff --git a/src/features/album/hooks/useAlbumDetailBack.test.tsx b/src/features/album/hooks/useAlbumDetailBack.test.tsx index 7ddc737e..3ce3b44d 100644 --- a/src/features/album/hooks/useAlbumDetailBack.test.tsx +++ b/src/features/album/hooks/useAlbumDetailBack.test.tsx @@ -4,10 +4,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { useAlbumDetailBack } from '@/features/album/hooks/useAlbumDetailBack'; -import { navigateAlbumDetailBack } from '@/utils/navigation/albumDetailNavigation'; +import { navigateAlbumDetailBack } from '@/lib/navigation/albumDetailNavigation'; -vi.mock('@/utils/navigation/albumDetailNavigation', async (importOriginal) => { - const mod = await importOriginal(); +vi.mock('@/lib/navigation/albumDetailNavigation', async (importOriginal) => { + const mod = await importOriginal(); return { ...mod, navigateAlbumDetailBack: vi.fn(mod.navigateAlbumDetailBack), diff --git a/src/features/album/hooks/useAlbumDetailBack.ts b/src/features/album/hooks/useAlbumDetailBack.ts index 26e07a06..6b3c8bc5 100644 --- a/src/features/album/hooks/useAlbumDetailBack.ts +++ b/src/features/album/hooks/useAlbumDetailBack.ts @@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { navigateAlbumDetailBack, readAlbumDetailReturnTo, -} from '@/utils/navigation/albumDetailNavigation'; +} from '@/lib/navigation/albumDetailNavigation'; /** Leave album/artist detail for the page that opened it (or history back as fallback). */ export function useAlbumDetailBack(fallback = '/') { diff --git a/src/features/album/hooks/useAlbumGridBrowseFilters.ts b/src/features/album/hooks/useAlbumGridBrowseFilters.ts index 23d9e714..1eedd1a4 100644 --- a/src/features/album/hooks/useAlbumGridBrowseFilters.ts +++ b/src/features/album/hooks/useAlbumGridBrowseFilters.ts @@ -9,7 +9,7 @@ import { isAlbumDetailPath, useAlbumBrowseSessionStore, } from '@/features/album/store/albumBrowseSessionStore'; -import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation'; +import { shouldRestoreAlbumBrowseSession } from '@/lib/navigation/albumDetailNavigation'; import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; import { inpageScrollViewportIdForSurface, diff --git a/src/features/album/hooks/useNavigateToAlbum.ts b/src/features/album/hooks/useNavigateToAlbum.ts index 203044eb..73c408b1 100644 --- a/src/features/album/hooks/useNavigateToAlbum.ts +++ b/src/features/album/hooks/useNavigateToAlbum.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { navigateToAlbumDetail } from '@/utils/navigation/albumDetailNavigation'; +import { navigateToAlbumDetail } from '@/lib/navigation/albumDetailNavigation'; /** Navigate to album detail, remembering the current page for the back button. */ export function useNavigateToAlbum() { diff --git a/src/features/album/pages/Albums.tsx b/src/features/album/pages/Albums.tsx index befa06fb..ca11cd1c 100644 --- a/src/features/album/pages/Albums.tsx +++ b/src/features/album/pages/Albums.tsx @@ -40,7 +40,7 @@ import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrow import { useAlbumBrowseScrollReset } from '@/features/album/hooks/useAlbumBrowseScrollReset'; import { useBrowseAlbumTextSearch } from '@/features/album/hooks/useBrowseAlbumTextSearch'; import { peekAlbumBrowseScrollRestore } from '@/features/album/store/albumBrowseSessionStore'; -import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation'; +import { readAlbumBrowseRestore } from '@/lib/navigation/albumDetailNavigation'; import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs'; import { useAlbumCatalogYearBounds } from '@/features/album/hooks/useAlbumCatalogYearBounds'; import type { AlbumBrowseSort } from '@/lib/library/albumBrowseSort'; diff --git a/src/features/album/pages/NewReleases.tsx b/src/features/album/pages/NewReleases.tsx index eba20299..c6a31ac9 100644 --- a/src/features/album/pages/NewReleases.tsx +++ b/src/features/album/pages/NewReleases.tsx @@ -34,7 +34,7 @@ import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrow import { useAlbumBrowseScrollReset } from '@/features/album/hooks/useAlbumBrowseScrollReset'; import { useBrowseAlbumTextSearch } from '@/features/album/hooks/useBrowseAlbumTextSearch'; import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters'; -import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation'; +import { readAlbumBrowseRestore } from '@/lib/navigation/albumDetailNavigation'; import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import { filterAlbumsByGenres } from '@/lib/library/albumBrowseFilters'; diff --git a/src/features/album/pages/RandomAlbums.tsx b/src/features/album/pages/RandomAlbums.tsx index 73c984ce..1c16ba05 100644 --- a/src/features/album/pages/RandomAlbums.tsx +++ b/src/features/album/pages/RandomAlbums.tsx @@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '@/store/authStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; -import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter'; +import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter'; import { runLocalRandomAlbums, runLocalAlbumsByGenres } from '@/lib/library/browseTextSearch'; import { useOfflineStore } from '@/features/offline'; import { useDownloadModalStore } from '@/features/offline'; @@ -36,7 +36,7 @@ import { useInpageScrollViewport } from '@/lib/hooks/useInpageScrollViewport'; import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '@/features/album/hooks/useAlbumGridBrowseFilters'; import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrowseScrollRestore'; import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters'; -import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation'; +import { readAlbumBrowseRestore } from '@/lib/navigation/albumDetailNavigation'; import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs'; const ALBUM_COUNT = 30; diff --git a/src/features/album/store/albumBrowseSessionStore.ts b/src/features/album/store/albumBrowseSessionStore.ts index 9ba6e490..59b0e645 100644 --- a/src/features/album/store/albumBrowseSessionStore.ts +++ b/src/features/album/store/albumBrowseSessionStore.ts @@ -1,6 +1,20 @@ import { create } from 'zustand'; import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import type { AlbumBrowseSort } from '@/lib/library/browseTextSearch'; +import { + isAlbumDetailPath, + isArtistDetailPath, + isComposerDetailPath, +} from '@/lib/navigation/detailRoutePaths'; + +// Detail-route predicates now live in lib/navigation (pure URL checks shared by +// both browse logic and lower-layer navigation helpers). Re-exported here so the +// `@/features/album` barrel surface for browse consumers stays unchanged. +export { + isAlbumDetailPath, + isArtistDetailPath, + isComposerDetailPath, +} from '@/lib/navigation/detailRoutePaths'; export const DEFAULT_ALBUM_BROWSE_SORT: AlbumBrowseSort = 'alphabeticalByName'; @@ -212,11 +226,6 @@ export function albumBrowseSurfaceForPath(pathname: string): AlbumBrowseSurface return null; } -/** True when pathname is a single album detail route (`/album/:id`). */ -export function isAlbumDetailPath(pathname: string): boolean { - return /^\/album\/[^/]+\/?$/.test(pathname.split('?')[0]?.replace(/\/$/, '') || pathname); -} - /** Single genre detail route (`/genres/:name`), not the genre cloud (`/genres`). */ export function isGenreDetailPath(pathname: string): boolean { const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname; @@ -229,16 +238,6 @@ export function genreDetailGenreFromPath(pathname: string): string | null { return match ? decodeURIComponent(match[1]) : null; } -/** True when pathname is a single artist detail route (`/artist/:id`). */ -export function isArtistDetailPath(pathname: string): boolean { - return /^\/artist\/[^/]+\/?$/.test(pathname); -} - -/** True when pathname is a single composer detail route (`/composer/:id`). */ -export function isComposerDetailPath(pathname: string): boolean { - return /^\/composer\/[^/]+\/?$/.test(pathname); -} - export function isAdvancedSearchLeaveTargetPath(pathname: string): boolean { return isAlbumDetailPath(pathname) || isArtistDetailPath(pathname) || isComposerDetailPath(pathname); } diff --git a/src/features/artist/hooks/useArtistsBrowseFilters.ts b/src/features/artist/hooks/useArtistsBrowseFilters.ts index ff2de615..a991db4c 100644 --- a/src/features/artist/hooks/useArtistsBrowseFilters.ts +++ b/src/features/artist/hooks/useArtistsBrowseFilters.ts @@ -9,7 +9,7 @@ import { useArtistBrowseSessionStore, } from '@/features/artist/store/artistBrowseSessionStore'; import { isArtistDetailPath } from '@/features/album'; -import { shouldRestoreArtistBrowseSession } from '@/utils/navigation/albumDetailNavigation'; +import { shouldRestoreArtistBrowseSession } from '@/lib/navigation/albumDetailNavigation'; import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; export type ArtistBrowseScrollSnapshot = { diff --git a/src/features/artist/hooks/useArtistsBrowseScrollRestore.ts b/src/features/artist/hooks/useArtistsBrowseScrollRestore.ts index e9c001ac..17ff2a56 100644 --- a/src/features/artist/hooks/useArtistsBrowseScrollRestore.ts +++ b/src/features/artist/hooks/useArtistsBrowseScrollRestore.ts @@ -4,7 +4,7 @@ import { peekArtistBrowseScrollRestore, useArtistBrowseSessionStore, } from '@/features/artist/store/artistBrowseSessionStore'; -import { shouldRestoreArtistBrowseSession } from '@/utils/navigation/albumDetailNavigation'; +import { shouldRestoreArtistBrowseSession } from '@/lib/navigation/albumDetailNavigation'; type PendingScroll = { scrollTop: number; diff --git a/src/features/artist/hooks/useNavigateToArtist.ts b/src/features/artist/hooks/useNavigateToArtist.ts index 9096959b..a61a179d 100644 --- a/src/features/artist/hooks/useNavigateToArtist.ts +++ b/src/features/artist/hooks/useNavigateToArtist.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { navigateToArtistDetail } from '@/utils/navigation/albumDetailNavigation'; +import { navigateToArtistDetail } from '@/lib/navigation/albumDetailNavigation'; /** Navigate to artist detail, remembering the current page for the back button. */ export function useNavigateToArtist() { diff --git a/src/features/artist/pages/Artists.tsx b/src/features/artist/pages/Artists.tsx index 5ef8dd51..27be8af3 100644 --- a/src/features/artist/pages/Artists.tsx +++ b/src/features/artist/pages/Artists.tsx @@ -36,7 +36,7 @@ import { useArtistsBrowseScrollRestore } from '@/features/artist/hooks/useArtist import { useArtistsBrowseScrollReset } from '@/features/artist/hooks/useArtistsBrowseScrollReset'; import { useNavigateToArtist } from '@/features/artist/hooks/useNavigateToArtist'; import { peekArtistBrowseScrollRestore } from '@/features/artist/store/artistBrowseSessionStore'; -import { readArtistBrowseRestore } from '@/utils/navigation/albumDetailNavigation'; +import { readArtistBrowseRestore } from '@/lib/navigation/albumDetailNavigation'; import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; diff --git a/src/features/composers/hooks/useComposersBrowseFilters.ts b/src/features/composers/hooks/useComposersBrowseFilters.ts index be465a20..afe00e0f 100644 --- a/src/features/composers/hooks/useComposersBrowseFilters.ts +++ b/src/features/composers/hooks/useComposersBrowseFilters.ts @@ -8,7 +8,7 @@ import { isComposersBrowsePath, useComposerBrowseSessionStore, } from '@/features/composers/store/composerBrowseSessionStore'; -import { shouldRestoreComposerBrowseSession } from '@/utils/navigation/albumDetailNavigation'; +import { shouldRestoreComposerBrowseSession } from '@/lib/navigation/albumDetailNavigation'; import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; export type ComposerBrowseScrollSnapshot = { diff --git a/src/features/composers/hooks/useComposersBrowseScrollRestore.ts b/src/features/composers/hooks/useComposersBrowseScrollRestore.ts index 76281288..7e07af75 100644 --- a/src/features/composers/hooks/useComposersBrowseScrollRestore.ts +++ b/src/features/composers/hooks/useComposersBrowseScrollRestore.ts @@ -4,7 +4,7 @@ import { peekComposerBrowseScrollRestore, useComposerBrowseSessionStore, } from '@/features/composers/store/composerBrowseSessionStore'; -import { shouldRestoreComposerBrowseSession } from '@/utils/navigation/albumDetailNavigation'; +import { shouldRestoreComposerBrowseSession } from '@/lib/navigation/albumDetailNavigation'; type PendingScroll = { scrollTop: number; diff --git a/src/features/composers/hooks/useNavigateToComposer.ts b/src/features/composers/hooks/useNavigateToComposer.ts index 94179344..dedd60e0 100644 --- a/src/features/composers/hooks/useNavigateToComposer.ts +++ b/src/features/composers/hooks/useNavigateToComposer.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { navigateToComposerDetail } from '@/utils/navigation/albumDetailNavigation'; +import { navigateToComposerDetail } from '@/lib/navigation/albumDetailNavigation'; /** Navigate to composer detail, remembering the current page for the back button. */ export function useNavigateToComposer() { diff --git a/src/features/composers/pages/Composers.tsx b/src/features/composers/pages/Composers.tsx index 8341b834..4ce704bc 100644 --- a/src/features/composers/pages/Composers.tsx +++ b/src/features/composers/pages/Composers.tsx @@ -18,7 +18,7 @@ import { useArtistsBrowseScrollReset } from '@/features/artist'; import { useNavigateToComposer } from '@/features/composers/hooks/useNavigateToComposer'; import { peekComposerBrowseScrollRestore } from '@/features/composers/store/composerBrowseSessionStore'; import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore'; -import { readComposerBrowseRestore } from '@/utils/navigation/albumDetailNavigation'; +import { readComposerBrowseRestore } from '@/lib/navigation/albumDetailNavigation'; import { filterArtistsWithRoleAlbumCredits } from '@/lib/library/composerBrowse'; import { ALL_SENTINEL, artistLetterBucket } from '@/features/artist'; import { useLibraryIgnoredArticles } from '@/lib/library/hooks/useLibraryIgnoredArticles'; diff --git a/src/features/contextMenu/utils/contextMenuActions.ts b/src/features/contextMenu/utils/contextMenuActions.ts index 0ddcdfe6..f864c686 100644 --- a/src/features/contextMenu/utils/contextMenuActions.ts +++ b/src/features/contextMenu/utils/contextMenuActions.ts @@ -1,7 +1,7 @@ import { join } from '@tauri-apps/api/path'; import { invoke } from '@tauri-apps/api/core'; import { getSimilarSongs2, fetchSimilarTracksRouted, getTopSongs } from '@/lib/api/subsonicArtists'; -import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter'; +import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter'; import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl'; import { useAuthStore } from '@/store/authStore'; import { usePlayerStore } from '@/features/playback/store/playerStore'; diff --git a/src/features/genre/hooks/useGenreDetailBrowse.ts b/src/features/genre/hooks/useGenreDetailBrowse.ts index cfe35fd8..728766ca 100644 --- a/src/features/genre/hooks/useGenreDetailBrowse.ts +++ b/src/features/genre/hooks/useGenreDetailBrowse.ts @@ -12,7 +12,7 @@ import { stashGenreDetailReturnFilters, useAlbumBrowseSessionStore, } from '@/features/album'; -import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation'; +import { shouldRestoreAlbumBrowseSession } from '@/lib/navigation/albumDetailNavigation'; import type { AlbumBrowseScrollSnapshot } from '@/features/album'; /** Genre detail: locked genre filter + leave/restore session (same contract as All Albums). */ diff --git a/src/features/genre/pages/GenreDetail.tsx b/src/features/genre/pages/GenreDetail.tsx index 8e2e1a7e..3d5a0ab0 100644 --- a/src/features/genre/pages/GenreDetail.tsx +++ b/src/features/genre/pages/GenreDetail.tsx @@ -28,7 +28,7 @@ import { libraryScopeForServer } from '@/lib/api/subsonicClient'; import { readAlbumBrowseRestore, readAlbumDetailReturnTo, -} from '@/utils/navigation/albumDetailNavigation'; +} from '@/lib/navigation/albumDetailNavigation'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; import { runBulkEnqueue, runBulkPlayAll, runBulkShuffle } from '@/features/playback/utils/playback/runBulkPlay'; diff --git a/src/features/home/components/Hero.tsx b/src/features/home/components/Hero.tsx index 3ada7ca6..00065534 100644 --- a/src/features/home/components/Hero.tsx +++ b/src/features/home/components/Hero.tsx @@ -17,7 +17,7 @@ import { useIsMobile } from '@/lib/hooks/useIsMobile'; import { useWindowVisibility } from '@/lib/hooks/useWindowVisibility'; import { useAuthStore } from '@/store/authStore'; import { useThemeStore } from '@/store/themeStore'; -import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter'; +import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; import { playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback/playAlbum'; import { useLongPressAction } from '@/lib/hooks/useLongPressAction'; diff --git a/src/features/home/pages/Home.tsx b/src/features/home/pages/Home.tsx index baff9532..743d5b60 100644 --- a/src/features/home/pages/Home.tsx +++ b/src/features/home/pages/Home.tsx @@ -13,7 +13,7 @@ import { NavLink, useNavigate } from 'react-router-dom'; import { ChevronRight } from 'lucide-react'; import { useHomeStore } from '@/features/home/store/homeStore'; import { useAuthStore } from '@/store/authStore'; -import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter'; +import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; import { bumpPerfCounter } from '@/lib/perf/perfTelemetry'; import { dedupeById } from '@/lib/util/dedupeById'; diff --git a/src/features/playback/hooks/usePlaybackLibraryNavigate.ts b/src/features/playback/hooks/usePlaybackLibraryNavigate.ts index 21ab77bb..f7213828 100644 --- a/src/features/playback/hooks/usePlaybackLibraryNavigate.ts +++ b/src/features/playback/hooks/usePlaybackLibraryNavigate.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { ensurePlaybackServerActive } from '@/features/playback/utils/playback/playbackServer'; -import { navigatePathWithAlbumReturnTo } from '@/utils/navigation/albumDetailNavigation'; +import { navigatePathWithAlbumReturnTo } from '@/lib/navigation/albumDetailNavigation'; /** Navigate to library routes for the playing queue — switches to {@link queueServerId} when needed. */ export function usePlaybackLibraryNavigate() { diff --git a/src/utils/mix/mixRatingFilter.test.ts b/src/features/playback/utils/mixRatingFilter.test.ts similarity index 96% rename from src/utils/mix/mixRatingFilter.test.ts rename to src/features/playback/utils/mixRatingFilter.test.ts index 2bbc7eaf..2eac334d 100644 --- a/src/utils/mix/mixRatingFilter.test.ts +++ b/src/features/playback/utils/mixRatingFilter.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { SubsonicSong } from '@/lib/api/subsonicTypes'; import { usePlayerStore } from '@/features/playback/store/playerStore'; -import { resetPlayerStore } from '../../test/helpers/storeReset'; +import { resetPlayerStore } from '@/test/helpers/storeReset'; vi.mock('@/lib/api/subsonicRatings', () => ({ prefetchArtistUserRatings: vi.fn(), @@ -14,7 +14,7 @@ import { enrichSongsForMixRatingFilter, filterTopArtistsForMixRatings, passesMixMinRatings, -} from './mixRatingFilter'; +} from '@/features/playback/utils/mixRatingFilter'; const enabledArtist2: { enabled: true; minSong: 0; minAlbum: 0; minArtist: 2 } = { enabled: true, diff --git a/src/utils/mix/mixRatingFilter.ts b/src/features/playback/utils/mixRatingFilter.ts similarity index 99% rename from src/utils/mix/mixRatingFilter.ts rename to src/features/playback/utils/mixRatingFilter.ts index 0b320ba1..3ce08afa 100644 --- a/src/utils/mix/mixRatingFilter.ts +++ b/src/features/playback/utils/mixRatingFilter.ts @@ -1,7 +1,7 @@ import { parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtistUserRatings } from '@/lib/api/subsonicRatings'; import { getRandomSongs } from '@/lib/api/subsonicLibrary'; import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes'; -import { useAuthStore } from '../../store/authStore'; +import { useAuthStore } from '@/store/authStore'; import { usePlayerStore } from '@/features/playback/store/playerStore'; /** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */ diff --git a/src/features/playback/utils/playback/buildInfiniteQueueCandidates.test.ts b/src/features/playback/utils/playback/buildInfiniteQueueCandidates.test.ts index ba66a928..319a0a4d 100644 --- a/src/features/playback/utils/playback/buildInfiniteQueueCandidates.test.ts +++ b/src/features/playback/utils/playback/buildInfiniteQueueCandidates.test.ts @@ -23,7 +23,7 @@ vi.mock('@/lib/api/subsonicLibrary', () => ({ getRandomSongs: vi.fn(), })); -vi.mock('@/utils/mix/mixRatingFilter', () => ({ +vi.mock('@/features/playback/utils/mixRatingFilter', () => ({ getMixMinRatingsConfigFromAuth: vi.fn(), enrichSongsForMixRatingFilter: vi.fn(), passesMixMinRatings: vi.fn(), @@ -33,7 +33,7 @@ import { buildInfiniteQueueCandidates } from '@/features/playback/utils/playback import { enrichSongsForMixRatingFilter, getMixMinRatingsConfigFromAuth, -} from '@/utils/mix/mixRatingFilter'; +} from '@/features/playback/utils/mixRatingFilter'; import { makeSubsonicSong } from '@/test/helpers/factories'; const seed = (overrides: Partial = {}): Track => ({ diff --git a/src/features/playback/utils/playback/buildInfiniteQueueCandidates.ts b/src/features/playback/utils/playback/buildInfiniteQueueCandidates.ts index 3624c4f0..e60b9ddb 100644 --- a/src/features/playback/utils/playback/buildInfiniteQueueCandidates.ts +++ b/src/features/playback/utils/playback/buildInfiniteQueueCandidates.ts @@ -5,7 +5,7 @@ import { enrichSongsForMixRatingFilter, getMixMinRatingsConfigFromAuth, passesMixMinRatings, -} from '@/utils/mix/mixRatingFilter'; +} from '@/features/playback/utils/mixRatingFilter'; import { shuffleArray } from '@/lib/util/shuffleArray'; import { songToTrack } from '@/lib/media/songToTrack'; /** diff --git a/src/features/randomMix/pages/RandomMix.tsx b/src/features/randomMix/pages/RandomMix.tsx index 93a648d6..ce3a529b 100644 --- a/src/features/randomMix/pages/RandomMix.tsx +++ b/src/features/randomMix/pages/RandomMix.tsx @@ -12,7 +12,7 @@ import { useOrbitSongRowBehavior } from '@/features/orbit'; import { fetchRandomMixSongsUntilFull, getMixMinRatingsConfigFromAuth, -} from '@/utils/mix/mixRatingFilter'; +} from '@/features/playback/utils/mixRatingFilter'; import { fetchGenreCatalog } from '@/features/playback/utils/playback/genreBrowsePlayback'; import { AUDIOBOOK_GENRES, filterRandomMixSongs } from '@/features/randomMix/utils/randomMixHelpers'; import RandomMixHeader from '@/features/randomMix/components/RandomMixHeader'; diff --git a/src/features/randomMix/utils/luckyMix.ts b/src/features/randomMix/utils/luckyMix.ts index 73d8021b..3b96bbd0 100644 --- a/src/features/randomMix/utils/luckyMix.ts +++ b/src/features/randomMix/utils/luckyMix.ts @@ -21,7 +21,7 @@ import { filterSongsForLuckyMixRatings, filterTopArtistsForMixRatings, getMixMinRatingsConfigFromAuth, -} from '@/utils/mix/mixRatingFilter'; +} from '@/features/playback/utils/mixRatingFilter'; import { MIX_TARGET_SIZE, SEED_TARGET_SIZE, diff --git a/src/features/randomMix/utils/luckyMixHelpers.ts b/src/features/randomMix/utils/luckyMixHelpers.ts index 276d6ad3..3d6518fb 100644 --- a/src/features/randomMix/utils/luckyMixHelpers.ts +++ b/src/features/randomMix/utils/luckyMixHelpers.ts @@ -5,7 +5,7 @@ import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes'; import { filterSongsForLuckyMixRatings, type MixMinRatingsConfig, -} from '@/utils/mix/mixRatingFilter'; +} from '@/features/playback/utils/mixRatingFilter'; export interface TopArtist { id: string; diff --git a/src/features/randomMix/utils/randomMixHelpers.ts b/src/features/randomMix/utils/randomMixHelpers.ts index 260492be..0cad151d 100644 --- a/src/features/randomMix/utils/randomMixHelpers.ts +++ b/src/features/randomMix/utils/randomMixHelpers.ts @@ -1,5 +1,5 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes'; -import { passesMixMinRatings, type MixMinRatingsConfig } from '@/utils/mix/mixRatingFilter'; +import { passesMixMinRatings, type MixMinRatingsConfig } from '@/features/playback/utils/mixRatingFilter'; export const AUDIOBOOK_GENRES = [ 'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel', diff --git a/src/features/search/components/MobileSearchOverlay.tsx b/src/features/search/components/MobileSearchOverlay.tsx index 916f4464..d98aafb0 100644 --- a/src/features/search/components/MobileSearchOverlay.tsx +++ b/src/features/search/components/MobileSearchOverlay.tsx @@ -5,7 +5,7 @@ import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useLocation, useNavigate } from 'react-router-dom'; -import { navigatePathWithAlbumReturnTo } from '@/utils/navigation/albumDetailNavigation'; +import { navigatePathWithAlbumReturnTo } from '@/lib/navigation/albumDetailNavigation'; import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { useAuthStore } from '@/store/authStore'; diff --git a/src/features/search/pages/SearchBrowsePage.tsx b/src/features/search/pages/SearchBrowsePage.tsx index 98bc5ec1..70cb120d 100644 --- a/src/features/search/pages/SearchBrowsePage.tsx +++ b/src/features/search/pages/SearchBrowsePage.tsx @@ -26,7 +26,7 @@ import { import { readAdvancedSearchRestore, shouldRestoreAdvancedSearchSession, -} from '@/utils/navigation/albumDetailNavigation'; +} from '@/lib/navigation/albumDetailNavigation'; import { clearAdvancedSearchLeaveSnapshots, consumeAdvancedSearchLeavingForDetail, diff --git a/src/utils/navigation/albumDetailNavigation.test.ts b/src/lib/navigation/albumDetailNavigation.test.ts similarity index 98% rename from src/utils/navigation/albumDetailNavigation.test.ts rename to src/lib/navigation/albumDetailNavigation.test.ts index 14fd7668..820aec48 100644 --- a/src/utils/navigation/albumDetailNavigation.test.ts +++ b/src/lib/navigation/albumDetailNavigation.test.ts @@ -12,8 +12,8 @@ import { shouldRestoreArtistBrowseSession, shouldRestoreComposerBrowseSession, shouldSkipMainScrollResetOnRouteChange, -} from './albumDetailNavigation'; -import { useAdvancedSearchSessionStore } from '../../store/advancedSearchSessionStore'; +} from '@/lib/navigation/albumDetailNavigation'; +import { useAdvancedSearchSessionStore } from '@/store/advancedSearchSessionStore'; describe('albumDetailNavigation', () => { afterEach(() => { diff --git a/src/utils/navigation/albumDetailNavigation.ts b/src/lib/navigation/albumDetailNavigation.ts similarity index 98% rename from src/utils/navigation/albumDetailNavigation.ts rename to src/lib/navigation/albumDetailNavigation.ts index 8e9f4b7e..c411479a 100644 --- a/src/utils/navigation/albumDetailNavigation.ts +++ b/src/lib/navigation/albumDetailNavigation.ts @@ -2,12 +2,12 @@ import type { Location, NavigateFunction, NavigationType } from 'react-router-do import { isAdvancedSearchPath, useAdvancedSearchSessionStore, -} from '../../store/advancedSearchSessionStore'; +} from '@/store/advancedSearchSessionStore'; import { isAlbumDetailPath, isArtistDetailPath, isComposerDetailPath, -} from '@/features/album'; +} from '@/lib/navigation/detailRoutePaths'; import { peekPersistedAdvancedSearchLeaveSnapshot, saveAdvancedSearchLeaveSnapshot, diff --git a/src/lib/navigation/detailRoutePaths.ts b/src/lib/navigation/detailRoutePaths.ts new file mode 100644 index 00000000..fdcc144b --- /dev/null +++ b/src/lib/navigation/detailRoutePaths.ts @@ -0,0 +1,21 @@ +/** + * Pure route-path predicates for the single-entity detail routes. These are + * domain-agnostic URL checks (no store/feature state), so they live in + * `lib/navigation` where both feature browse logic and lower-layer navigation + * helpers (`albumDetailNavigation`) can consume them without an inversion. + */ + +/** True when pathname is a single album detail route (`/album/:id`). */ +export function isAlbumDetailPath(pathname: string): boolean { + return /^\/album\/[^/]+\/?$/.test(pathname.split('?')[0]?.replace(/\/$/, '') || pathname); +} + +/** True when pathname is a single artist detail route (`/artist/:id`). */ +export function isArtistDetailPath(pathname: string): boolean { + return /^\/artist\/[^/]+\/?$/.test(pathname); +} + +/** True when pathname is a single composer detail route (`/composer/:id`). */ +export function isComposerDetailPath(pathname: string): boolean { + return /^\/composer\/[^/]+\/?$/.test(pathname); +} diff --git a/src/utils/share/applySharePaste.ts b/src/utils/share/applySharePaste.ts index 5442513b..bc00f305 100644 --- a/src/utils/share/applySharePaste.ts +++ b/src/utils/share/applySharePaste.ts @@ -6,7 +6,7 @@ import type { Location, NavigateFunction } from 'react-router-dom'; import type { TFunction } from 'i18next'; import { useAuthStore } from '../../store/authStore'; import { usePlayerStore } from '@/features/playback/store/playerStore'; -import { navigateToAlbumDetail } from '../navigation/albumDetailNavigation'; +import { navigateToAlbumDetail } from '@/lib/navigation/albumDetailNavigation'; import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink'; import { showToast } from '@/lib/dom/toast';