diff --git a/web/src/appRoutes.tsx b/web/src/appRoutes.tsx index 8556b12..514d9f7 100644 --- a/web/src/appRoutes.tsx +++ b/web/src/appRoutes.tsx @@ -6,6 +6,7 @@ const HomePageLoader = () => import('./pages/HomePage').then((m) => ({ default: const LibraryPageLoader = () => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })) const LibrariesPageLoader = () => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })) const FavouritesPageLoader = () => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })) +const MediaSearchPageLoader = () => import('./pages/MediaSearchPage').then((m) => ({ default: m.MediaSearchPage })) const PlaylistsPageLoader = () => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })) const PlaylistDetailPageLoader = () => import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })) @@ -21,6 +22,7 @@ const HomePage = lazy(HomePageLoader) const LibraryPage = lazy(LibraryPageLoader) const LibrariesPage = lazy(LibrariesPageLoader) const FavouritesPage = lazy(FavouritesPageLoader) +const MediaSearchPage = lazy(MediaSearchPageLoader) const PlaylistsPage = lazy(PlaylistsPageLoader) const PlaylistDetailPage = lazy(PlaylistDetailPageLoader) const MediaDetailPage = lazy(MediaDetailPageLoader) @@ -73,6 +75,7 @@ export function prefetchCommonRouteChunks() { LibrariesPageLoader, LibraryPageLoader, MediaDetailPageLoader, + MediaSearchPageLoader, FavouritesPageLoader, WatchHistoryPageLoader, PlaylistsPageLoader, @@ -98,6 +101,7 @@ export type AppRoute = { export const appRoutes: AppRoute[] = [ { index: true, element: }, + { path: 'search', element: }, { path: 'libraries', element: }, { path: 'library/:id', element: }, { path: 'favourites', element: }, diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index aa5e84c..e427efc 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -25,6 +25,7 @@ function isMediaView(pathname: string, search: string): boolean { return ( pathname === '/' || + pathname === '/search' || pathname === '/libraries' || pathname.startsWith('/library') || pathname.startsWith('/media') || diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index 8827e8e..8ddc8c0 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -7,7 +7,9 @@ import { ARTWORK, imageURL } from '../api/client' import { mediaAPI } from '../api/library' import { readerAPI, type ReaderBook } from '../api/reader' import type { Media, PlayProfile, User } from '../types' -import { favouriteMediaLink } from '../utils/mediaNavigation' +import { favouriteMediaLink, mediaSearchLink, mediaSearchReturnPath } from '../utils/mediaNavigation' +import { useAuthStore } from '../stores/auth' +import { usePlayProfileStore } from '../stores/playProfile' import { resolveHeaderBack } from './layoutNavigation' import { LayoutThemeToggle } from './LayoutThemeToggle' import { LayoutUserMenu } from './LayoutUserMenu' @@ -56,7 +58,11 @@ export function LayoutHeader({ pathname = '', }: LayoutHeaderProps) { const navigate = useNavigate() - const headerBack = resolveHeaderBack(pathname) + const location = useLocation() + const searchFrom = mediaSearchReturnPath(location.state) + const headerBack = searchFrom && (pathname.startsWith('/library/') || pathname.startsWith('/media/')) + ? { to: searchFrom, label: '搜索结果' } + : resolveHeaderBack(pathname) return (
@@ -295,7 +301,12 @@ function LayoutHeaderBookSearch() { const SEARCH_PAGE_SIZE = 8 function LayoutHeaderSearch() { - const [query, setQuery] = useState('') + const location = useLocation() + const userId = useAuthStore((s) => s.user?.id) + const profileId = usePlayProfileStore((s) => s.activeProfileId) + const [query, setQuery] = useState(() => + location.pathname === '/search' ? new URLSearchParams(location.search).get('q') ?? '' : '', + ) const [isOpen, setIsOpen] = useState(false) const [loading, setLoading] = useState(false) const [loadingMore, setLoadingMore] = useState(false) @@ -312,7 +323,6 @@ function LayoutHeaderSearch() { const resultsRef = useRef([]) const hasMoreRef = useRef(false) const navigate = useNavigate() - const location = useLocation() const locationKey = `${location.pathname}${location.search}` const prevLocationKeyRef = useRef(locationKey) @@ -392,14 +402,21 @@ function LayoutHeaderSearch() { } }, 250) - return () => clearTimeout(timer) - }, [query]) + return () => { + clearTimeout(timer) + // 卸载或关键词变化时,已发出的旧请求也不能覆盖当前结果。 + searchSeqRef.current += 1 + } + }, [query, userId, profileId]) useLayoutEffect(() => { if (prevLocationKeyRef.current === locationKey) return prevLocationKeyRef.current = locationKey - dismissSearchRef.current(true) - }, [locationKey]) + dismissSearchRef.current(false) + if (location.pathname === '/search') { + setQuery(new URLSearchParams(location.search).get('q') ?? '') + } + }, [locationKey, location.pathname, location.search]) useEffect(() => { function handlePointerDown(e: PointerEvent) { @@ -446,28 +463,34 @@ function LayoutHeaderSearch() { } } - const handleSelect = (item: Media) => { + const openSearchPage = () => { if (suppressInputRef.current) return - const to = favouriteMediaLink(item) - dismissSearch(true) - navigate(to) + const trimmed = query.trim() + if (!trimmed) return + dismissSearch(false) + navigate(mediaSearchLink(trimmed)) } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { dismissSearch(false) - } else if (e.key === 'Enter' && results.length > 0) { - handleSelect(results[0]) + } else if (e.key === 'Enter' && !e.nativeEvent.isComposing && e.keyCode !== 229) { + e.preventDefault() + openSearchPage() } } return (
- + {loading ? ( @@ -498,6 +522,7 @@ function LayoutHeaderSearch() { onClick={() => { dismissSearch(true) }} + aria-label="清空媒体搜索" className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg" > @@ -538,15 +563,11 @@ function LayoutHeaderSearch() { ) : (
{results.map((item) => ( -
- + ))} {loadingMore && (
@@ -605,6 +626,15 @@ function LayoutHeaderSearch() { )}
)} + dismissSearch(false)} + className="sticky -bottom-2 flex items-center justify-center gap-2 border-t border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-3 text-xs font-bold text-brand-500 hover:bg-[var(--app-hover)]" + > + + 查看全部搜索结果 + 按回车搜索 +
) : null}
diff --git a/web/src/components/layoutNavigation.ts b/web/src/components/layoutNavigation.ts index c0e7b6b..1a72b40 100644 --- a/web/src/components/layoutNavigation.ts +++ b/web/src/components/layoutNavigation.ts @@ -74,7 +74,7 @@ export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [ }, ] -const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history']) +const ROOT_MEDIA_PATHS = new Set(['/', '/search', '/libraries', '/favourites', '/playlists', '/history']) /** True only for the fullscreen player route (`/play` or `/play/:id`), not `/playlists`. */ export function isPlayerRoute(pathname: string): boolean { @@ -131,7 +131,7 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null { if (pathname === '/history/stats') { return { to: '/history', label: '观看历史' } } - if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') { + if (pathname === '/search' || pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') { return { to: '/', label: '首页' } } if (pathname.startsWith('/playlist/')) { @@ -171,6 +171,7 @@ export function shouldShowMobileBottomNav(pathname: string): boolean { if (isPlayerRoute(pathname)) return false return ( pathname === '/' || + pathname === '/search' || pathname === '/libraries' || pathname.startsWith('/library') || pathname.startsWith('/media') || diff --git a/web/src/hooks/useScrollMemory.test.ts b/web/src/hooks/useScrollMemory.test.ts index ee26dcb..e24d8ff 100644 --- a/web/src/hooks/useScrollMemory.test.ts +++ b/web/src/hooks/useScrollMemory.test.ts @@ -11,6 +11,7 @@ function check(name: string, condition: boolean) { } check('home remembers scroll', shouldRememberScroll('/')) +check('media search remembers scroll', shouldRememberScroll('/search')) check('library list remembers scroll', shouldRememberScroll('/libraries')) check('library detail remembers scroll', shouldRememberScroll('/library/lib-1')) check('library series panel remembers scroll', shouldRememberScroll('/library/lib-1')) diff --git a/web/src/hooks/useScrollMemory.ts b/web/src/hooks/useScrollMemory.ts index ea484c1..5b4d8ac 100644 --- a/web/src/hooks/useScrollMemory.ts +++ b/web/src/hooks/useScrollMemory.ts @@ -63,6 +63,7 @@ function writeScrollPosition(key: string, value: number): void { export function shouldRememberScroll(pathname: string): boolean { return ( pathname === '/' || + pathname === '/search' || pathname === '/libraries' || pathname.startsWith('/library/') || pathname.startsWith('/media/') diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index ec8c578..62ccccd 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -32,12 +32,14 @@ import { useLibrarySeriesSelection } from './useLibrarySeriesSelection' import { useLibraryAdminActions } from './useLibraryAdminActions' import { usePermission } from '../hooks/usePermission' import { useFavourites } from '../hooks/useFavourites' +import { mediaSearchReturnPath } from '../utils/mediaNavigation' export function LibraryPage() { const { id = '' } = useParams() const [searchParams, setSearchParams] = useSearchParams() const location = useLocation() const navigate = useNavigate() + const searchFrom = mediaSearchReturnPath(location.state) const role = useAuthStore((s) => s.user?.role) const canFavorite = usePermission('can_favorite') const { isFavourite, toggleFavourite } = useFavourites() @@ -358,7 +360,7 @@ export function LibraryPage() { void handleToggleFavourite(selectedSeries.rep.id) }} seriesToolBusy={seriesToolBusy} - onBack={clearSelectedSeries} + onBack={() => searchFrom ? navigate(searchFrom) : clearSelectedSeries()} onSmartScrape={handleSeriesSmartScrape} onManualScrape={() => setManualSeriesScrapeOpen(true)} onMetadataEdit={() => setSeriesMetadataEditOpen(true)} diff --git a/web/src/pages/MediaSearchPage.tsx b/web/src/pages/MediaSearchPage.tsx new file mode 100644 index 0000000..c214cd8 --- /dev/null +++ b/web/src/pages/MediaSearchPage.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { ChevronLeft, ChevronRight, LoaderCircle, RefreshCw, Search } from 'lucide-react' + +import { mediaAPI, type MediaSearchPage as SearchPageData } from '../api/library' +import { MediaCard } from '../components/MediaCard' +import { PageBackButton } from '../components/PageBackButton' +import { normalizeListPosition } from '../hooks/useListPositionMemory' +import { useAuthStore } from '../stores/auth' +import { usePlayProfileStore } from '../stores/playProfile' +import { favouriteMediaLink, mediaSearchLink } from '../utils/mediaNavigation' + +const PAGE_SIZE = 48 +const CACHE_TTL_MS = 5 * 60_000 +// 返回详情前的结果可立即显示;按账号、播放档案、关键词和页码隔离。 +const pageCache = new Map() + +function readCachedPage(key: string): SearchPageData | null { + const cached = pageCache.get(key) + if (!cached) return null + if (Date.now() - cached.at >= CACHE_TTL_MS) { + pageCache.delete(key) + return null + } + return cached.data +} + +export function MediaSearchPage() { + const [params, setParams] = useSearchParams() + const query = (params.get('q') ?? '').trim() + const page = normalizeListPosition(params.get('page')) + const userId = useAuthStore((s) => s.user?.id) + const profileId = usePlayProfileStore((s) => s.activeProfileId) + const cacheKey = JSON.stringify([userId, profileId, query, page]) + + const changePage = (next: number) => { + const nextParams = new URLSearchParams({ q: query }) + if (next > 1) nextParams.set('page', String(next)) + setParams(nextParams) + } + + return ( + + ) +} + +function MediaSearchResults({ cacheKey, query, page, onPageChange }: { + cacheKey: string + query: string + page: number + onPageChange: (page: number) => void +}) { + const [data, setData] = useState(() => readCachedPage(cacheKey)) + const [loading, setLoading] = useState(() => !!query && !readCachedPage(cacheKey)) + const [error, setError] = useState('') + const [revision, setRevision] = useState(0) + + useEffect(() => { + if (!query) return + const cached = readCachedPage(cacheKey) + if (cached) { + setData(cached) + setLoading(false) + return + } + + let cancelled = false + setLoading(true) + setError('') + mediaAPI.searchPage(query, page, PAGE_SIZE, { groupSeries: true }) + .then((result) => { + if (cancelled) return + // 限制缓存大小,避免长时间搜索大量关键词后占用过多内存。 + if (pageCache.size >= 20) pageCache.delete(pageCache.keys().next().value!) + pageCache.set(cacheKey, { at: Date.now(), data: result }) + setData(result) + }) + .catch(() => { + if (!cancelled) setError('搜索失败,请稍后重试') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { cancelled = true } + }, [cacheKey, query, page, revision]) + + const refresh = () => { + pageCache.delete(cacheKey) + setRevision((value) => value + 1) + } + + const items = data?.items ?? [] + const total = data?.total ?? items.length + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + const from = mediaSearchLink(query, page) + + return ( +
+ +
+
+

搜索结果

+

+ {query + ? <>“{query}”{data && !loading ? ` · 共 ${total} 条结果` : ''} + : '在顶部搜索框输入媒体名称,按回车开始搜索'} +

+
+ {query && ( + + )} +
+ + {loading && ( +
+ + 正在搜索… +
+ )} + + {error && ( +
+ {error} + +
+ )} + + {!loading && !error && query && items.length === 0 && ( +
+ +

未搜索到与 “{query}” 相关的媒体内容

+

试试其他名称或更简短的关键词

+
+ )} + + {!loading && items.length > 0 && ( +
+ {items.map((item) => ( + + ))} +
+ )} + + {!loading && !error && query && (totalPages > 1 || page > 1) && ( + + )} +
+ ) +} diff --git a/web/src/utils/mediaNavigation.ts b/web/src/utils/mediaNavigation.ts index 2afb507..debed80 100644 --- a/web/src/utils/mediaNavigation.ts +++ b/web/src/utils/mediaNavigation.ts @@ -2,6 +2,17 @@ import type { Media } from '../types' import { isRemoteEmbyID } from './remoteEmby' import { getSeriesKey, isEpisodeLike, pathLooksEpisodic } from './groupSeries' +export function mediaSearchLink(query: string, page = 1): string { + const params = new URLSearchParams({ q: query.trim() }) + if (page > 1) params.set('page', String(page)) + return `/search?${params}` +} + +export function mediaSearchReturnPath(state: unknown): string | undefined { + if (!state || typeof state !== 'object' || !('from' in state)) return undefined + return typeof state.from === 'string' && state.from.startsWith('/search?') ? state.from : undefined +} + function pathLooksLikeMovie(media: Media): boolean { const path = (media.path || media.display_library_path || media.library_path || '').toLowerCase() return /[\\/](?:movie|movies|film|films|电影)[\\/]/i.test(path)